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_events`] 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//!
30//! # One append, many events (write amplification)
31//!
32//! The append primitive takes a SLICE of same-stream events, not one event,
33//! because a durable backend pays per COMMIT, not per byte: haematite
34//! re-persists the whole containing storage leaf as a new permanent blob on
35//! every commit, so appending N events one at a time costs N whole-leaf
36//! rewrites of a leaf that can reach tens of megabytes (forensics 2026-08-17:
37//! 2,359 honest bytes bought a 2,303,416-byte blob). One batched append of N
38//! same-stream events is ONE commit and therefore one rewrite. The single-event
39//! [`ObservabilityStore::append_activity_event`] remains as a documented
40//! one-line convenience over the slice form for callers that genuinely hold a
41//! single event; it is not a second implementation.
42
43use async_trait::async_trait;
44
45use aion_core::{ActivityEvent, ActivityId, RunId, WorkflowId};
46
47use crate::StoreError;
48
49/// The durable key of one observability stream: a `(workflow, run, activity,
50/// attempt)` quad. Every [`ActivityRecord`] for one running agent attempt shares
51/// this key and is ordered by `store_seq` within it.
52///
53/// # Why the run axis exists
54///
55/// A continue-as-new chain reuses one [`WorkflowId`] across generations, and
56/// BOTH of the remaining axes restart inside each new run: activity ordinals
57/// count from `0` again and attempts from `1`. A `(workflow, activity, attempt)`
58/// key is therefore not unique across a chain — generation two's first event
59/// would be appended onto generation one's stream head, silently fusing two
60/// transcripts into one. The run is the axis that separates them, so it is a
61/// required component of the key with no `None` arm.
62#[derive(Clone, Debug, PartialEq, Eq, Hash)]
63pub struct ActivityStreamKey {
64 /// The workflow the activity belongs to.
65 pub workflow_id: WorkflowId,
66 /// The concrete run of that workflow — the second key axis. Two generations
67 /// of one continue-as-new chain are DISTINCT streams even when their
68 /// `(activity, attempt)` coordinates coincide, which they routinely do.
69 pub run_id: RunId,
70 /// The activity within the workflow.
71 pub activity_id: ActivityId,
72 /// The attempt number — the fourth key axis (NOI-0). Two attempts of one
73 /// activity are DISTINCT streams; a within-attempt failover shares one stream
74 /// (so a dying + adopting worker's events dedupe), while a retry is a new
75 /// attempt and therefore a new stream.
76 pub attempt: u32,
77}
78
79impl ActivityStreamKey {
80 /// Build a stream key from its four components.
81 #[must_use]
82 pub const fn new(
83 workflow_id: WorkflowId,
84 run_id: RunId,
85 activity_id: ActivityId,
86 attempt: u32,
87 ) -> Self {
88 Self {
89 workflow_id,
90 run_id,
91 activity_id,
92 attempt,
93 }
94 }
95
96 /// The stream key an [`ActivityEvent`] belongs to.
97 #[must_use]
98 pub fn of(event: &ActivityEvent) -> Self {
99 Self {
100 workflow_id: event.workflow_id.clone(),
101 run_id: event.run_id.clone(),
102 activity_id: event.activity_id.clone(),
103 attempt: event.attempt,
104 }
105 }
106}
107
108/// A durably persisted observability event: an [`ActivityEvent`] with its
109/// server-stamped `store_seq` guaranteed present.
110///
111/// The wire envelope carries `store_seq: Option<u64>` (`None` until persisted);
112/// once read back from the `O` keyspace the sequence is always present, so this
113/// record exposes it as a non-optional field alongside the event.
114#[derive(Clone, Debug, PartialEq)]
115pub struct ActivityRecord {
116 /// The monotonic, server-allocated sequence assigned at durable commit.
117 pub store_seq: u64,
118 /// The persisted event. Its `store_seq` field is populated to match
119 /// [`Self::store_seq`] so a record read back is self-describing.
120 pub event: ActivityEvent,
121}
122
123/// One retained transcript stream of a workflow run: its key and its head
124/// (the number of durably retained records / the next `store_seq`).
125#[derive(Clone, Debug, PartialEq, Eq)]
126pub struct ActivityStreamSummary {
127 /// The stream's `(workflow, run, activity, attempt)` key — self-describing,
128 /// so a summary read out of one enumeration names the run it came from.
129 pub key: ActivityStreamKey,
130 /// Next `store_seq` to be written == count of retained records.
131 pub head: u64,
132}
133
134/// Durable, append-only observability keyspace contract.
135///
136/// Implemented by the haematite backend for production and by
137/// [`InMemoryObservabilityStore`] for tests + conformance. The server is the
138/// single writer; every method keys on the `(workflow, run, activity, attempt)`
139/// quad, never on the workflow alone and never on a run-ambiguous triple.
140#[async_trait]
141pub trait ObservabilityStore: Send + Sync + 'static {
142 /// Atomically append `events` — all belonging to ONE
143 /// `(workflow, run, activity, attempt)` stream — starting at `expected_seq`
144 /// (the current head the caller believes it holds), as a SINGLE durable
145 /// commit.
146 ///
147 /// The `i`-th event is assigned `store_seq == expected_seq + i`. On success
148 /// returns the stream's new head (`expected_seq + events.len()`), which is
149 /// the next `store_seq` to be written. On a stale expectation returns
150 /// [`StoreError::SequenceConflict`] with the actual head, leaving the stream
151 /// **entirely** unchanged — a batch is all-or-nothing, so the server's
152 /// sequencer can re-read the advanced head and retry the whole batch.
153 ///
154 /// An EMPTY slice is a no-op that commits nothing and returns
155 /// `expected_seq`: there is no partial state to reason about and no commit
156 /// to pay for.
157 ///
158 /// **Every event must share one stream key** — the durable backend addresses
159 /// one stream per commit, so a mixed slice is a caller bug and is refused
160 /// with [`StoreError::Backend`] rather than silently split (silently
161 /// splitting would re-introduce the per-event commit this method exists to
162 /// remove, and would assign sequences from the wrong stream's head).
163 /// **Ephemeral events must never be passed here** — they are
164 /// WS-forward-only and are filtered out before this call.
165 ///
166 /// # Errors
167 /// [`StoreError::SequenceConflict`] on a stale `expected_seq`;
168 /// [`StoreError::Backend`] when the slice mixes stream keys; otherwise a
169 /// backend or serialization error.
170 async fn append_activity_events(
171 &self,
172 expected_seq: u64,
173 events: &[ActivityEvent],
174 ) -> Result<u64, StoreError>;
175
176 /// Append ONE event at `expected_seq`, returning its assigned `store_seq`
177 /// (which equals `expected_seq`).
178 ///
179 /// A one-line convenience over [`Self::append_activity_events`] for callers
180 /// that genuinely hold a single event — never a second implementation, and
181 /// never the way to write N events (that is N commits; see the module docs).
182 ///
183 /// # Errors
184 /// As [`Self::append_activity_events`].
185 async fn append_activity_event(
186 &self,
187 expected_seq: u64,
188 event: &ActivityEvent,
189 ) -> Result<u64, StoreError> {
190 self.append_activity_events(expected_seq, std::slice::from_ref(event))
191 .await?;
192 Ok(expected_seq)
193 }
194
195 /// Read the current head (next `store_seq` to be written) for `key`.
196 ///
197 /// An unwritten stream reads head `0`. The server's sequencer seeds its
198 /// retry loop from this value.
199 ///
200 /// # Errors
201 /// A backend or serialization error.
202 async fn activity_head(&self, key: &ActivityStreamKey) -> Result<u64, StoreError>;
203
204 /// Read every record for `key` with `store_seq >= from_seq`, in order.
205 ///
206 /// This is the resume-by-`store_seq` primitive: a reconnecting transcript
207 /// client replays from its last-seen cursor without paying for the whole
208 /// stream. An unwritten stream (or a `from_seq` beyond the head) reads empty.
209 ///
210 /// # Errors
211 /// A backend or serialization error.
212 async fn read_activity_events_from(
213 &self,
214 key: &ActivityStreamKey,
215 from_seq: u64,
216 ) -> Result<Vec<ActivityRecord>, StoreError>;
217
218 /// Enumerate every retained transcript stream of ONE RUN of `workflow_id`,
219 /// ordered by `(activity_id, attempt)` ascending. A run with no retained
220 /// transcript reads empty (old runs simply have none).
221 ///
222 /// The run is a required argument, not an optional filter: a workflow-wide
223 /// enumeration over a continue-as-new chain would return several
224 /// generations' streams under coordinates that collide pairwise, which is
225 /// exactly the ambiguity this keyspace exists to remove.
226 ///
227 /// # Errors
228 /// A backend or serialization error.
229 async fn list_activity_streams(
230 &self,
231 workflow_id: &WorkflowId,
232 run_id: &RunId,
233 ) -> Result<Vec<ActivityStreamSummary>, StoreError>;
234}
235
236/// An in-memory [`ObservabilityStore`] reference implementation for tests.
237///
238/// Enforces the SAME optimistic-concurrency contract the haematite backend does:
239/// an append with a stale `expected_seq` returns [`StoreError::SequenceConflict`]
240/// and writes nothing, so the server's retry loop can be exercised without a real
241/// database. A `std::sync::Mutex` serializes the read-compare-write so two racing
242/// appends on one stream cannot both win — the same single-shard-actor guarantee
243/// the haematite backend gives.
244#[derive(Debug, Default)]
245pub struct InMemoryObservabilityStore {
246 streams:
247 std::sync::Mutex<std::collections::HashMap<ActivityStreamKeyBytes, Vec<ActivityRecord>>>,
248}
249
250/// A hashable, owned encoding of [`ActivityStreamKey`] for the in-memory map:
251/// `(workflow uuid, run uuid, activity ordinal, attempt)`, in the SAME axis
252/// order the durable `O`-region key encodes, so the two implementations agree on
253/// which events share a stream and on the order an enumeration returns them in.
254type ActivityStreamKeyBytes = (uuid::Uuid, uuid::Uuid, u64, u32);
255
256fn key_bytes(key: &ActivityStreamKey) -> ActivityStreamKeyBytes {
257 (
258 key.workflow_id.as_uuid(),
259 key.run_id.as_uuid(),
260 key.activity_id.sequence_position(),
261 key.attempt,
262 )
263}
264
265/// The next `store_seq` for an in-memory stream = its record count.
266///
267/// A `Vec` length that does not fit in `u64` is unrepresentable on any supported
268/// target (a 64-bit `usize` maxes at `u64::MAX`), so the saturating conversion is
269/// exact in practice; it is written as a fallible convert to satisfy the
270/// deny-level pedantic cast lints without an `as` cast.
271fn stream_head(stream: &[ActivityRecord]) -> u64 {
272 u64::try_from(stream.len()).unwrap_or(u64::MAX)
273}
274
275#[async_trait]
276impl ObservabilityStore for InMemoryObservabilityStore {
277 async fn append_activity_events(
278 &self,
279 expected_seq: u64,
280 events: &[ActivityEvent],
281 ) -> Result<u64, StoreError> {
282 let Some(first) = events.first() else {
283 // An empty batch commits nothing and leaves the head where it is.
284 return Ok(expected_seq);
285 };
286 let key = ActivityStreamKey::of(first);
287 for event in events {
288 if ActivityStreamKey::of(event) != key {
289 return Err(StoreError::Backend(format!(
290 "observability batch mixes stream keys: {key:?} and {:?} — one batch \
291 addresses exactly one stream",
292 ActivityStreamKey::of(event)
293 )));
294 }
295 }
296 let mut streams = self.streams.lock().map_err(|error| {
297 StoreError::Backend(format!("observability mutex poisoned: {error}"))
298 })?;
299 let stream = streams.entry(key_bytes(&key)).or_default();
300 let head = stream_head(stream);
301 if head != expected_seq {
302 // All-or-nothing: nothing of this batch is written.
303 return Err(StoreError::SequenceConflict {
304 expected: expected_seq,
305 found: head,
306 });
307 }
308 for (offset, event) in events.iter().enumerate() {
309 let store_seq = head.saturating_add(u64::try_from(offset).unwrap_or(u64::MAX));
310 let mut event = event.clone();
311 event.store_seq = Some(store_seq);
312 stream.push(ActivityRecord { store_seq, event });
313 }
314 Ok(stream_head(stream))
315 }
316
317 async fn activity_head(&self, key: &ActivityStreamKey) -> Result<u64, StoreError> {
318 let streams = self.streams.lock().map_err(|error| {
319 StoreError::Backend(format!("observability mutex poisoned: {error}"))
320 })?;
321 Ok(streams
322 .get(&key_bytes(key))
323 .map_or(0, |stream| stream_head(stream)))
324 }
325
326 async fn read_activity_events_from(
327 &self,
328 key: &ActivityStreamKey,
329 from_seq: u64,
330 ) -> Result<Vec<ActivityRecord>, StoreError> {
331 let streams = self.streams.lock().map_err(|error| {
332 StoreError::Backend(format!("observability mutex poisoned: {error}"))
333 })?;
334 Ok(streams
335 .get(&key_bytes(key))
336 .map_or_else(Vec::new, |stream| {
337 stream
338 .iter()
339 .filter(|record| record.store_seq >= from_seq)
340 .cloned()
341 .collect()
342 }))
343 }
344
345 async fn list_activity_streams(
346 &self,
347 workflow_id: &WorkflowId,
348 run_id: &RunId,
349 ) -> Result<Vec<ActivityStreamSummary>, StoreError> {
350 let streams = self.streams.lock().map_err(|error| {
351 StoreError::Backend(format!("observability mutex poisoned: {error}"))
352 })?;
353 let mut summaries: Vec<ActivityStreamSummary> = streams
354 .iter()
355 .filter(|((workflow, run, _activity, _attempt), _records)| {
356 *workflow == workflow_id.as_uuid() && *run == run_id.as_uuid()
357 })
358 .map(
359 |(&(workflow, run, activity_seq, attempt), records)| ActivityStreamSummary {
360 key: ActivityStreamKey::new(
361 WorkflowId::new(workflow),
362 RunId::new(run),
363 ActivityId::from_sequence_position(activity_seq),
364 attempt,
365 ),
366 head: stream_head(records),
367 },
368 )
369 .collect();
370 summaries.sort_by_key(|summary| {
371 (
372 summary.key.activity_id.sequence_position(),
373 summary.key.attempt,
374 )
375 });
376 Ok(summaries)
377 }
378}
379
380#[cfg(test)]
381mod tests {
382 use super::*;
383 use aion_core::{ActivityEventKind, MessageRole};
384 use chrono::Utc;
385 use uuid::Uuid;
386
387 fn workflow() -> WorkflowId {
388 WorkflowId::new(Uuid::from_u128(1))
389 }
390
391 /// The first generation of the continue-as-new chain under test.
392 fn generation_one() -> RunId {
393 RunId::new(Uuid::from_u128(0x11))
394 }
395
396 /// The second generation: SAME workflow, different run.
397 fn generation_two() -> RunId {
398 RunId::new(Uuid::from_u128(0x22))
399 }
400
401 fn event(attempt: u32, worker_seq: u64, text: &str) -> ActivityEvent {
402 ActivityEvent {
403 workflow_id: workflow(),
404 run_id: generation_one(),
405 activity_id: ActivityId::from_sequence_position(3),
406 attempt,
407 agent_id: Uuid::from_u128(9),
408 agent_role: "orchestrator".to_owned(),
409 emitted_at: Utc::now(),
410 worker_seq,
411 store_seq: None,
412 ephemeral: false,
413 kind: ActivityEventKind::Message {
414 role: MessageRole::Assistant,
415 text: text.to_owned(),
416 },
417 }
418 }
419
420 fn key(attempt: u32) -> ActivityStreamKey {
421 ActivityStreamKey::new(
422 workflow(),
423 generation_one(),
424 ActivityId::from_sequence_position(3),
425 attempt,
426 )
427 }
428
429 #[tokio::test]
430 async fn append_assigns_contiguous_store_seq_from_zero() -> Result<(), StoreError> {
431 let store = InMemoryObservabilityStore::default();
432 let key = key(0);
433 assert_eq!(store.activity_head(&key).await?, 0);
434 assert_eq!(store.append_activity_event(0, &event(0, 1, "a")).await?, 0);
435 assert_eq!(store.append_activity_event(1, &event(0, 2, "b")).await?, 1);
436 assert_eq!(store.activity_head(&key).await?, 2);
437 let records = store.read_activity_events_from(&key, 0).await?;
438 assert_eq!(records.len(), 2);
439 assert_eq!(records[0].store_seq, 0);
440 assert_eq!(records[0].event.store_seq, Some(0));
441 assert_eq!(records[1].store_seq, 1);
442 Ok(())
443 }
444
445 #[tokio::test]
446 async fn stale_expected_seq_conflicts_and_writes_nothing() -> Result<(), StoreError> {
447 let store = InMemoryObservabilityStore::default();
448 store.append_activity_event(0, &event(0, 1, "a")).await?;
449 // Re-appending at the already-consumed seq 0 conflicts against head 1.
450 let conflict = store.append_activity_event(0, &event(0, 2, "dup")).await;
451 assert_eq!(
452 conflict,
453 Err(StoreError::SequenceConflict {
454 expected: 0,
455 found: 1
456 })
457 );
458 let key = ActivityStreamKey::of(&event(0, 0, ""));
459 // Nothing partial was written: still exactly one record.
460 assert_eq!(store.read_activity_events_from(&key, 0).await?.len(), 1);
461 Ok(())
462 }
463
464 #[tokio::test]
465 async fn attempts_are_disjoint_streams() -> Result<(), StoreError> {
466 let store = InMemoryObservabilityStore::default();
467 store
468 .append_activity_event(0, &event(0, 1, "attempt-0"))
469 .await?;
470 // A different attempt is a fresh stream with its own head at 0.
471 store
472 .append_activity_event(0, &event(1, 1, "attempt-1"))
473 .await?;
474 assert_eq!(store.activity_head(&key(0)).await?, 1);
475 assert_eq!(store.activity_head(&key(1)).await?, 1);
476 Ok(())
477 }
478
479 /// THE RUN-SCOPING INVARIANT at the store contract.
480 ///
481 /// Two generations of one continue-as-new chain emit from the SAME
482 /// `(workflow, activity ordinal 0, attempt 1)` coordinates — which is what
483 /// actually happens, because ordinals restart at `0` and attempts at `1` in
484 /// each new run. Both appends must therefore succeed at `expected_seq == 0`
485 /// (each is its own stream head), the two events must land under DIFFERENT
486 /// keys, and a read scoped to generation two must return EXACTLY ONE event:
487 /// its own. Under the pre-run-axis key the second append would have
488 /// conflicted against generation one's head and then fused onto its stream.
489 #[tokio::test]
490 async fn two_generations_of_one_chain_never_share_a_stream() -> Result<(), StoreError> {
491 let store = InMemoryObservabilityStore::default();
492 let ordinal_zero = ActivityId::from_sequence_position(0);
493 let mut first = event(1, 1, "generation one");
494 first.activity_id = ordinal_zero.clone();
495 let mut second = first.clone();
496 second.run_id = generation_two();
497 second.kind = ActivityEventKind::Message {
498 role: MessageRole::Assistant,
499 text: "generation two".to_owned(),
500 };
501
502 // Both are the FIRST event of their own stream: both append at seq 0.
503 assert_eq!(store.append_activity_event(0, &first).await?, 0);
504 assert_eq!(store.append_activity_event(0, &second).await?, 0);
505
506 let first_key = ActivityStreamKey::of(&first);
507 let second_key = ActivityStreamKey::of(&second);
508 assert_ne!(
509 first_key, second_key,
510 "one chain's two generations must not share a stream key"
511 );
512 // The keys differ ONLY in the run axis — the collision this guards.
513 assert_eq!(first_key.workflow_id, second_key.workflow_id);
514 assert_eq!(first_key.activity_id, second_key.activity_id);
515 assert_eq!(first_key.attempt, second_key.attempt);
516
517 let second_generation = store.read_activity_events_from(&second_key, 0).await?;
518 assert_eq!(
519 second_generation.len(),
520 1,
521 "a read scoped to generation two must return exactly its own event"
522 );
523 assert_eq!(second_generation[0].event.run_id, generation_two());
524 // And generation one is likewise untouched by generation two's append.
525 let first_generation = store.read_activity_events_from(&first_key, 0).await?;
526 assert_eq!(first_generation.len(), 1);
527 assert_eq!(first_generation[0].event.run_id, generation_one());
528 Ok(())
529 }
530
531 /// Two activities x two attempts of run one plus one stream of run two:
532 /// listing run one yields exactly its three streams, ordered by
533 /// `(activity, attempt)` ascending, each with the correct head. The run-two
534 /// stream shares a workflow with them and must NOT appear.
535 #[tokio::test]
536 async fn list_activity_streams_orders_by_activity_then_attempt() -> Result<(), StoreError> {
537 let store = InMemoryObservabilityStore::default();
538 let event_for = |activity_seq: u64, attempt: u32, run: RunId| {
539 let mut event = event(attempt, 1, "x");
540 event.run_id = run;
541 event.activity_id = ActivityId::from_sequence_position(activity_seq);
542 event
543 };
544 // run one: activity 3 attempt 0 (two records), activity 3 attempt 1
545 // (one), activity 5 attempt 0 (one). Inserted deliberately out of order.
546 store
547 .append_activity_event(0, &event_for(5, 0, generation_one()))
548 .await?;
549 store
550 .append_activity_event(0, &event_for(3, 1, generation_one()))
551 .await?;
552 store
553 .append_activity_event(0, &event_for(3, 0, generation_one()))
554 .await?;
555 store
556 .append_activity_event(1, &event_for(3, 0, generation_one()))
557 .await?;
558 // run two of the SAME workflow: one stream that must not leak in.
559 store
560 .append_activity_event(0, &event_for(3, 0, generation_two()))
561 .await?;
562
563 let summaries = store
564 .list_activity_streams(&workflow(), &generation_one())
565 .await?;
566 let listed: Vec<(u64, u32, u64)> = summaries
567 .iter()
568 .map(|summary| {
569 (
570 summary.key.activity_id.sequence_position(),
571 summary.key.attempt,
572 summary.head,
573 )
574 })
575 .collect();
576 assert_eq!(listed, vec![(3, 0, 2), (3, 1, 1), (5, 0, 1)]);
577 assert!(
578 summaries
579 .iter()
580 .all(|summary| summary.key.run_id == generation_one()),
581 "every summary names the run it was enumerated for"
582 );
583 Ok(())
584 }
585
586 #[tokio::test]
587 async fn list_activity_streams_is_empty_for_unknown_workflow() -> Result<(), StoreError> {
588 let store = InMemoryObservabilityStore::default();
589 store.append_activity_event(0, &event(0, 1, "a")).await?;
590 let summaries = store
591 .list_activity_streams(&WorkflowId::new(Uuid::from_u128(99)), &generation_one())
592 .await?;
593 assert!(summaries.is_empty(), "an unwritten workflow lists empty");
594 // A known workflow with an unwritten RUN is likewise empty, not the
595 // sibling generation's streams.
596 let other_run = store
597 .list_activity_streams(&workflow(), &generation_two())
598 .await?;
599 assert!(other_run.is_empty(), "an unwritten run lists empty");
600 Ok(())
601 }
602
603 /// The batch contract: one append, contiguous sequences from `expected_seq`,
604 /// the new head returned, order preserved, each record self-describing.
605 #[tokio::test]
606 async fn a_batch_assigns_contiguous_sequences_and_returns_the_new_head()
607 -> Result<(), StoreError> {
608 let store = InMemoryObservabilityStore::default();
609 let batch: Vec<ActivityEvent> = (0..4).map(|seq| event(0, seq, "batched")).collect();
610 assert_eq!(store.append_activity_events(0, &batch).await?, 4);
611 let records = store.read_activity_events_from(&key(0), 0).await?;
612 assert_eq!(
613 records
614 .iter()
615 .map(|record| (
616 record.store_seq,
617 record.event.store_seq,
618 record.event.worker_seq
619 ))
620 .collect::<Vec<(u64, Option<u64>, u64)>>(),
621 vec![
622 (0, Some(0), 0),
623 (1, Some(1), 1),
624 (2, Some(2), 2),
625 (3, Some(3), 3)
626 ],
627 "the i-th event lands at expected_seq + i, in order, self-describing"
628 );
629 // A second batch continues from the returned head.
630 let more: Vec<ActivityEvent> = (4..6).map(|seq| event(0, seq, "more")).collect();
631 assert_eq!(store.append_activity_events(4, &more).await?, 6);
632 assert_eq!(store.activity_head(&key(0)).await?, 6);
633 Ok(())
634 }
635
636 /// All-or-nothing: a stale `expected_seq` writes NO event of the batch, so a
637 /// losing writer never leaves a partial batch for the retry to duplicate.
638 #[tokio::test]
639 async fn a_conflicted_batch_writes_none_of_its_events() -> Result<(), StoreError> {
640 let store = InMemoryObservabilityStore::default();
641 store
642 .append_activity_event(0, &event(0, 1, "first"))
643 .await?;
644 let batch: Vec<ActivityEvent> = (2..6).map(|seq| event(0, seq, "losing")).collect();
645 assert_eq!(
646 store.append_activity_events(0, &batch).await,
647 Err(StoreError::SequenceConflict {
648 expected: 0,
649 found: 1
650 })
651 );
652 assert_eq!(
653 store.read_activity_events_from(&key(0), 0).await?.len(),
654 1,
655 "nothing partial was written"
656 );
657 Ok(())
658 }
659
660 /// One batch addresses ONE stream: a mixed slice is refused whole, never
661 /// silently split (which would re-introduce the per-event commit) and never
662 /// appended under the wrong stream's head.
663 #[tokio::test]
664 async fn a_batch_that_mixes_streams_is_refused() -> Result<(), StoreError> {
665 let store = InMemoryObservabilityStore::default();
666 let mixed = vec![event(0, 1, "attempt-0"), event(1, 2, "attempt-1")];
667 match store.append_activity_events(0, &mixed).await {
668 Err(StoreError::Backend(message)) => {
669 assert!(message.contains("mixes stream keys"), "{message}");
670 }
671 other => {
672 return Err(StoreError::Backend(format!(
673 "expected a refusal: {other:?}"
674 )));
675 }
676 }
677 assert_eq!(store.activity_head(&key(0)).await?, 0);
678 assert_eq!(store.activity_head(&key(1)).await?, 0);
679 Ok(())
680 }
681
682 /// An empty batch is a no-op that returns the head it was given.
683 #[tokio::test]
684 async fn an_empty_batch_is_a_no_op() -> Result<(), StoreError> {
685 let store = InMemoryObservabilityStore::default();
686 assert_eq!(store.append_activity_events(0, &[]).await?, 0);
687 assert_eq!(store.activity_head(&key(0)).await?, 0);
688 Ok(())
689 }
690
691 #[tokio::test]
692 async fn read_from_resumes_by_store_seq() -> Result<(), StoreError> {
693 let store = InMemoryObservabilityStore::default();
694 for seq in 0..5u64 {
695 store
696 .append_activity_event(seq, &event(0, seq, "x"))
697 .await?;
698 }
699 let key = ActivityStreamKey::of(&event(0, 0, ""));
700 let tail = store.read_activity_events_from(&key, 3).await?;
701 assert_eq!(tail.len(), 2);
702 assert_eq!(tail[0].store_seq, 3);
703 assert_eq!(tail[1].store_seq, 4);
704 Ok(())
705 }
706}