Skip to main content

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}