Add event causality tracking (id + caused_by chains)
Every BreadEvent now gets a unique id at construction (via BreadEvent::new/with_timestamp, and the normalizer's struct-literal call sites, which all now assign one explicitly). A new caused_by field is populated only when a Lua bread.emit() call runs synchronously inside a bread.on subscriber's handler: LuaEngine tracks the currently-dispatching event's id (set/restored around each handle_event invocation, single- threaded so a plain Mutex-guarded slot suffices) and bread.emit()'s Rust binding reads it when constructing the outgoing event. This lets chains of Lua modules that react to each other's events be reconstructed instead of timestamp-guessed from a live `bread events` log. Also: - bread events --tree renders the caused_by chain as an indented, live-streaming-friendly tree instead of a flat line-per-event stream. - API_VERSION bumped 1.4.0 -> 1.5.0 (additive-only change). - Documentation.md's event envelope, bread.emit, and debugging-tips sections updated with Since: v1.5 markers. - New end-to-end regression test spawning the real daemon with 3 chained Lua handlers (A emits X on trigger, B emits Y on X, C emits Z on Y) and asserting caused_by threads correctly through all three hops.
This commit is contained in:
parent
96639516b1
commit
6ff1ee910b
10 changed files with 420 additions and 9 deletions
|
|
@ -1,3 +1,4 @@
|
|||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
|
|
@ -521,6 +522,147 @@ async fn events_stream_receives_emitted_events() -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// A chain of 3 handlers, each reacting to the previous one's emitted event
|
||||
/// entirely inside the Lua runtime (no test-side pumping between links):
|
||||
/// module A handles the external trigger and emits X, module B subscribes
|
||||
/// to X and emits Y, module C subscribes to Y and emits Z. This exercises
|
||||
/// the "current dispatch id" mechanism in `breadd::lua::LuaEngine` end to
|
||||
/// end: `caused_by` must thread through every hop of the chain, not just a
|
||||
/// single emit.
|
||||
#[tokio::test]
|
||||
async fn event_causality_chain_threads_caused_by_across_handlers() -> Result<()> {
|
||||
let harness = TestHarness::spawn_with_init(
|
||||
r#"
|
||||
-- module A: reacts to the external trigger, emits X
|
||||
bread.on("bread.chain.trigger", function(event)
|
||||
bread.emit("bread.chain.x", {})
|
||||
end)
|
||||
|
||||
-- module B: reacts to X, emits Y
|
||||
bread.on("bread.chain.x", function(event)
|
||||
bread.emit("bread.chain.y", {})
|
||||
end)
|
||||
|
||||
-- module C: reacts to Y, emits Z
|
||||
bread.on("bread.chain.y", function(event)
|
||||
bread.emit("bread.chain.z", {})
|
||||
end)
|
||||
"#,
|
||||
)?;
|
||||
harness.wait_until_ready().await?;
|
||||
|
||||
let stream = UnixStream::connect(harness.socket_path()).await?;
|
||||
let (read_half, mut write_half) = stream.into_split();
|
||||
let subscribe = json!({
|
||||
"id": "sub-chain",
|
||||
"method": "events.subscribe",
|
||||
"params": { "filter": "bread.chain.*" }
|
||||
});
|
||||
write_half
|
||||
.write_all(format!("{}\n", serde_json::to_string(&subscribe)?).as_bytes())
|
||||
.await?;
|
||||
|
||||
let mut reader = BufReader::new(read_half).lines();
|
||||
let ack = reader
|
||||
.next_line()
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("missing subscribe ack"))?;
|
||||
let ack_json: Value = serde_json::from_str(&ack)?;
|
||||
assert_eq!(
|
||||
ack_json
|
||||
.get("result")
|
||||
.and_then(|v| v.get("subscribed"))
|
||||
.and_then(Value::as_bool),
|
||||
Some(true)
|
||||
);
|
||||
|
||||
// The trigger itself comes in over IPC's unsourced `emit`, outside any
|
||||
// Lua handler — its `caused_by` must be None. Everything downstream
|
||||
// (X, Y, Z) is emitted by `bread.emit()` from inside a running handler.
|
||||
harness
|
||||
.send_request("emit", json!({ "event": "bread.chain.trigger", "data": {} }))
|
||||
.await?;
|
||||
|
||||
let mut events: HashMap<String, Value> = HashMap::new();
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while events.len() < 4 && Instant::now() < deadline {
|
||||
let Some(line) = reader.next_line().await? else {
|
||||
break;
|
||||
};
|
||||
let event: Value = serde_json::from_str(&line)?;
|
||||
let name = event
|
||||
.get("event")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
if name.starts_with("bread.chain.") {
|
||||
events.insert(name, event);
|
||||
}
|
||||
}
|
||||
|
||||
let trigger = events
|
||||
.get("bread.chain.trigger")
|
||||
.expect("missing trigger event");
|
||||
let x = events.get("bread.chain.x").expect("missing X event");
|
||||
let y = events.get("bread.chain.y").expect("missing Y event");
|
||||
let z = events.get("bread.chain.z").expect("missing Z event");
|
||||
|
||||
let trigger_id = trigger
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.expect("trigger event missing id")
|
||||
.to_string();
|
||||
let x_id = x
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.expect("X event missing id")
|
||||
.to_string();
|
||||
let y_id = y
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.expect("Y event missing id")
|
||||
.to_string();
|
||||
let z_id = z
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.expect("Z event missing id")
|
||||
.to_string();
|
||||
|
||||
assert_eq!(
|
||||
trigger.get("caused_by").and_then(Value::as_str),
|
||||
None,
|
||||
"IPC-originated trigger event should have no caused_by"
|
||||
);
|
||||
assert_eq!(
|
||||
x.get("caused_by").and_then(Value::as_str),
|
||||
Some(trigger_id.as_str()),
|
||||
"X should be caused_by the trigger event's id"
|
||||
);
|
||||
assert_eq!(
|
||||
y.get("caused_by").and_then(Value::as_str),
|
||||
Some(x_id.as_str()),
|
||||
"Y should be caused_by X's id, not the original trigger"
|
||||
);
|
||||
assert_eq!(
|
||||
z.get("caused_by").and_then(Value::as_str),
|
||||
Some(y_id.as_str()),
|
||||
"Z should be caused_by Y's id"
|
||||
);
|
||||
|
||||
let ids: std::collections::HashSet<&str> =
|
||||
[trigger_id.as_str(), x_id.as_str(), y_id.as_str(), z_id.as_str()]
|
||||
.into_iter()
|
||||
.collect();
|
||||
assert_eq!(
|
||||
ids.len(),
|
||||
4,
|
||||
"every event in the chain should have a distinct id"
|
||||
);
|
||||
|
||||
harness.shutdown();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workflow_reaches_done_via_wait_any_happy_path() -> Result<()> {
|
||||
let harness = TestHarness::spawn_with_init(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue