salvor_server/wake.rs
1//! The wake sweeper: the background task that re-drives runs whose durable
2//! timer has come due.
3//!
4//! # Why a sweeper at all
5//!
6//! A run parked on a timer is passive data. Nothing in this process holds it,
7//! nothing is scheduled for its instant, and a restart forgets nothing because
8//! there was nothing to forget: the deadline lives in the log. So waking is not
9//! a callback firing, it is somebody re-reading the store and re-driving what
10//! is overdue. This task is that somebody, for an operator who runs a server;
11//! `salvor wake` is the same thing for one who runs cron.
12//!
13//! # It is the resume path, not a second driver
14//!
15//! Every due run goes through [`crate::runs::redrive`], the exact function the
16//! resume endpoint's recover arm calls. A woken run therefore rebuilds its
17//! agent the same way, drives over the same loop (or the same graph engine),
18//! records the same events, and reports the same errors as one a person woke
19//! over HTTP. There is no wake-specific drive to keep in step with the real
20//! one, and no wake-specific verb: the deadline is enforced inside
21//! [`RunCtx::await_wake`](salvor_runtime::RunCtx::await_wake) against the
22//! injected clock, so a run driven a minute early simply records nothing and
23//! stays asleep.
24//!
25//! # Not fighting the drivers already running
26//!
27//! The server tracks which runs a task in this process is still driving
28//! ([`AppState::is_run_active`]). The sweeper skips those, and it drives
29//! sequentially, so within one pass it can never queue a run twice; across
30//! passes, a run stays in that set from the moment
31//! [`crate::runs::redrive`] spawns its task until the task ends, which is
32//! exactly the span during which re-driving it would be wrong. A `sleeping`
33//! status and an active driver are contradictory states in any case (the fold
34//! reports sleeping only for a log that stopped), so the check is a guard
35//! against a stale read, not the normal case.
36//!
37//! # Nor fighting a client
38//!
39//! A run opened through `/v1/client-runs` is driven by its caller under a
40//! single-writer drive token, not by a task in this process, so
41//! [`AppState::is_run_active`] never sees it; a separate check
42//! ([`AppState::is_client_run`]) is what keeps the sweeper off it. A
43//! client-driven run's timer is the client's to wake, since re-driving one
44//! here would be a second writer racing its drive token, so a due one is
45//! left asleep here regardless of how overdue it is.
46//!
47//! That check reads a registry that dies with the process, so the run's own
48//! log is consulted too (see
49//! [`client_runs::log_is_client_driven`](crate::client_runs::log_is_client_driven)):
50//! a client-driven run's `RunStarted` records `driven_by: client`, which
51//! survives a restart the leases do not. A restarted server therefore still
52//! leaves a napping client-driven run to its client, rather than adopting
53//! every one it no longer remembers.
54//!
55//! # One bad run does not stop the sweep
56//!
57//! Every failure is per-run: an agent this server has never had registered, a
58//! graph it does not hold, a build that will not build. Each is logged and the
59//! loop moves to the next run, and the run is left asleep with its log
60//! untouched, still due, so registering the missing definition is enough to
61//! make the next pass wake it. Only the store listing itself failing ends a
62//! pass, and even that only ends the pass: the next one tries again.
63//!
64//! An unwakeable run logs the same fields every pass, but only the first
65//! sighting is loud: [`AppState::mark_unwakeable_warned`] names the first pass
66//! `WARN` and every later one `DEBUG`, so an operator learns about the gap
67//! once instead of every sweep interval for as long as it stays unregistered,
68//! while the fields to find and fix it stay available to anyone watching at
69//! debug level. The record clears the moment the run wakes or drops out of
70//! the due set, so it never mutes a genuinely new nap.
71
72use std::collections::HashSet;
73
74use salvor_core::RunId;
75use tokio::task::JoinHandle;
76
77use crate::state::AppState;
78
79/// A running sweeper, which stops when this value is dropped.
80///
81/// A guard rather than a bare [`JoinHandle`] because the task outlives every
82/// scope that could remember to stop it otherwise: [`crate::serve`] is itself
83/// commonly aborted (a test tearing a server down, a shutdown signal), and an
84/// abort runs no cleanup code, only drops. Dropping the guard is therefore the
85/// only teardown that always happens.
86pub struct Sweeper(Option<JoinHandle<()>>);
87
88impl Drop for Sweeper {
89 fn drop(&mut self) {
90 if let Some(handle) = self.0.take() {
91 handle.abort();
92 }
93 }
94}
95
96/// Spawns the sweeper over `state`.
97///
98/// A zero [`AppState::wake_interval`] is the off switch: no task is spawned,
99/// and nothing on this server wakes a timer. The returned guard is inert in
100/// that case, so a caller holds it unconditionally.
101#[must_use]
102pub fn spawn_sweeper(state: AppState) -> Sweeper {
103 let interval = state.wake_interval();
104 if interval.is_zero() {
105 tracing::info!("wake sweeper off; sleeping runs wake only through `salvor wake`");
106 return Sweeper(None);
107 }
108 tracing::info!(
109 interval_secs = interval.as_secs_f64(),
110 "wake sweeper started"
111 );
112 Sweeper(Some(tokio::spawn(async move {
113 loop {
114 // Sleep first. A server that has just started has nothing in flight
115 // and every sweep costs a fold of every log, so the interval is the
116 // right amount of work to do before the first pass, not after it.
117 tokio::time::sleep(interval).await;
118 sweep(&state).await;
119 }
120 })))
121}
122
123/// One pass: select the runs whose deadline has passed, re-drive each, and
124/// report the ids a drive was started for.
125///
126/// The loop calls this on its interval; it is public so a host on its own
127/// schedule, or a test that must not race one, can run exactly one pass.
128pub async fn sweep(state: &AppState) -> Vec<RunId> {
129 // The state's own clock, so a test that injects one selects against the
130 // same instant the drive will measure the deadline with.
131 let now = state.now();
132 let store = state.store();
133 let due = match salvor_runtime::due_runs(store.as_ref(), now).await {
134 Ok(due) => due,
135 Err(error) => {
136 tracing::warn!(%error, "wake sweep could not list runs; retrying next pass");
137 return Vec::new();
138 }
139 };
140
141 // A run that warned on an earlier pass but is not due this pass has
142 // nothing left to warn about here; drop its record rather than let it
143 // linger and mute a warning about some unrelated later nap.
144 let due_ids: HashSet<RunId> = due.iter().map(|run| run.run_id).collect();
145 state.prune_unwakeable_warned(&due_ids);
146
147 let mut driven = Vec::new();
148 for run in due {
149 // A client-driven run (opened through `/v1/client-runs`) holds a
150 // single-writer drive token that a caller presents on every append;
151 // `runs::redrive` spawning a server task against the same log would
152 // be a second writer racing the client for the same sequence
153 // numbers. A client-driven run's timer is the client's to wake for
154 // exactly that reason, so the sweeper leaves any run with a lease
155 // alone, current or lapsed: a
156 // lapsed lease still means a client opened this run and may resume
157 // driving it, not that this server may. It stays due; the client's
158 // own resume path (or a fresh open) is what wakes it.
159 if state.is_client_run(run.run_id) {
160 tracing::debug!(
161 run_id = %run.run_id.as_uuid(),
162 "skipping a due run that is client-driven"
163 );
164 continue;
165 }
166 if state.is_run_active(run.run_id) {
167 tracing::debug!(
168 run_id = %run.run_id.as_uuid(),
169 "skipping a due run a driver in this process is already on"
170 );
171 continue;
172 }
173 let log = match store.read_log(run.run_id).await {
174 Ok(log) => log,
175 Err(error) => {
176 tracing::warn!(
177 run_id = %run.run_id.as_uuid(),
178 %error,
179 "wake sweep could not read a due run's log; leaving it asleep"
180 );
181 continue;
182 }
183 };
184 // The same skip, on the log's own evidence. The lease check above
185 // knows only the runs this process opened, so after a restart it does
186 // not recognize a run a client is still driving; the `driven_by` its
187 // `RunStarted` records does, because the log outlived the process the
188 // leases died with. Without this, the first restart would put this
189 // sweeper back to racing clients for their runs' log positions.
190 if crate::client_runs::log_is_client_driven(&log) {
191 tracing::debug!(
192 run_id = %run.run_id.as_uuid(),
193 "skipping a due run whose log records it as client-driven"
194 );
195 continue;
196 }
197 match crate::runs::redrive(state.clone(), run.run_id, &log).await {
198 Ok(_) => {
199 state.clear_unwakeable_warned(run.run_id);
200 tracing::info!(
201 run_id = %run.run_id.as_uuid(),
202 wake_at = %run.wake_at,
203 "waking a run whose timer came due"
204 );
205 driven.push(run.run_id);
206 }
207 // Not an error of the sweeper's: this server cannot wake a run
208 // whose agent or graph it does not hold. The run stays asleep and
209 // still due, so registering the definition is all it takes; no
210 // rate limiting on whether this is logged, only on how loud: the
211 // first sighting is `warn!`, an ongoing, actionable condition an
212 // operator should find without turning on debug-level noise;
213 // every later pass while the record stands repeats the same
214 // fields at `debug!` instead, so a definition left unregistered
215 // for a week does not page the same warning every sweep interval.
216 Err(error) => {
217 if state.mark_unwakeable_warned(run.run_id) {
218 tracing::warn!(
219 run_id = %run.run_id.as_uuid(),
220 ?error,
221 "cannot wake this run here; leaving it asleep; wake it with salvor wake, \
222 passing the --agent/--graph files it was started with"
223 );
224 } else {
225 tracing::debug!(
226 run_id = %run.run_id.as_uuid(),
227 ?error,
228 "cannot wake this run here; leaving it asleep; wake it with salvor wake, \
229 passing the --agent/--graph files it was started with"
230 );
231 }
232 }
233 }
234 }
235 driven
236}