git_policy_cli/work_item.rs
1//! What:
2//! The durable work-item record: a small file in the Git directory that says "this landed object needs this
3//! asynchronous work", written by the wrapper and drained by a separate resident consumer.
4//! Why:
5//! A wrapper process exits when its command ends, and it may start no program, so work that must not delay a landing
6//! (the standards review, later auto-push and maintenance) is left as a record instead. The record is generic: a kind,
7//! the landed object and an id derived from both, so a retry cannot duplicate it. The design, its failure cases and
8//! the consumer contract are in `doc/planning/cli-git-standards-review-port.md`. Nothing calls this yet.
9//!
10//! In TS you'd write (pseudocode):
11//! ```ts
12//! await enqueue(workDir, { kind: 'review', oid, ... }); const item = await claimNext(workDir); await finish(workDir, item);
13//! ```
14
15use super::claim_time::ClaimTime;
16use super::private_storage::{create_private_directory_all, write_private_file_unsynced};
17use super::random_id::random_uuid;
18use serde_json::{Map, Value};
19use std::path::{Path, PathBuf};
20use std::time::{Duration, SystemTime};
21
22/// The record format version.
23pub const VERSION: u64 = 1;
24
25/// Directory under the worktree's own Git directory (`WorkItem::git_dir`) that holds the state directories.
26pub const WORK_DIRECTORY: &str = "cli-git/work";
27
28/// The state directories, in the order an item moves through them.
29pub const PENDING: &str = "pending";
30/// Held by one consumer, with a lease.
31pub const CLAIMED: &str = "claimed";
32/// Held by one instance that is returning an expired claim to `pending`; see `requeue_with`.
33pub const RECOVERING: &str = "recovering";
34/// Finished.
35pub const DONE: &str = "done";
36/// Given up after too many tries.
37pub const FAILED: &str = "failed";
38
39/// What:
40/// The kind of work an item asks for.
41/// Why:
42/// One record format carries every asynchronous job; the kind picks the consumer's handler.
43///
44/// In TS you'd write (pseudocode):
45/// ```ts
46/// type WorkKind = 'review' | 'push' | 'maintenance';
47/// ```
48#[derive(Clone, Copy, Debug, Eq, PartialEq)]
49pub enum WorkKind {
50 /// The standards-review shadow run.
51 Review,
52 /// An auto-push of the branch.
53 Push,
54 /// Repository maintenance.
55 Maintenance,
56}
57
58/// Methods over a kind.
59impl WorkKind {
60 /// The kind's name, which is also the start of an item's id.
61 pub fn name(self) -> &'static str {
62 return match self {
63 WorkKind::Review => "review",
64 WorkKind::Push => "push",
65 WorkKind::Maintenance => "maintenance",
66 };
67 }
68
69 /// The kind a name spells.
70 pub fn from_name(name: &str) -> Option<WorkKind> {
71 return [WorkKind::Review, WorkKind::Push, WorkKind::Maintenance].into_iter().find(|kind| return kind.name() == name);
72 }
73}
74
75/// What:
76/// One work item: what to do, to which landed object, and where.
77/// Why:
78/// The consumer needs only the object name and the places to find the repository; it reads the patch itself.
79///
80/// In TS you'd write (pseudocode):
81/// ```ts
82/// type WorkItem = { kind; repository: string; gitDir: string; ref: string; oid: string; parent: string; created: string; tries: number };
83/// ```
84#[derive(Clone, Debug, Eq, PartialEq)]
85pub struct WorkItem {
86 /// What to do.
87 pub kind: WorkKind,
88 /// The worktree root.
89 pub repository: String,
90 /// The Git directory of the worktree that made the commit and whose service instance runs the item: the common directory
91 /// for the main worktree, `<common>/worktrees/<name>` for a linked one. The work records are under it, and
92 /// `is_acceptable` refuses an item whose value is not the directory it is read from.
93 pub git_dir: String,
94 /// The branch reference that received the commit.
95 pub branch_ref: String,
96 /// The landed commit.
97 pub oid: String,
98 /// Its first parent, or empty for a root commit.
99 pub parent: String,
100 /// `toISOString` text of the enqueue time.
101 pub created: String,
102 /// Failed or abandoned attempts so far.
103 pub tries: u32,
104}
105
106/// Methods over an item.
107impl WorkItem {
108 /// The id and file name: the kind and the landed object, so the same landing always yields the same id.
109 pub fn id(&self) -> String {
110 return format!("{}-{}", self.kind.name(), self.oid);
111 }
112
113 /// The record's one JSON line.
114 pub fn encode(&self) -> String {
115 let mut fields: Map<String, Value> = Map::new();
116 fields.insert(String::from("v"), Value::from(VERSION));
117 fields.insert(String::from("tries"), Value::from(self.tries));
118 for (name, text) in [
119 ("kind", self.kind.name()),
120 ("id", self.id().as_str()),
121 ("repository", self.repository.as_str()),
122 ("gitDir", self.git_dir.as_str()),
123 ("ref", self.branch_ref.as_str()),
124 ("oid", self.oid.as_str()),
125 ("parent", self.parent.as_str()),
126 ("created", self.created.as_str()),
127 ] {
128 fields.insert(String::from(name), Value::from(text));
129 }
130 return format!("{}\n", Value::Object(fields));
131 }
132
133 /// Read a record; any other version, an unknown kind, a missing field or an id that is not derived from the kind and
134 /// object is refused with a message.
135 pub fn decode(text: &str) -> Result<WorkItem, String> {
136 let value: Value = serde_json::from_str(text).map_err(|error| return format!("the record is not JSON: {error}"))?;
137 let field = |name: &str| -> Result<String, String> {
138 return value.get(name).and_then(Value::as_str).map(String::from).ok_or_else(|| return format!("the record has no text field {name}"));
139 };
140 if value.get("v").and_then(Value::as_u64) != Some(VERSION) {
141 return Err(String::from("the record has an unsupported version"));
142 }
143 let kind: WorkKind = WorkKind::from_name(field("kind")?.as_str()).ok_or_else(|| return String::from("the record has an unknown kind"))?;
144 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"))?;
145 let item: WorkItem = WorkItem {
146 kind,
147 repository: field("repository")?,
148 git_dir: field("gitDir")?,
149 branch_ref: field("ref")?,
150 oid: field("oid")?,
151 parent: field("parent")?,
152 created: field("created")?,
153 tries,
154 };
155 if field("id")? != item.id() {
156 return Err(String::from("the record's id is not derived from its kind and object"));
157 }
158 return Ok(item);
159 }
160}
161
162/// What:
163/// Whether an enqueue added the item or found it already known.
164/// Why:
165/// A landing that is replayed, or a daemon that restarts, enqueues again; the second time must change nothing.
166///
167/// In TS you'd write (pseudocode):
168/// ```ts
169/// type Enqueued = 'added' | 'known';
170/// ```
171#[derive(Clone, Copy, Debug, Eq, PartialEq)]
172pub enum Enqueued {
173 /// A new pending item was written.
174 Added,
175 /// The id already exists in some state directory.
176 Known,
177}
178
179/// The directory of one state.
180pub fn state_directory(work_dir: &Path, state: &str) -> PathBuf {
181 return work_dir.join(state);
182}
183
184/// What:
185/// Write the item as a pending record, whole or absent, unless its id is already in any state.
186/// The producer never waits for a consumer and never reads anything back.
187/// Why:
188/// The id is the idempotency key; the exclusive publish makes two racing producers agree on one winner.
189///
190/// In TS you'd write (pseudocode):
191/// ```ts
192/// await writeExclusive(join(workDir, 'pending', item.id), item.encode())
193/// ```
194pub fn enqueue(work_dir: &Path, item: &WorkItem) -> Result<Enqueued, String> {
195 for state in [PENDING, CLAIMED, DONE, FAILED] {
196 create_private_directory_all(state_directory(work_dir, state).as_path()).map_err(|error| return format!("creating the {state} directory failed: {error}"))?;
197 }
198 let id: String = item.id();
199 // The states are looked at in the order an item moves through them, so an item that moves on while they are looked at is
200 // found in the next one: looking at `done` first could miss an item that settled after that look and left `claimed`
201 // before the look at it.
202 if [CLAIMED, RECOVERING].iter().any(|state| { return entries_oldest_first(work_dir, state).iter().any(|(name, _)| { return id_of_entry(name.as_str()) == id; }); }) {
203 return Ok(Enqueued::Known);
204 }
205 if state_directory(work_dir, DONE).join(id.as_str()).exists() || state_directory(work_dir, FAILED).join(id.as_str()).exists() {
206 return Ok(Enqueued::Known);
207 }
208 return match write_private_file_unsynced(state_directory(work_dir, PENDING).join(id.as_str()).as_path(), item.encode().as_bytes()) {
209 Ok(()) => Ok(Enqueued::Added),
210 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(Enqueued::Known),
211 Err(error) => Err(format!("writing the work item {id} failed: {error}")),
212 };
213}
214
215/// What:
216/// A held item: the decoded record, its id, and the name of the file that holds it.
217/// Why:
218/// Finishing needs the claim's file name, not the id: the name carries the generation of the claim, so a settlement or a
219/// renewal reaches only the claim it was made for. The consumer needs the item.
220///
221/// In TS you'd write (pseudocode):
222/// ```ts
223/// type Claimed = { id: string; name: string; item: WorkItem };
224/// ```
225#[derive(Clone, Debug, Eq, PartialEq)]
226pub struct Claimed {
227 /// File name in every state directory but `claimed` and `recovering`, where the file name is `name`.
228 pub id: String,
229 /// File name in `claimed` as of the claim or the last renewal: the id, the claim's generation and the clock reading.
230 pub name: String,
231 /// The decoded record.
232 pub item: WorkItem,
233}
234
235/// Separates the parts of a claim's file name; an id is a kind and a hexadecimal object name and holds none.
236const NAME_SEPARATOR: char = '~';
237
238/// The most a stray temporary file of a recovery may age before a recovery removes it.
239const STRAY_TEMPORARY_AGE: Duration = Duration::from_secs(24 * 60 * 60);
240
241/// The end of the name of a recovery's temporary file.
242const TEMPORARY_SUFFIX: &str = ".retry";
243
244/// What:
245/// The id an entry of `claimed` or `recovering` is the claim of: the part of its name before the generation.
246/// Why:
247/// A claim by an earlier release is named by the id alone, so a name with no separator is its own id.
248///
249/// In TS you'd write (pseudocode):
250/// ```ts
251/// name.split('~')[0]
252/// ```
253pub fn id_of_entry(name: &str) -> &str {
254 return name.split_once(NAME_SEPARATOR).map_or(name, |(id, _)| { return id; });
255}
256
257/// What:
258/// The name of the file that holds the claim of `id` of the generation `token` as of the reading `stamp`.
259/// Why:
260/// The generation is unique to one acquisition and the reading says when the holder last proved it was alive, so the name
261/// alone tells every other instance whose claim this is and whether it ran out, and a rename of exactly that name is the only
262/// way to take it: a holder that renews changes the name, so a take made from an older look finds nothing to take.
263///
264/// In TS you'd write (pseudocode):
265/// ```ts
266/// `${id}~${token}~${stamp.encode()}`
267/// ```
268pub fn claim_file_name(id: &str, token: &str, stamp: &ClaimTime) -> String {
269 return format!("{id}{NAME_SEPARATOR}{token}{NAME_SEPARATOR}{}", stamp.encode());
270}
271
272/// The generation and the reading in a claim's file name, or nothing for a name by an earlier release.
273fn parse_claim_name(name: &str) -> Option<(&str, ClaimTime)> {
274 let (_, rest) = name.split_once(NAME_SEPARATOR)?;
275 let (token, stamp) = rest.split_once(NAME_SEPARATOR)?;
276 if token.is_empty() || !token.bytes().all(|byte| { return byte.is_ascii_hexdigit(); }) {
277 return None;
278 }
279 return Some((token, ClaimTime::decode(stamp)?));
280}
281
282/// A fresh generation: 32 random hexadecimal digits.
283fn new_generation() -> Result<String, String> {
284 return random_uuid()
285 .map(|uuid| { return uuid.replace('-', ""); })
286 .map_err(|error| { return format!("making the generation of a claim failed: {}", error.0); });
287}
288
289/// The names in a state directory with their modification times, oldest first.
290fn entries_oldest_first(work_dir: &Path, state: &str) -> Vec<(String, SystemTime)> {
291 let mut found: Vec<(String, SystemTime)> = Vec::new();
292 if let Ok(listing) = std::fs::read_dir(state_directory(work_dir, state)) {
293 for entry in listing.flatten() {
294 let modified: SystemTime = entry.metadata().and_then(|meta| return meta.modified()).unwrap_or(SystemTime::UNIX_EPOCH);
295 found.push((entry.file_name().to_string_lossy().into_owned(), modified));
296 }
297 }
298 found.sort_by(|left, right| return left.1.cmp(&right.1).then_with(|| return left.0.cmp(&right.0)));
299 return found;
300}
301
302/// The path of the file that holds the claim of `id` now, or nothing when it is not claimed or being recovered.
303pub fn find_claim(work_dir: &Path, id: &str) -> Option<PathBuf> {
304 return [CLAIMED, RECOVERING]
305 .into_iter()
306 .flat_map(|state| { return entries_oldest_first(work_dir, state).into_iter().map(move |(name, _)| { return (state, name); }); })
307 .find(|(_, name)| { return id_of_entry(name.as_str()) == id; })
308 .map(|(state, name)| { return state_directory(work_dir, state).join(name); });
309}
310
311/// What:
312/// Claim the oldest pending item by renaming it into `claimed` under a name that carries a fresh generation and the
313/// reading `now`. A record that does not decode is moved to `failed` so it cannot block the queue.
314/// Why:
315/// A rename is atomic: of two consumers racing for one item, the loser sees the file gone and takes the next one. The
316/// generation and the reading are in the name, so the claim is whole from the instant it exists; nothing is written to it.
317///
318/// In TS you'd write (pseudocode):
319/// ```ts
320/// await rename(join(workDir, 'pending', id), join(workDir, 'claimed', claimFileName(id, newGeneration(), now)))
321/// ```
322pub fn claim_next(work_dir: &Path, now: &ClaimTime) -> Result<Option<Claimed>, String> {
323 return claim_next_of(work_dir, now, &[WorkKind::Review, WorkKind::Push, WorkKind::Maintenance]);
324}
325
326/// What:
327/// Claim the oldest pending item of the given kinds, leaving every other kind where it is.
328/// Why:
329/// Consumers of different kinds share one queue; the kind starts the file name, so a consumer never opens, moves or
330/// stamps an item that belongs to another.
331///
332/// In TS you'd write (pseudocode):
333/// ```ts
334/// await rename(join(workDir, 'pending', id), join(workDir, 'claimed', name)) // only ids that start with a handled kind
335/// ```
336pub fn claim_next_of(work_dir: &Path, now: &ClaimTime, kinds: &[WorkKind]) -> Result<Option<Claimed>, String> {
337 for (id, _) in entries_oldest_first(work_dir, PENDING) {
338 if !kinds.iter().any(|kind| return id.starts_with(format!("{}-", kind.name()).as_str())) {
339 continue;
340 }
341 let name: String = claim_file_name(id.as_str(), new_generation()?.as_str(), now);
342 let claimed_path: PathBuf = state_directory(work_dir, CLAIMED).join(name.as_str());
343 if std::fs::rename(state_directory(work_dir, PENDING).join(id.as_str()), claimed_path.as_path()).is_err() {
344 continue;
345 }
346 let text: String = std::fs::read_to_string(claimed_path.as_path()).map_err(|error| return format!("reading the claim of {id} failed: {error}"))?;
347 match WorkItem::decode(text.as_str()) {
348 Ok(item) => return Ok(Some(Claimed { id, name, item })),
349 Err(_) => {
350 let _ = std::fs::rename(claimed_path.as_path(), state_directory(work_dir, FAILED).join(id.as_str()));
351 }
352 }
353 }
354 return Ok(None);
355}
356
357/// What:
358/// Move a claimed item to `done` (or to `failed`), after the consumer's result is durable.
359/// Why:
360/// A crash before this rename leaves the item claimed, and its lease runs out; the work is then done again, so a
361/// handler must tolerate a repeat. The rename names the claim's generation, so a holder whose claim was taken over and
362/// claimed again by another settles nothing of the other's.
363///
364/// In TS you'd write (pseudocode):
365/// ```ts
366/// await rename(join(workDir, 'claimed', claimed.name), join(workDir, 'done', claimed.id))
367/// ```
368pub fn settle(work_dir: &Path, claimed: &Claimed, state: &str) -> Result<(), String> {
369 return settle_named(work_dir, claimed.name.as_str(), state);
370}
371
372/// `settle` for the claim file `name`, whose id is the part of the name before the generation.
373pub fn settle_named(work_dir: &Path, name: &str, state: &str) -> Result<(), String> {
374 let id: &str = id_of_entry(name);
375 return std::fs::rename(state_directory(work_dir, CLAIMED).join(name), state_directory(work_dir, state).join(id))
376 .map_err(|error| return format!("moving {id} to {state} failed: {error}"));
377}
378
379/// What:
380/// Start the lease of the claim file `name` again at `now`, returning its new name; nothing when the claim is no longer in
381/// `claimed` under that name.
382/// Why:
383/// A holder that is still working must not look abandoned: every consumer of a directory that several consumers drain reads
384/// the reading in the name as the proof of life. The renewal is a rename of the exact name the holder last wrote, so a
385/// claim that was taken over, settled, or taken over and claimed again by someone else is not found and is never brought
386/// back or renewed on the other's behalf; and an instance that looked at the old name and means to take it finds nothing.
387///
388/// In TS you'd write (pseudocode):
389/// ```ts
390/// await rename(join(workDir, 'claimed', name), join(workDir, 'claimed', claimFileName(id, token, now)))
391/// ```
392pub fn renew_claim(work_dir: &Path, name: &str, now: &ClaimTime) -> Option<String> {
393 let id: &str = id_of_entry(name);
394 // A claim by an earlier release has no generation; its renewal gives it one.
395 let token: String = match parse_claim_name(name) {
396 Some((token, _)) => token.to_owned(),
397 None => new_generation().ok()?,
398 };
399 let renewed: String = claim_file_name(id, token.as_str(), now);
400 let directory: PathBuf = state_directory(work_dir, CLAIMED);
401 return std::fs::rename(directory.join(name), directory.join(renewed.as_str())).ok().map(|()| { return renewed; });
402}
403
404/// What:
405/// Return claims whose lease ran out to `pending` with one more try, or to `failed` at `max_tries`.
406/// Returns how many were moved.
407/// Why:
408/// This is how a consumer killed at any instruction is noticed when nothing says its successor started: by its lease,
409/// never by a process id.
410///
411/// In TS you'd write (pseudocode):
412/// ```ts
413/// for (const stale of claimed.filter((c) => now.elapsedSince(c.stamp) > lease)) requeue(stale)
414/// ```
415pub fn requeue_expired(work_dir: &Path, now: &ClaimTime, lease: Duration, max_tries: u32) -> usize {
416 return requeue_expired_reporting(work_dir, now, lease, max_tries).moved;
417}
418
419/// What:
420/// What a recovery pass did: how many claims it moved, and the items it gave up on.
421/// Why:
422/// An item that reaches `failed` through recovery was never settled by a handler, so nothing recorded a notice for it; the
423/// caller needs the items to tell the person.
424///
425/// In TS you'd write (pseudocode):
426/// ```ts
427/// type Recovered = { moved: number; givenUp: WorkItem[] };
428/// ```
429#[derive(Clone, Debug, Default, Eq, PartialEq)]
430pub struct Recovered {
431 /// How many claims this pass moved to `pending` or `failed`.
432 pub moved: usize,
433 /// The decoded items it moved to `failed` because they reached the try limit.
434 pub given_up: Vec<WorkItem>,
435}
436
437/// `requeue_expired` that also gives the items it moved to `failed` for want of tries.
438pub fn requeue_expired_reporting(work_dir: &Path, now: &ClaimTime, lease: Duration, max_tries: u32) -> Recovered {
439 return requeue_with(work_dir, now, max_tries, &|stamp| { return now.elapsed_since(stamp) > lease; });
440}
441
442/// What:
443/// How long until the first claim that is still held runs out its lease, or nothing when no claim is held.
444/// Why:
445/// An instance that finds claims it may not take yet has nothing to wake it when they run out, so the service asks when to
446/// look again; without this, work whose holder died would wait for the next commit.
447///
448/// In TS you'd write (pseudocode):
449/// ```ts
450/// Math.min(...claimed.map((c) => lease - now.elapsedSince(c.stamp)))
451/// ```
452pub fn next_claim_expiry(work_dir: &Path, now: &ClaimTime, lease: Duration) -> Option<Duration> {
453 return [CLAIMED, RECOVERING]
454 .into_iter()
455 .flat_map(|state| { return entries_oldest_first(work_dir, state); })
456 .filter(|(name, _)| { return !name.ends_with(TEMPORARY_SUFFIX); })
457 .map(|(name, modified)| { return lease.saturating_sub(now.elapsed_since(&stamp_of_entry(name.as_str(), modified))); })
458 .min();
459}
460
461/// The reading in a claim's file name, or for a claim by an earlier release the wall-clock time of its last stamp.
462fn stamp_of_entry(name: &str, modified: SystemTime) -> ClaimTime {
463 return parse_claim_name(name).map_or_else(|| { return ClaimTime::from_wall(modified); }, |(_, stamp)| { return stamp; });
464}
465
466/// What:
467/// Take each claim whose reading `expired` accepts, by renaming the exact file name that was looked at into `recovering`
468/// under a generation of its own, then return it to `pending` with one more try, or to `failed` at `max_tries`. Returns how
469/// many were moved.
470/// Why:
471/// The look and the take are separate moments, and in between another instance may have returned the same claim and claimed
472/// it again, or its holder may have renewed it. Both change the name, so the take of the old name finds nothing and takes
473/// nothing: the rename is the only check and it is atomic. The file in `recovering` is this instance's alone, so its record
474/// is rewritten there whole (a temporary file renamed over it) and the item reaches `pending` by one rename; a recovery
475/// killed anywhere leaves the item in exactly one place, and an item left in `recovering` runs out its lease and is
476/// recovered like a claim, which costs it one more try.
477///
478/// In TS you'd write (pseudocode):
479/// ```ts
480/// for (const seen of claimed.filter(expired)) { if (await tryRename(seen.name, owned)) { await rewrite(owned); await rename(owned, pending) } }
481/// ```
482fn requeue_with(work_dir: &Path, now: &ClaimTime, max_tries: u32, expired: &dyn Fn(&ClaimTime) -> bool) -> Recovered {
483 let mut recovered: Recovered = Recovered::default();
484 for state in [CLAIMED, RECOVERING] {
485 for (name, modified) in entries_oldest_first(work_dir, state) {
486 if name.ends_with(TEMPORARY_SUFFIX) {
487 remove_stray_temporary(work_dir, state, name.as_str(), modified, now);
488 continue;
489 }
490 if !expired(&stamp_of_entry(name.as_str(), modified)) {
491 continue;
492 }
493 if let Some(given_up) = recover(work_dir, state, name.as_str(), now, max_tries) {
494 recovered.moved += 1;
495 recovered.given_up.extend(given_up);
496 }
497 }
498 }
499 return recovered;
500}
501
502/// Removes a recovery's temporary file that a killed recovery left, once it is old enough that nobody can still be using it.
503fn remove_stray_temporary(work_dir: &Path, state: &str, name: &str, modified: SystemTime, now: &ClaimTime) {
504 if now.elapsed_since(&ClaimTime::from_wall(modified)) > STRAY_TEMPORARY_AGE {
505 let _removed = std::fs::remove_file(state_directory(work_dir, state).join(name));
506 }
507}
508
509/// Returns the claim file `name` of `state` to `pending` or `failed`; nothing when another instance moved it first, otherwise
510/// the item when it was given up on (moved to `failed` for want of tries).
511fn recover(work_dir: &Path, state: &str, name: &str, now: &ClaimTime, max_tries: u32) -> Option<Option<WorkItem>> {
512 let id: &str = id_of_entry(name);
513 let Ok(token) = new_generation() else {
514 return None;
515 };
516 let owned_name: String = claim_file_name(id, token.as_str(), now);
517 let recovering: PathBuf = state_directory(work_dir, RECOVERING);
518 if create_private_directory_all(recovering.as_path()).is_err() {
519 return None;
520 }
521 let owned: PathBuf = recovering.join(owned_name.as_str());
522 // The take: only one instance can rename this exact name, and a claim that was renewed or claimed again is not this name.
523 if std::fs::rename(state_directory(work_dir, state).join(name), owned.as_path()).is_err() {
524 return None;
525 }
526 // A record that cannot be read after it was taken is settled like one that cannot be decoded: leaving it in `recovering`
527 // would have every later pass take it, fail the same way, and keep the service waiting on it for good.
528 let Ok(text) = std::fs::read_to_string(owned.as_path()) else {
529 return std::fs::rename(owned.as_path(), state_directory(work_dir, FAILED).join(id)).is_ok().then_some(None);
530 };
531 let (target, given_up): (&str, Option<WorkItem>) = match WorkItem::decode(text.as_str()) {
532 Ok(mut item) => {
533 item.tries += 1;
534 let rewritten: PathBuf = recovering.join(format!("{owned_name}{TEMPORARY_SUFFIX}"));
535 let destination: &str = if item.tries >= max_tries { FAILED } else { PENDING };
536 if std::fs::write(rewritten.as_path(), item.encode()).is_err() || std::fs::rename(rewritten.as_path(), owned.as_path()).is_err() {
537 return None;
538 }
539 (destination, (destination == FAILED).then_some(item))
540 }
541 // The record never changes once it is pending, so this is a record that was never readable.
542 Err(_) => (FAILED, None),
543 };
544 return std::fs::rename(owned.as_path(), state_directory(work_dir, target).join(id)).is_ok().then_some(given_up);
545}
546
547/// What:
548/// How long the oldest pending item has waited, or `None` when nothing is pending.
549/// Why:
550/// The wrapper's one-line notice that no consumer is draining the queue is this age past a bound; a process id in a
551/// file would be meaningless across process-id namespaces.
552///
553/// In TS you'd write (pseudocode):
554/// ```ts
555/// now - oldestPending.mtime
556/// ```
557pub fn oldest_pending_age(work_dir: &Path, now: SystemTime) -> Option<Duration> {
558 return entries_oldest_first(work_dir, PENDING).first().map(|(_, created)| return now.duration_since(*created).unwrap_or(Duration::ZERO));
559}
560
561/// Controls stay out of the release executable.
562#[cfg(test)]
563#[path = "work_item_tests.rs"]
564mod tests;