1use std::collections::BTreeMap;
14use std::time::{SystemTime, UNIX_EPOCH};
15
16use forge_primitives::cron::CronName;
17use redb::{ReadableTable, TableDefinition};
18use serde::{Deserialize, Serialize};
19
20use super::Db;
21
22const CRON_NAMES: TableDefinition<&str, &[u8]> = TableDefinition::new("cron_names");
23
24const RETENTION_SECS: u64 = 30 * 24 * 60 * 60;
27
28#[derive(Serialize, Deserialize)]
33struct Stored {
34 #[serde(flatten)]
35 name: CronName,
36 #[serde(default)]
37 fired_at: Option<u64>,
38}
39
40pub fn put(db: &Db, uuid: &str, name: &CronName, fired_at: SystemTime) -> anyhow::Result<()> {
43 let value =
44 serde_json::to_vec(&Stored { name: name.clone(), fired_at: Some(epoch(fired_at)) })?;
45 let txn = db.database().begin_write()?;
46 {
47 let mut table = txn.open_table(CRON_NAMES)?;
48 table.insert(uuid, value.as_slice())?;
49 }
50 txn.commit()?;
51 Ok(())
52}
53
54fn epoch(at: SystemTime) -> u64 {
57 at.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs()
58}
59
60pub fn prune_expired(db: &Db, now: SystemTime) -> anyhow::Result<usize> {
68 let now = epoch(now);
69 let mut expired = Vec::new();
70 let mut unstamped = Vec::new();
71 {
72 let txn = db.database().begin_read()?;
73 let table = match txn.open_table(CRON_NAMES) {
74 Ok(table) => table,
75 Err(redb::TableError::TableDoesNotExist(_)) => return Ok(0),
77 Err(error) => return Err(error.into()),
78 };
79 for row in table.iter()? {
80 let (uuid, value) = row?;
81 match serde_json::from_slice::<Stored>(value.value()) {
82 Ok(stored) => match stored.fired_at {
83 Some(at) if now.saturating_sub(at) > RETENTION_SECS => {
84 expired.push(uuid.value().to_owned());
85 }
86 Some(_) => {}
87 None => unstamped.push((uuid.value().to_owned(), stored.name)),
88 },
89 Err(error) => tracing::warn!(
90 target: "forge_workspace::store::cron_names",
91 event_name = "cron_name_record_undecodable",
92 uuid = %uuid.value(),
93 %error,
94 "leaving a cron-name record this build cannot date",
95 ),
96 }
97 }
98 }
99 if expired.is_empty() && unstamped.is_empty() {
100 return Ok(0);
101 }
102 let txn = db.database().begin_write()?;
103 {
104 let mut table = txn.open_table(CRON_NAMES)?;
105 for uuid in &expired {
106 table.remove(uuid.as_str())?;
107 }
108 for (uuid, name) in &unstamped {
109 let bytes = serde_json::to_vec(&Stored { name: name.clone(), fired_at: Some(now) })?;
110 table.insert(uuid.as_str(), bytes.as_slice())?;
111 }
112 }
113 txn.commit()?;
114 Ok(expired.len())
115}
116
117pub fn for_uuids(db: &Db, uuids: &[&str]) -> anyhow::Result<BTreeMap<String, CronName>> {
120 let txn = db.database().begin_read()?;
121 let table = match txn.open_table(CRON_NAMES) {
122 Ok(table) => table,
123 Err(redb::TableError::TableDoesNotExist(_)) => return Ok(BTreeMap::new()),
126 Err(error) => return Err(error.into()),
127 };
128 let mut found = BTreeMap::new();
129 for uuid in uuids {
130 let Some(value) = table.get(*uuid)? else {
131 continue;
132 };
133 match serde_json::from_slice::<CronName>(value.value()) {
134 Ok(name) => {
135 found.insert((*uuid).to_owned(), name);
136 }
137 Err(error) => tracing::warn!(
140 target: "forge_workspace::store::cron_names",
141 event_name = "cron_name_record_undecodable",
142 uuid = %uuid,
143 %error,
144 "skipping a cron-name record that failed to decode",
145 ),
146 }
147 }
148 Ok(found)
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154 use std::time::Duration;
155 use tempfile::tempdir;
156
157 fn name(description: Option<&str>) -> CronName {
158 CronName { cron_id: "cron-1".to_owned(), description: description.map(str::to_owned) }
159 }
160
161 fn at(secs: u64) -> SystemTime {
162 SystemTime::UNIX_EPOCH + Duration::from_secs(secs)
163 }
164
165 fn secs(at: SystemTime) -> u64 {
166 at.duration_since(SystemTime::UNIX_EPOCH).expect("after the epoch").as_secs()
167 }
168
169 #[test]
170 fn a_fired_prompt_keeps_its_schedule_across_a_reopen() {
171 let dir = tempdir().expect("tempdir");
172 let path = dir.path().join("db.redb");
173 {
174 let db = Db::open(&path).expect("open db");
175 put(&db, "p-1", &name(Some("Morning summary")), at(1_000)).expect("write");
176 }
177 let db = Db::open(&path).expect("reopen db");
178 let found = for_uuids(&db, &["p-1"]).expect("read");
179 assert_eq!(
180 found.get("p-1"),
181 Some(&name(Some("Morning summary"))),
182 "a fire's pairing survives the restart that loses the frame carrying it"
183 );
184 }
185
186 #[test]
187 fn a_read_answers_only_the_prompts_it_was_asked_for() {
188 let dir = tempdir().expect("tempdir");
189 let db = Db::open(&dir.path().join("db.redb")).expect("open db");
190 put(&db, "asked", &name(None), at(1_000)).expect("write asked");
191 put(&db, "other", &name(Some("not this page")), at(1_000)).expect("write other");
192 put(&db, "missing-description", &name(None), at(1_000)).expect("write bare");
193
194 let found = for_uuids(&db, &["asked", "missing-description", "never-fired"]).expect("read");
195 assert_eq!(
196 found.keys().collect::<Vec<_>>(),
197 vec!["asked", "missing-description"],
198 "a page carries the pairings its own rows need, and nothing else"
199 );
200 assert_eq!(
201 found.get("asked").and_then(|held| held.description.clone()),
202 None,
203 "a cron registered without a description keeps its absence"
204 );
205 }
206
207 #[test]
208 fn a_sweep_drops_a_pairing_past_the_window_and_keeps_one_inside_it() {
209 let dir = tempdir().expect("tempdir");
210 let db = Db::open(&dir.path().join("db.redb")).expect("open db");
211 let now = at(10_000_000);
212 put(&db, "old", &name(None), at(secs(now) - RETENTION_SECS - 1)).expect("write old");
213 put(&db, "edge", &name(None), at(secs(now) - RETENTION_SECS)).expect("write edge");
214 put(&db, "new", &name(None), at(secs(now))).expect("write new");
215
216 let removed = prune_expired(&db, now).expect("sweep");
217
218 assert_eq!(removed, 1, "only the pairing older than the window goes");
219 let kept = for_uuids(&db, &["old", "edge", "new"]).expect("read");
220 assert_eq!(
221 kept.keys().collect::<Vec<_>>(),
222 vec!["edge", "new"],
223 "the window keeps a row at its edge and every row inside it"
224 );
225 }
226
227 #[test]
228 fn a_sweep_leaves_a_record_it_cannot_read_alone() {
229 let dir = tempdir().expect("tempdir");
230 let db = Db::open(&dir.path().join("db.redb")).expect("open db");
231 let now = at(10_000_000);
232 put(&db, "expired", &name(None), at(secs(now) - RETENTION_SECS - 1)).expect("write");
236 let txn = db.database().begin_write().expect("begin write");
237 {
238 let mut table = txn.open_table(CRON_NAMES).expect("open table");
239 table.insert("broken", b"not json".as_slice()).expect("insert");
240 }
241 txn.commit().expect("commit");
242
243 let removed = prune_expired(&db, now).expect("sweep");
244
245 assert_eq!(removed, 1, "the decodable row past the window goes, and only it");
246 let txn = db.database().begin_read().expect("begin read");
247 let table = txn.open_table(CRON_NAMES).expect("open table");
248 assert!(
249 table.get("broken").expect("get").is_some(),
250 "a record that will not decode stays readable to a build that can"
251 );
252 assert!(
253 table.get("expired").expect("get").is_none(),
254 "the row beside it was dated and swept"
255 );
256 }
257
258 #[test]
259 fn a_sweep_stamps_a_pairing_written_before_the_window_existed() {
260 let dir = tempdir().expect("tempdir");
261 let db = Db::open(&dir.path().join("db.redb")).expect("open db");
262 let legacy = serde_json::to_vec(&name(Some("Morning summary"))).expect("serialize");
265 let txn = db.database().begin_write().expect("begin write");
266 {
267 let mut table = txn.open_table(CRON_NAMES).expect("open table");
268 table.insert("p-1", legacy.as_slice()).expect("insert");
269 }
270 txn.commit().expect("commit");
271 let now = at(10_000_000);
272
273 let removed = prune_expired(&db, now).expect("sweep");
274
275 assert_eq!(removed, 0, "a row with no age is kept, not dropped");
276 assert_eq!(
277 for_uuids(&db, &["p-1"]).expect("read").get("p-1"),
278 Some(&name(Some("Morning summary"))),
279 "the pairing it carried survives the stamp"
280 );
281 assert_eq!(
284 prune_expired(&db, at(secs(now) + RETENTION_SECS - 1)).expect("inside the window"),
285 0,
286 "one second short of the window the row stays, which a stamp of the epoch would fail",
287 );
288 assert_eq!(
289 prune_expired(&db, at(secs(now) + RETENTION_SECS + 1)).expect("second sweep"),
290 1,
291 "once stamped, the legacy row obeys the window"
292 );
293 }
294
295 #[test]
296 fn a_fresh_store_answers_no_pairings_rather_than_failing() {
297 let dir = tempdir().expect("tempdir");
298 let db = Db::open(&dir.path().join("db.redb")).expect("open db");
299 assert!(for_uuids(&db, &["p-1"]).expect("a fresh store reads empty").is_empty());
300 }
301}