aion_store/observability.rs
1//! The durable observability (`O`) keyspace contract — NOI-5's durability spine.
2//!
3//! This module defines the persistence contract for the agent-observability
4//! transcript: an **append-only, per-`(workflow, run, activity, attempt)`** stream of
5//! [`aion_core::ActivityEvent`] records that survives kill-9 and failover, is
6//! replayable by `store_seq`, and is **never** part of the workflow replay log.
7//!
8//! # The `O` keyspace is NOT the `E`-stream (LOCKED)
9//!
10//! Workflow replay authority lives exclusively on the `E`-stream (the
11//! [`crate::WritableEventStore`] append path). An [`ActivityRecord`] is an
12//! observability record: the replay decoder never scans this keyspace and could
13//! not decode one of these records as an `Event` even if it did (different region
14//! tag, different schema). The byte-level disjointness is what makes "durable but
15//! non-replay-authoritative" a *guarantee*, not a hope — see
16//! `aion-store-haematite`'s `observability` module for the `O` (0x4F) region tag
17//! and the disjointness test.
18//!
19//! # Single-writer, server-allocated `store_seq`
20//!
21//! `store_seq` is **not** allocated by the store: it is a caller-supplied
22//! `expected_seq` under optimistic concurrency, exactly like the workflow-history
23//! append path. [`ObservabilityStore::append_activity_event`] returns the
24//! `SequenceConflict` the server's sequencer re-reads-head-and-retries on. The
25//! server is the *single writer* to this keyspace, so monotonicity is enforced by
26//! the server's read-head -> append(expected_seq) -> retry loop, not by any magic
27//! in the store. This mirrors [`StoreError::SequenceConflict`] on the workflow
28//! path and is why the store deliberately does not auto-allocate an id.
29
30use async_trait::async_trait;
31
32use aion_core::{ActivityEvent, ActivityId, RunId, WorkflowId};
33
34use crate::StoreError;
35
36/// The durable key of one observability stream: a `(workflow, run, activity,
37/// attempt)` quad. Every [`ActivityRecord`] for one running agent attempt shares
38/// this key and is ordered by `store_seq` within it.
39///
40/// # Why the run axis exists
41///
42/// A continue-as-new chain reuses one [`WorkflowId`] across generations, and
43/// BOTH of the remaining axes restart inside each new run: activity ordinals
44/// count from `0` again and attempts from `1`. A `(workflow, activity, attempt)`
45/// key is therefore not unique across a chain — generation two's first event
46/// would be appended onto generation one's stream head, silently fusing two
47/// transcripts into one. The run is the axis that separates them, so it is a
48/// required component of the key with no `None` arm.
49#[derive(Clone, Debug, PartialEq, Eq, Hash)]
50pub struct ActivityStreamKey {
51 /// The workflow the activity belongs to.
52 pub workflow_id: WorkflowId,
53 /// The concrete run of that workflow — the second key axis. Two generations
54 /// of one continue-as-new chain are DISTINCT streams even when their
55 /// `(activity, attempt)` coordinates coincide, which they routinely do.
56 pub run_id: RunId,
57 /// The activity within the workflow.
58 pub activity_id: ActivityId,
59 /// The attempt number — the fourth key axis (NOI-0). Two attempts of one
60 /// activity are DISTINCT streams; a within-attempt failover shares one stream
61 /// (so a dying + adopting worker's events dedupe), while a retry is a new
62 /// attempt and therefore a new stream.
63 pub attempt: u32,
64}
65
66impl ActivityStreamKey {
67 /// Build a stream key from its four components.
68 #[must_use]
69 pub const fn new(
70 workflow_id: WorkflowId,
71 run_id: RunId,
72 activity_id: ActivityId,
73 attempt: u32,
74 ) -> Self {
75 Self {
76 workflow_id,
77 run_id,
78 activity_id,
79 attempt,
80 }
81 }
82
83 /// The stream key an [`ActivityEvent`] belongs to.
84 #[must_use]
85 pub fn of(event: &ActivityEvent) -> Self {
86 Self {
87 workflow_id: event.workflow_id.clone(),
88 run_id: event.run_id.clone(),
89 activity_id: event.activity_id.clone(),
90 attempt: event.attempt,
91 }
92 }
93}
94
95/// A durably persisted observability event: an [`ActivityEvent`] with its
96/// server-stamped `store_seq` guaranteed present.
97///
98/// The wire envelope carries `store_seq: Option<u64>` (`None` until persisted);
99/// once read back from the `O` keyspace the sequence is always present, so this
100/// record exposes it as a non-optional field alongside the event.
101#[derive(Clone, Debug, PartialEq)]
102pub struct ActivityRecord {
103 /// The monotonic, server-allocated sequence assigned at durable commit.
104 pub store_seq: u64,
105 /// The persisted event. Its `store_seq` field is populated to match
106 /// [`Self::store_seq`] so a record read back is self-describing.
107 pub event: ActivityEvent,
108}
109
110/// One retained transcript stream of a workflow run: its key and its head
111/// (the number of durably retained records / the next `store_seq`).
112#[derive(Clone, Debug, PartialEq, Eq)]
113pub struct ActivityStreamSummary {
114 /// The stream's `(workflow, run, activity, attempt)` key — self-describing,
115 /// so a summary read out of one enumeration names the run it came from.
116 pub key: ActivityStreamKey,
117 /// Next `store_seq` to be written == count of retained records.
118 pub head: u64,
119}
120
121/// Durable, append-only observability keyspace contract.
122///
123/// Implemented by the haematite backend for production and by
124/// [`InMemoryObservabilityStore`] for tests + conformance. The server is the
125/// single writer; every method keys on the `(workflow, run, activity, attempt)`
126/// quad, never on the workflow alone and never on a run-ambiguous triple.
127#[async_trait]
128pub trait ObservabilityStore: Send + Sync + 'static {
129 /// Append `event` to its `(workflow, run, activity, attempt)` stream at
130 /// `expected_seq` (the current head the caller believes it holds).
131 ///
132 /// On success returns the newly assigned `store_seq` (which equals
133 /// `expected_seq`) — the caller advances its head to `store_seq + 1`. On a
134 /// stale expectation returns [`StoreError::SequenceConflict`] with the actual
135 /// head, leaving the stream unchanged, so the server's sequencer can re-read
136 /// the advanced head and retry. **Ephemeral events must never be passed
137 /// here** — they are WS-forward-only and are filtered out before this call.
138 ///
139 /// # Errors
140 /// [`StoreError::SequenceConflict`] on a stale `expected_seq`; otherwise a
141 /// backend or serialization error.
142 async fn append_activity_event(
143 &self,
144 expected_seq: u64,
145 event: &ActivityEvent,
146 ) -> Result<u64, StoreError>;
147
148 /// Read the current head (next `store_seq` to be written) for `key`.
149 ///
150 /// An unwritten stream reads head `0`. The server's sequencer seeds its
151 /// retry loop from this value.
152 ///
153 /// # Errors
154 /// A backend or serialization error.
155 async fn activity_head(&self, key: &ActivityStreamKey) -> Result<u64, StoreError>;
156
157 /// Read every record for `key` with `store_seq >= from_seq`, in order.
158 ///
159 /// This is the resume-by-`store_seq` primitive: a reconnecting transcript
160 /// client replays from its last-seen cursor without paying for the whole
161 /// stream. An unwritten stream (or a `from_seq` beyond the head) reads empty.
162 ///
163 /// # Errors
164 /// A backend or serialization error.
165 async fn read_activity_events_from(
166 &self,
167 key: &ActivityStreamKey,
168 from_seq: u64,
169 ) -> Result<Vec<ActivityRecord>, StoreError>;
170
171 /// Enumerate every retained transcript stream of ONE RUN of `workflow_id`,
172 /// ordered by `(activity_id, attempt)` ascending. A run with no retained
173 /// transcript reads empty (old runs simply have none).
174 ///
175 /// The run is a required argument, not an optional filter: a workflow-wide
176 /// enumeration over a continue-as-new chain would return several
177 /// generations' streams under coordinates that collide pairwise, which is
178 /// exactly the ambiguity this keyspace exists to remove.
179 ///
180 /// # Errors
181 /// A backend or serialization error.
182 async fn list_activity_streams(
183 &self,
184 workflow_id: &WorkflowId,
185 run_id: &RunId,
186 ) -> Result<Vec<ActivityStreamSummary>, StoreError>;
187}
188
189/// An in-memory [`ObservabilityStore`] reference implementation for tests.
190///
191/// Enforces the SAME optimistic-concurrency contract the haematite backend does:
192/// an append with a stale `expected_seq` returns [`StoreError::SequenceConflict`]
193/// and writes nothing, so the server's retry loop can be exercised without a real
194/// database. A `std::sync::Mutex` serializes the read-compare-write so two racing
195/// appends on one stream cannot both win — the same single-shard-actor guarantee
196/// the haematite backend gives.
197#[derive(Debug, Default)]
198pub struct InMemoryObservabilityStore {
199 streams:
200 std::sync::Mutex<std::collections::HashMap<ActivityStreamKeyBytes, Vec<ActivityRecord>>>,
201}
202
203/// A hashable, owned encoding of [`ActivityStreamKey`] for the in-memory map:
204/// `(workflow uuid, run uuid, activity ordinal, attempt)`, in the SAME axis
205/// order the durable `O`-region key encodes, so the two implementations agree on
206/// which events share a stream and on the order an enumeration returns them in.
207type ActivityStreamKeyBytes = (uuid::Uuid, uuid::Uuid, u64, u32);
208
209fn key_bytes(key: &ActivityStreamKey) -> ActivityStreamKeyBytes {
210 (
211 key.workflow_id.as_uuid(),
212 key.run_id.as_uuid(),
213 key.activity_id.sequence_position(),
214 key.attempt,
215 )
216}
217
218/// The next `store_seq` for an in-memory stream = its record count.
219///
220/// A `Vec` length that does not fit in `u64` is unrepresentable on any supported
221/// target (a 64-bit `usize` maxes at `u64::MAX`), so the saturating conversion is
222/// exact in practice; it is written as a fallible convert to satisfy the
223/// deny-level pedantic cast lints without an `as` cast.
224fn stream_head(stream: &[ActivityRecord]) -> u64 {
225 u64::try_from(stream.len()).unwrap_or(u64::MAX)
226}
227
228#[async_trait]
229impl ObservabilityStore for InMemoryObservabilityStore {
230 async fn append_activity_event(
231 &self,
232 expected_seq: u64,
233 event: &ActivityEvent,
234 ) -> Result<u64, StoreError> {
235 let key = ActivityStreamKey::of(event);
236 let mut streams = self.streams.lock().map_err(|error| {
237 StoreError::Backend(format!("observability mutex poisoned: {error}"))
238 })?;
239 let stream = streams.entry(key_bytes(&key)).or_default();
240 let head = stream_head(stream);
241 if head != expected_seq {
242 return Err(StoreError::SequenceConflict {
243 expected: expected_seq,
244 found: head,
245 });
246 }
247 let mut event = event.clone();
248 event.store_seq = Some(head);
249 stream.push(ActivityRecord {
250 store_seq: head,
251 event,
252 });
253 Ok(head)
254 }
255
256 async fn activity_head(&self, key: &ActivityStreamKey) -> Result<u64, StoreError> {
257 let streams = self.streams.lock().map_err(|error| {
258 StoreError::Backend(format!("observability mutex poisoned: {error}"))
259 })?;
260 Ok(streams
261 .get(&key_bytes(key))
262 .map_or(0, |stream| stream_head(stream)))
263 }
264
265 async fn read_activity_events_from(
266 &self,
267 key: &ActivityStreamKey,
268 from_seq: u64,
269 ) -> Result<Vec<ActivityRecord>, StoreError> {
270 let streams = self.streams.lock().map_err(|error| {
271 StoreError::Backend(format!("observability mutex poisoned: {error}"))
272 })?;
273 Ok(streams
274 .get(&key_bytes(key))
275 .map_or_else(Vec::new, |stream| {
276 stream
277 .iter()
278 .filter(|record| record.store_seq >= from_seq)
279 .cloned()
280 .collect()
281 }))
282 }
283
284 async fn list_activity_streams(
285 &self,
286 workflow_id: &WorkflowId,
287 run_id: &RunId,
288 ) -> Result<Vec<ActivityStreamSummary>, StoreError> {
289 let streams = self.streams.lock().map_err(|error| {
290 StoreError::Backend(format!("observability mutex poisoned: {error}"))
291 })?;
292 let mut summaries: Vec<ActivityStreamSummary> = streams
293 .iter()
294 .filter(|((workflow, run, _activity, _attempt), _records)| {
295 *workflow == workflow_id.as_uuid() && *run == run_id.as_uuid()
296 })
297 .map(
298 |(&(workflow, run, activity_seq, attempt), records)| ActivityStreamSummary {
299 key: ActivityStreamKey::new(
300 WorkflowId::new(workflow),
301 RunId::new(run),
302 ActivityId::from_sequence_position(activity_seq),
303 attempt,
304 ),
305 head: stream_head(records),
306 },
307 )
308 .collect();
309 summaries.sort_by_key(|summary| {
310 (
311 summary.key.activity_id.sequence_position(),
312 summary.key.attempt,
313 )
314 });
315 Ok(summaries)
316 }
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322 use aion_core::{ActivityEventKind, MessageRole};
323 use chrono::Utc;
324 use uuid::Uuid;
325
326 fn workflow() -> WorkflowId {
327 WorkflowId::new(Uuid::from_u128(1))
328 }
329
330 /// The first generation of the continue-as-new chain under test.
331 fn generation_one() -> RunId {
332 RunId::new(Uuid::from_u128(0x11))
333 }
334
335 /// The second generation: SAME workflow, different run.
336 fn generation_two() -> RunId {
337 RunId::new(Uuid::from_u128(0x22))
338 }
339
340 fn event(attempt: u32, worker_seq: u64, text: &str) -> ActivityEvent {
341 ActivityEvent {
342 workflow_id: workflow(),
343 run_id: generation_one(),
344 activity_id: ActivityId::from_sequence_position(3),
345 attempt,
346 agent_id: Uuid::from_u128(9),
347 agent_role: "orchestrator".to_owned(),
348 emitted_at: Utc::now(),
349 worker_seq,
350 store_seq: None,
351 ephemeral: false,
352 kind: ActivityEventKind::Message {
353 role: MessageRole::Assistant,
354 text: text.to_owned(),
355 },
356 }
357 }
358
359 fn key(attempt: u32) -> ActivityStreamKey {
360 ActivityStreamKey::new(
361 workflow(),
362 generation_one(),
363 ActivityId::from_sequence_position(3),
364 attempt,
365 )
366 }
367
368 #[tokio::test]
369 async fn append_assigns_contiguous_store_seq_from_zero() -> Result<(), StoreError> {
370 let store = InMemoryObservabilityStore::default();
371 let key = key(0);
372 assert_eq!(store.activity_head(&key).await?, 0);
373 assert_eq!(store.append_activity_event(0, &event(0, 1, "a")).await?, 0);
374 assert_eq!(store.append_activity_event(1, &event(0, 2, "b")).await?, 1);
375 assert_eq!(store.activity_head(&key).await?, 2);
376 let records = store.read_activity_events_from(&key, 0).await?;
377 assert_eq!(records.len(), 2);
378 assert_eq!(records[0].store_seq, 0);
379 assert_eq!(records[0].event.store_seq, Some(0));
380 assert_eq!(records[1].store_seq, 1);
381 Ok(())
382 }
383
384 #[tokio::test]
385 async fn stale_expected_seq_conflicts_and_writes_nothing() -> Result<(), StoreError> {
386 let store = InMemoryObservabilityStore::default();
387 store.append_activity_event(0, &event(0, 1, "a")).await?;
388 // Re-appending at the already-consumed seq 0 conflicts against head 1.
389 let conflict = store.append_activity_event(0, &event(0, 2, "dup")).await;
390 assert_eq!(
391 conflict,
392 Err(StoreError::SequenceConflict {
393 expected: 0,
394 found: 1
395 })
396 );
397 let key = ActivityStreamKey::of(&event(0, 0, ""));
398 // Nothing partial was written: still exactly one record.
399 assert_eq!(store.read_activity_events_from(&key, 0).await?.len(), 1);
400 Ok(())
401 }
402
403 #[tokio::test]
404 async fn attempts_are_disjoint_streams() -> Result<(), StoreError> {
405 let store = InMemoryObservabilityStore::default();
406 store
407 .append_activity_event(0, &event(0, 1, "attempt-0"))
408 .await?;
409 // A different attempt is a fresh stream with its own head at 0.
410 store
411 .append_activity_event(0, &event(1, 1, "attempt-1"))
412 .await?;
413 assert_eq!(store.activity_head(&key(0)).await?, 1);
414 assert_eq!(store.activity_head(&key(1)).await?, 1);
415 Ok(())
416 }
417
418 /// THE RUN-SCOPING INVARIANT at the store contract.
419 ///
420 /// Two generations of one continue-as-new chain emit from the SAME
421 /// `(workflow, activity ordinal 0, attempt 1)` coordinates — which is what
422 /// actually happens, because ordinals restart at `0` and attempts at `1` in
423 /// each new run. Both appends must therefore succeed at `expected_seq == 0`
424 /// (each is its own stream head), the two events must land under DIFFERENT
425 /// keys, and a read scoped to generation two must return EXACTLY ONE event:
426 /// its own. Under the pre-run-axis key the second append would have
427 /// conflicted against generation one's head and then fused onto its stream.
428 #[tokio::test]
429 async fn two_generations_of_one_chain_never_share_a_stream() -> Result<(), StoreError> {
430 let store = InMemoryObservabilityStore::default();
431 let ordinal_zero = ActivityId::from_sequence_position(0);
432 let mut first = event(1, 1, "generation one");
433 first.activity_id = ordinal_zero.clone();
434 let mut second = first.clone();
435 second.run_id = generation_two();
436 second.kind = ActivityEventKind::Message {
437 role: MessageRole::Assistant,
438 text: "generation two".to_owned(),
439 };
440
441 // Both are the FIRST event of their own stream: both append at seq 0.
442 assert_eq!(store.append_activity_event(0, &first).await?, 0);
443 assert_eq!(store.append_activity_event(0, &second).await?, 0);
444
445 let first_key = ActivityStreamKey::of(&first);
446 let second_key = ActivityStreamKey::of(&second);
447 assert_ne!(
448 first_key, second_key,
449 "one chain's two generations must not share a stream key"
450 );
451 // The keys differ ONLY in the run axis — the collision this guards.
452 assert_eq!(first_key.workflow_id, second_key.workflow_id);
453 assert_eq!(first_key.activity_id, second_key.activity_id);
454 assert_eq!(first_key.attempt, second_key.attempt);
455
456 let second_generation = store.read_activity_events_from(&second_key, 0).await?;
457 assert_eq!(
458 second_generation.len(),
459 1,
460 "a read scoped to generation two must return exactly its own event"
461 );
462 assert_eq!(second_generation[0].event.run_id, generation_two());
463 // And generation one is likewise untouched by generation two's append.
464 let first_generation = store.read_activity_events_from(&first_key, 0).await?;
465 assert_eq!(first_generation.len(), 1);
466 assert_eq!(first_generation[0].event.run_id, generation_one());
467 Ok(())
468 }
469
470 /// Two activities x two attempts of run one plus one stream of run two:
471 /// listing run one yields exactly its three streams, ordered by
472 /// `(activity, attempt)` ascending, each with the correct head. The run-two
473 /// stream shares a workflow with them and must NOT appear.
474 #[tokio::test]
475 async fn list_activity_streams_orders_by_activity_then_attempt() -> Result<(), StoreError> {
476 let store = InMemoryObservabilityStore::default();
477 let event_for = |activity_seq: u64, attempt: u32, run: RunId| {
478 let mut event = event(attempt, 1, "x");
479 event.run_id = run;
480 event.activity_id = ActivityId::from_sequence_position(activity_seq);
481 event
482 };
483 // run one: activity 3 attempt 0 (two records), activity 3 attempt 1
484 // (one), activity 5 attempt 0 (one). Inserted deliberately out of order.
485 store
486 .append_activity_event(0, &event_for(5, 0, generation_one()))
487 .await?;
488 store
489 .append_activity_event(0, &event_for(3, 1, generation_one()))
490 .await?;
491 store
492 .append_activity_event(0, &event_for(3, 0, generation_one()))
493 .await?;
494 store
495 .append_activity_event(1, &event_for(3, 0, generation_one()))
496 .await?;
497 // run two of the SAME workflow: one stream that must not leak in.
498 store
499 .append_activity_event(0, &event_for(3, 0, generation_two()))
500 .await?;
501
502 let summaries = store
503 .list_activity_streams(&workflow(), &generation_one())
504 .await?;
505 let listed: Vec<(u64, u32, u64)> = summaries
506 .iter()
507 .map(|summary| {
508 (
509 summary.key.activity_id.sequence_position(),
510 summary.key.attempt,
511 summary.head,
512 )
513 })
514 .collect();
515 assert_eq!(listed, vec![(3, 0, 2), (3, 1, 1), (5, 0, 1)]);
516 assert!(
517 summaries
518 .iter()
519 .all(|summary| summary.key.run_id == generation_one()),
520 "every summary names the run it was enumerated for"
521 );
522 Ok(())
523 }
524
525 #[tokio::test]
526 async fn list_activity_streams_is_empty_for_unknown_workflow() -> Result<(), StoreError> {
527 let store = InMemoryObservabilityStore::default();
528 store.append_activity_event(0, &event(0, 1, "a")).await?;
529 let summaries = store
530 .list_activity_streams(&WorkflowId::new(Uuid::from_u128(99)), &generation_one())
531 .await?;
532 assert!(summaries.is_empty(), "an unwritten workflow lists empty");
533 // A known workflow with an unwritten RUN is likewise empty, not the
534 // sibling generation's streams.
535 let other_run = store
536 .list_activity_streams(&workflow(), &generation_two())
537 .await?;
538 assert!(other_run.is_empty(), "an unwritten run lists empty");
539 Ok(())
540 }
541
542 #[tokio::test]
543 async fn read_from_resumes_by_store_seq() -> Result<(), StoreError> {
544 let store = InMemoryObservabilityStore::default();
545 for seq in 0..5u64 {
546 store
547 .append_activity_event(seq, &event(0, seq, "x"))
548 .await?;
549 }
550 let key = ActivityStreamKey::of(&event(0, 0, ""));
551 let tail = store.read_activity_events_from(&key, 3).await?;
552 assert_eq!(tail.len(), 2);
553 assert_eq!(tail[0].store_seq, 3);
554 assert_eq!(tail[1].store_seq, 4);
555 Ok(())
556 }
557}