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);
31const DOWN_BUDGET: Duration = Duration::from_secs(900);
32
33/// Run the reaper until the process exits. Opens the store fresh each
34/// tick (short-lived; the store is multi-process-safe rusqlite).
35pub async fn run(paths: Paths, proxy_port: TcpPort) {
36    let mut interval = time::interval(TICK);
37    // A slow tick (a hung `down` subprocess held us) must not burst-fire
38    // to catch up — one pass per period is the contract.
39    interval.set_missed_tick_behavior(MissedTickBehavior::Delay);
40    loop {
41        interval.tick().await;
42        tick(&paths, proxy_port).await;
43    }
44}
45
46/// One reaper pass. Errors opening or reading the store are swallowed
47/// and retried next tick — the reaper must never crash the daemon.
48///
49/// The rusqlite `Store` is neither `Send` nor `Sync`, so it cannot be
50/// held across the `run_down` await. Each phase opens it fresh (the
51/// store is multi-process-safe and these are short-lived): decide the
52/// worklist, drop the store, run the subprocess `down`s, then re-open to
53/// record outcomes.
54async fn tick(paths: &Paths, proxy_port: TcpPort) {
55    let worklist = plan_reaps(paths);
56    let exe = std::env::current_exe().map_err(|err| format!("cannot resolve binary path: {err}"));
57    for instance in worklist {
58        let outcome = match &exe {
59            Ok(path) => run_down(&instance, path, paths, proxy_port).await,
60            Err(err) => Err(err.clone()),
61        };
62        record_outcome(paths, &instance, outcome);
63    }
64    if let Ok(store) = Store::open_with_paths(paths) {
65        gc_tombstones(&store, paths);
66    }
67}
68
69/// The instances to reap this tick — the pure decision applied to each
70/// expired instance. The store is borrowed only here, never across an
71/// await.
72fn plan_reaps(paths: &Paths) -> Vec<String> {
73    let Ok(store) = Store::open_with_paths(paths) else {
74        return Vec::new();
75    };
76    let expired = store.expired_instances().unwrap_or_default();
77    let now = Store::now_secs();
78    expired
79        .into_iter()
80        .filter(|instance| {
81            let lock_held = store.lock_holder_alive(instance).unwrap_or(false);
82            let prior = store.reap_attempt(instance).ok().flatten();
83            matches!(
84                ReapDecision::decide(now, lock_held, prior.as_ref()),
85                ReapDecision::Reap
86            )
87        })
88        .collect()
89}
90
91/// Record a reap's result: clear the failure row on success (also done
92/// by the engine's `down`; this covers a row from an earlier tick), or
93/// advance the backoff and surface it on failure.
94fn record_outcome(paths: &Paths, instance: &str, outcome: Result<(), String>) {
95    let Ok(store) = Store::open_with_paths(paths) else {
96        return;
97    };
98    match outcome {
99        Ok(()) => {
100            let _ = store.clear_reap_failure(instance);
101        }
102        Err(reason) => {
103            let _ = store.record_reap_failure(instance, &reason);
104            let attempts = store
105                .reap_attempt(instance)
106                .ok()
107                .flatten()
108                .map(|a| a.attempts)
109                .unwrap_or(1);
110            eprintln!(
111                "stackless reaper: reap of {instance:?} failed: {reason} \
112                 (attempt {attempts}, retrying in {}s)",
113                ReapAttempt::backoff_after(attempts).as_secs()
114            );
115        }
116    }
117}
118
119/// Spawn `stackless down <instance> --json` and wait. The subprocess
120/// connects back to this daemon over the socket — a separate process,
121/// the daemon's accept loop runs concurrently, so there is no
122/// reentrancy. Exit zero is success; anything else carries the reason.
123///
124/// Passes `--state-dir` / `--proxy-port` so the child targets this
125/// daemon's layout, not `Paths::from_env()`.
126async fn run_down(
127    instance: &str,
128    executable: &Path,
129    paths: &Paths,
130    proxy_port: TcpPort,
131) -> Result<(), String> {
132    let port = proxy_port.get().to_string();
133    let mut cmd = tokio::process::Command::new(executable);
134    cmd.args(["down", instance, "--json"])
135        .arg("--state-dir")
136        .arg(paths.state_dir())
137        .arg("--proxy-port")
138        .arg(&port)
139        .env("STACKLESS_NO_SELF_UPDATE", "1")
140        .stdout(std::process::Stdio::piped())
141        .stderr(std::process::Stdio::piped());
142    #[cfg(unix)]
143    {
144        cmd.process_group(0);
145    }
146    let child = cmd
147        .spawn()
148        .map_err(|err| format!("cannot spawn `down`: {err}"))?;
149    let pid = child.id();
150    match tokio::time::timeout(DOWN_BUDGET, child.wait_with_output()).await {
151        Ok(Ok(output)) => {
152            if output.status.success() {
153                return Ok(());
154            }
155            let stdout = String::from_utf8_lossy(&output.stdout);
156            Err(reap_failure_reason(
157                &stdout,
158                &format!("`down` exited with {}", output.status),
159            ))
160        }
161        Ok(Err(err)) => Err(format!("cannot wait for `down`: {err}")),
162        Err(_) => {
163            if let Some(pid) = pid {
164                stackless_core::process::kill_process_tree(pid);
165            }
166            Err(format!(
167                "reaper.down_timeout: `down {instance}` exceeded {}s",
168                DOWN_BUDGET.as_secs()
169            ))
170        }
171    }
172}
173
174/// Prefer `error.code` / `error.message` from a `--json` envelope over the
175/// last pretty-print line (which is often just `}`).
176pub(crate) fn reap_failure_reason(stdout: &str, fallback: &str) -> String {
177    let Some(start) = stdout.find('{') else {
178        let last = stdout
179            .lines()
180            .rev()
181            .map(str::trim)
182            .find(|line| !line.is_empty() && *line != "}" && *line != "{");
183        return last.unwrap_or(fallback).to_owned();
184    };
185    let Ok(value) = serde_json::from_str::<serde_json::Value>(&stdout[start..]) else {
186        return fallback.to_owned();
187    };
188    let code = value
189        .pointer("/error/code")
190        .and_then(serde_json::Value::as_str)
191        .unwrap_or("");
192    let message = value
193        .pointer("/error/message")
194        .and_then(serde_json::Value::as_str)
195        .unwrap_or("");
196    match (code.is_empty(), message.is_empty()) {
197        (true, true) => fallback.to_owned(),
198        (false, true) => code.to_owned(),
199        (true, false) => message.to_owned(),
200        (false, false) => format!("{code}: {message}"),
201    }
202}
203
204fn gc_tombstones(store: &Store, paths: &Paths) {
205    for instance in store.gc_due_tombstones().unwrap_or_default() {
206        // Remove the logs dir first: if deleting the row fails, the next
207        // tick retries and the (now-absent) logs are simply re-skipped.
208        let logs = paths.logs_dir(&instance);
209        if logs.exists() {
210            let _ = std::fs::remove_dir_all(&logs);
211        }
212        if let Err(err) = store.delete_instance(&instance) {
213            eprintln!("stackless reaper: GC of tombstone {instance:?} failed: {err}");
214        }
215    }
216}
217
218/// Re-exported so the daemon's startup pass can run one immediate reap
219/// on boot/wake (the §6 "reaps overdue leases immediately on start").
220pub async fn tick_once(paths: &Paths, proxy_port: TcpPort) {
221    tick(paths, proxy_port).await;
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227    use stackless_core::state::TOMBSTONE_GC_WINDOW;
228    use std::collections::BTreeMap;
229    use std::time::SystemTime;
230
231    fn temp_store() -> (tempfile::TempDir, Paths, Store) {
232        let dir = tempfile::tempdir().expect("tempdir");
233        let paths = Paths::new(dir.path());
234        let store = Store::open_with_paths(&paths).expect("open");
235        (dir, paths, store)
236    }
237
238    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";
239
240    #[test]
241    fn reaper_skips_an_instance_holding_its_lock() {
242        let (_dir, _paths, store) = temp_store();
243        store
244            .create_instance("held", "mock", DEF, &BTreeMap::new(), "", false)
245            .expect("create");
246        store
247            .renew_lease("held", Duration::from_secs(0))
248            .expect("renew");
249        // An operation holds the lock (a live claim by this process).
250        let _claim = store.claim_lock("held", "up").expect("claim");
251        assert_eq!(store.expired_instances().expect("expired"), vec!["held"]);
252        let lock_held = store.lock_holder_alive("held").expect("alive");
253        assert!(lock_held);
254        // The decision the per-tick logic makes: never reap mid-flight.
255        assert_eq!(
256            ReapDecision::decide(Store::now_secs(), lock_held, None),
257            ReapDecision::SkipLocked
258        );
259    }
260
261    #[test]
262    fn gc_removes_only_tombstones_past_the_window() {
263        let (_dir, paths, store) = temp_store();
264        store
265            .create_instance("old", "mock", DEF, &BTreeMap::new(), "", false)
266            .expect("create old");
267        store
268            .create_instance("recent", "mock", DEF, &BTreeMap::new(), "", false)
269            .expect("create recent");
270        store.tombstone_instance("old").expect("tombstone old");
271        store
272            .tombstone_instance("recent")
273            .expect("tombstone recent");
274        // Backdate `old` past the GC window via the test-only conn.
275        let now = SystemTime::now()
276            .duration_since(SystemTime::UNIX_EPOCH)
277            .expect("clock")
278            .as_secs() as i64;
279        let stale = now - TOMBSTONE_GC_WINDOW.as_secs() as i64 - 1;
280        store
281            .conn_for_tests()
282            .execute(
283                "UPDATE instances SET tombstoned_at = ?1 WHERE name = 'old'",
284                [stale],
285            )
286            .expect("backdate");
287        assert_eq!(store.gc_due_tombstones().expect("due"), vec!["old"]);
288        gc_tombstones(&store, &paths);
289        assert!(store.instance("old").expect("q").is_none());
290        assert!(store.instance("recent").expect("q").is_some());
291    }
292
293    #[test]
294    fn reap_reason_reads_pretty_json_envelope_not_closing_brace() {
295        let stdout = r#"{
296  "ok": false,
297  "error": {
298    "schema_version": 1,
299    "code": "stripe.projects.timeout",
300    "message": "stripe projects timed out after 90s",
301    "remediation": "kill leftover stripe processes"
302  }
303}"#;
304        let reason = reap_failure_reason(stdout, "`down` exited with 1");
305        assert_eq!(
306            reason,
307            "stripe.projects.timeout: stripe projects timed out after 90s"
308        );
309        assert!(!reason.contains('}'));
310    }
311
312    #[test]
313    fn reap_reason_falls_back_when_stdout_is_empty() {
314        assert_eq!(
315            reap_failure_reason("   \n", "`down` exited with 1"),
316            "`down` exited with 1"
317        );
318    }
319}