Skip to main content

stackless_daemon/
reaper.rs

1//! The lease reaper (ARCHITECTURE.md §6). Ticks every minute inside the
2//! daemon (so lease expiry is a system guarantee across reboots via
3//! launchd keep-alive). Each tick:
4//!
5//!  1. reaps every overdue instance — *unless* an operation holds its
6//!     lock (§2: an operation that outlives its whole lease finishes
7//!     first) or a prior failure's backoff has not elapsed;
8//!  2. records each failed reap with backoff and surfaces it (the
9//!     `reap_attempts` row `status`/`list` read — silence is not
10//!     success, invariant 4);
11//!  3. garbage-collects tombstones past the 7-day window (D14): the
12//!     instance row (FK cascade cleans leases/locks/checkpoints) and the
13//!     instance's logs dir.
14//!
15//! Teardown is the *same* verified path `down` uses. The daemon must not
16//! depend on stackless-local (a cycle — local depends on the daemon), so
17//! the reaper does not call the engine in-process; it spawns the CLI
18//! (`current_exe down <name> --json`), which holds the op lock correctly
19//! and is literally the `down` verb. Exit code zero is a successful
20//! reap.
21
22use std::path::Path;
23use std::time::Duration;
24
25use stackless_core::paths::Paths;
26use stackless_core::state::{ReapAttempt, ReapDecision, Store};
27use stackless_core::types::TcpPort;
28use tokio::time::{self, MissedTickBehavior};
29
30const TICK: Duration = Duration::from_secs(60);
31
32/// Run the reaper until the process exits. Opens the store fresh each
33/// tick (short-lived; the store is multi-process-safe rusqlite).
34pub async fn run(paths: Paths, proxy_port: TcpPort) {
35    let mut interval = time::interval(TICK);
36    // A slow tick (a hung `down` subprocess held us) must not burst-fire
37    // to catch up — one pass per period is the contract.
38    interval.set_missed_tick_behavior(MissedTickBehavior::Delay);
39    loop {
40        interval.tick().await;
41        tick(&paths, proxy_port).await;
42    }
43}
44
45/// One reaper pass. Errors opening or reading the store are swallowed
46/// and retried next tick — the reaper must never crash the daemon.
47///
48/// The rusqlite `Store` is neither `Send` nor `Sync`, so it cannot be
49/// held across the `run_down` await. Each phase opens it fresh (the
50/// store is multi-process-safe and these are short-lived): decide the
51/// worklist, drop the store, run the subprocess `down`s, then re-open to
52/// record outcomes.
53async fn tick(paths: &Paths, proxy_port: TcpPort) {
54    let worklist = plan_reaps(paths);
55    let exe = std::env::current_exe().map_err(|err| format!("cannot resolve binary path: {err}"));
56    for instance in worklist {
57        let outcome = match &exe {
58            Ok(path) => run_down(&instance, path, paths, proxy_port).await,
59            Err(err) => Err(err.clone()),
60        };
61        record_outcome(paths, &instance, outcome);
62    }
63    if let Ok(store) = Store::open_with_paths(paths) {
64        gc_tombstones(&store, paths);
65    }
66}
67
68/// The instances to reap this tick — the pure decision applied to each
69/// expired instance. The store is borrowed only here, never across an
70/// await.
71fn plan_reaps(paths: &Paths) -> Vec<String> {
72    let Ok(store) = Store::open_with_paths(paths) else {
73        return Vec::new();
74    };
75    let expired = store.expired_instances().unwrap_or_default();
76    let now = Store::now_secs();
77    expired
78        .into_iter()
79        .filter(|instance| {
80            let lock_held = store.lock_holder_alive(instance).unwrap_or(false);
81            let prior = store.reap_attempt(instance).ok().flatten();
82            matches!(
83                ReapDecision::decide(now, lock_held, prior.as_ref()),
84                ReapDecision::Reap
85            )
86        })
87        .collect()
88}
89
90/// Record a reap's result: clear the failure row on success (also done
91/// by the engine's `down`; this covers a row from an earlier tick), or
92/// advance the backoff and surface it on failure.
93fn record_outcome(paths: &Paths, instance: &str, outcome: Result<(), String>) {
94    let Ok(store) = Store::open_with_paths(paths) else {
95        return;
96    };
97    match outcome {
98        Ok(()) => {
99            let _ = store.clear_reap_failure(instance);
100        }
101        Err(reason) => {
102            let _ = store.record_reap_failure(instance, &reason);
103            let attempts = store
104                .reap_attempt(instance)
105                .ok()
106                .flatten()
107                .map(|a| a.attempts)
108                .unwrap_or(1);
109            eprintln!(
110                "stackless reaper: reap of {instance:?} failed: {reason} \
111                 (attempt {attempts}, retrying in {}s)",
112                ReapAttempt::backoff_after(attempts).as_secs()
113            );
114        }
115    }
116}
117
118/// Spawn `stackless down <instance> --json` and wait. The subprocess
119/// connects back to this daemon over the socket — a separate process,
120/// the daemon's accept loop runs concurrently, so there is no
121/// reentrancy. Exit zero is success; anything else carries the reason.
122///
123/// Passes `--state-dir` / `--proxy-port` so the child targets this
124/// daemon's layout, not `Paths::from_env()`.
125async fn run_down(
126    instance: &str,
127    executable: &Path,
128    paths: &Paths,
129    proxy_port: TcpPort,
130) -> Result<(), String> {
131    let port = proxy_port.get().to_string();
132    let output = tokio::process::Command::new(executable)
133        .args(["down", instance, "--json"])
134        .arg("--state-dir")
135        .arg(paths.state_dir())
136        .arg("--proxy-port")
137        .arg(&port)
138        .env("STACKLESS_NO_SELF_UPDATE", "1")
139        .output()
140        .await
141        .map_err(|err| format!("cannot spawn `down`: {err}"))?;
142    if output.status.success() {
143        return Ok(());
144    }
145    // The `--json` error envelope is the agent-facing reason; fall back
146    // to the exit code when stdout was empty.
147    let stdout = String::from_utf8_lossy(&output.stdout);
148    let reason = stdout
149        .lines()
150        .last()
151        .map(str::trim)
152        .filter(|line| !line.is_empty())
153        .map(str::to_owned)
154        .unwrap_or_else(|| format!("`down` exited with {}", output.status));
155    Err(reason)
156}
157
158fn gc_tombstones(store: &Store, paths: &Paths) {
159    for instance in store.gc_due_tombstones().unwrap_or_default() {
160        // Remove the logs dir first: if deleting the row fails, the next
161        // tick retries and the (now-absent) logs are simply re-skipped.
162        let logs = paths.logs_dir(&instance);
163        if logs.exists() {
164            let _ = std::fs::remove_dir_all(&logs);
165        }
166        if let Err(err) = store.delete_instance(&instance) {
167            eprintln!("stackless reaper: GC of tombstone {instance:?} failed: {err}");
168        }
169    }
170}
171
172/// Re-exported so the daemon's startup pass can run one immediate reap
173/// on boot/wake (the §6 "reaps overdue leases immediately on start").
174pub async fn tick_once(paths: &Paths, proxy_port: TcpPort) {
175    tick(paths, proxy_port).await;
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use stackless_core::state::TOMBSTONE_GC_WINDOW;
182    use std::collections::BTreeMap;
183    use std::time::SystemTime;
184
185    fn temp_store() -> (tempfile::TempDir, Paths, Store) {
186        let dir = tempfile::tempdir().expect("tempdir");
187        let paths = Paths::new(dir.path());
188        let store = Store::open_with_paths(&paths).expect("open");
189        (dir, paths, store)
190    }
191
192    const DEF: &str = "[stack]\nname = \"t\"\n[services.web]\nsource = { repo = \"https://example.invalid/x\", ref = \"main\" }\nhealth = { path = \"/\" }\n[services.web.mock]\nrun = \"true\"\n";
193
194    #[test]
195    fn reaper_skips_an_instance_holding_its_lock() {
196        let (_dir, _paths, store) = temp_store();
197        store
198            .create_instance("held", "mock", DEF, &BTreeMap::new(), "", false)
199            .expect("create");
200        store
201            .renew_lease("held", Duration::from_secs(0))
202            .expect("renew");
203        // An operation holds the lock (a live claim by this process).
204        let _claim = store.claim_lock("held", "up").expect("claim");
205        assert_eq!(store.expired_instances().expect("expired"), vec!["held"]);
206        let lock_held = store.lock_holder_alive("held").expect("alive");
207        assert!(lock_held);
208        // The decision the per-tick logic makes: never reap mid-flight.
209        assert_eq!(
210            ReapDecision::decide(Store::now_secs(), lock_held, None),
211            ReapDecision::SkipLocked
212        );
213    }
214
215    #[test]
216    fn gc_removes_only_tombstones_past_the_window() {
217        let (_dir, paths, store) = temp_store();
218        store
219            .create_instance("old", "mock", DEF, &BTreeMap::new(), "", false)
220            .expect("create old");
221        store
222            .create_instance("recent", "mock", DEF, &BTreeMap::new(), "", false)
223            .expect("create recent");
224        store.tombstone_instance("old").expect("tombstone old");
225        store
226            .tombstone_instance("recent")
227            .expect("tombstone recent");
228        // Backdate `old` past the GC window via the test-only conn.
229        let now = SystemTime::now()
230            .duration_since(SystemTime::UNIX_EPOCH)
231            .expect("clock")
232            .as_secs() as i64;
233        let stale = now - TOMBSTONE_GC_WINDOW.as_secs() as i64 - 1;
234        store
235            .conn_for_tests()
236            .execute(
237                "UPDATE instances SET tombstoned_at = ?1 WHERE name = 'old'",
238                [stale],
239            )
240            .expect("backdate");
241        assert_eq!(store.gc_due_tombstones().expect("due"), vec!["old"]);
242        gc_tombstones(&store, &paths);
243        assert!(store.instance("old").expect("q").is_none());
244        assert!(store.instance("recent").expect("q").is_some());
245    }
246}