use crate::claim_time::ClaimTime;
use crate::private_storage::{create_private_directory_all, write_private_file_unsynced};
use crate::service_timing::log;
pub use crate::work_item_state_directories_and_claim_names::{CLAIMED, DONE, FAILED, PENDING, RECOVERING, claim_file_name, id_of_entry, state_directory};
pub(crate) use crate::work_item_state_directories_and_claim_names::{entries_oldest_first, new_generation, parse_claim_name};
use serde_json::{Map, Value};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
pub const VERSION: u64 = 1;
pub const WORK_DIRECTORY: &str = "monochromatic-git-policy-cli/work";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum WorkKind {
Review,
Push,
Maintenance,
Trash,
}
impl WorkKind {
pub fn name(self) -> &'static str {
return match self {
WorkKind::Review => "review",
WorkKind::Push => "push",
WorkKind::Maintenance => "maintenance",
WorkKind::Trash => "trash",
};
}
pub fn from_name(name: &str) -> Option<WorkKind> {
for kind in [WorkKind::Review, WorkKind::Push, WorkKind::Maintenance, WorkKind::Trash] {
if kind.name() == name {
return Some(kind);
}
}
return None;
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct WorkItem {
pub kind: WorkKind,
pub repository: String,
pub git_dir: String,
pub branch_ref: String,
pub oid: String,
pub parent: String,
pub created: String,
pub tries: u32,
}
impl WorkItem {
pub fn id(&self) -> String {
return format!("{}-{}", self.kind.name(), self.oid);
}
pub fn encode(&self) -> String {
let mut fields: Map<String, Value> = Map::new();
fields.insert(String::from("v"), Value::from(VERSION));
fields.insert(String::from("tries"), Value::from(self.tries));
for (name, text) in [
("kind", self.kind.name()),
("id", self.id().as_str()),
("repository", self.repository.as_str()),
("gitDir", self.git_dir.as_str()),
("ref", self.branch_ref.as_str()),
("oid", self.oid.as_str()),
("parent", self.parent.as_str()),
("created", self.created.as_str()),
] {
fields.insert(String::from(name), Value::from(text));
}
return format!("{}\n", Value::Object(fields));
}
pub fn decode(text: &str) -> Result<WorkItem, String> {
let value: Value = match serde_json::from_str(text) {
Ok(parsed) => parsed,
Err(error) => return Err(format!("the record is not JSON: {error}")),
};
if value.get("v").and_then(Value::as_u64) != Some(VERSION) {
return Err(String::from("the record has an unsupported version"));
}
let kind: WorkKind = match WorkKind::from_name(text_field(&value, "kind")?.as_str()) {
Some(known) => known,
None => return Err(String::from("the record has an unknown kind")),
};
let Some(count) = value.get("tries").and_then(Value::as_u64) else {
return Err(String::from("the record has no tries"));
};
let Ok(tries) = u32::try_from(count) else {
return Err(String::from("the record has no tries"));
};
let item: WorkItem = WorkItem {
kind,
repository: text_field(&value, "repository")?,
git_dir: text_field(&value, "gitDir")?,
branch_ref: text_field(&value, "ref")?,
oid: text_field(&value, "oid")?,
parent: text_field(&value, "parent")?,
created: text_field(&value, "created")?,
tries,
};
if text_field(&value, "id")? != item.id() {
return Err(String::from("the record's id is not derived from its kind and object"));
}
return Ok(item);
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Enqueued {
Added,
Known,
}
fn text_field(value: &Value, name: &str) -> Result<String, String> {
return match value.get(name).and_then(Value::as_str) {
Some(text) => Ok(String::from(text)),
None => Err(format!("the record has no text field {name}")),
};
}
fn is_claimed_or_recovering(work_dir: &Path, id: &str) -> bool {
for state in [CLAIMED, RECOVERING] {
for (name, _modified) in entries_oldest_first(work_dir, state) {
if id_of_entry(name.as_str()) == id {
return true;
}
}
}
return false;
}
pub fn enqueue(work_dir: &Path, item: &WorkItem) -> Result<Enqueued, String> {
for state in [PENDING, CLAIMED, DONE, FAILED] {
if let Err(error) = create_private_directory_all(state_directory(work_dir, state).as_path()) {
return Err(format!("creating the {state} directory failed: {error}"));
}
}
let id: String = item.id();
if is_claimed_or_recovering(work_dir, id.as_str()) {
return Ok(Enqueued::Known);
}
if state_directory(work_dir, DONE).join(id.as_str()).exists() || state_directory(work_dir, FAILED).join(id.as_str()).exists() {
return Ok(Enqueued::Known);
}
return match write_private_file_unsynced(state_directory(work_dir, PENDING).join(id.as_str()).as_path(), item.encode().as_bytes()) {
Ok(()) => Ok(Enqueued::Added),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(Enqueued::Known),
Err(error) => Err(format!("writing the work item {id} failed: {error}")),
};
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Claimed {
pub id: String,
pub name: String,
pub item: WorkItem,
}
pub(crate) const MAX_OBJECT_NAME_LENGTH: usize = 64;
fn is_object_name(object_name: &str) -> bool {
return !object_name.is_empty() && object_name.len() <= MAX_OBJECT_NAME_LENGTH && object_name.as_bytes().iter().all(u8::is_ascii_alphanumeric);
}
fn stamp_modification_time(path: &Path) -> std::io::Result<()> {
return std::fs::File::open(path)?.set_modified(SystemTime::now());
}
type Stamp<'a> = &'a dyn Fn(&Path) -> std::io::Result<()>;
pub(crate) fn is_valid_id(id: &str) -> bool {
for kind in [WorkKind::Review, WorkKind::Push, WorkKind::Maintenance, WorkKind::Trash] {
if let Some(rest) = id.strip_prefix(format!("{}-", kind.name()).as_str())
&& is_object_name(rest)
{
return true;
}
}
return false;
}
pub fn find_claim(work_dir: &Path, id: &str) -> Option<PathBuf> {
for state in [CLAIMED, RECOVERING] {
for (name, _modified) in entries_oldest_first(work_dir, state) {
if id_of_entry(name.as_str()) == id {
return Some(state_directory(work_dir, state).join(name));
}
}
}
return None;
}
pub fn claim_next(work_dir: &Path, now: &ClaimTime) -> Result<Option<Claimed>, String> {
return claim_next_of(work_dir, now, &[WorkKind::Review, WorkKind::Push, WorkKind::Maintenance, WorkKind::Trash]);
}
pub fn claim_next_of(work_dir: &Path, now: &ClaimTime, kinds: &[WorkKind]) -> Result<Option<Claimed>, String> {
return claim_next_stamped(work_dir, now, kinds, &stamp_modification_time);
}
fn claim_next_stamped(work_dir: &Path, now: &ClaimTime, kinds: &[WorkKind], stamp: Stamp<'_>) -> Result<Option<Claimed>, String> {
for (id, queued) in entries_oldest_first(work_dir, PENDING) {
let mut found_kind: Option<&WorkKind> = None;
for candidate in kinds {
if id.starts_with(format!("{}-", candidate.name()).as_str()) {
found_kind = Some(candidate);
break;
}
}
let Some(kind) = found_kind else {
continue;
};
if !is_object_name(&id[kind.name().len() + 1..]) {
quarantine_pending(work_dir, id.as_str(), format!("is not a kind and an object name of at most {MAX_OBJECT_NAME_LENGTH} letters and digits").as_str());
continue;
}
let name: String = claim_file_name(id.as_str(), new_generation()?.as_str(), now);
let pending_path: PathBuf = state_directory(work_dir, PENDING).join(id.as_str());
let staged: PathBuf = state_directory(work_dir, RECOVERING).join(name.as_str());
let claimed_path: PathBuf = state_directory(work_dir, CLAIMED).join(name.as_str());
if create_private_directory_all(state_directory(work_dir, RECOVERING).as_path()).is_err() || std::fs::rename(pending_path.as_path(), staged.as_path()).is_err() {
continue;
}
let stamped: std::io::Result<()> = stamp(staged.as_path());
let published: std::io::Result<()> = match stamped {
Ok(()) => std::fs::rename(staged.as_path(), claimed_path.as_path()),
Err(error) => Err(error),
};
if let Err(error) = published {
log(format!("the claim of {id} could not be taken, so the item stays queued: {error}").as_str());
let opened: std::io::Result<std::fs::File> = std::fs::File::open(staged.as_path());
let restored: std::io::Result<()> = match opened {
Ok(file) => file.set_modified(queued),
Err(open_error) => Err(open_error),
};
let requeued: std::io::Result<()> = match restored {
Ok(()) => std::fs::rename(staged.as_path(), pending_path.as_path()),
Err(restore_error) => Err(restore_error),
};
if let Err(requeue_error) = requeued {
log(format!("the staged claim of {id} could not be queued again; its lease will run out and recover it: {requeue_error}").as_str());
}
continue;
}
let text: String = match std::fs::read_to_string(claimed_path.as_path()) {
Ok(read) => read,
Err(error) => return Err(format!("reading the claim of {id} failed: {error}")),
};
match WorkItem::decode(text.as_str()) {
Ok(item) if item.id() == id => return Ok(Some(Claimed { id, name, item })),
_ => {
let _ = std::fs::rename(claimed_path.as_path(), state_directory(work_dir, FAILED).join(id.as_str()));
}
}
}
return Ok(None);
}
fn quarantine_pending(work_dir: &Path, id: &str, reason: &str) {
let failed: PathBuf = state_directory(work_dir, FAILED);
let moved: bool = create_private_directory_all(failed.as_path()).is_ok() && std::fs::rename(state_directory(work_dir, PENDING).join(id), failed.join(id)).is_ok();
log(format!("the queued entry {id:?} {reason}, so it was {}", if moved { "moved to failed" } else { "left in pending because moving it failed" }).as_str());
}
pub fn settle(work_dir: &Path, claimed: &Claimed, state: &str) -> Result<(), String> {
return settle_named(work_dir, claimed.name.as_str(), state);
}
pub fn settle_named(work_dir: &Path, name: &str, state: &str) -> Result<(), String> {
let id: &str = id_of_entry(name);
return match std::fs::rename(state_directory(work_dir, CLAIMED).join(name), state_directory(work_dir, state).join(id)) {
Ok(()) => Ok(()),
Err(error) => Err(format!("moving {id} to {state} failed: {error}")),
};
}
pub fn renew_claim(work_dir: &Path, name: &str, now: &ClaimTime) -> Option<String> {
return renew_claim_stamped(work_dir, name, now, &stamp_modification_time);
}
fn renew_claim_stamped(work_dir: &Path, name: &str, now: &ClaimTime, stamp: Stamp<'_>) -> Option<String> {
let id: &str = id_of_entry(name);
let token: String = match parse_claim_name(name) {
Some((token, _)) => token.to_owned(),
None => new_generation().ok()?,
};
let renewed: String = claim_file_name(id, token.as_str(), now);
let directory: PathBuf = state_directory(work_dir, CLAIMED);
if let Err(error) = stamp(directory.join(name).as_path()) {
log(format!("the claim {name} could not be renewed: {error}").as_str());
return None;
}
return match std::fs::rename(directory.join(name), directory.join(renewed.as_str())) {
Ok(()) => Some(renewed),
Err(_refused) => None,
};
}
pub fn oldest_pending_age(work_dir: &Path, now: SystemTime) -> Option<Duration> {
let pending: Vec<(String, SystemTime)> = entries_oldest_first(work_dir, PENDING);
let (_name, created) = pending.first()?;
return Some(now.duration_since(*created).unwrap_or(Duration::ZERO));
}
#[cfg(test)]
#[path = "work_item_tests.rs"]
mod tests;