pub use crate::service_busy_answers_and_name_checks::{EarlyAnswer, GroupAdmission};
use crate::service_busy_answers_and_name_checks::{CANCELLED_EXTENSION, Decision, Naming, WITHDRAWN, is_confirmation, is_plain, modified_and_path, withdrawal_token};
#[cfg(test)]
use std::cell::RefCell;
use crate::service_journal::{DONE_EXTENSION, DONE_RETAINED, OPEN_EXTENSION, parse_intent};
use crate::service_messages::RESTART_COMMAND;
use std::io::Write;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
const DIRECTORY: &str = "busy";
pub(crate) const STEP_FILE: &str = "step";
const DECISION_EXTENSION: &str = "decision";
const LANDING: &str = "landing";
const WITHDRAWALS_RETAINED: usize = DONE_RETAINED;
const WITHDRAWALS_CEILING: usize = 2 * WITHDRAWALS_RETAINED;
pub(crate) fn now_millis() -> u128 {
return match SystemTime::now().duration_since(UNIX_EPOCH) {
Ok(elapsed) => elapsed.as_millis(),
Err(_before_the_epoch) => 0,
};
}
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);
fn new_token() -> String {
return format!("{}-{}-{}", std::process::id(), TEMPORARY_NUMBER.fetch_add(1, Ordering::Relaxed), now_millis());
}
pub struct LandingBoard {
slow_after: Duration,
journal: PathBuf,
pub(crate) 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, token: &str) -> PathBuf {
return self.directory.join(format!("{id}.{token}.{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))));
let mut file: std::fs::File = std::fs::File::create(beside.0.as_path())?;
file.write_all(outcome.as_bytes())?;
#[cfg(test)]
AFTER_THE_TEMPORARY_IS_WRITTEN.with(|hook| { return hook.borrow().as_ref().map_or(Ok(()), |fail| { return fail(); }); })?;
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(std::ffi::OsStr::to_str);
if extension != Some(OPEN_EXTENSION) && extension != Some(DONE_EXTENSION) {
continue;
}
let Ok(record) = std::fs::read_to_string(path.as_path()) else {
return Naming::Unknown;
};
let Some(intent) = parse_intent(record.as_str()) else {
return Naming::Unknown;
};
let mut found_at: Option<usize> = None;
for (at, named) in intent.ids.iter().enumerate() {
if named == id {
found_at = Some(at);
break;
}
}
let Some(position) = found_at else { continue };
if extension == Some(DONE_EXTENSION) {
return match intent.commits.get(position) {
Some(commit) => Naming::Landed(commit.clone()),
None => Naming::Unknown,
};
}
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 {
if !is_plain(id.as_str()) {
continue;
}
match self.decide(id.as_str(), LANDING) {
Ok(Decision::Won) => {}
Ok(Decision::Lost(content)) if content == LANDING => {}
Ok(Decision::Lost(content)) if withdrawal_token(content.as_str()).is_some() => admission.dropped.push(id.clone()),
Ok(Decision::Lost(_)) | Err(_) => admission.unavailable.push(id.clone()),
}
}
return admission;
}
pub fn end_group(&self, ids: &[String]) {
self.prune_withdrawals();
for id in ids {
if !is_plain(id.as_str()) {
continue;
}
if matches!(std::fs::read_to_string(self.decision_path(id.as_str())), Ok(content) if 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) && matches!(std::fs::read_to_string(self.decision_path(id)), Ok(content) if 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> = match lines.next() {
Some(line) if !line.is_empty() => Some(String::from(line)),
_ => None,
};
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 = match found {
Some(text) => format!(" ({text})"),
None => String::new(),
};
return Some(format!(
"monochromatic-git-policy-cli: 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 = match found {
Some(text) => format!(" ({text})"),
None => String::new(),
};
return Some((format!("{began}:start"), format!(
"monochromatic-git-policy-cli: 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 => {}
}
if self.confirmed_withdrawals() >= WITHDRAWALS_CEILING {
return EarlyAnswer::Queued;
}
let token: String = new_token();
match self.decide(id, format!("{WITHDRAWN}:{token}").as_str()) {
Err(_) => return EarlyAnswer::Queued,
Ok(Decision::Lost(content)) => {
let confirmed: bool = match withdrawal_token(content.as_str()) {
Some(theirs) => self.cancelled_path(id, theirs).exists(),
None => false,
};
return if confirmed { EarlyAnswer::Withdrawn(words) } else { 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, token.as_str())).is_err() {
let _undone = std::fs::remove_file(self.decision_path(id));
return EarlyAnswer::Queued;
}
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 confirmed_withdrawals(&self) -> usize {
let Ok(entries) = std::fs::read_dir(self.directory.as_path()) else { return usize::MAX };
return entries
.flatten()
.filter(is_confirmation)
.count();
}
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(is_confirmation)
.filter_map(modified_and_path)
.collect();
if remembered.len() <= WITHDRAWALS_RETAINED {
return;
}
remembered.sort();
let surplus: usize = remembered.len() - WITHDRAWALS_RETAINED;
#[cfg(test)]
AFTER_THE_LISTING.with(|hook| { if let Some(pause) = hook.borrow().as_ref() { pause(); } });
for (_, confirmation) in remembered.into_iter().take(surplus) {
let Some(name) = confirmation.file_stem().and_then(std::ffi::OsStr::to_str) else { continue };
let Some((id, token)) = name.split_once('.') else { continue };
let decision: PathBuf = self.decision_path(id);
let names_this_token: bool = match std::fs::read_to_string(decision.as_path()) {
Ok(content) => withdrawal_token(content.as_str()) == Some(token),
Err(_unreadable) => false,
};
if names_this_token {
let _forgotten = std::fs::remove_file(decision);
}
let _also = std::fs::remove_file(confirmation);
}
}
}
#[cfg(test)]
thread_local! {
pub(crate) static AFTER_THE_TEMPORARY_IS_WRITTEN: RefCell<Option<Box<dyn Fn() -> std::io::Result<()>>>> = const { RefCell::new(None) };
pub(crate) static AFTER_THE_LISTING: RefCell<Option<Box<dyn Fn()>>> = const { RefCell::new(None) };
pub(crate) static AFTER_THE_DECISION_IS_WON: RefCell<Option<Box<dyn Fn()>>> = const { RefCell::new(None) };
}
#[cfg(test)]
#[path = "service_busy_tests.rs"]
mod tests;