use crate::service_journal::{OPEN_EXTENSION, landed_commits, list_intents};
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::time::{Duration, SystemTime, UNIX_EPOCH};
const DIRECTORY: &str = "busy";
const STEP_FILE: &str = "step";
const LONGEST_ID: usize = 80;
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum EarlyAnswer {
Queued,
Landed(String),
Withdrawn(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(); });
}
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 });
}
fn marker(&self, id: &str, kind: &str) -> PathBuf {
return self.directory.join(format!("{id}.{kind}"));
}
fn create_marker(&self, id: &str, kind: &str) -> bool {
return std::fs::OpenOptions::new().write(true).create_new(true).open(self.marker(id, kind)).is_ok();
}
pub fn begin_group(&self, ids: &[String]) -> Vec<String> {
let mut dropped: Vec<String> = Vec::new();
for id in ids.iter().filter(|id| { return is_plain(id.as_str()); }) {
let _created = self.create_marker(id.as_str(), "flight");
if self.marker(id.as_str(), "withdrawn").exists() {
let _unmarked = std::fs::remove_file(self.marker(id.as_str(), "flight"));
let _answered = std::fs::remove_file(self.marker(id.as_str(), "withdrawn"));
dropped.push(id.clone());
}
}
return dropped;
}
pub fn end_group(&self, ids: &[String]) {
for id in ids.iter().filter(|id| { return is_plain(id.as_str()); }) {
let _removed = std::fs::remove_file(self.marker(id.as_str(), "flight"));
}
}
pub fn is_in_flight(&self, id: &str) -> bool {
return is_plain(id) && self.marker(id, "flight").exists();
}
fn running(&self) -> Option<(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((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 withdraw_early(&self, id: &str) -> EarlyAnswer {
if !is_plain(id) {
return EarlyAnswer::Queued;
}
let Some(words) = self.describe() else {
return EarlyAnswer::Queued;
};
if let Some(commit) = landed_commits(self.journal.as_path()).remove(id) {
return EarlyAnswer::Landed(commit);
}
let named_by_open_batch: bool = list_intents(self.journal.as_path(), OPEN_EXTENSION)
.iter()
.any(|(_, intent)| { return intent.as_ref().is_some_and(|open| { return open.ids.iter().any(|named| { return named == id; }); }); });
if named_by_open_batch || self.marker(id, "flight").exists() {
return EarlyAnswer::Queued;
}
let _marked = self.create_marker(id, "withdrawn");
if self.marker(id, "flight").exists() {
let _unmarked = std::fs::remove_file(self.marker(id, "withdrawn"));
return EarlyAnswer::Queued;
}
return EarlyAnswer::Withdrawn(words);
}
}
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;