use crate::claim_time::ClaimTime;
use crate::private_storage::{create_private_directory_all, write_private_file_unsynced};
use crate::random_id::random_uuid;
use crate::service_timing::log;
pub use crate::work_item_recovery::{Recovered, next_claim_expiry, requeue_expired, requeue_expired_reporting};
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 = "cli-git/work";
pub const PENDING: &str = "pending";
pub const CLAIMED: &str = "claimed";
pub const RECOVERING: &str = "recovering";
pub const DONE: &str = "done";
pub const FAILED: &str = "failed";
#[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> {
return [WorkKind::Review, WorkKind::Push, WorkKind::Maintenance, WorkKind::Trash].into_iter().find(|kind| return kind.name() == name);
}
}
#[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 = serde_json::from_str(text).map_err(|error| return format!("the record is not JSON: {error}"))?;
let field = |name: &str| -> Result<String, String> {
return value.get(name).and_then(Value::as_str).map(String::from).ok_or_else(|| return format!("the record has no text field {name}"));
};
if value.get("v").and_then(Value::as_u64) != Some(VERSION) {
return Err(String::from("the record has an unsupported version"));
}
let kind: WorkKind = WorkKind::from_name(field("kind")?.as_str()).ok_or_else(|| return String::from("the record has an unknown kind"))?;
let tries: u32 = value.get("tries").and_then(Value::as_u64).and_then(|count| return u32::try_from(count).ok()).ok_or_else(|| return String::from("the record has no tries"))?;
let item: WorkItem = WorkItem {
kind,
repository: field("repository")?,
git_dir: field("gitDir")?,
branch_ref: field("ref")?,
oid: field("oid")?,
parent: field("parent")?,
created: field("created")?,
tries,
};
if field("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,
}
pub fn state_directory(work_dir: &Path, state: &str) -> PathBuf {
return work_dir.join(state);
}
pub fn enqueue(work_dir: &Path, item: &WorkItem) -> Result<Enqueued, String> {
for state in [PENDING, CLAIMED, DONE, FAILED] {
create_private_directory_all(state_directory(work_dir, state).as_path()).map_err(|error| return format!("creating the {state} directory failed: {error}"))?;
}
let id: String = item.id();
if [CLAIMED, RECOVERING].iter().any(|state| { return entries_oldest_first(work_dir, state).iter().any(|(name, _)| { return id_of_entry(name.as_str()) == id; }); }) {
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,
}
const NAME_SEPARATOR: char = '~';
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.bytes().all(|byte| { return byte.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 {
return [WorkKind::Review, WorkKind::Push, WorkKind::Maintenance, WorkKind::Trash]
.iter()
.any(|kind| { return id.strip_prefix(format!("{}-", kind.name()).as_str()).is_some_and(is_object_name); });
}
pub fn id_of_entry(name: &str) -> &str {
return name.split_once(NAME_SEPARATOR).map_or(name, |(id, _)| { return id; });
}
pub fn claim_file_name(id: &str, token: &str, stamp: &ClaimTime) -> String {
return format!("{id}{NAME_SEPARATOR}{token}{NAME_SEPARATOR}{}", stamp.encode());
}
pub(crate) fn parse_claim_name(name: &str) -> Option<(&str, ClaimTime)> {
let (_, rest) = name.split_once(NAME_SEPARATOR)?;
let (token, stamp) = rest.split_once(NAME_SEPARATOR)?;
if token.is_empty() || !token.bytes().all(|byte| { return byte.is_ascii_hexdigit(); }) {
return None;
}
return Some((token, ClaimTime::decode(stamp)?));
}
pub(crate) fn new_generation() -> Result<String, String> {
return random_uuid()
.map(|uuid| { return uuid.replace('-', ""); })
.map_err(|error| { return format!("making the generation of a claim failed: {}", error.0); });
}
pub(crate) fn entries_oldest_first(work_dir: &Path, state: &str) -> Vec<(String, SystemTime)> {
let mut found: Vec<(String, SystemTime)> = Vec::new();
if let Ok(listing) = std::fs::read_dir(state_directory(work_dir, state)) {
for entry in listing.flatten() {
let modified: SystemTime = entry.metadata().and_then(|meta| return meta.modified()).unwrap_or(SystemTime::UNIX_EPOCH);
found.push((entry.file_name().to_string_lossy().into_owned(), modified));
}
}
found.sort_by(|left, right| return left.1.cmp(&right.1).then_with(|| return left.0.cmp(&right.0)));
return found;
}
pub fn find_claim(work_dir: &Path, id: &str) -> Option<PathBuf> {
return [CLAIMED, RECOVERING]
.into_iter()
.flat_map(|state| { return entries_oldest_first(work_dir, state).into_iter().map(move |(name, _)| { return (state, name); }); })
.find(|(_, name)| { return id_of_entry(name.as_str()) == id; })
.map(|(state, name)| { return state_directory(work_dir, state).join(name); });
}
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 Some(kind) = kinds.iter().find(|kind| return id.starts_with(format!("{}-", kind.name()).as_str())) 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 published: std::io::Result<()> = stamp(staged.as_path()).and_then(|()| return std::fs::rename(staged.as_path(), claimed_path.as_path()));
if let Err(error) = published {
log(format!("the claim of {id} could not be taken, so the item stays queued: {error}").as_str());
let restored: std::io::Result<()> = std::fs::File::open(staged.as_path()).and_then(|file| return file.set_modified(queued));
if let Err(requeue_error) = restored.and_then(|()| return std::fs::rename(staged.as_path(), pending_path.as_path())) {
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 = std::fs::read_to_string(claimed_path.as_path()).map_err(|error| return 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 std::fs::rename(state_directory(work_dir, CLAIMED).join(name), state_directory(work_dir, state).join(id))
.map_err(|error| return 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 std::fs::rename(directory.join(name), directory.join(renewed.as_str())).ok().map(|()| { return renewed; });
}
pub fn oldest_pending_age(work_dir: &Path, now: SystemTime) -> Option<Duration> {
return entries_oldest_first(work_dir, PENDING).first().map(|(_, created)| return now.duration_since(*created).unwrap_or(Duration::ZERO));
}
#[cfg(test)]
#[path = "work_item_tests.rs"]
mod tests;