use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use bamboo_subagent::{InboxMessage, Mailbox, MsgId};
use tokio::sync::{mpsc, Mutex};
use crate::error::{BrokerError, BrokerResult};
pub const DEFAULT_MAX_PENDING_PER_MAILBOX: usize = 50_000;
#[derive(Debug)]
pub enum PushItem {
Message(InboxMessage),
Cancel(MsgId),
}
struct Subscriber {
sink: mpsc::UnboundedSender<PushItem>,
role: Option<String>,
}
pub struct BrokerCore {
root: PathBuf,
subscribers: Mutex<HashMap<String, Subscriber>>,
max_pending_per_mailbox: usize,
pending_counts: Mutex<HashMap<String, usize>>,
}
impl BrokerCore {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self {
root: root.into(),
subscribers: Mutex::new(HashMap::new()),
max_pending_per_mailbox: DEFAULT_MAX_PENDING_PER_MAILBOX,
pending_counts: Mutex::new(HashMap::new()),
}
}
pub fn with_max_pending_per_mailbox(mut self, max: usize) -> Self {
self.max_pending_per_mailbox = max;
self
}
fn mailbox(&self, session_id: &str) -> Mailbox {
Mailbox::at(self.root.join("mailboxes").join(session_id))
}
pub async fn deliver(&self, to: &str, msg: &InboxMessage) -> BrokerResult<MsgId> {
let pending = self.pending_count_for(to).await?;
if pending >= self.max_pending_per_mailbox {
return Err(BrokerError::MailboxFull {
session: to.to_string(),
limit: self.max_pending_per_mailbox,
});
}
let id = self.mailbox(to).deliver(msg).await?;
*self
.pending_counts
.lock()
.await
.entry(to.to_string())
.or_insert(0) += 1;
self.push_new(to).await?;
Ok(id)
}
async fn pending_count_for(&self, session_id: &str) -> BrokerResult<usize> {
if let Some(&c) = self.pending_counts.lock().await.get(session_id) {
return Ok(c);
}
let scanned = self.mailbox(session_id).pending_count().await?;
let mut counts = self.pending_counts.lock().await;
Ok(*counts.entry(session_id.to_string()).or_insert(scanned))
}
pub async fn subscribe(
&self,
session_id: &str,
role: Option<&str>,
) -> BrokerResult<mpsc::UnboundedReceiver<PushItem>> {
let (tx, rx) = mpsc::unbounded_channel();
self.subscribers.lock().await.insert(
session_id.to_string(),
Subscriber {
sink: tx.clone(),
role: role.map(str::to_string),
},
);
let mb = self.mailbox(session_id);
for d in mb.recover().await? {
let _ = tx.send(PushItem::Message(d.msg));
}
for d in mb.drain().await? {
let _ = tx.send(PushItem::Message(d.msg));
}
Ok(rx)
}
pub async fn cancel(&self, to: &str, correlation_id: &MsgId) -> bool {
let subs = self.subscribers.lock().await;
match subs.get(to) {
Some(sub) => sub
.sink
.send(PushItem::Cancel(correlation_id.clone()))
.is_ok(),
None => false,
}
}
pub async fn unsubscribe(&self, session_id: &str) {
self.subscribers.lock().await.remove(session_id);
}
pub async fn ack(&self, session_id: &str, id: &MsgId) -> BrokerResult<()> {
let _ = self.pending_count_for(session_id).await;
let removed = self.mailbox(session_id).ack(id).await?;
if removed {
if let Some(c) = self.pending_counts.lock().await.get_mut(session_id) {
*c = c.saturating_sub(1);
}
}
Ok(())
}
pub async fn is_subscribed(&self, session_id: &str) -> bool {
self.subscribers.lock().await.contains_key(session_id)
}
pub async fn gc_empty_mailboxes(&self) -> usize {
let root = self.root.join("mailboxes");
let subscribed: std::collections::HashSet<String> =
self.subscribers.lock().await.keys().cloned().collect();
let scan_root = root.clone();
let candidates: Vec<std::path::PathBuf> = tokio::task::spawn_blocking(move || {
let mut out = Vec::new();
let Ok(entries) = std::fs::read_dir(&scan_root) else {
return out;
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let id = entry.file_name().to_string_lossy().into_owned();
if subscribed.contains(&id) {
continue; }
if Mailbox::at(&path).is_fully_empty() {
out.push(path);
}
}
out
})
.await
.unwrap_or_default();
let mut purged = 0;
for path in candidates {
let id = path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_default();
if self.subscribers.lock().await.contains_key(&id) {
continue; }
let removed = tokio::task::spawn_blocking(move || {
Mailbox::at(&path).is_fully_empty() && std::fs::remove_dir_all(&path).is_ok()
})
.await
.unwrap_or(false);
if removed {
self.pending_counts.lock().await.remove(&id);
purged += 1;
}
}
purged
}
pub fn spawn_mailbox_gc(self: Arc<Self>, interval: Duration) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
let mut tick = tokio::time::interval(interval);
tick.tick().await; loop {
tick.tick().await;
let n = self.gc_empty_mailboxes().await;
if n > 0 {
tracing::debug!("broker mailbox GC reclaimed {n} empty mailbox(es)");
}
}
})
}
pub async fn connected(&self) -> Vec<(String, Option<String>)> {
self.subscribers
.lock()
.await
.iter()
.map(|(id, sub)| (id.clone(), sub.role.clone()))
.collect()
}
pub async fn connected_by_role(&self, role: &str) -> Vec<String> {
self.subscribers
.lock()
.await
.iter()
.filter(|(_, sub)| sub.role.as_deref() == Some(role))
.map(|(id, _)| id.clone())
.collect()
}
async fn push_new(&self, session_id: &str) -> BrokerResult<()> {
let tx = {
let subs = self.subscribers.lock().await;
match subs.get(session_id) {
Some(sub) => sub.sink.clone(),
None => return Ok(()),
}
};
for d in self.mailbox(session_id).drain().await? {
let _ = tx.send(PushItem::Message(d.msg));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use bamboo_subagent::{AgentRef, InboxKind};
use chrono::Utc;
use tempfile::TempDir;
fn msg(seq: u32) -> InboxMessage {
InboxMessage {
id: MsgId::new(),
from: AgentRef {
session_id: "from".into(),
role: None,
},
kind: InboxKind::Ask,
body: serde_json::json!({ "seq": seq }),
created_at: Utc::now(),
correlation_id: None,
}
}
fn core() -> (TempDir, BrokerCore) {
let d = TempDir::new().unwrap();
let c = BrokerCore::new(d.path());
(d, c)
}
fn expect_message(item: PushItem) -> InboxMessage {
match item {
PushItem::Message(m) => m,
PushItem::Cancel(c) => panic!("expected a message, got Cancel({c:?})"),
}
}
#[tokio::test]
async fn deliver_then_subscribe_drains_backlog() {
let (_d, c) = core();
let m = msg(1);
c.deliver("child", &m).await.unwrap();
assert!(!c.is_subscribed("child").await);
let mut rx = c.subscribe("child", None).await.unwrap();
let got = expect_message(rx.try_recv().expect("backlog delivered on subscribe"));
assert_eq!(got.id, m.id);
}
#[tokio::test]
async fn subscribe_then_deliver_pushes_live() {
let (_d, c) = core();
let mut rx = c.subscribe("child", None).await.unwrap();
assert!(rx.try_recv().is_err());
let m = msg(2);
c.deliver("child", &m).await.unwrap();
let got = expect_message(rx.recv().await.expect("live push"));
assert_eq!(got.id, m.id);
}
#[tokio::test]
async fn ack_removes_so_resubscribe_does_not_redeliver() {
let (_d, c) = core();
let m = msg(3);
c.deliver("child", &m).await.unwrap();
let mut rx = c.subscribe("child", None).await.unwrap();
let got = expect_message(rx.recv().await.unwrap());
assert_eq!(got.id, m.id);
c.ack("child", &got.id).await.unwrap();
c.unsubscribe("child").await;
let mut rx2 = c.subscribe("child", None).await.unwrap();
assert!(rx2.try_recv().is_err(), "acked message must not redeliver");
}
#[tokio::test]
async fn unacked_message_redelivers_on_resubscribe() {
let (_d, c) = core();
let m = msg(4);
c.deliver("child", &m).await.unwrap();
let mut rx = c.subscribe("child", None).await.unwrap();
let got = expect_message(rx.recv().await.unwrap()); assert_eq!(got.id, m.id);
c.unsubscribe("child").await;
let mut rx2 = c.subscribe("child", None).await.unwrap();
let again = expect_message(rx2.try_recv().expect("unacked message redelivers"));
assert_eq!(again.id, m.id);
}
#[tokio::test]
async fn deliver_to_unsubscribed_is_durable_and_isolated_per_session() {
let (_d, c) = core();
c.deliver("a", &msg(1)).await.unwrap();
c.deliver("b", &msg(2)).await.unwrap();
let mut rx_a = c.subscribe("a", None).await.unwrap();
assert!(rx_a.try_recv().is_ok());
assert!(rx_a.try_recv().is_err());
}
#[tokio::test]
async fn cancel_pushes_control_item_without_touching_mailbox() {
let (_d, c) = core();
let cid = MsgId::new();
assert!(!c.cancel("worker", &cid).await);
let mut rx = c.subscribe("worker", None).await.unwrap();
assert!(
c.cancel("worker", &cid).await,
"a live subscriber received the cancel"
);
match rx.try_recv().expect("cancel was pushed") {
PushItem::Cancel(got) => assert_eq!(got, cid),
PushItem::Message(_) => panic!("expected a Cancel, got a Message"),
}
c.unsubscribe("worker").await;
let mut rx2 = c.subscribe("worker", None).await.unwrap();
assert!(
rx2.try_recv().is_err(),
"cancel must not persist anything to the mailbox"
);
}
#[tokio::test]
async fn subscriber_table_is_the_live_actor_registry() {
let (_d, c) = core();
let _a = c.subscribe("w1", Some("explorer")).await.unwrap();
let _b = c.subscribe("w2", Some("explorer")).await.unwrap();
let _r = c.subscribe("w3", Some("reviewer")).await.unwrap();
let _n = c.subscribe("w4", None).await.unwrap();
let mut explorers = c.connected_by_role("explorer").await;
explorers.sort();
assert_eq!(explorers, vec!["w1".to_string(), "w2".to_string()]);
assert_eq!(
c.connected_by_role("reviewer").await,
vec!["w3".to_string()]
);
assert!(c.connected_by_role("missing").await.is_empty());
assert_eq!(c.connected().await.len(), 4, "all four are live");
c.unsubscribe("w1").await;
assert_eq!(
c.connected_by_role("explorer").await,
vec!["w2".to_string()]
);
}
#[tokio::test]
async fn gc_purges_empty_unsubscribed_mailboxes_only() {
let (_d, c) = core();
{
let mut rx = c.subscribe("done", None).await.unwrap();
let id = c.deliver("done", &msg(1)).await.unwrap();
let _ = expect_message(rx.recv().await.unwrap());
c.ack("done", &id).await.unwrap();
}
c.unsubscribe("done").await;
let _ = c.deliver("pending", &msg(2)).await.unwrap();
let _live = c.subscribe("live", None).await.unwrap();
assert_eq!(c.gc_empty_mailboxes().await, 1);
assert_eq!(c.gc_empty_mailboxes().await, 0);
}
#[tokio::test]
async fn deliver_rejects_once_pending_cap_reached() {
let d = TempDir::new().unwrap();
let c = BrokerCore::new(d.path()).with_max_pending_per_mailbox(2);
c.deliver("hoard", &msg(1)).await.expect("1st under cap");
c.deliver("hoard", &msg(2)).await.expect("2nd reaches cap");
let err = c.deliver("hoard", &msg(3)).await;
assert!(
matches!(
err,
Err(BrokerError::MailboxFull {
ref session,
limit: 2
}) if session == "hoard"
),
"delivery beyond the cap must be rejected: {err:?}"
);
c.deliver("other", &msg(4))
.await
.expect("cap is per-mailbox, not global");
}
#[tokio::test]
async fn deliver_succeeds_again_after_ack_frees_a_slot() {
let d = TempDir::new().unwrap();
let c = BrokerCore::new(d.path()).with_max_pending_per_mailbox(1);
let m1 = msg(1);
c.deliver("hoard", &m1).await.expect("1st reaches cap");
assert!(
c.deliver("hoard", &msg(2)).await.is_err(),
"2nd delivery is over the cap"
);
let mut rx = c.subscribe("hoard", None).await.unwrap();
let got = expect_message(rx.recv().await.unwrap());
assert_eq!(got.id, m1.id);
c.ack("hoard", &got.id).await.unwrap();
c.deliver("hoard", &msg(3))
.await
.expect("delivery succeeds again once a slot is freed");
}
#[tokio::test]
async fn pending_count_seeds_from_preexisting_disk_backlog_on_first_touch() {
let d = TempDir::new().unwrap();
let raw = Mailbox::at(d.path().join("mailboxes").join("hoard"));
raw.deliver(&msg(1)).await.unwrap();
raw.deliver(&msg(2)).await.unwrap();
let c = BrokerCore::new(d.path()).with_max_pending_per_mailbox(2);
let err = c.deliver("hoard", &msg(3)).await;
assert!(
matches!(err, Err(BrokerError::MailboxFull { limit: 2, .. })),
"the cap must account for backlog that predates this BrokerCore, got {err:?}"
);
}
#[tokio::test]
async fn pending_count_stays_consistent_with_disk_under_concurrent_delivers() {
let d = TempDir::new().unwrap();
let c = Arc::new(BrokerCore::new(d.path()).with_max_pending_per_mailbox(20));
let mut handles = Vec::new();
for i in 0..100u32 {
let c = c.clone();
handles.push(tokio::spawn(
async move { c.deliver("hoard", &msg(i)).await },
));
}
let mut succeeded = 0usize;
for h in handles {
if h.await.unwrap().is_ok() {
succeeded += 1;
}
}
assert!(succeeded > 0, "at least some concurrent deliveries land");
let on_disk = Mailbox::at(d.path().join("mailboxes").join("hoard"))
.pending_count()
.await
.unwrap();
assert_eq!(
on_disk, succeeded,
"the in-memory counter must not drift from the on-disk message count"
);
}
}