Skip to main content

forge_workspace/store/
dynamic_workers.rs

1//! The retired `dynamic_workers` table, kept only to drain it.
2//!
3//! Worker persistence moved onto the `sessions` table, which is keyed by
4//! `(org, project, label)` and carries the same re-spawn arguments. This
5//! module survives for one thing: [`list_all`], which the boot's sweep
6//! reads, [`delete`], which removes each row once it has been copied, and
7//! [`drop_table`], which then removes the table. Do not add a write path
8//! back.
9
10use redb::{ReadableTable, ReadableTableMetadata, TableDefinition};
11use serde::{Deserialize, Serialize};
12
13use super::Db;
14
15const DYNAMIC_WORKERS: TableDefinition<(&str, &str), &[u8]> =
16    TableDefinition::new("dynamic_workers");
17
18/// A persisted worker's re-spawn args, as the retired table stored them.
19/// The spawning lead's session_id was deliberately absent: a re-spawn
20/// re-parents to whatever lead is current on reconnect.
21#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
22pub struct DynamicWorker {
23    pub project_key: String,
24    pub label: String,
25    pub charter: String,
26    pub kick: Option<String>,
27    /// Re-orient message delivered instead of the generic forge restart
28    /// note when this worker resumes; `None` keeps the generic note.
29    pub resume_kick: Option<String>,
30    /// Whether this worker was spawned to be talked to directly, and so
31    /// keeps the built-in `AskUserQuestion` tool. `default` because a
32    /// bare `bool` would otherwise fail to decode every row written
33    /// before the field existed, and an undecodable row is skipped
34    /// rather than reported.
35    #[serde(default)]
36    pub interactive: bool,
37}
38
39/// Every persisted worker, in key order. The one-time sweep into the
40/// `sessions` table reads the whole set, not one project's slice.
41pub fn list_all(db: &Db) -> anyhow::Result<Vec<DynamicWorker>> {
42    let txn = db.database().begin_read()?;
43    let table = match txn.open_table(DYNAMIC_WORKERS) {
44        Ok(t) => t,
45        Err(redb::TableError::TableDoesNotExist(_)) => return Ok(Vec::new()),
46        Err(e) => return Err(e.into()),
47    };
48    let mut out = Vec::new();
49    for entry in table.iter()? {
50        let (key, value) = entry?;
51        match serde_json::from_slice::<DynamicWorker>(value.value()) {
52            Ok(worker) => out.push(worker),
53            // One undecodable record (schema drift, a corrupt blob) must not
54            // wipe the rest of the durable set - skip it and warn. The key
55            // stays readable even when the value doesn't, so name which
56            // worker lost durability.
57            Err(err) => {
58                let (row_project, row_label) = key.value();
59                tracing::warn!(
60                    target: "forge_workspace::store::dynamic_workers",
61                    project = %row_project,
62                    label = %row_label,
63                    error = %err,
64                    "skipping dynamic worker record that failed to decode",
65                );
66            }
67        }
68    }
69    Ok(out)
70}
71
72/// How many rows the table holds, readable or not.
73///
74/// [`list_all`] skips a value that will not decode, which is right - one
75/// corrupt record must not wipe the rest of the durable set - but it makes
76/// the decoded rows an undercount of what is there. The sweep compares
77/// this with what it moved, so the count has to come from the table.
78pub fn count(db: &Db) -> anyhow::Result<usize> {
79    let txn = db.database().begin_read()?;
80    let table = match txn.open_table(DYNAMIC_WORKERS) {
81        Ok(t) => t,
82        Err(redb::TableError::TableDoesNotExist(_)) => return Ok(0),
83        Err(e) => return Err(e.into()),
84    };
85    Ok(usize::try_from(table.len()?)?)
86}
87
88/// Remove one row, once the sweep has copied it into `sessions`. This is
89/// what makes the sweep's own progress durable: a row still here is a row
90/// whose arguments exist nowhere else yet, whatever happened to an earlier
91/// boot.
92pub fn delete(db: &Db, project_key: &str, label: &str) -> anyhow::Result<bool> {
93    let txn = db.database().begin_write()?;
94    let removed = match txn.open_table(DYNAMIC_WORKERS) {
95        Ok(mut table) => table.remove((project_key, label))?.is_some(),
96        Err(redb::TableError::TableDoesNotExist(_)) => false,
97        Err(e) => return Err(e.into()),
98    };
99    txn.commit()?;
100    Ok(removed)
101}
102
103/// Remove the table, once the sweep has drained it. Returns whether a
104/// table was there to drop. Callers compact afterwards, because dropping
105/// a table reclaims nothing on its own.
106pub fn drop_table(db: &Db) -> anyhow::Result<bool> {
107    let txn = db.database().begin_write()?;
108    // `false` for a store that never had the table, which is not an
109    // error: the boot drains cleanly either way.
110    let dropped = txn.delete_table(DYNAMIC_WORKERS)?;
111    txn.commit()?;
112    Ok(dropped)
113}
114
115/// Plant a row, so a test can exercise the sweep against a store that
116/// still has the old table. Production has no write path here.
117#[cfg(test)]
118pub(crate) fn insert_for_test(db: &Db, worker: &DynamicWorker) -> anyhow::Result<()> {
119    put_raw_for_test(db, &worker.project_key, &worker.label, &serde_json::to_vec(worker)?)
120}
121
122/// Plant raw bytes at a row, so a test can exercise the sweep against an
123/// entry whose value will not decode.
124#[cfg(test)]
125pub(crate) fn put_raw_for_test(
126    db: &Db,
127    project_key: &str,
128    label: &str,
129    value: &[u8],
130) -> anyhow::Result<()> {
131    let txn = db.database().begin_write()?;
132    {
133        let mut table = txn.open_table(DYNAMIC_WORKERS)?;
134        table.insert((project_key, label), value)?;
135    }
136    txn.commit()?;
137    Ok(())
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use tempfile::tempdir;
144
145    fn worker(project: &str, label: &str) -> DynamicWorker {
146        DynamicWorker {
147            project_key: project.to_owned(),
148            label: label.to_owned(),
149            charter: format!("charter for {label}"),
150            kick: Some(format!("kick for {label}")),
151            resume_kick: Some(format!("resume kick for {label}")),
152            interactive: false,
153        }
154    }
155
156    /// A row persisted before `interactive` existed decodes as
157    /// not-interactive. `interactive` is a bare `bool`, so without
158    /// `#[serde(default)]` every already-persisted row fails to decode
159    /// and the sweep skips it - the worker silently stops being
160    /// re-spawned at all.
161    #[test]
162    fn row_written_before_interactive_existed_loads_as_not_interactive() {
163        let dir = tempdir().expect("tempdir");
164        let db = Db::open(&dir.path().join("db.redb")).expect("open db");
165
166        let old_shape = serde_json::json!({
167            "project_key": "proj-a",
168            "label": "steward",
169            "charter": "mind the queues",
170            "kick": "begin",
171            "resume_kick": "re-read the notes",
172        });
173        let txn = db.database().begin_write().expect("begin");
174        {
175            let mut table = txn.open_table(DYNAMIC_WORKERS).expect("open table");
176            let value = serde_json::to_vec(&old_shape).expect("serialize old row");
177            table.insert(("proj-a", "steward"), value.as_slice()).expect("insert old row");
178        }
179        txn.commit().expect("commit");
180
181        let loaded = list_all(&db).expect("list");
182        assert_eq!(loaded.len(), 1, "a row written before `interactive` existed still decodes");
183        assert_eq!(
184            loaded[0],
185            DynamicWorker {
186                project_key: "proj-a".to_owned(),
187                label: "steward".to_owned(),
188                charter: "mind the queues".to_owned(),
189                kick: Some("begin".to_owned()),
190                resume_kick: Some("re-read the notes".to_owned()),
191                interactive: false,
192            },
193            "every prior field survives and an absent `interactive` reads as not-interactive",
194        );
195    }
196
197    #[test]
198    fn list_all_round_trips_and_skips_a_corrupt_row() {
199        let dir = tempdir().expect("tempdir");
200        let db = Db::open(&dir.path().join("db.redb")).expect("open db");
201        insert_for_test(&db, &worker("proj-a", "reviewer")).expect("insert reviewer");
202        insert_for_test(&db, &worker("proj-b", "tester")).expect("insert tester");
203        let txn = db.database().begin_write().expect("begin");
204        {
205            let mut table = txn.open_table(DYNAMIC_WORKERS).expect("open table");
206            table.insert(("proj-a", "corrupt"), "not a worker".as_bytes()).expect("insert corrupt");
207        }
208        txn.commit().expect("commit");
209
210        let all = list_all(&db).expect("list tolerates the corrupt blob");
211        assert_eq!(all.len(), 2, "the good records survive a corrupt sibling");
212        assert_eq!(
213            all[1].resume_kick.as_deref(),
214            Some("resume kick for tester"),
215            "the record round-trips through redb",
216        );
217    }
218
219    /// Dropping the table reports whether it was there, and a second
220    /// drop is not an error - a store that never had the table opens and
221    /// drains cleanly either way.
222    #[test]
223    fn drop_table_reports_whether_the_table_was_there() {
224        let dir = tempdir().expect("tempdir");
225        let db = Db::open(&dir.path().join("db.redb")).expect("open db");
226        assert!(
227            !drop_table(&db).expect("drop an absent table"),
228            "a store that never had the table drops nothing",
229        );
230
231        insert_for_test(&db, &worker("proj-a", "reviewer")).expect("insert reviewer");
232        assert!(drop_table(&db).expect("drop the table"), "a planted table drops to true");
233        assert!(list_all(&db).expect("list a dropped table").is_empty(), "and its rows are gone");
234    }
235}