stackless_daemon/
reaper.rs1use 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
32pub async fn run(paths: Paths, proxy_port: TcpPort) {
35 let mut interval = time::interval(TICK);
36 interval.set_missed_tick_behavior(MissedTickBehavior::Delay);
39 loop {
40 interval.tick().await;
41 tick(&paths, proxy_port).await;
42 }
43}
44
45async 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
68fn 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
90fn 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
118async 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 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 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
172pub 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 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 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 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}