use std::{
fs::{File, OpenOptions},
io,
sync::{
Mutex,
atomic::{AtomicU64, Ordering},
},
time::{SystemTime, UNIX_EPOCH},
};
use camino::{Utf8Path, Utf8PathBuf};
use color_eyre::eyre::{Report, eyre};
use fs4::{FileExt, TryLockError};
use tracing::{info, warn};
use super::orphan::{self, OrphanStop};
use crate::{
error::{BootstrapError, BootstrapResult},
observability::LOG_TARGET,
};
const LOCK_SUFFIX: &str = ".lock";
const PASSWORD_SUFFIX: &str = ".pgpass";
static HELD: Mutex<Vec<File>> = Mutex::new(Vec::new());
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct ClusterSlot {
pub(super) data_dir: Utf8PathBuf,
pub(super) password_file: Utf8PathBuf,
}
const CLAIM_GUARD: &str = ".claim-guard";
#[derive(Debug)]
pub(crate) struct ClaimGuard {
_file: File,
}
impl ClaimGuard {
fn acquire(parent: &std::path::Path) -> io::Result<Self> {
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(parent.join(CLAIM_GUARD))?;
FileExt::lock(&file)?;
Ok(Self { _file: file })
}
}
pub(crate) fn claim_guard_for(data_dir: &std::path::Path) -> Option<io::Result<ClaimGuard>> {
match slot_lookup(data_dir) {
SlotLookup::NotSlot => None,
SlotLookup::Slot(parent) => Some(ClaimGuard::acquire(parent)),
SlotLookup::Unknown => Some(Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"cannot tell whether the data directory is a slot",
))),
}
}
enum SlotLookup<'a> {
Slot(&'a std::path::Path),
NotSlot,
Unknown,
}
fn slot_lookup(data_dir: &std::path::Path) -> SlotLookup<'_> {
let (Some(parent), Some(own)) = (
data_dir.parent(),
data_dir.file_name().and_then(std::ffi::OsStr::to_str),
) else {
return SlotLookup::NotSlot;
};
match std::fs::metadata(parent.join(format!("{own}{LOCK_SUFFIX}"))) {
Ok(metadata) if metadata.is_file() => SlotLookup::Slot(parent),
Ok(_) => SlotLookup::NotSlot,
Err(err) if err.kind() == io::ErrorKind::NotFound => SlotLookup::NotSlot,
Err(_) => SlotLookup::Unknown,
}
}
pub(super) fn claim_slot(parent: &Utf8Path) -> BootstrapResult<ClusterSlot> {
std::fs::create_dir_all(parent).map_err(|err| slot_error(parent, "create", err))?;
sweep_dead_slots(parent, &orphan::SignalStop)?;
let name = slot_name();
let _guard = ClaimGuard::acquire(parent.as_std_path())
.map_err(|err| slot_error(&parent.join(CLAIM_GUARD), "lock", err))?;
let lock = lock_new_slot(parent, &name)?;
HELD.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(lock);
let slot = slot_paths(parent, &name);
info!(target: LOG_TARGET, data_dir = %slot.data_dir, "claimed a per-cluster data directory");
Ok(slot)
}
pub(super) fn sweep_dead_slots(parent: &Utf8Path, stop: &dyn OrphanStop) -> BootstrapResult<usize> {
let entries = std::fs::read_dir(parent).map_err(|err| slot_error(parent, "list", err))?;
let mut swept = 0;
for name in entries.filter_map(|entry| lock_stem(&entry.ok()?)) {
if sweep_one(parent, &name, stop) {
swept += 1;
}
}
Ok(swept)
}
pub(super) fn lock_path(data_dir: &Utf8Path) -> Utf8PathBuf {
Utf8PathBuf::from(format!("{data_dir}{LOCK_SUFFIX}"))
}
pub(crate) fn has_live_peers(data_dir: &std::path::Path) -> bool {
let parent = match slot_lookup(data_dir) {
SlotLookup::Slot(parent) => parent,
SlotLookup::NotSlot => return false,
SlotLookup::Unknown => return true,
};
let Some(own) = data_dir.file_name().and_then(std::ffi::OsStr::to_str) else {
return false;
};
any_slot_held(parent, Some(own))
}
fn any_slot_held(parent: &std::path::Path, own: Option<&str>) -> bool {
let Ok(entries) = std::fs::read_dir(parent) else {
return true;
};
entries.into_iter().any(|listed| {
listed.map_or(true, |entry| {
lock_stem(&entry).is_some_and(|name| {
Some(name.as_str()) != own
&& !is_lock_free(&parent.join(format!("{name}{LOCK_SUFFIX}")))
})
})
})
}
pub(crate) fn derived_slot_parent(install_dir: &std::path::Path) -> Option<std::path::PathBuf> {
let named_install =
|dir: &&std::path::Path| dir.file_name().is_some_and(|name| name == "install");
let install_root = [Some(install_dir), install_dir.parent()]
.into_iter()
.flatten()
.find(named_install)?;
Some(install_root.parent()?.join("data"))
}
pub(crate) fn has_live_slots_in(parent: &std::path::Path) -> bool { any_slot_held(parent, None) }
pub(crate) fn claim_guard_at(parent: &std::path::Path) -> io::Result<ClaimGuard> {
std::fs::create_dir_all(parent)?;
ClaimGuard::acquire(parent)
}
fn is_lock_free(path: &std::path::Path) -> bool {
let Ok(file) = OpenOptions::new().read(true).write(true).open(path) else {
return false;
};
FileExt::try_lock(&file).is_ok()
}
fn sweep_one(parent: &Utf8Path, name: &str, stop: &dyn OrphanStop) -> bool {
let Some(_lock) = take_dead_lock(parent, name) else {
return false;
};
let slot = slot_paths(parent, name);
if !orphan::stop_orphaned_server(&slot.data_dir, stop) {
warn!(
target: LOG_TARGET,
data_dir = %slot.data_dir,
"a dead owner's server could not be stopped; leaving its directory"
);
return false;
}
remove_slot(name, &slot);
true
}
fn take_dead_lock(parent: &Utf8Path, name: &str) -> Option<File> {
let file = OpenOptions::new()
.read(true)
.write(true)
.open(parent.join(format!("{name}{LOCK_SUFFIX}")))
.ok()?;
FileExt::try_lock(&file).ok().map(|()| file)
}
fn remove_slot(name: &str, slot: &ClusterSlot) {
match remove_slot_files(slot) {
Ok(()) => log_swept(name),
Err(err) => log_removal_failed(name, &err),
}
}
fn log_swept(name: &str) {
info!(target: LOG_TARGET, slot = name, "swept a dead cluster's data directory");
}
fn log_removal_failed(name: &str, err: &io::Error) {
warn!(target: LOG_TARGET, slot = name, error = %err, "could not fully remove a dead slot");
}
fn remove_slot_files(slot: &ClusterSlot) -> io::Result<()> {
let results = [
remove_if_present(std::fs::remove_dir_all(&slot.data_dir)),
remove_if_present(std::fs::remove_file(&slot.password_file)),
remove_if_present(std::fs::remove_file(lock_path(&slot.data_dir))),
];
results.into_iter().collect()
}
fn remove_if_present(result: io::Result<()>) -> io::Result<()> {
match result {
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()),
other => other,
}
}
fn lock_new_slot(parent: &Utf8Path, name: &str) -> BootstrapResult<File> {
let path = parent.join(format!("{name}{LOCK_SUFFIX}"));
let file = OpenOptions::new()
.read(true)
.write(true)
.create_new(true)
.open(&path)
.map_err(|err| slot_error(&path, "create", err))?;
match FileExt::try_lock(&file) {
Ok(()) => Ok(file),
Err(TryLockError::WouldBlock) => Err(BootstrapError::from(eyre!(
"slot lock {path} is already held"
))),
Err(TryLockError::Error(err)) => Err(slot_error(&path, "lock", err)),
}
}
fn slot_name() -> String {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |elapsed| elapsed.as_nanos());
let count = COUNTER.fetch_add(1, Ordering::Relaxed);
format!("{}-{nanos}-{count}", std::process::id())
}
fn slot_paths(parent: &Utf8Path, name: &str) -> ClusterSlot {
ClusterSlot {
data_dir: parent.join(name),
password_file: parent.join(format!("{name}{PASSWORD_SUFFIX}")),
}
}
fn lock_stem(entry: &std::fs::DirEntry) -> Option<String> {
let file_name = entry.file_name().into_string().ok()?;
file_name
.strip_suffix(LOCK_SUFFIX)
.filter(|stem| !stem.is_empty())
.map(str::to_owned)
}
fn slot_error(path: &Utf8Path, action: &str, err: io::Error) -> BootstrapError {
BootstrapError::from(Report::new(err).wrap_err(format!("cannot {action} {path}")))
}
#[cfg(test)]
#[path = "cluster_slot_tests.rs"]
mod tests;