Skip to main content

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}