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        .output()
139        .await
140        .map_err(|err| format!("cannot spawn `down`: {err}"))?;
141    if output.status.success() {
142        return Ok(());
143    }
144    // The `--json` error envelope is the agent-facing reason; fall back
145    // to the exit code when stdout was empty.
146    let stdout = String::from_utf8_lossy(&output.stdout);
147    let reason = stdout
148        .lines()
149        .last()
150        .map(str::trim)
151        .filter(|line| !line.is_empty())
152        .map(str::to_owned)
153        .unwrap_or_else(|| format!("`down` exited with {}", output.status));
154    Err(reason)
155}
156
157fn gc_tombstones(store: &Store, paths: &Paths) {
158    for instance in store.gc_due_tombstones().unwrap_or_default() {
159        // Remove the logs dir first: if deleting the row fails, the next
160        // tick retries and the (now-absent) logs are simply re-skipped.
161        let logs = paths.logs_dir(&instance);
162        if logs.exists() {
163            let _ = std::fs::remove_dir_all(&logs);
164        }
165        if let Err(err) = store.delete_instance(&instance) {
166            eprintln!("stackless reaper: GC of tombstone {instance:?} failed: {err}");
167        }
168    }
169}
170
171/// Re-exported so the daemon's startup pass can run one immediate reap
172/// on boot/wake (the §6 "reaps overdue leases immediately on start").
173pub async fn tick_once(paths: &Paths, proxy_port: TcpPort) {
174    tick(paths, proxy_port).await;
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use stackless_core::state::TOMBSTONE_GC_WINDOW;
181    use std::collections::BTreeMap;
182    use std::time::SystemTime;
183
184    fn temp_store() -> (tempfile::TempDir, Paths, Store) {
185        let dir = tempfile::tempdir().expect("tempdir");
186        let paths = Paths::new(dir.path());
187        let store = Store::open_with_paths(&paths).expect("open");
188        (dir, paths, store)
189    }
190
191    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";
192
193    #[test]
194    fn reaper_skips_an_instance_holding_its_lock() {
195        let (_dir, _paths, store) = temp_store();
196        store
197            .create_instance("held", "mock", DEF, &BTreeMap::new(), "", false)
198            .expect("create");
199        store
200            .renew_lease("held", Duration::from_secs(0))
201            .expect("renew");
202        // An operation holds the lock (a live claim by this process).
203        let _claim = store.claim_lock("held", "up").expect("claim");
204        assert_eq!(store.expired_instances().expect("expired"), vec!["held"]);
205        let lock_held = store.lock_holder_alive("held").expect("alive");
206        assert!(lock_held);
207        // The decision the per-tick logic makes: never reap mid-flight.
208        assert_eq!(
209            ReapDecision::decide(Store::now_secs(), lock_held, None),
210            ReapDecision::SkipLocked
211        );
212    }
213
214    #[test]
215    fn gc_removes_only_tombstones_past_the_window() {
216        let (_dir, paths, store) = temp_store();
217        store
218            .create_instance("old", "mock", DEF, &BTreeMap::new(), "", false)
219            .expect("create old");
220        store
221            .create_instance("recent", "mock", DEF, &BTreeMap::new(), "", false)
222            .expect("create recent");
223        store.tombstone_instance("old").expect("tombstone old");
224        store
225            .tombstone_instance("recent")
226            .expect("tombstone recent");
227        // Backdate `old` past the GC window via the test-only conn.
228        let now = SystemTime::now()
229            .duration_since(SystemTime::UNIX_EPOCH)
230            .expect("clock")
231            .as_secs() as i64;
232        let stale = now - TOMBSTONE_GC_WINDOW.as_secs() as i64 - 1;
233        store
234            .conn_for_tests()
235            .execute(
236                "UPDATE instances SET tombstoned_at = ?1 WHERE name = 'old'",
237                [stale],
238            )
239            .expect("backdate");
240        assert_eq!(store.gc_due_tombstones().expect("due"), vec!["old"]);
241        gc_tombstones(&store, &paths);
242        assert!(store.instance("old").expect("q").is_none());
243        assert!(store.instance("recent").expect("q").is_some());
244    }
245}