Skip to main content

onevcs/pool/
maintain.rs

1//! `pool maintain`: run the identity's maintenance command in each idle slot, one slot
2//! at a time, and record the attempt on the slot.
3//!
4//! `onevcs` holds **no schedule**. What maintenance *is* — the argv and its bound — is
5//! the host's, per identity, in `workspaces.yml`; *when* it runs is the caller's — a
6//! driver's idle branch, a cron, a person — and what this crate keeps is the fact of
7//! the last attempt, on the slot record, so every caller reads the same clock. That is
8//! what makes the verb idempotent and cheap: a slot maintained inside the caller's own
9//! `older_than` is reported `NotDue` and costs no process, and a slot never maintained
10//! is always due.
11//!
12//! **One slot per identity at a time.** Maintenance takes a slot out of service, and
13//! a run that took every idle slot at once would turn a pool's headroom into a queue.
14//! So an identity is maintained under an exclusive lock keyed on it — a second run
15//! meeting it answers `Claimed` naming the holder and touches nothing — and its slots
16//! are visited in number order, each claimed for exactly the duration of its own
17//! command: the claim the previous amendment declared is written under the placement
18//! lock, so an `open` reads it as `Maintaining` and goes elsewhere, and is cleared with
19//! the outcome before the next slot is looked at. Never waiting is the placement
20//! order's promise, and this verb keeps it by never holding the placement lock across
21//! a command.
22//!
23//! **The attempt is what is recorded.** `last_maintained` is stamped whether the
24//! command succeeded, failed or timed out, and `last_outcome` says which: a command
25//! that fails is not retried on every tick until `older_than` has elapsed again, which
26//! is what keeps a broken maintenance script from running on every idle beat of every
27//! driver — and `pool status` shows the outcome beside the stamp so a person can see it.
28
29use std::path::{Path, PathBuf};
30use std::process::{Child, Command, Stdio};
31use std::sync::mpsc;
32use std::time::{Duration, Instant, SystemTime};
33
34use serde::{Deserialize, Serialize};
35
36use super::{
37    placement_identity, pool_dir, shed, state_of, survey, write_record, Claim, MaintenanceOutcome,
38    SlotRecord, SlotState, Timestamp,
39};
40use crate::error::{self, Result};
41use crate::event::ArtifactId;
42use crate::session::{Lifecycle, Scope, SessionToken};
43use crate::store::{self, Resolution};
44use crate::workspaces::{self, Maintenance, Overrides, Span};
45use crate::{git, ids, lock, processes, stream, workspace};
46
47/// The longest a run waits between looks at its command's exit, where neither pipe
48/// reports an ending — the same ceiling a bounded git command sleeps under.
49const POLL: Duration = Duration::from_millis(10);
50
51/// What `pool maintain` did: every identity in scope, and what became of each.
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53pub struct MaintainReport {
54    /// One entry per identity in scope, in identity order.
55    pub identities: Vec<IdentityMaintenance>,
56}
57
58impl MaintainReport {
59    /// Whether every command this run ran succeeded — what decides the exit code:
60    /// `0` when nothing ran or every command succeeded, `1` when any failed or timed
61    /// out.
62    pub(crate) fn every_command_succeeded(&self) -> bool {
63        self.identities
64            .iter()
65            .all(|identity| match &identity.outcome {
66                IdentityOutcome::Slots(slots) => slots.iter().all(|slot| {
67                    !matches!(
68                        slot.outcome,
69                        SlotOutcome::Ran {
70                            outcome: MaintenanceOutcome::Failed { .. }
71                                | MaintenanceOutcome::TimedOut,
72                            ..
73                        }
74                    )
75                }),
76                _ => true,
77            })
78    }
79}
80
81/// One identity, and what maintaining it came to.
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83pub struct IdentityMaintenance {
84    /// The identity key.
85    pub identity: String,
86    /// What became of it.
87    pub outcome: IdentityOutcome,
88}
89
90/// What became of one identity.
91///
92/// Externally tagged in kebab case, the way [`MaintenanceOutcome`] is: `"no-slots"`,
93/// `{"claimed": {"by_pid": N}}`, `{"slots": [...]}`.
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
95#[serde(rename_all = "kebab-case")]
96pub enum IdentityOutcome {
97    /// The resolved policy names no `maintain`, so there is nothing to run.
98    NoMaintainCommand,
99    /// Nothing has cut a slot for this identity.
100    NoSlots,
101    /// Another `pool maintain` holds this identity right now; nothing was touched.
102    Claimed {
103        /// The process holding it — `0` where it had not stamped its pid yet.
104        by_pid: u32,
105    },
106    /// Every slot, in number order, and what became of each.
107    Slots(Vec<SlotMaintenance>),
108}
109
110/// One slot, and what became of it.
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112pub struct SlotMaintenance {
113    /// The slot's number.
114    pub number: u32,
115    /// What became of it.
116    pub outcome: SlotOutcome,
117}
118
119/// What became of one slot.
120///
121/// Externally tagged in kebab case, the way [`IdentityOutcome`] is.
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123#[serde(rename_all = "kebab-case")]
124pub enum SlotOutcome {
125    /// Maintained inside the caller's `older_than`, so not run.
126    NotDue {
127        /// When it was last maintained, RFC3339.
128        last_maintained: String,
129    },
130    /// An open session record names it, so not run.
131    InUse {
132        /// The session working in it.
133        session: SessionToken,
134    },
135    /// Something else has it right now: a live claim this run did not write, an
136    /// occupancy it could not take, or a process working inside it.
137    ///
138    /// A healthy slot that is busy, and a later run finds it clear — which is what
139    /// separates it from [`SlotOutcome::Broken`], so a consumer reads breakage off
140    /// the variant rather than off a reason it has to parse.
141    Unavailable {
142        /// What holds it: the claim, the occupancy, or the processes inside it.
143        holder: String,
144    },
145    /// It could not be claimed because its clone, worktree or record is not usable —
146    /// nothing waiting clears.
147    Broken {
148        /// What is wrong with it.
149        reason: String,
150    },
151    /// The command ran in it, and how that ended.
152    Ran {
153        /// How the command ended.
154        outcome: MaintenanceOutcome,
155        /// How long it ran, in milliseconds.
156        duration_ms: u64,
157        /// Everything it wrote, stored as one artifact — `None` where storing it
158        /// failed, which is warned about on stderr.
159        log: Option<ArtifactId>,
160    },
161}
162
163/// The lock identity one identity's maintenance is serialized under.
164///
165/// Keyed on the pool directory the way placement is, and separate from the placement
166/// lock on purpose: this one is held for as long as every command takes, and the
167/// placement lock is what `open` must never wait on.
168fn maintenance_identity(pool: &Path) -> String {
169    format!("maintain:{}", pool.display())
170}
171
172/// Maintain the idle slots of every identity in `scope`.
173///
174/// `older_than` skips a slot maintained within that span; absent, every idle slot is
175/// due. A slot never maintained is always due.
176pub fn pool_maintain(scope: Scope, older_than: Option<Span>) -> Result<MaintainReport> {
177    let registry = store::load()?;
178    let resolutions: Vec<Resolution> = match &scope {
179        Scope::Repo(repo) => vec![store::resolve(&registry, repo)?],
180        Scope::All => {
181            // Once per identity rather than once per checkout of one, and in a
182            // stable order, so two callers over the whole host visit identities the
183            // same way round.
184            let mut identities: Vec<&str> = registry
185                .checkouts
186                .values()
187                .map(|checkout| checkout.identity.as_str())
188                .collect();
189            identities.sort_unstable();
190            identities.dedup();
191            identities
192                .into_iter()
193                .map(|identity| store::resolve(&registry, identity))
194                .collect::<Result<Vec<_>>>()?
195        }
196    };
197    let mut report = MaintainReport {
198        identities: Vec::new(),
199    };
200    for resolution in resolutions {
201        let outcome = maintain_identity(&resolution, older_than)?;
202        report.identities.push(IdentityMaintenance {
203            identity: resolution.key,
204            outcome,
205        });
206    }
207    Ok(report)
208}
209
210/// Maintain one identity: cheap answers first, then the identity lock, then each slot.
211fn maintain_identity(resolution: &Resolution, older_than: Option<Span>) -> Result<IdentityOutcome> {
212    let resolved = workspaces::resolve_for(resolution, Overrides::default())?;
213    let Some(maintenance) = resolved.maintain else {
214        return Ok(IdentityOutcome::NoMaintainCommand);
215    };
216    let pool = pool_dir(&workspace::identity_dir(&resolution.key)?);
217    if !pool.is_dir() {
218        return Ok(IdentityOutcome::NoSlots);
219    }
220    let held = maintenance_identity(&pool);
221    let Some(_maintaining) = lock::try_exclusive(&held)? else {
222        return Ok(IdentityOutcome::Claimed {
223            by_pid: lock::stamped_owner(&held)?.unwrap_or(0),
224        });
225    };
226    // Surplus is shed exactly as `open` sheds it, under the placement lock and
227    // before any slot is looked at; only the numbers survive the lock, because a
228    // state read here is stale by the time an earlier slot's command has run.
229    let numbers: Vec<u32> = {
230        let _serial = lock::exclusive(&placement_identity(&pool))?;
231        let mut survey = survey(&resolution.key, &pool)?;
232        shed(&mut survey, resolved.file_pool, &resolution.publication);
233        survey.slots.iter().map(|slot| slot.number).collect()
234    };
235    if numbers.is_empty() {
236        return Ok(IdentityOutcome::NoSlots);
237    }
238    let mut slots = Vec::with_capacity(numbers.len());
239    for number in numbers {
240        let outcome = maintain_slot(&resolution.key, &pool, number, &maintenance, older_than)?;
241        slots.push(SlotMaintenance { number, outcome });
242    }
243    Ok(IdentityOutcome::Slots(slots))
244}
245
246/// A slot this run has claimed: its directory, the record carrying the claim, and the
247/// exclusive take that proves nothing else is inside it — held until the outcome is
248/// written.
249struct Claimed {
250    dir: PathBuf,
251    record: SlotRecord,
252    _held: lock::Guard,
253}
254
255/// Maintain one slot: claim it under the placement lock, run the command with the
256/// lock released, and write the outcome.
257fn maintain_slot(
258    identity: &str,
259    pool: &Path,
260    number: u32,
261    maintenance: &Maintenance,
262    older_than: Option<Span>,
263) -> Result<SlotOutcome> {
264    let claimed = match claim(identity, pool, number, older_than)? {
265        Ok(claimed) => claimed,
266        Err(kept) => return Ok(kept),
267    };
268    let ran = run(&claimed.dir.join("worktree"), maintenance);
269    let log = match stream::store_artifact("log", &ran.output) {
270        Ok(stored) => Some(ArtifactId(stored.id)),
271        Err(failure) => {
272            eprintln!(
273                "onevcs: warning: the maintenance of slot {number} of {identity} is recorded \
274                 without what it wrote: {failure}"
275            );
276            None
277        }
278    };
279    let mut record = claimed.record;
280    record.maintaining = None;
281    record.last_maintained = Some(Timestamp::now());
282    record.last_outcome = Some(ran.outcome);
283    write_record(&claimed.dir, &record)?;
284    Ok(SlotOutcome::Ran {
285        outcome: ran.outcome,
286        duration_ms: u64::try_from(ran.duration.as_millis()).unwrap_or(u64::MAX),
287        log,
288    })
289}
290
291/// Claim one slot, or say why it is kept.
292///
293/// Under the placement lock, and with the slot's state read *now* rather than at the
294/// survey: an earlier slot's command may have run for the whole of its bound, and a
295/// slot idle then may be a session's by this time. The claim is written before the
296/// lock is released, so the next `open` reads it.
297fn claim(
298    identity: &str,
299    pool: &Path,
300    number: u32,
301    older_than: Option<Span>,
302) -> Result<std::result::Result<Claimed, SlotOutcome>> {
303    let _serial = lock::exclusive(&placement_identity(pool))?;
304    let dir = pool.join(number.to_string());
305    let open: Vec<workspace::Record> = workspace::all()?
306        .into_iter()
307        .filter(|record| record.identity == identity && record.state == Lifecycle::Open)
308        .collect();
309    let broken = |reason: String| Ok(Err(SlotOutcome::Broken { reason }));
310    let held_by = |holder: String| Ok(Err(SlotOutcome::Unavailable { holder }));
311    let (record, state) = state_of(&dir, number, identity, &open);
312    let mut record = match (record, state) {
313        (_, SlotState::InUse { session }) => return Ok(Err(SlotOutcome::InUse { session })),
314        (_, SlotState::Broken { reason }) => return broken(reason),
315        (_, SlotState::Maintaining { pid, since }) => {
316            return held_by(format!(
317                "a maintenance run this one did not start (pid {pid}) has claimed it since {since}"
318            ))
319        }
320        (Some(record), SlotState::Idle) => record,
321        // llmlint: ignore[changed_behavior_has_e2e] unreachable by construction:
322        // `state_of` answers `Idle` only after it has read the record, and it hands
323        // that record back beside the state. Said as a kept slot rather than a panic,
324        // because the answer to a reader that could not say is always "kept".
325        (None, SlotState::Idle) => return broken("its record could not be read".to_owned()),
326    };
327    if let (Some(span), Some(last)) = (older_than, &record.last_maintained) {
328        if !due(last, span) {
329            return Ok(Err(SlotOutcome::NotDue {
330                last_maintained: last.clone().into(),
331            }));
332        }
333    }
334    // The exclusive take proves nothing is inside the slot right now, and holding it
335    // through the run is what a prune meeting the slot then refuses on.
336    let Some(held) = lock::try_exclusive(&workspace::occupancy_identity(&dir))? else {
337        return held_by("a command is working in it right now".to_owned());
338    };
339    // A slot whose last session closed without returning it may still have that
340    // session's worker inside; the census `open` skips it on keeps a maintenance
341    // command from running under a build.
342    if !super::returned(&dir.join("worktree")) {
343        let holders = processes::holding(&dir);
344        if !holders.is_empty() {
345            let named: Vec<String> = holders.iter().map(ToString::to_string).collect();
346            return held_by(format!(
347                "a process is still working inside it: {}",
348                named.join("; ")
349            ));
350        }
351    }
352    let pid = std::process::id();
353    let Some(started) = workspace::process_started(pid) else {
354        // llmlint: ignore[changed_behavior_has_e2e] uncovered: every host this crate
355        // runs its journeys on answers its own process's start time; one that does not
356        // could write a claim no reader could tell from a dead process's, so it is
357        // refused rather than written.
358        return Err(error::invalid(format!(
359            "this host cannot identify process {pid} by its start time, so a maintenance \
360             claim it wrote could not be told from one a dead process left"
361        )));
362    };
363    record.maintaining = Some(Claim {
364        pid,
365        started,
366        since: Timestamp::now(),
367    });
368    write_record(&dir, &record)?;
369    Ok(Ok(Claimed {
370        dir,
371        record,
372        _held: held,
373    }))
374}
375
376/// Whether a slot last maintained at `last` is due again under `older_than`.
377///
378/// A stamp of the right shape naming no instant — which the record's own conversion
379/// cannot refuse — reads as due: what it fails to prove is that the slot was
380/// maintained recently, and a slot nothing can prove maintained is one that is not.
381/// A stamp in the future is the clock having moved backwards, which is *within* any
382/// span.
383fn due(last: &Timestamp, older_than: Span) -> bool {
384    let Some(instant) = ids::instant_of(&String::from(last.clone())) else {
385        return true;
386    };
387    let elapsed = SystemTime::now()
388        .duration_since(instant)
389        .unwrap_or(Duration::ZERO);
390    elapsed >= older_than.as_duration()
391}
392
393/// What one command did: how it ended, how long it took, and everything it wrote.
394struct Ran {
395    outcome: MaintenanceOutcome,
396    duration: Duration,
397    output: String,
398}
399
400/// Run the maintenance command in `worktree`, bounded by its timeout.
401///
402/// No shell, this process's environment, stdin closed, both streams captured — read
403/// as they arrive, the way every bounded command in this crate is read — and the
404/// whole process group ended when the bound fires, so a command's own children do
405/// not outlive the run that started them. A command that cannot be started at all
406/// is a failed one whose log says why: the attempt is what is recorded, and a
407/// missing program is not retried on every tick any more than a failing one is.
408/// There is no argv to find missing — [`workspaces::MaintenanceCommand`] carries the program
409/// beside its arguments, so the empty one cannot arrive here.
410fn run(worktree: &Path, maintenance: &Maintenance) -> Ran {
411    let started = Instant::now();
412    let bound = maintenance.timeout.as_duration();
413    let program = &maintenance.command.program;
414    let mut command = Command::new(program);
415    command
416        .args(&maintenance.command.args)
417        .current_dir(worktree)
418        .stdin(Stdio::null())
419        .stdout(Stdio::piped())
420        .stderr(Stdio::piped());
421    git::detach_process_group(&mut command);
422    let mut child = match command.spawn() {
423        Ok(child) => child,
424        Err(failure) => {
425            return Ran {
426                outcome: MaintenanceOutcome::Failed { exit: None },
427                duration: started.elapsed(),
428                output: format!("{program} could not be started: {failure}\n"),
429            }
430        }
431    };
432    let (ended, endings) = mpsc::channel();
433    let out_reader = git::PipeCapture::start(
434        child.stdout.take().expect("stdout was piped"),
435        ended.clone(),
436    );
437    let err_reader = git::PipeCapture::start(child.stderr.take().expect("stderr was piped"), ended);
438    let exited = exited_within(&mut child, started, bound, &endings);
439    let Some(status) = exited else {
440        git::terminate_group(&child);
441        let _ = child.kill();
442        let _ = child.wait();
443        let duration = started.elapsed();
444        let mut output = combined(out_reader.finish().bytes, err_reader.finish().bytes);
445        output.push_str(&format!(
446            "\n[onevcs: timed out after {:.3}s, bound {}]\n",
447            duration.as_secs_f64(),
448            maintenance.timeout
449        ));
450        return Ran {
451            outcome: MaintenanceOutcome::TimedOut,
452            duration,
453            output,
454        };
455    };
456    let duration = started.elapsed();
457    let output = combined(out_reader.finish().bytes, err_reader.finish().bytes);
458    let outcome = match status {
459        Ok(status) if status.success() => MaintenanceOutcome::Succeeded,
460        Ok(status) => MaintenanceOutcome::Failed {
461            exit: status.code(),
462        },
463        // llmlint: ignore[changed_behavior_has_e2e] uncovered: a child this process
464        // spawned and cannot ask about, which no interface produces; it is the failure
465        // it is, with the reason in the log.
466        Err(_) => MaintenanceOutcome::Failed { exit: None },
467    };
468    Ran {
469        outcome,
470        duration,
471        output,
472    }
473}
474
475/// Collect the child's exit within the bound its pipes are read under, waiting on
476/// the readers rather than on the clock between looks.
477fn exited_within(
478    child: &mut Child,
479    started: Instant,
480    bound: Duration,
481    endings: &mpsc::Receiver<()>,
482) -> Option<std::io::Result<std::process::ExitStatus>> {
483    loop {
484        match child.try_wait() {
485            Ok(Some(status)) => return Some(Ok(status)),
486            Err(failure) => return Some(Err(failure)),
487            Ok(None) if started.elapsed() >= bound => return None,
488            Ok(None) => git::await_an_ending(endings, POLL),
489        }
490    }
491}
492
493/// Everything the command wrote, standard output first then diagnostics — the one
494/// artifact the report names. Interleaving is not recoverable from two captured
495/// pipes; what matters is that the whole run survives.
496fn combined(stdout: Vec<u8>, stderr: Vec<u8>) -> String {
497    let mut output = String::from_utf8_lossy(&stdout).into_owned();
498    output.push_str(&String::from_utf8_lossy(&stderr));
499    output
500}