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 .output()
139 .await
140 .map_err(|err| format!("cannot spawn `down`: {err}"))?;
141 if output.status.success() {
142 return Ok(());
143 }
144 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 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
171pub 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 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 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 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}