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::private_storage::{create_private_directory_all, write_private_file_unsynced};
16use serde_json::{Map, Value};
17use std::path::{Path, PathBuf};
18use std::time::{Duration, SystemTime};
19
20/// The record format version.
21pub const VERSION: u64 = 1;
22
23/// Directory under the common Git directory that holds the state directories.
24pub const WORK_DIRECTORY: &str = "cli-git/work";
25
26/// The state directories, in the order an item moves through them.
27pub const PENDING: &str = "pending";
28/// Held by one consumer, with a lease.
29pub const CLAIMED: &str = "claimed";
30/// Finished.
31pub const DONE: &str = "done";
32/// Given up after too many tries.
33pub const FAILED: &str = "failed";
34
35/// What:
36/// The kind of work an item asks for.
37/// Why:
38/// One record format carries every asynchronous job; the kind picks the consumer's handler.
39///
40/// In TS you'd write (pseudocode):
41/// ```ts
42/// type WorkKind = 'review' | 'push' | 'maintenance';
43/// ```
44#[derive(Clone, Copy, Debug, Eq, PartialEq)]
45pub enum WorkKind {
46 /// The standards-review shadow run.
47 Review,
48 /// An auto-push of the branch.
49 Push,
50 /// Repository maintenance.
51 Maintenance,
52}
53
54/// Methods over a kind.
55impl WorkKind {
56 /// The kind's name, which is also the start of an item's id.
57 pub fn name(self) -> &'static str {
58 return match self {
59 WorkKind::Review => "review",
60 WorkKind::Push => "push",
61 WorkKind::Maintenance => "maintenance",
62 };
63 }
64
65 /// The kind a name spells.
66 pub fn from_name(name: &str) -> Option<WorkKind> {
67 return [WorkKind::Review, WorkKind::Push, WorkKind::Maintenance].into_iter().find(|kind| return kind.name() == name);
68 }
69}
70
71/// What:
72/// One work item: what to do, to which landed object, and where.
73/// Why:
74/// The consumer needs only the object name and the places to find the repository; it reads the patch itself.
75///
76/// In TS you'd write (pseudocode):
77/// ```ts
78/// type WorkItem = { kind; repository: string; gitDir: string; ref: string; oid: string; parent: string; created: string; tries: number };
79/// ```
80#[derive(Clone, Debug, Eq, PartialEq)]
81pub struct WorkItem {
82 /// What to do.
83 pub kind: WorkKind,
84 /// The worktree root.
85 pub repository: String,
86 /// The common Git directory.
87 pub git_dir: String,
88 /// The branch reference that received the commit.
89 pub branch_ref: String,
90 /// The landed commit.
91 pub oid: String,
92 /// Its first parent, or empty for a root commit.
93 pub parent: String,
94 /// `toISOString` text of the enqueue time.
95 pub created: String,
96 /// Failed or abandoned attempts so far.
97 pub tries: u32,
98}
99
100/// Methods over an item.
101impl WorkItem {
102 /// The id and file name: the kind and the landed object, so the same landing always yields the same id.
103 pub fn id(&self) -> String {
104 return format!("{}-{}", self.kind.name(), self.oid);
105 }
106
107 /// The record's one JSON line.
108 pub fn encode(&self) -> String {
109 let mut fields: Map<String, Value> = Map::new();
110 fields.insert(String::from("v"), Value::from(VERSION));
111 fields.insert(String::from("tries"), Value::from(self.tries));
112 for (name, text) in [
113 ("kind", self.kind.name()),
114 ("id", self.id().as_str()),
115 ("repository", self.repository.as_str()),
116 ("gitDir", self.git_dir.as_str()),
117 ("ref", self.branch_ref.as_str()),
118 ("oid", self.oid.as_str()),
119 ("parent", self.parent.as_str()),
120 ("created", self.created.as_str()),
121 ] {
122 fields.insert(String::from(name), Value::from(text));
123 }
124 return format!("{}\n", Value::Object(fields));
125 }
126
127 /// Read a record; any other version, an unknown kind, a missing field or an id that is not derived from the kind and
128 /// object is refused with a message.
129 pub fn decode(text: &str) -> Result<WorkItem, String> {
130 let value: Value = serde_json::from_str(text).map_err(|error| return format!("the record is not JSON: {error}"))?;
131 let field = |name: &str| -> Result<String, String> {
132 return value.get(name).and_then(Value::as_str).map(String::from).ok_or_else(|| return format!("the record has no text field {name}"));
133 };
134 if value.get("v").and_then(Value::as_u64) != Some(VERSION) {
135 return Err(String::from("the record has an unsupported version"));
136 }
137 let kind: WorkKind = WorkKind::from_name(field("kind")?.as_str()).ok_or_else(|| return String::from("the record has an unknown kind"))?;
138 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"))?;
139 let item: WorkItem = WorkItem {
140 kind,
141 repository: field("repository")?,
142 git_dir: field("gitDir")?,
143 branch_ref: field("ref")?,
144 oid: field("oid")?,
145 parent: field("parent")?,
146 created: field("created")?,
147 tries,
148 };
149 if field("id")? != item.id() {
150 return Err(String::from("the record's id is not derived from its kind and object"));
151 }
152 return Ok(item);
153 }
154}
155
156/// What:
157/// Whether an enqueue added the item or found it already known.
158/// Why:
159/// A landing that is replayed, or a daemon that restarts, enqueues again; the second time must change nothing.
160///
161/// In TS you'd write (pseudocode):
162/// ```ts
163/// type Enqueued = 'added' | 'known';
164/// ```
165#[derive(Clone, Copy, Debug, Eq, PartialEq)]
166pub enum Enqueued {
167 /// A new pending item was written.
168 Added,
169 /// The id already exists in some state directory.
170 Known,
171}
172
173/// The directory of one state.
174pub fn state_directory(work_dir: &Path, state: &str) -> PathBuf {
175 return work_dir.join(state);
176}
177
178/// What:
179/// Write the item as a pending record, whole or absent, unless its id is already in any state.
180/// The producer never waits for a consumer and never reads anything back.
181/// Why:
182/// The id is the idempotency key; the exclusive publish makes two racing producers agree on one winner.
183///
184/// In TS you'd write (pseudocode):
185/// ```ts
186/// await writeExclusive(join(workDir, 'pending', item.id), item.encode())
187/// ```
188pub fn enqueue(work_dir: &Path, item: &WorkItem) -> Result<Enqueued, String> {
189 for state in [PENDING, CLAIMED, DONE, FAILED] {
190 create_private_directory_all(state_directory(work_dir, state).as_path()).map_err(|error| return format!("creating the {state} directory failed: {error}"))?;
191 }
192 let id: String = item.id();
193 for state in [CLAIMED, DONE, FAILED] {
194 if state_directory(work_dir, state).join(id.as_str()).exists() {
195 return Ok(Enqueued::Known);
196 }
197 }
198 return match write_private_file_unsynced(state_directory(work_dir, PENDING).join(id.as_str()).as_path(), item.encode().as_bytes()) {
199 Ok(()) => Ok(Enqueued::Added),
200 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(Enqueued::Known),
201 Err(error) => Err(format!("writing the work item {id} failed: {error}")),
202 };
203}
204
205/// What:
206/// A held item: the decoded record and its id.
207/// Why:
208/// Finishing needs only the id; the consumer needs the item.
209///
210/// In TS you'd write (pseudocode):
211/// ```ts
212/// type Claimed = { id: string; item: WorkItem };
213/// ```
214#[derive(Clone, Debug, Eq, PartialEq)]
215pub struct Claimed {
216 /// File name in every state directory.
217 pub id: String,
218 /// The decoded record.
219 pub item: WorkItem,
220}
221
222/// The names in a state directory with their modification times, oldest first.
223fn entries_oldest_first(work_dir: &Path, state: &str) -> Vec<(String, SystemTime)> {
224 let mut found: Vec<(String, SystemTime)> = Vec::new();
225 if let Ok(listing) = std::fs::read_dir(state_directory(work_dir, state)) {
226 for entry in listing.flatten() {
227 let modified: SystemTime = entry.metadata().and_then(|meta| return meta.modified()).unwrap_or(SystemTime::UNIX_EPOCH);
228 found.push((entry.file_name().to_string_lossy().into_owned(), modified));
229 }
230 }
231 found.sort_by(|left, right| return left.1.cmp(&right.1).then_with(|| return left.0.cmp(&right.0)));
232 return found;
233}
234
235/// What:
236/// Claim the oldest pending item by renaming it into `claimed`, stamping the claim time as its lease start.
237/// A record that does not decode is moved to `failed` so it cannot block the queue.
238/// Why:
239/// A rename is atomic: of two consumers racing for one item, the loser sees the file gone and takes the next one.
240///
241/// In TS you'd write (pseudocode):
242/// ```ts
243/// await rename(join(workDir, 'pending', id), join(workDir, 'claimed', id))
244/// ```
245pub fn claim_next(work_dir: &Path, now: SystemTime) -> Result<Option<Claimed>, String> {
246 return claim_next_of(work_dir, now, &[WorkKind::Review, WorkKind::Push, WorkKind::Maintenance]);
247}
248
249/// What:
250/// Claim the oldest pending item of the given kinds, leaving every other kind where it is.
251/// Why:
252/// Consumers of different kinds share one queue; the kind starts the file name, so a consumer never opens, moves or
253/// stamps an item that belongs to another.
254///
255/// In TS you'd write (pseudocode):
256/// ```ts
257/// await rename(join(workDir, 'pending', id), join(workDir, 'claimed', id)) // only ids that start with a handled kind
258/// ```
259pub fn claim_next_of(work_dir: &Path, now: SystemTime, kinds: &[WorkKind]) -> Result<Option<Claimed>, String> {
260 for (id, _) in entries_oldest_first(work_dir, PENDING) {
261 if !kinds.iter().any(|kind| return id.starts_with(format!("{}-", kind.name()).as_str())) {
262 continue;
263 }
264 let claimed_path: PathBuf = state_directory(work_dir, CLAIMED).join(id.as_str());
265 if std::fs::rename(state_directory(work_dir, PENDING).join(id.as_str()), claimed_path.as_path()).is_err() {
266 continue;
267 }
268 let stamped: std::io::Result<()> = std::fs::File::options().append(true).open(claimed_path.as_path()).and_then(|file| return file.set_modified(now));
269 stamped.map_err(|error| return format!("stamping the claim of {id} failed: {error}"))?;
270 let text: String = std::fs::read_to_string(claimed_path.as_path()).map_err(|error| return format!("reading the claim of {id} failed: {error}"))?;
271 match WorkItem::decode(text.as_str()) {
272 Ok(item) => return Ok(Some(Claimed { id, item })),
273 Err(_) => {
274 let _ = std::fs::rename(claimed_path.as_path(), state_directory(work_dir, FAILED).join(id.as_str()));
275 }
276 }
277 }
278 return Ok(None);
279}
280
281/// What:
282/// Move a claimed item to `done` (or to `failed`), after the consumer's result is durable.
283/// Why:
284/// A crash before this rename leaves the item claimed, and its lease runs out; the work is then done again, so a
285/// handler must tolerate a repeat.
286///
287/// In TS you'd write (pseudocode):
288/// ```ts
289/// await rename(join(workDir, 'claimed', id), join(workDir, 'done', id))
290/// ```
291pub fn settle(work_dir: &Path, claimed: &Claimed, state: &str) -> Result<(), String> {
292 return std::fs::rename(state_directory(work_dir, CLAIMED).join(claimed.id.as_str()), state_directory(work_dir, state).join(claimed.id.as_str()))
293 .map_err(|error| return format!("moving {} to {state} failed: {error}", claimed.id));
294}
295
296/// What:
297/// Start the lease of a claim again at `now`; false when the claim is no longer in `claimed`.
298/// Why:
299/// A holder that is still working must not look abandoned: every consumer of a directory that several consumers drain reads
300/// the stamp as the proof of life, and a settled or taken-over claim has nothing left to stamp. The open does not create
301/// the file, so a renewal can never bring back a claim that was settled.
302///
303/// In TS you'd write (pseudocode):
304/// ```ts
305/// await utimes(join(workDir, 'claimed', id), now, now)
306/// ```
307pub fn renew_claim(work_dir: &Path, id: &str, now: SystemTime) -> bool {
308 return std::fs::File::options()
309 .append(true)
310 .open(state_directory(work_dir, CLAIMED).join(id))
311 .and_then(|file| return file.set_modified(now))
312 .is_ok();
313}
314
315/// What:
316/// Return claims whose lease ran out to `pending` with one more try, or to `failed` at `max_tries`.
317/// Returns how many were moved.
318/// Why:
319/// This is how a consumer killed at any instruction is noticed when nothing says its successor started: by its lease,
320/// never by a process id.
321///
322/// In TS you'd write (pseudocode):
323/// ```ts
324/// for (const stale of claimed.filter((c) => now - c.mtime > lease)) requeue(stale)
325/// ```
326pub fn requeue_expired(work_dir: &Path, now: SystemTime, lease: Duration, max_tries: u32) -> usize {
327 return requeue_where(work_dir, max_tries, &|started| { return now.duration_since(started).unwrap_or(Duration::ZERO) > lease; });
328}
329
330/// What:
331/// Return every claim to `pending` with one more try, or to `failed` at `max_tries`, whatever its age. Returns how many
332/// were moved.
333/// Why:
334/// A consumer that starts holds no claim yet, and the manager runs one consumer at a time, so a claim it finds was left by a
335/// consumer that died or was replaced; waiting out the lease would strand the work for as long as the lease lasts.
336///
337/// In TS you'd write (pseudocode):
338/// ```ts
339/// for (const left of claimed) requeue(left)
340/// ```
341pub fn requeue_claimed(work_dir: &Path, max_tries: u32) -> usize {
342 return requeue_where(work_dir, max_tries, &|_started| { return true; });
343}
344
345/// Returns the claims whose stamp satisfies `due` to `pending`, or to `failed` at `max_tries`; how many were moved.
346fn requeue_where(work_dir: &Path, max_tries: u32, due: &dyn Fn(SystemTime) -> bool) -> usize {
347 let mut moved: usize = 0;
348 for (id, started) in entries_oldest_first(work_dir, CLAIMED) {
349 if !due(started) {
350 continue;
351 }
352 let from: PathBuf = state_directory(work_dir, CLAIMED).join(id.as_str());
353 let Ok(text) = std::fs::read_to_string(from.as_path()) else {
354 continue;
355 };
356 let target: &str = match WorkItem::decode(text.as_str()) {
357 Ok(mut item) => {
358 item.tries += 1;
359 let rewritten: PathBuf = from.with_extension("retry");
360 let state: &str = if item.tries >= max_tries { FAILED } else { PENDING };
361 if std::fs::write(rewritten.as_path(), item.encode()).is_err() || std::fs::rename(rewritten.as_path(), from.as_path()).is_err() {
362 continue;
363 }
364 state
365 }
366 Err(_) => FAILED,
367 };
368 if std::fs::rename(from.as_path(), state_directory(work_dir, target).join(id.as_str())).is_ok() {
369 moved += 1;
370 }
371 }
372 return moved;
373}
374
375/// What:
376/// How long the oldest pending item has waited, or `None` when nothing is pending.
377/// Why:
378/// The wrapper's one-line notice that no consumer is draining the queue is this age past a bound; a process id in a
379/// file would be meaningless across process-id namespaces.
380///
381/// In TS you'd write (pseudocode):
382/// ```ts
383/// now - oldestPending.mtime
384/// ```
385pub fn oldest_pending_age(work_dir: &Path, now: SystemTime) -> Option<Duration> {
386 return entries_oldest_first(work_dir, PENDING).first().map(|(_, created)| return now.duration_since(*created).unwrap_or(Duration::ZERO));
387}
388
389/// Controls stay out of the release executable.
390#[cfg(test)]
391#[path = "work_item_tests.rs"]
392mod tests;