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(®istry, 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(®istry, 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}