hyperhive/hive-c0re/src/broker.rs
müde 6d52f67292 broker: hourly vacuum of delivered messages older than 30 days
undelivered rows are always kept regardless of age (still in flight).
sweep runs immediately on serve start then every hour. logs row count
when non-zero. keep_secs is hard-coded for now (30 days); can be
config-driven later if a host wants to retain more / less for audit.
2026-05-15 19:40:38 +02:00

226 lines
7.7 KiB
Rust

//! Sqlite-backed message broker. Survives `hive-c0re` restart, and taps every
//! send/recv onto a broadcast channel so the dashboard can stream it.
use std::path::Path;
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result};
use hive_sh4re::Message;
use rusqlite::{Connection, OptionalExtension, params};
use serde::Serialize;
use tokio::sync::broadcast;
const SCHEMA: &str = r"
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sender TEXT NOT NULL,
recipient TEXT NOT NULL,
body TEXT NOT NULL,
sent_at INTEGER NOT NULL,
delivered_at INTEGER
);
CREATE INDEX IF NOT EXISTS idx_messages_undelivered
ON messages (recipient, id) WHERE delivered_at IS NULL;
";
/// Capacity of the live event channel. Slow subscribers (e.g. an idle browser)
/// may drop events past this; we send a `lagged` notice in their stream.
const EVENT_CHANNEL: usize = 256;
/// One row in a `recent_for()` query — the broker's flat view of a
/// message addressed to a given recipient.
#[derive(Debug, Clone, Serialize)]
pub struct InboxRow {
pub id: i64,
pub from: String,
pub body: String,
pub at: i64,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case", tag = "kind")]
pub enum MessageEvent {
Sent {
from: String,
to: String,
body: String,
at: i64,
},
Delivered {
from: String,
to: String,
body: String,
at: i64,
},
}
pub struct Broker {
conn: Mutex<Connection>,
events: broadcast::Sender<MessageEvent>,
}
impl Broker {
pub fn open(path: &Path) -> Result<Self> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("create db parent {}", parent.display()))?;
}
let conn =
Connection::open(path).with_context(|| format!("open broker db {}", path.display()))?;
conn.execute_batch(SCHEMA).context("apply broker schema")?;
let (events, _) = broadcast::channel(EVENT_CHANNEL);
Ok(Self {
conn: Mutex::new(conn),
events,
})
}
pub fn subscribe(&self) -> broadcast::Receiver<MessageEvent> {
self.events.subscribe()
}
pub fn send(&self, message: &Message) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO messages (sender, recipient, body, sent_at) VALUES (?1, ?2, ?3, ?4)",
params![message.from, message.to, message.body, now_unix()],
)?;
drop(conn);
let _ = self.events.send(MessageEvent::Sent {
from: message.from.clone(),
to: message.to.clone(),
body: message.body.clone(),
at: now_unix(),
});
Ok(())
}
/// Latest `limit` messages addressed to `recipient`, newest-first.
/// Includes delivered + undelivered alike — used for the operator
/// inbox view on the dashboard. Caller decides what to show.
pub fn recent_for(&self, recipient: &str, limit: u64) -> Result<Vec<InboxRow>> {
let conn = self.conn.lock().unwrap();
let limit_i = i64::try_from(limit.min(i64::MAX as u64)).unwrap_or(i64::MAX);
let mut stmt = conn.prepare(
"SELECT id, sender, body, sent_at
FROM messages
WHERE recipient = ?1
ORDER BY id DESC
LIMIT ?2",
)?;
let rows = stmt.query_map(params![recipient, limit_i], |row| {
Ok(InboxRow {
id: row.get(0)?,
from: row.get(1)?,
body: row.get(2)?,
at: row.get(3)?,
})
})?;
rows.collect::<rusqlite::Result<Vec<_>>>()
.map_err(Into::into)
}
/// Number of undelivered messages addressed to `recipient`. Non-mutating
/// — used by the harness to surface "N unread" in tool-result status
/// lines without popping the queue.
pub fn count_pending(&self, recipient: &str) -> Result<u64> {
let conn = self.conn.lock().unwrap();
let n: i64 = conn.query_row(
"SELECT COUNT(*) FROM messages
WHERE recipient = ?1 AND delivered_at IS NULL",
params![recipient],
|row| row.get(0),
)?;
Ok(u64::try_from(n.max(0)).unwrap_or(0))
}
/// Long-poll variant of `recv`: returns immediately if there's a
/// pending message; otherwise waits up to `timeout` for the broker to
/// emit a `Sent { to: recipient }` event, then retries the pop. Lets
/// agents react to new mail without polling their socket on a fixed
/// interval.
pub async fn recv_blocking(
&self,
recipient: &str,
timeout: std::time::Duration,
) -> Result<Option<Message>> {
if let Some(m) = self.recv(recipient)? {
return Ok(Some(m));
}
let mut rx = self.subscribe();
let deadline = tokio::time::Instant::now() + timeout;
loop {
let Some(remaining) = deadline.checked_duration_since(tokio::time::Instant::now())
else {
return Ok(None);
};
match tokio::time::timeout(remaining, rx.recv()).await {
Err(_) => return Ok(None),
// Channel lagged or closed — fall back to a single direct
// pop (in case we missed our notification while behind).
Ok(Err(_)) => return self.recv(recipient),
Ok(Ok(MessageEvent::Sent { to, .. })) if to == recipient => {
if let Some(m) = self.recv(recipient)? {
return Ok(Some(m));
}
// Lost a race (concurrent recv elsewhere). Keep waiting.
}
Ok(Ok(_)) => {}
}
}
}
/// Delete delivered messages older than `older_than_secs`. Undelivered
/// rows are always kept regardless of age — those are still in flight
/// from the broker's POV. Returns the number of rows removed.
pub fn vacuum_delivered(&self, older_than_secs: i64) -> Result<u64> {
let cutoff = now_unix() - older_than_secs;
let conn = self.conn.lock().unwrap();
let n = conn.execute(
"DELETE FROM messages
WHERE delivered_at IS NOT NULL
AND delivered_at < ?1",
params![cutoff],
)?;
Ok(u64::try_from(n).unwrap_or(0))
}
pub fn recv(&self, recipient: &str) -> Result<Option<Message>> {
let conn = self.conn.lock().unwrap();
let row: Option<(i64, String, String, String)> = conn
.query_row(
"SELECT id, sender, recipient, body
FROM messages
WHERE recipient = ?1 AND delivered_at IS NULL
ORDER BY id ASC
LIMIT 1",
params![recipient],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
)
.optional()?;
let Some((id, from, to, body)) = row else {
return Ok(None);
};
conn.execute(
"UPDATE messages SET delivered_at = ?1 WHERE id = ?2",
params![now_unix(), id],
)?;
drop(conn);
let _ = self.events.send(MessageEvent::Delivered {
from: from.clone(),
to: to.clone(),
body: body.clone(),
at: now_unix(),
});
Ok(Some(Message { from, to, body }))
}
}
fn now_unix() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.ok()
.and_then(|d| i64::try_from(d.as_secs()).ok())
.unwrap_or(0)
}