use crate::service_journal::{DONE_EXTENSION, DONE_RETAINED, OPEN_EXTENSION, parse_intent};
use crate::service_messages::RESTART_COMMAND;
use std::cell::RefCell;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
const DIRECTORY: &str = "busy";
const STEP_FILE: &str = "step";
const DECISION_EXTENSION: &str = "decision";
const LANDING: &str = "landing";
const WITHDRAWN: &str = "withdrawn";
const CANCELLED_EXTENSION: &str = "cancelled";
const LONGEST_ID: usize = 80;
const WITHDRAWALS_RETAINED: usize = DONE_RETAINED;
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum EarlyAnswer {
Queued,
Landed(String),
Withdrawn(String),
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct GroupAdmission {
pub dropped: Vec<String>,
pub unavailable: Vec<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
enum Naming {
Landed(String),
Open,
No,
Unknown,
}
enum Decision {
Won,
Lost(String),
}
fn is_plain(id: &str) -> bool {
return !id.is_empty() && id.len() <= LONGEST_ID && id.chars().all(|character| { return character.is_ascii_alphanumeric() || character == '-'; });
}
fn now_millis() -> u128 {
return SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |elapsed| { return elapsed.as_millis(); });
}
struct TemporaryFile(PathBuf);
impl Drop for TemporaryFile {
fn drop(&mut self) {
let _removed = std::fs::remove_file(self.0.as_path());
}
}
static TEMPORARY_NUMBER: AtomicU64 = AtomicU64::new(0);
pub struct LandingBoard {
slow_after: Duration,
journal: PathBuf,
directory: PathBuf,
}
impl LandingBoard {
pub fn new(journal: PathBuf, slow_after: Duration) -> Arc<Self> {
let directory: PathBuf = journal.join(DIRECTORY);
let _removed = std::fs::remove_dir_all(directory.as_path());
let _made = std::fs::create_dir_all(directory.as_path());
return Arc::new(Self { slow_after, journal, directory });
}
pub(crate) fn decision_path(&self, id: &str) -> PathBuf {
return self.directory.join(format!("{id}.{DECISION_EXTENSION}"));
}
fn cancelled_path(&self, id: &str) -> PathBuf {
return self.directory.join(format!("{id}.{CANCELLED_EXTENSION}"));
}
fn decide(&self, id: &str, outcome: &str) -> std::io::Result<Decision> {
let beside: TemporaryFile = TemporaryFile(self.directory.join(format!("{id}.{}.{}.tmp", std::process::id(), TEMPORARY_NUMBER.fetch_add(1, Ordering::Relaxed))));
std::fs::File::create(beside.0.as_path()).and_then(|mut file| { return file.write_all(outcome.as_bytes()); })?;
let linked: std::io::Result<()> = std::fs::hard_link(beside.0.as_path(), self.decision_path(id));
return match linked {
Ok(()) => Ok(Decision::Won),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(Decision::Lost(std::fs::read_to_string(self.decision_path(id))?)),
Err(error) => Err(error),
};
}
fn naming(&self, id: &str) -> Naming {
let Ok(entries) = std::fs::read_dir(self.journal.as_path()) else {
return Naming::Unknown;
};
let mut open: bool = false;
for entry in entries {
let Ok(found) = entry else { return Naming::Unknown };
let path: PathBuf = found.path();
let extension: Option<&str> = path.extension().and_then(|text| { return text.to_str(); });
if extension != Some(OPEN_EXTENSION) && extension != Some(DONE_EXTENSION) {
continue;
}
let Some(intent) = std::fs::read_to_string(path.as_path()).ok().and_then(|text| { return parse_intent(text.as_str()); }) else {
return Naming::Unknown;
};
let Some(position) = intent.ids.iter().position(|named| { return named == id; }) else { continue };
if extension == Some(DONE_EXTENSION) {
return intent.commits.get(position).map_or(Naming::Unknown, |commit| { return Naming::Landed(commit.clone()); });
}
open = true;
}
return if open { Naming::Open } else { Naming::No };
}
pub fn begin_group(&self, ids: &[String]) -> GroupAdmission {
let mut admission: GroupAdmission = GroupAdmission::default();
for id in ids.iter().filter(|id| { return is_plain(id.as_str()); }) {
match self.decide(id.as_str(), LANDING) {
Ok(Decision::Won) => {}
Ok(Decision::Lost(content)) if content == LANDING => {}
Ok(Decision::Lost(content)) if content == WITHDRAWN => admission.dropped.push(id.clone()),
Ok(Decision::Lost(_)) | Err(_) => admission.unavailable.push(id.clone()),
}
}
return admission;
}
pub fn end_group(&self, ids: &[String]) {
for id in ids.iter().filter(|id| { return is_plain(id.as_str()); }) {
if std::fs::read_to_string(self.decision_path(id.as_str())).is_ok_and(|content| { return content == LANDING; }) {
let _removed = std::fs::remove_file(self.decision_path(id.as_str()));
}
}
}
pub fn is_in_flight(&self, id: &str) -> bool {
return is_plain(id) && std::fs::read_to_string(self.decision_path(id)).is_ok_and(|content| { return content == LANDING; });
}
fn running(&self) -> Option<(u128, String, Option<String>, u128)> {
let text: String = std::fs::read_to_string(self.directory.join(STEP_FILE)).ok()?;
let mut lines = text.lines();
let began: u128 = lines.next()?.parse().ok()?;
let command: String = String::from(lines.next()?);
let detail: Option<String> = lines.next().filter(|line| { return !line.is_empty(); }).map(String::from);
return Some((began, command, detail, now_millis().saturating_sub(began)));
}
pub fn describe(&self) -> Option<String> {
let (_, command, found, running) = self.running()?;
if running <= self.slow_after.as_millis() {
return None;
}
let minutes: u128 = running / 60_000;
let detail: String = found.map_or_else(String::new, |text| { return format!(" ({text})"); });
return Some(format!(
"cli-git: the commit service has been inside a write for {minutes} minutes and has not heard back: `{command}`{detail}. The service never ends a write in \
the middle, because that could lose data, so commits queue behind it. If it never returns, look at what that process \
waits for with `ps` and at the disk, and when no program uses the repository, restart the service with `{RESTART_COMMAND}`.\n"
));
}
pub fn inside_notice(&self, id: &str) -> Option<(String, String)> {
let (began, command, found, running) = self.running()?;
if !self.is_in_flight(id) {
return None;
}
if let Some(stuck) = self.describe() {
return Some((format!("{began}:{}", running / 60_000), stuck));
}
let detail: String = found.map_or_else(String::new, |text| { return format!(" ({text})"); });
return Some((format!("{began}:start"), format!(
"cli-git: this commit is inside a write of the commit service, `{command}`{detail}, begun {} seconds ago. The service never ends a write \
in the middle, so if it does not return this command reports that the result is not known after its wait.\n",
running / 1000
)));
}
pub fn withdraw_early(&self, id: &str) -> EarlyAnswer {
if !is_plain(id) {
return EarlyAnswer::Queued;
}
let Some(words) = self.describe() else {
return EarlyAnswer::Queued;
};
match self.naming(id) {
Naming::Landed(commit) => return EarlyAnswer::Landed(commit),
Naming::Open | Naming::Unknown => return EarlyAnswer::Queued,
Naming::No => {}
}
match self.decide(id, WITHDRAWN) {
Err(_) => return EarlyAnswer::Queued,
Ok(Decision::Lost(content)) if content == WITHDRAWN && self.cancelled_path(id).exists() => return EarlyAnswer::Withdrawn(words),
Ok(Decision::Lost(_)) => return EarlyAnswer::Queued,
Ok(Decision::Won) => {}
}
#[cfg(test)]
AFTER_THE_DECISION_IS_WON.with(|hook| { if let Some(pause) = hook.borrow().as_ref() { pause(); } });
match self.naming(id) {
Naming::No => {
if std::fs::File::create_new(self.cancelled_path(id)).is_err() {
let _undone = std::fs::remove_file(self.decision_path(id));
return EarlyAnswer::Queued;
}
self.prune_withdrawals();
return EarlyAnswer::Withdrawn(words);
}
Naming::Landed(commit) => {
let _undone = std::fs::remove_file(self.decision_path(id));
return EarlyAnswer::Landed(commit);
}
Naming::Open | Naming::Unknown => {
let _undone = std::fs::remove_file(self.decision_path(id));
return EarlyAnswer::Queued;
}
}
}
fn prune_withdrawals(&self) {
let Ok(entries) = std::fs::read_dir(self.directory.as_path()) else { return };
let mut remembered: Vec<(SystemTime, PathBuf)> = entries
.flatten()
.filter(|entry| { return entry.path().extension().and_then(|text| { return text.to_str(); }) == Some(CANCELLED_EXTENSION); })
.filter_map(|entry| { return Some((entry.metadata().ok()?.modified().ok()?, entry.path())); })
.collect();
if remembered.len() <= WITHDRAWALS_RETAINED {
return;
}
remembered.sort();
let surplus: usize = remembered.len() - WITHDRAWALS_RETAINED;
for (_, confirmation) in remembered.into_iter().take(surplus) {
let _forgotten = std::fs::remove_file(confirmation.with_extension(DECISION_EXTENSION));
let _also = std::fs::remove_file(confirmation);
}
}
}
#[cfg(test)]
thread_local! {
pub(crate) static AFTER_THE_DECISION_IS_WON: RefCell<Option<Box<dyn Fn()>>> = const { RefCell::new(None) };
}
thread_local! {
static BOARD: RefCell<Option<Arc<LandingBoard>>> = const { RefCell::new(None) };
static DETAIL: RefCell<Option<String>> = const { RefCell::new(None) };
}
pub fn on_landing_thread() -> bool {
return BOARD.with(|slot| { return slot.borrow().is_some(); });
}
pub fn use_on_this_thread(board: Arc<LandingBoard>) {
BOARD.with(|slot| { *slot.borrow_mut() = Some(board); });
}
pub struct DetailGuard;
impl Drop for DetailGuard {
fn drop(&mut self) {
DETAIL.with(|slot| { *slot.borrow_mut() = None; });
}
}
pub fn detail(text: String) -> DetailGuard {
DETAIL.with(|slot| { *slot.borrow_mut() = Some(text); });
return DetailGuard;
}
pub struct StepGuard {
file: PathBuf,
}
impl Drop for StepGuard {
fn drop(&mut self) {
let _removed = std::fs::remove_file(self.file.as_path());
}
}
fn publish(path: &Path, text: &str) -> bool {
let beside: PathBuf = path.with_extension("tmp");
let written: bool = std::fs::File::create(beside.as_path()).and_then(|mut file| { return file.write_all(text.as_bytes()); }).is_ok();
return written && std::fs::rename(beside.as_path(), path).is_ok();
}
pub fn enter_step(command: String) -> Option<StepGuard> {
let board: Arc<LandingBoard> = BOARD.with(|slot| { return slot.borrow().clone(); })?;
let detail: Option<String> = DETAIL.with(|slot| { return slot.borrow().clone(); });
let file: PathBuf = board.directory.join(STEP_FILE);
let one_line = |text: &str| -> String { return text.split_whitespace().collect::<Vec<&str>>().join(" "); };
let text: String = format!("{}\n{}\n{}\n", now_millis(), one_line(command.as_str()), detail.as_deref().map_or_else(String::new, one_line));
let _recorded = publish(file.as_path(), text.as_str());
return Some(StepGuard { file });
}
#[cfg(test)]
#[path = "service_busy_tests.rs"]
mod tests;