From 62ab45297ca6deb015ff2842595b06b745c8e423 Mon Sep 17 00:00:00 2001 From: damocles Date: Sun, 18 Oct 2026 01:36:41 +0200 Subject: [PATCH] hive-agent: run the event store in WAL and give turn-stats writes a busy timeout --- docs/agent-lifecycle/persistence.md | 4 ++ hive-agent/src/events.rs | 1 + hive-agent/src/main.rs | 1 + hive-agent/src/sqlite_writer.rs | 99 +++++++++++++++++++++++++++++ hive-agent/src/turn_stats.rs | 4 ++ hive-agent/src/vacuum.rs | 1 + 6 files changed, 110 insertions(+) create mode 100644 hive-agent/src/sqlite_writer.rs diff --git a/docs/agent-lifecycle/persistence.md b/docs/agent-lifecycle/persistence.md index c5808cb1..142b7d05 100644 --- a/docs/agent-lifecycle/persistence.md +++ b/docs/agent-lifecycle/persistence.md @@ -92,6 +92,10 @@ One table: - `events(id, ts, kind, payload_json)` — every `LiveEvent` the harness emits during turn loop execution. +The db runs in WAL mode, so `-wal` and `-shm` files sit beside it while +the harness is up. Copy or back up the three together, or the recent rows +sit in the `-wal` file until a checkpoint. + The harness both writes and vacuums it. Cleanup runs in-container because hive-c0re runs as the unprivileged `hive-core` user under diff --git a/hive-agent/src/events.rs b/hive-agent/src/events.rs index ca165f71..7b25eb0f 100644 --- a/hive-agent/src/events.rs +++ b/hive-agent/src/events.rs @@ -150,6 +150,7 @@ impl EventStore { let _ = std::fs::create_dir_all(parent); } let conn = Connection::open(path)?; + crate::sqlite_writer::configure_wal(&conn)?; conn.execute_batch(SCHEMA)?; Ok(Self { conn: Mutex::new(conn), diff --git a/hive-agent/src/main.rs b/hive-agent/src/main.rs index c0a7dfb2..d8b6a53b 100644 --- a/hive-agent/src/main.rs +++ b/hive-agent/src/main.rs @@ -30,6 +30,7 @@ mod reminder_timer; mod reminders; mod schedule_timer; mod serve_common; +mod sqlite_writer; mod state_entry_watch; mod stats; mod subagent_role_watch; diff --git a/hive-agent/src/sqlite_writer.rs b/hive-agent/src/sqlite_writer.rs new file mode 100644 index 00000000..2966089a --- /dev/null +++ b/hive-agent/src/sqlite_writer.rs @@ -0,0 +1,99 @@ +//! Shared connection setup for the harness's own sqlite writers. +//! +//! The default rollback journal costs an fsync per commit and takes an +//! exclusive lock, so a write that overlaps a read on another connection (the +//! stats page, the hourly vacuum) fails at once with `SQLITE_BUSY`. WAL with +//! `synchronous=NORMAL` removes the per-row fsync and lets readers and the +//! writer overlap, and the busy timeout makes the remaining writer-vs-writer +//! overlap wait instead of dropping the row. +//! +//! WAL is only for databases that nothing outside the agent reads: a +//! read-only connection to a WAL database needs the `-wal` and `-shm` +//! sidecars to exist or be creatable, and hive-c0re reads as another uid +//! across the agent-owned harness dir. Such a database takes just +//! [`BUSY_TIMEOUT`]. + +use std::time::Duration; + +use rusqlite::Connection; + +/// How long a writer waits on a locked database before giving up. +pub const BUSY_TIMEOUT: Duration = Duration::from_secs(5); + +/// Put `conn` into WAL mode with `synchronous=NORMAL` and a busy timeout. +/// +/// The timeout is set first so the journal-mode switch, which needs a lock, +/// waits too. `journal_mode` persists in the database file; the other two are +/// per connection, so every writer connection calls this. +/// +/// When sqlite answers the switch with the old mode (it does that, not an +/// error, on a filesystem that cannot do WAL), this logs a warning and leaves +/// the database in that mode: a working rollback-journal database beats no +/// database. +/// +/// # Errors +/// +/// Propagates sqlite pragma failures. +pub fn configure_wal(conn: &Connection) -> rusqlite::Result<()> { + conn.busy_timeout(BUSY_TIMEOUT)?; + let mode = + conn.pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get::<_, String>(0))?; + if !mode.eq_ignore_ascii_case("wal") { + tracing::warn!(%mode, "sqlite journal_mode stayed put instead of switching to wal"); + } + conn.pragma_update(None, "synchronous", "NORMAL") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn configure_wal_sets_wal_normal_sync_and_busy_timeout() { + let dir = tempfile::tempdir().unwrap(); + let conn = Connection::open(dir.path().join("w.sqlite")).unwrap(); + configure_wal(&conn).unwrap(); + + let mode: String = conn + .query_row("PRAGMA journal_mode", [], |r| r.get(0)) + .unwrap(); + let sync: i64 = conn + .query_row("PRAGMA synchronous", [], |r| r.get(0)) + .unwrap(); + let busy: i64 = conn + .query_row("PRAGMA busy_timeout", [], |r| r.get(0)) + .unwrap(); + assert_eq!(mode, "wal"); + assert_eq!(sync, 1, "NORMAL"); + assert_eq!(busy, 5000); + } + + #[test] + fn configure_wal_keeps_a_database_that_cannot_switch_usable() { + // An in-memory database cannot switch to WAL; sqlite answers "memory". + let conn = Connection::open_in_memory().unwrap(); + configure_wal(&conn).unwrap(); + let mode: String = conn + .query_row("PRAGMA journal_mode", [], |r| r.get(0)) + .unwrap(); + assert_eq!(mode, "memory"); + conn.execute_batch("CREATE TABLE t(x); INSERT INTO t VALUES (1)") + .unwrap(); + } + + #[test] + fn a_second_connection_still_sees_committed_rows() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("w.sqlite"); + let writer = Connection::open(&path).unwrap(); + configure_wal(&writer).unwrap(); + writer.execute_batch("CREATE TABLE t(x)").unwrap(); + let reader = Connection::open(&path).unwrap(); + configure_wal(&reader).unwrap(); + writer.execute("INSERT INTO t VALUES (1)", []).unwrap(); + let n: i64 = reader + .query_row("SELECT COUNT(*) FROM t", [], |r| r.get(0)) + .unwrap(); + assert_eq!(n, 1); + } +} diff --git a/hive-agent/src/turn_stats.rs b/hive-agent/src/turn_stats.rs index 83e8c183..513d190e 100644 --- a/hive-agent/src/turn_stats.rs +++ b/hive-agent/src/turn_stats.rs @@ -162,6 +162,10 @@ impl TurnStats { } let conn = Connection::open(path) .with_context(|| format!("open turn_stats db {}", path.display()))?; + // Not WAL: hive-c0re reads this db read-only as another uid, which + // fails on a WAL database whose sidecars it cannot create. + conn.busy_timeout(crate::sqlite_writer::BUSY_TIMEOUT) + .context("set turn_stats busy_timeout")?; conn.execute_batch(SCHEMA) .context("apply turn_stats schema")?; for stmt in MIGRATIONS { diff --git a/hive-agent/src/vacuum.rs b/hive-agent/src/vacuum.rs index 53730a50..4b5c1fa0 100644 --- a/hive-agent/src/vacuum.rs +++ b/hive-agent/src/vacuum.rs @@ -169,6 +169,7 @@ fn delete_trio(dir: &Path, stem: &str) { /// agent's `events.sqlite`. Returns the number of rows deleted. fn vacuum_events(path: &Path) -> Result { let conn = Connection::open(path)?; + crate::sqlite_writer::configure_wal(&conn)?; let cutoff = chrono::Utc::now().timestamp() - STREAM_KEEP_SECS; let removed = conn.execute( "DELETE FROM events WHERE kind = 'stream' AND ts < ?1",