Skip to main content

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///  Return claims whose lease ran out to `pending` with one more try, or to `failed` at `max_tries`.
298///  Returns how many were moved.
299/// Why:
300///   This is the only way a consumer killed at any instruction is noticed: by its lease, never by a process id.
301///
302/// In TS you'd write (pseudocode):
303/// ```ts
304/// for (const stale of claimed.filter((c) => now - c.mtime > lease)) requeue(stale)
305/// ```
306pub fn requeue_expired(work_dir: &Path, now: SystemTime, lease: Duration, max_tries: u32) -> usize {
307    let mut moved: usize = 0;
308    for (id, started) in entries_oldest_first(work_dir, CLAIMED) {
309        if now.duration_since(started).unwrap_or(Duration::ZERO) <= lease {
310            continue;
311        }
312        let from: PathBuf = state_directory(work_dir, CLAIMED).join(id.as_str());
313        let Ok(text) = std::fs::read_to_string(from.as_path()) else {
314            continue;
315        };
316        let target: &str = match WorkItem::decode(text.as_str()) {
317            Ok(mut item) => {
318                item.tries += 1;
319                let rewritten: PathBuf = from.with_extension("retry");
320                let state: &str = if item.tries >= max_tries { FAILED } else { PENDING };
321                if std::fs::write(rewritten.as_path(), item.encode()).is_err() || std::fs::rename(rewritten.as_path(), from.as_path()).is_err() {
322                    continue;
323                }
324                state
325            }
326            Err(_) => FAILED,
327        };
328        if std::fs::rename(from.as_path(), state_directory(work_dir, target).join(id.as_str())).is_ok() {
329            moved += 1;
330        }
331    }
332    return moved;
333}
334
335/// What:
336///  How long the oldest pending item has waited, or `None` when nothing is pending.
337/// Why:
338///   The wrapper's one-line notice that no consumer is draining the queue is this age past a bound; a process id in a
339///   file would be meaningless across process-id namespaces.
340///
341/// In TS you'd write (pseudocode):
342/// ```ts
343/// now - oldestPending.mtime
344/// ```
345pub fn oldest_pending_age(work_dir: &Path, now: SystemTime) -> Option<Duration> {
346    return entries_oldest_first(work_dir, PENDING).first().map(|(_, created)| return now.duration_since(*created).unwrap_or(Duration::ZERO));
347}
348
349/// Controls stay out of the release executable.
350#[cfg(test)]
351#[path = "work_item_tests.rs"]
352mod tests;