use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MemChangedEvent {
pub mem: String,
pub head: String,
pub previous: String,
pub n_commits: u32,
}
pub type EventCallback = Arc<dyn Fn(&MemChangedEvent) + Send + Sync + 'static>;
#[derive(Default)]
pub(crate) struct SubscriberRegistry {
next_id: u64,
by_mem: HashMap<String, Vec<(u64, EventCallback)>>,
}
impl SubscriberRegistry {
pub(crate) fn new() -> Self {
Self::default()
}
pub(crate) fn register(&mut self, mem: String, callback: EventCallback) -> u64 {
self.next_id += 1;
let id = self.next_id;
self.by_mem.entry(mem).or_default().push((id, callback));
id
}
pub(crate) fn remove(&mut self, mem: &str, id: u64) {
if let Some(list) = self.by_mem.get_mut(mem) {
list.retain(|(slot, _)| *slot != id);
if list.is_empty() {
self.by_mem.remove(mem);
}
}
}
pub(crate) fn snapshot(&self, mem: &str) -> Vec<EventCallback> {
self.by_mem
.get(mem)
.map(|list| list.iter().map(|(_, cb)| cb.clone()).collect())
.unwrap_or_default()
}
}
pub struct SubscriptionHandle {
id: u64,
mem: String,
registry: Arc<Mutex<SubscriberRegistry>>,
}
impl std::fmt::Debug for SubscriptionHandle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SubscriptionHandle")
.field("id", &self.id)
.field("mem", &self.mem)
.finish()
}
}
impl SubscriptionHandle {
pub(crate) fn new(id: u64, mem: String, registry: Arc<Mutex<SubscriberRegistry>>) -> Self {
Self { id, mem, registry }
}
pub fn mem(&self) -> &str {
&self.mem
}
pub fn unsubscribe(self) {
drop(self);
}
}
impl Drop for SubscriptionHandle {
fn drop(&mut self) {
if let Ok(mut reg) = self.registry.lock() {
reg.remove(&self.mem, self.id);
}
}
}
impl super::Engine {
pub fn subscribe_mem_changes(
&self,
mem: &str,
callback: EventCallback,
) -> Result<SubscriptionHandle, crate::EngineError> {
if !self.has_mem(mem) {
return Err(crate::EngineError::UnknownMem(mem.to_string()));
}
let id = self
.event_subscribers
.lock()
.expect("event subscriber registry mutex must not be poisoned")
.register(mem.to_string(), callback);
Ok(SubscriptionHandle::new(
id,
mem.to_string(),
self.event_subscribers.clone(),
))
}
pub(crate) fn emit_mem_changed(&self, event: &MemChangedEvent) {
let callbacks = self
.event_subscribers
.lock()
.expect("event subscriber registry mutex must not be poisoned")
.snapshot(&event.mem);
for cb in callbacks {
cb(event);
}
}
fn has_mem(&self, mem: &str) -> bool {
self.mounts.iter().any(|m| m.mount.mem == mem)
}
}
#[cfg(feature = "tokio")]
pub const DEFAULT_BROADCAST_CAPACITY: usize = 128;
#[cfg(feature = "tokio")]
impl super::Engine {
pub fn subscribe_mem_changes_broadcast(
&self,
mem: &str,
) -> Result<
(
SubscriptionHandle,
tokio::sync::broadcast::Receiver<MemChangedEvent>,
),
crate::EngineError,
> {
self.subscribe_mem_changes_broadcast_with_capacity(mem, DEFAULT_BROADCAST_CAPACITY)
}
pub fn subscribe_mem_changes_broadcast_with_capacity(
&self,
mem: &str,
capacity: usize,
) -> Result<
(
SubscriptionHandle,
tokio::sync::broadcast::Receiver<MemChangedEvent>,
),
crate::EngineError,
> {
let (tx, rx) = tokio::sync::broadcast::channel(capacity);
let callback: EventCallback = Arc::new(move |event: &MemChangedEvent| {
let _ = tx.send(event.clone());
});
let handle = self.subscribe_mem_changes(mem, callback)?;
Ok((handle, rx))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex as StdMutex;
use crate::backend::MemBackend;
use crate::engine::test_helpers::{
archive_mount, build_archive, cli_actor, empty_create_args, folder_mount,
};
use crate::storage::{ArchiveBackend, FilesystemMemWriter};
fn collector() -> (Arc<StdMutex<Vec<MemChangedEvent>>>, EventCallback) {
let sink: Arc<StdMutex<Vec<MemChangedEvent>>> = Arc::new(StdMutex::new(Vec::new()));
let sink_for_cb = sink.clone();
let cb: EventCallback = Arc::new(move |e: &MemChangedEvent| {
sink_for_cb.lock().unwrap().push(e.clone());
});
(sink, cb)
}
fn writable_specs_engine() -> (crate::Engine, tempfile::TempDir) {
let tmp = tempfile::TempDir::new().unwrap();
let mem_dir = tmp.path().to_path_buf();
let writer = FilesystemMemWriter::new(mem_dir.clone());
let engine = crate::Engine::from_mounts(vec![(
folder_mount("specs", mem_dir),
Box::new(writer) as Box<dyn MemBackend>,
)])
.unwrap();
(engine, tmp)
}
#[test]
fn mem_changed_event_json_matches_concept_doc_shape() {
let event = MemChangedEvent {
mem: "specs".to_string(),
head: "abc1234".to_string(),
previous: "def5678".to_string(),
n_commits: 3,
};
let json = serde_json::to_string(&event).unwrap();
assert_eq!(
json,
r#"{"mem":"specs","head":"abc1234","previous":"def5678","n_commits":3}"#,
);
}
#[test]
fn registry_register_and_snapshot_roundtrip() {
let mut reg = SubscriberRegistry::new();
let cb: EventCallback = Arc::new(|_| {});
let id = reg.register("v1".to_string(), cb.clone());
assert_eq!(reg.snapshot("v1").len(), 1);
reg.remove("v1", id);
assert!(reg.snapshot("v1").is_empty());
}
#[test]
fn registry_remove_unknown_id_is_noop() {
let mut reg = SubscriberRegistry::new();
reg.remove("missing", 42);
let cb: EventCallback = Arc::new(|_| {});
let _ = reg.register("v1".to_string(), cb);
reg.remove("v1", 999);
assert_eq!(reg.snapshot("v1").len(), 1);
}
#[test]
fn subscribe_unknown_mem_refuses_with_typed_code() {
let (engine, _tmp) = writable_specs_engine();
let cb: EventCallback = Arc::new(|_| {});
let err = engine.subscribe_mem_changes("missing", cb).unwrap_err();
match err {
crate::EngineError::UnknownMem(v) => assert_eq!(v, "missing"),
other => panic!("expected UnknownMem, got {other:?}"),
}
}
#[test]
fn create_entity_emits_one_event_per_commit() {
let (mut engine, _tmp) = writable_specs_engine();
let (sink, cb) = collector();
let _handle = engine.subscribe_mem_changes("specs", cb).unwrap();
let (actor, client) = cli_actor();
engine
.create_entity(
empty_create_args("specs", "Alpha"),
actor,
Some(&client),
None,
)
.unwrap();
engine
.create_entity(
empty_create_args("specs", "Beta"),
actor,
Some(&client),
None,
)
.unwrap();
let captured = sink.lock().unwrap();
assert_eq!(captured.len(), 2, "two mutations must produce two events");
for ev in captured.iter() {
assert_eq!(ev.mem, "specs");
assert!(!ev.head.is_empty(), "head must be the new sha");
assert_eq!(ev.n_commits, 1);
}
assert_eq!(captured[1].previous, captured[0].head);
}
#[test]
fn multiple_subscribers_each_see_every_event() {
let (mut engine, _tmp) = writable_specs_engine();
let (sink_a, cb_a) = collector();
let (sink_b, cb_b) = collector();
let _h1 = engine.subscribe_mem_changes("specs", cb_a).unwrap();
let _h2 = engine.subscribe_mem_changes("specs", cb_b).unwrap();
let (actor, client) = cli_actor();
engine
.create_entity(
empty_create_args("specs", "Alpha"),
actor,
Some(&client),
None,
)
.unwrap();
assert_eq!(sink_a.lock().unwrap().len(), 1);
assert_eq!(sink_b.lock().unwrap().len(), 1);
}
#[test]
fn dropping_handle_stops_further_events() {
let (mut engine, _tmp) = writable_specs_engine();
let (sink, cb) = collector();
let handle = engine.subscribe_mem_changes("specs", cb).unwrap();
let (actor, client) = cli_actor();
engine
.create_entity(
empty_create_args("specs", "Before"),
actor,
Some(&client),
None,
)
.unwrap();
drop(handle);
engine
.create_entity(
empty_create_args("specs", "After"),
actor,
Some(&client),
None,
)
.unwrap();
let captured = sink.lock().unwrap();
assert_eq!(
captured.len(),
1,
"only the pre-drop mutation must be observed",
);
}
#[test]
fn unsubscribe_method_equivalent_to_drop() {
let (mut engine, _tmp) = writable_specs_engine();
let (sink, cb) = collector();
let handle = engine.subscribe_mem_changes("specs", cb).unwrap();
handle.unsubscribe();
let (actor, client) = cli_actor();
engine
.create_entity(
empty_create_args("specs", "Solo"),
actor,
Some(&client),
None,
)
.unwrap();
assert!(sink.lock().unwrap().is_empty());
}
#[test]
fn subscribe_archive_mount_accepted_but_no_emit() {
let tmp = tempfile::TempDir::new().unwrap();
let archive_path = build_archive(
tmp.path(),
"ext",
&[("a.md", b"---\ntype: spec\n---\n# A\n\n## Identity\n\nx.\n")],
);
let engine = crate::Engine::from_mounts(vec![(
archive_mount("ext", archive_path.clone()),
Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
)])
.unwrap();
let (sink, cb) = collector();
let handle = engine.subscribe_mem_changes("ext", cb);
assert!(handle.is_ok(), "subscribe must accept read-only mems");
assert!(sink.lock().unwrap().is_empty());
}
#[test]
fn sync_slow_callback_blocks_writer_by_design() {
let (mut engine, _tmp) = writable_specs_engine();
let sleep_ms = 80u64;
let cb: EventCallback = Arc::new(move |_event: &MemChangedEvent| {
std::thread::sleep(std::time::Duration::from_millis(sleep_ms));
});
let _handle = engine.subscribe_mem_changes("specs", cb).unwrap();
let (actor, client) = cli_actor();
let start = std::time::Instant::now();
engine
.create_entity(
empty_create_args("specs", "Slow"),
actor,
Some(&client),
None,
)
.unwrap();
let elapsed_ms = start.elapsed().as_millis() as u64;
assert!(
elapsed_ms >= sleep_ms,
"mutation must wait for sync callback (elapsed={elapsed_ms}ms < expected≥{sleep_ms}ms)",
);
}
#[test]
fn emit_overhead_under_ten_subscribers_is_microsecond_scale() {
let (mut engine, _tmp) = writable_specs_engine();
let (actor, client) = cli_actor();
engine
.create_entity(
empty_create_args("specs", "Warmup"),
actor,
Some(&client),
None,
)
.unwrap();
let t0 = std::time::Instant::now();
engine
.create_entity(
empty_create_args("specs", "Bare One"),
actor,
Some(&client),
None,
)
.unwrap();
let bare_us = t0.elapsed().as_micros();
let mut handles = Vec::new();
for _ in 0..9 {
let cb: EventCallback = Arc::new(|_: &MemChangedEvent| {});
handles.push(engine.subscribe_mem_changes("specs", cb).unwrap());
}
let t1 = std::time::Instant::now();
engine
.create_entity(
empty_create_args("specs", "Subscribed One"),
actor,
Some(&client),
None,
)
.unwrap();
let subscribed_us = t1.elapsed().as_micros();
let delta = subscribed_us.saturating_sub(bare_us);
assert!(
delta < 1_000,
"emit fanout cost too high: bare={bare_us}µs subscribed={subscribed_us}µs delta={delta}µs",
);
}
#[cfg(feature = "tokio")]
mod tokio_convenience {
use super::*;
fn rt() -> tokio::runtime::Runtime {
tokio::runtime::Builder::new_current_thread()
.enable_time()
.build()
.unwrap()
}
#[test]
fn broadcast_receiver_delivers_events_after_mutation() {
let (mut engine, _tmp) = writable_specs_engine();
let (_handle, mut rx) = engine.subscribe_mem_changes_broadcast("specs").unwrap();
let (actor, client) = cli_actor();
engine
.create_entity(
empty_create_args("specs", "Alpha"),
actor,
Some(&client),
None,
)
.unwrap();
let rt = rt();
let event = rt
.block_on(async {
tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv()).await
})
.expect("broadcast recv did not time out")
.expect("broadcast recv returned an event");
assert_eq!(event.mem, "specs");
assert_eq!(event.n_commits, 1);
assert!(!event.head.is_empty());
}
#[test]
fn broadcast_slow_subscriber_does_not_block_writer() {
let (mut engine, _tmp) = writable_specs_engine();
let capacity = 8;
let (_handle, mut rx) = engine
.subscribe_mem_changes_broadcast_with_capacity("specs", capacity)
.unwrap();
let (actor, client) = cli_actor();
let start = std::time::Instant::now();
let n_writes = capacity * 4;
for i in 0..n_writes {
engine
.create_entity(
empty_create_args("specs", &format!("Burst-{i}")),
actor,
Some(&client),
None,
)
.unwrap();
}
let elapsed_ms = start.elapsed().as_millis();
assert!(
elapsed_ms < 5_000,
"writer should not be backpressured by an un-drained broadcast subscriber (elapsed={elapsed_ms}ms)",
);
let rt = rt();
let result = rt.block_on(async {
tokio::time::timeout(std::time::Duration::from_millis(50), rx.recv()).await
});
match result {
Ok(Ok(_event)) => {}
Ok(Err(tokio::sync::broadcast::error::RecvError::Lagged(_))) => {}
Ok(Err(tokio::sync::broadcast::error::RecvError::Closed)) => {
panic!("broadcast channel closed while handle is alive");
}
Err(_) => panic!("broadcast recv timed out — channel may be stalled"),
}
}
#[test]
fn broadcast_unknown_mem_refuses_with_typed_code() {
let (engine, _tmp) = writable_specs_engine();
let err = engine
.subscribe_mem_changes_broadcast("missing")
.unwrap_err();
match err {
crate::EngineError::UnknownMem(v) => assert_eq!(v, "missing"),
other => panic!("expected UnknownMem, got {other:?}"),
}
}
}
#[test]
fn callback_can_read_engine_during_emit_without_deadlock() {
let (mut engine, _tmp) = writable_specs_engine();
let observed: Arc<StdMutex<Option<String>>> = Arc::new(StdMutex::new(None));
let observed_for_cb = observed.clone();
let cb: EventCallback = Arc::new(move |event: &MemChangedEvent| {
*observed_for_cb.lock().unwrap() = Some(event.head.clone());
});
let _handle = engine.subscribe_mem_changes("specs", cb).unwrap();
let (actor, client) = cli_actor();
let outcome = engine
.create_entity(
empty_create_args("specs", "Hello"),
actor,
Some(&client),
None,
)
.unwrap();
let captured = observed.lock().unwrap().clone().expect("callback ran");
assert!(!captured.is_empty(), "head must be present in event");
let entity = engine.get_entity(&outcome.id).expect("entity exists");
assert_eq!(entity.title, "Hello");
}
}