Skip to main content

forge_workspace/store/
cron_names.rs

1//! The schedule each fired prompt came from, keyed by the prompt's own id.
2//!
3//! The `CronPromptAppended` frame carries the pairing once, when the fire is
4//! delivered; a row replayed from a transcript afterwards holds the prompt's
5//! prose alone. This is the read half of that pairing - written when the fire
6//! is delivered, read when a conversation page is built - so a page that
7//! reloads later titles the row as the live frame did (rule 25's read/live
8//! symmetry).
9//!
10//! Machine-local, like the transcript it describes, and one row per fired
11//! prompt: the boot sweeps a row once it is past the retention window.
12
13use 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
24/// How long a fired prompt's pairing is kept: long enough that a row replayed
25/// after a reload still names its schedule, bounded so the store stops growing.
26const RETENTION_SECS: u64 = 30 * 24 * 60 * 60;
27
28/// The stored value: the name plus when the fire happened.
29///
30/// `fired_at` is optional only so a row written before the stamp existed still
31/// decodes; every row [`put`] writes carries it.
32#[derive(Serialize, Deserialize)]
33struct Stored {
34    #[serde(flatten)]
35    name: CronName,
36    #[serde(default)]
37    fired_at: Option<u64>,
38}
39
40/// Remember the schedule a fired prompt came from, replacing any prior record
41/// for the same prompt.
42pub 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
54/// Seconds since the epoch, with a clock before it read as the epoch rather
55/// than as an error: a wrongly set clock costs retention accuracy, not a boot.
56fn epoch(at: SystemTime) -> u64 {
57    at.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs()
58}
59
60/// Drop the pairings older than the retention window and stamp the ones
61/// written before the stamp existed, so they age out one window from the
62/// upgrade.
63///
64/// **A record this build cannot decode is left alone**, the same rule the read
65/// path keeps: deleting what cannot be read is how a corrupt row becomes a
66/// silent loss. Returns how many rows went.
67pub 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            // A store that has never fired has nothing to sweep.
76            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
117/// The schedules for the prompts in `uuids`, keyed by the prompt's id. An id
118/// this store never saw is absent from the answer rather than an error.
119pub 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        // A fresh database has no table until the first fire; an absent table
124        // is an empty answer, not an error.
125        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            // One undecodable row must not cost the rest of the page its
138            // titles, the same rule the cron table keeps.
139            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        // The unreadable row sorts first and the row beside it is past the
233        // window, so a sweep that gives up on the decode failure answers zero
234        // and leaves the expired row behind: the count below catches both.
235        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        // A row exactly as the previous version wrote it: the name and nothing
263        // that dates it.
264        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        // The stamp is the boot's own instant, so the row ages out one window
282        // from the upgrade rather than never.
283        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}