fix(#1957): persist hivectl-start running-agents snapshot in the broker kv table

This commit is contained in:
damocles 2026-06-23 21:34:49 +02:00 committed by mara
commit f7d84f4847
2 changed files with 110 additions and 5 deletions

View file

@ -36,6 +36,11 @@ CREATE TABLE IF NOT EXISTS reminders (
);
CREATE INDEX IF NOT EXISTS idx_reminders_due
ON reminders (agent, due_at) WHERE sent_at IS NULL;
CREATE TABLE IF NOT EXISTS kv (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
";
/// Capacity of the live event channel. Slow subscribers (e.g. an idle browser)
@ -197,6 +202,52 @@ impl Broker {
self.events.subscribe()
}
/// Set a small persistent key/value pair (upsert). The `kv` table is a
/// general single-value store for state that must survive a hive-c0re
/// restart but isn't worth a dedicated table — currently just the
/// `hivectl start` running-agents snapshot (see
/// `Coordinator::set_last_stopped_running`).
///
/// # Errors
///
/// Returns an error if the sqlite upsert fails.
pub fn kv_set(&self, key: &str, value: &str) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO kv (key, value) VALUES (?1, ?2)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
params![key, value],
)?;
Ok(())
}
/// Read a persistent key/value pair. `None` when the key is absent.
///
/// # Errors
///
/// Returns an error if the sqlite query fails.
pub fn kv_get(&self, key: &str) -> Result<Option<String>> {
let conn = self.conn.lock().unwrap();
let value: Option<String> = conn
.query_row("SELECT value FROM kv WHERE key = ?1", params![key], |row| {
row.get(0)
})
.optional()?;
Ok(value)
}
/// Delete a persistent key/value pair. Idempotent — deleting an absent
/// key is a no-op.
///
/// # Errors
///
/// Returns an error if the sqlite delete fails.
pub fn kv_delete(&self, key: &str) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute("DELETE FROM kv WHERE key = ?1", params![key])?;
Ok(())
}
pub fn send(&self, message: &Message) -> Result<()> {
let conn = self.conn.lock().unwrap();
let now = now_unix();
@ -1216,6 +1267,24 @@ mod tests {
batch.pop()
}
#[test]
fn kv_set_get_delete_roundtrip() {
let tb = open_broker();
let b = &tb.broker;
// Absent key -> None.
assert_eq!(b.kv_get("k").unwrap(), None);
// Set then get.
b.kv_set("k", "v1").unwrap();
assert_eq!(b.kv_get("k").unwrap(), Some("v1".to_owned()));
// Upsert overwrites.
b.kv_set("k", "v2").unwrap();
assert_eq!(b.kv_get("k").unwrap(), Some("v2".to_owned()));
// Delete clears; deleting again is a no-op.
b.kv_delete("k").unwrap();
assert_eq!(b.kv_get("k").unwrap(), None);
b.kv_delete("k").unwrap();
}
/// Happy path: send → recv → `ack_turn` drains the in-memory list
/// and marks the row `acked_at IS NOT NULL`. A second recv finds
/// nothing pending (the row stays in the table for vacuum).