Skip to main content

polyc_eventlog/
lib.rs

1//! Append-only conversation event log on a `commonware-storage` journal.
2//!
3//! This crate persists the ordered stream of events that make up a conversation
4//! (user messages, planner decisions, tool calls, …) to an append-only log
5//! backed by the Commonware storage stack — keeping persistence on the
6//! Commonware primitives rather than a relational store.
7//!
8//! # Storage primitive
9//!
10//! [`EventLog`] wraps
11//! [`commonware_storage::journal::contiguous::variable::Journal`]: a
12//! **contiguous, position-based, variable-length** append-only journal. It is
13//! the natural fit here:
14//!
15//! - **Append-only.** [`EventLog::append`] writes one [`Event`] and returns the
16//!   monotonically increasing `u64` *position* the journal assigned it.
17//!   Positions start at `0` and never reused; pruning earlier entries does not
18//!   shift later positions.
19//! - **Ordered replay.** [`EventLog::replay`] returns every event in append
20//!   order, each paired with its position. **Append order is the ordering
21//!   contract**: the caller appends events in conversation order (turn, then
22//!   sequence within a turn), and replay yields them back in exactly that
23//!   order. The position therefore *is* the (turn, seq) ordinal flattened into
24//!   one strictly increasing sequence — there is no separate sort key to
25//!   maintain, which is precisely what an append-only log buys us.
26//! - **Variable-length items.** Each event's `payload` is an opaque,
27//!   buffa-encoded byte blob of arbitrary size; the `variable` journal stores
28//!   variable-length items natively (the `contiguous::fixed` sibling is for
29//!   fixed-width records and would not fit).
30//!
31//! # Runtime genericity (tokio vs. deterministic)
32//!
33//! The journal — and therefore [`EventLog`] — is generic over a
34//! [`commonware_storage::Context`] (the `Storage + Clock + Metrics` bound every
35//! Commonware storage type carries). Production drives it on the
36//! `commonware_runtime::tokio` backend; tests drive it on the
37//! `commonware_runtime::deterministic` backend for seeded, reproducible runs.
38//! The two never nest: per the prior `commonware-transport` spike, the
39//! Commonware runtime cannot be started from inside a live tokio runtime, so a
40//! tokio control plane hosts it on a dedicated thread. This crate stays
41//! runtime-agnostic and leaves that hosting decision to the caller.
42//!
43//! # Conversation scoping
44//!
45//! One [`EventLog`] instance maps to one conversation's log, identified by the
46//! storage *partition* name passed to [`EventLog::open`] (derive it from the
47//! conversation id, e.g. `format!("conv-{uid}")`). Distinct conversations use
48//! distinct partitions and so are fully isolated on disk.
49//!
50//! # Example
51//!
52//! <!--
53//! Marked `ignore`, not run: a runnable doctest statically links the entire
54//! Commonware storage stack into its own dedicated binary, and that link
55//! OOMs/bus-errors CI's linker. The example is mirrored verbatim by the
56//! `doc_example_open_append_replay` unit test, which folds into the crate's
57//! existing (already-linked) test binary rather than adding a second heavy
58//! link — so the snippet stays verified without the extra link unit.
59//! -->
60//! ```ignore
61//! use commonware_runtime::{deterministic, Runner};
62//! use polyc_eventlog::{Event, EventLog, EventLogConfig};
63//!
64//! let executor = deterministic::Runner::default();
65//! executor.start(|context| async move {
66//!     let log = EventLog::open(context, EventLogConfig::for_partition("conv-1"))
67//!         .await
68//!         .expect("open log");
69//!
70//!     log.append(&Event::new("user_msg", b"hello".to_vec())).await.unwrap();
71//!     log.append(&Event::new("tool_call", b"\x01\x02".to_vec())).await.unwrap();
72//!     log.commit().await.unwrap();
73//!
74//!     let events = log.replay().await.unwrap();
75//!     assert_eq!(events.len(), 2);
76//!     assert_eq!(events[0].kind, "user_msg");
77//! });
78//! ```
79
80pub mod error;
81pub mod event;
82
83pub use error::EventLogError;
84pub use event::{Event, EventCfg};
85
86use commonware_runtime::buffer::paged::CacheRef;
87use commonware_storage::journal::contiguous::{Reader as _, variable};
88use commonware_utils::{NZU16, NZU64, NZUsize};
89use futures::StreamExt as _;
90use std::num::{NonZeroU16, NonZeroU64, NonZeroUsize};
91
92/// Buffer size (in items) for the replay stream from the underlying journal.
93const REPLAY_BUFFER: NonZeroUsize = NZUsize!(1024);
94
95/// Configuration for opening an [`EventLog`].
96///
97/// Most fields mirror the underlying journal's tuning knobs and have sensible
98/// defaults via [`EventLogConfig::for_partition`]; only the `partition`
99/// (which conversation's log) is mandatory.
100#[derive(Debug, Clone)]
101pub struct EventLogConfig {
102    /// Storage partition name — one per conversation. Sub-partitions for the
103    /// data and offset indexes are derived from it by the journal.
104    pub partition: String,
105
106    /// Number of events stored per journal section. Sections roll over at this
107    /// count; only the final (partial) section is replayed on open to recover
108    /// the exact size. **Immutable once a partition exists** — changing it
109    /// across restarts corrupts the log.
110    pub items_per_section: NonZeroU64,
111
112    /// Decode-time bounds applied to each event during [`EventLog::replay`].
113    pub event_cfg: EventCfg,
114
115    /// Page size for the read cache over the underlying storage blobs.
116    pub page_size: NonZeroU16,
117
118    /// Page cache capacity, in pages.
119    pub page_cache_pages: NonZeroUsize,
120
121    /// Per-section write buffer size, in bytes.
122    pub write_buffer: NonZeroUsize,
123}
124
125impl EventLogConfig {
126    /// Build a config for `partition` with defaults for every other field.
127    ///
128    /// Defaults: 1024 events per section, [`EventCfg::DEFAULT`] decode bounds,
129    /// a 16 KiB page size with a 64-page cache, and a 64 KiB write buffer.
130    #[must_use]
131    pub fn for_partition(partition: impl Into<String>) -> Self {
132        Self {
133            partition: partition.into(),
134            items_per_section: NZU64!(1024),
135            event_cfg: EventCfg::DEFAULT,
136            page_size: NZU16!(16384),
137            page_cache_pages: NZUsize!(64),
138            write_buffer: NZUsize!(65536),
139        }
140    }
141}
142
143/// An append-only, ordered log of conversation [`Event`]s.
144///
145/// Generic over a [`commonware_storage::Context`] so the same code runs on the
146/// tokio backend in production and the deterministic backend in tests. See the
147/// crate-level docs for the ordering contract and runtime-coexistence notes.
148pub struct EventLog<E>
149where
150    E: commonware_storage::Context + commonware_runtime::BufferPooler,
151{
152    journal: variable::Journal<E, Event>,
153}
154
155impl<E> EventLog<E>
156where
157    E: commonware_storage::Context + commonware_runtime::BufferPooler,
158{
159    /// Open (creating if absent, recovering if present) the event log for a
160    /// conversation on the given runtime `context`.
161    ///
162    /// On open the journal replays only its final section to recover the exact
163    /// append size, and self-heals any data/offset divergence left by a crash.
164    ///
165    /// # Errors
166    ///
167    /// Returns [`EventLogError::Journal`] if the underlying storage fails to
168    /// initialize or recover the journal.
169    pub async fn open(context: E, config: EventLogConfig) -> Result<Self, EventLogError> {
170        let page_cache = CacheRef::from_pooler(&context, config.page_size, config.page_cache_pages);
171        let journal_cfg = variable::Config {
172            partition: config.partition,
173            items_per_section: config.items_per_section,
174            compression: None,
175            codec_config: config.event_cfg,
176            page_cache,
177            write_buffer: config.write_buffer,
178        };
179        let journal = variable::Journal::init(context, journal_cfg).await?;
180        Ok(Self { journal })
181    }
182
183    /// Append a single event, returning the position the journal assigned it.
184    ///
185    /// Positions are strictly increasing from `0` and define replay order. The
186    /// caller must append in conversation order (turn, then seq within a turn)
187    /// for replay to reflect that order.
188    ///
189    /// Takes `&self`: the underlying journal serializes writes internally via
190    /// interior mutability, so a shared reference suffices.
191    ///
192    /// Appends are buffered for durability; call [`EventLog::commit`] (or
193    /// [`EventLog::sync`]) to guarantee they survive a crash.
194    ///
195    /// # Errors
196    ///
197    /// Returns [`EventLogError::Journal`] if the item cannot be encoded or the
198    /// underlying storage write fails.
199    pub async fn append(&self, event: &Event) -> Result<u64, EventLogError> {
200        Ok(self.journal.append(event).await?)
201    }
202
203    /// Number of events appended to the log (the position the *next* append
204    /// will receive). Not reduced by pruning.
205    pub async fn len(&self) -> u64 {
206        self.journal.size().await
207    }
208
209    /// Whether the log has no appended events.
210    pub async fn is_empty(&self) -> bool {
211        self.len().await == 0
212    }
213
214    /// Replay every event in append order, each paired with its position.
215    ///
216    /// The returned `Vec` is ordered by position ascending (`0, 1, 2, …`),
217    /// which is conversation order. Each tuple is `(position, event)`.
218    ///
219    /// This collects the full log into memory; it is intended for rebuilding
220    /// in-memory conversation state on resume. For very large logs a streaming
221    /// variant could be added later (the journal exposes a `Stream`), but the
222    /// foundational API materializes for simplicity.
223    ///
224    /// # Errors
225    ///
226    /// Returns [`EventLogError::Journal`] if the journal cannot start the replay
227    /// stream or if decoding any stored event fails.
228    // The replay stream borrows the `reader` guard for its whole lifetime, so
229    // the guard cannot be dropped before the stream is consumed — the lint's
230    // suggested early drop would not compile here.
231    #[allow(clippy::significant_drop_tightening)]
232    pub async fn replay_with_positions(&self) -> Result<Vec<(u64, Event)>, EventLogError> {
233        let reader = self.journal.reader().await;
234        let start = reader.bounds().start;
235        let stream = reader.replay(REPLAY_BUFFER, start).await?;
236        futures::pin_mut!(stream);
237        let mut out = Vec::new();
238        while let Some(item) = stream.next().await {
239            out.push(item?);
240        }
241        Ok(out)
242    }
243
244    /// Replay every event in append order, discarding positions.
245    ///
246    /// Convenience over [`EventLog::replay_with_positions`] for callers that
247    /// only need the ordered events.
248    ///
249    /// # Errors
250    ///
251    /// Returns [`EventLogError::Journal`] on the same conditions as
252    /// [`EventLog::replay_with_positions`].
253    pub async fn replay(&self) -> Result<Vec<Event>, EventLogError> {
254        Ok(self
255            .replay_with_positions()
256            .await?
257            .into_iter()
258            .map(|(_pos, event)| event)
259            .collect())
260    }
261
262    /// Durably persist all buffered appends, guaranteeing they survive a crash.
263    ///
264    /// # Errors
265    ///
266    /// Returns [`EventLogError::Journal`] if the underlying flush fails.
267    pub async fn commit(&self) -> Result<(), EventLogError> {
268        Ok(self.journal.commit().await?)
269    }
270
271    /// Stronger durability than [`EventLog::commit`]: persist and guarantee no
272    /// recovery work is needed on next open.
273    ///
274    /// # Errors
275    ///
276    /// Returns [`EventLogError::Journal`] if the underlying sync fails.
277    pub async fn sync(&self) -> Result<(), EventLogError> {
278        Ok(self.journal.sync().await?)
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::{Event, EventLog, EventLogConfig};
285    use commonware_runtime::{Runner, Supervisor as _, deterministic};
286
287    /// Mirrors the crate-level `# Example` doctest verbatim. The doc block is
288    /// marked `ignore` because a runnable doctest links the whole Commonware
289    /// stack into its own binary, exhausting CI's linker; this test re-verifies the
290    /// same code in the crate's already-linked test binary so the documented
291    /// example can't silently rot.
292    #[test]
293    fn doc_example_open_append_replay() {
294        let executor = deterministic::Runner::default();
295        executor.start(|context| async move {
296            let log = EventLog::open(context, EventLogConfig::for_partition("conv-1"))
297                .await
298                .expect("open log");
299
300            log.append(&Event::new("user_msg", b"hello".to_vec()))
301                .await
302                .unwrap();
303            log.append(&Event::new("tool_call", b"\x01\x02".to_vec()))
304                .await
305                .unwrap();
306            log.commit().await.unwrap();
307
308            let events = log.replay().await.unwrap();
309            assert_eq!(events.len(), 2);
310            assert_eq!(events[0].kind, "user_msg");
311        });
312    }
313
314    /// Append events across several conversation turns, then assert replay
315    /// returns them in append (conversation) order with payload bytes intact.
316    #[test]
317    fn append_then_replay_preserves_order_and_payload() {
318        let executor = deterministic::Runner::default();
319        executor.start(|context| async move {
320            let log = EventLog::open(context, EventLogConfig::for_partition("conv-order"))
321                .await
322                .expect("open");
323
324            // Two turns: turn 0 = user_msg + planner_decision; turn 1 =
325            // tool_call + tool_result. Appended in conversation order.
326            let appended = vec![
327                Event::new("user_msg", b"what is 2+2?".to_vec()),
328                Event::new("planner_decision", vec![0xde, 0xad]),
329                Event::new("tool_call", vec![0x01, 0x02, 0x03]),
330                Event::new("tool_result", vec![0xff, 0x00, 0xff]),
331            ];
332            for (i, event) in appended.iter().enumerate() {
333                let pos = log.append(event).await.expect("append");
334                assert_eq!(pos, i as u64, "positions are 0-indexed and contiguous");
335            }
336            log.commit().await.expect("commit");
337
338            assert_eq!(log.len().await, 4);
339            assert!(!log.is_empty().await);
340
341            // Replay yields exactly the appended sequence, in order.
342            let replayed = log.replay().await.expect("replay");
343            assert_eq!(replayed, appended);
344
345            // Positions are ascending and dense.
346            let with_pos = log.replay_with_positions().await.expect("replay+pos");
347            let positions: Vec<u64> = with_pos.iter().map(|(p, _)| *p).collect();
348            assert_eq!(positions, vec![0, 1, 2, 3]);
349
350            // Payload bytes round-trip verbatim.
351            assert_eq!(with_pos[2].1.payload, vec![0x01, 0x02, 0x03]);
352        });
353    }
354
355    /// A freshly opened log is empty and replays nothing.
356    #[test]
357    fn empty_log_replays_empty() {
358        let executor = deterministic::Runner::default();
359        executor.start(|context| async move {
360            let log = EventLog::open(context, EventLogConfig::for_partition("conv-empty"))
361                .await
362                .expect("open");
363            assert!(log.is_empty().await);
364            assert_eq!(log.len().await, 0);
365            assert!(log.replay().await.expect("replay").is_empty());
366        });
367    }
368
369    /// Events appended, committed, and re-opened from the same partition
370    /// replay identically — persistence survives dropping the handle.
371    #[test]
372    fn reopen_recovers_committed_events() {
373        let executor = deterministic::Runner::default();
374        executor.start(|context| async move {
375            let cfg = EventLogConfig::for_partition("conv-reopen");
376
377            {
378                // Distinct supervision-tree label per open simulates a separate
379                // process (the deterministic runtime's metric registry is
380                // shared for the whole run, so re-registering under the same
381                // label panics — a real restart gets a fresh registry).
382                let log = EventLog::open(context.child("first"), cfg.clone())
383                    .await
384                    .expect("open first");
385                log.append(&Event::new("user_msg", b"persist me".to_vec()))
386                    .await
387                    .expect("append");
388                log.sync().await.expect("sync");
389            } // drop the handle
390
391            let log = EventLog::open(context.child("second"), cfg)
392                .await
393                .expect("reopen");
394            let replayed = log.replay().await.expect("replay");
395            assert_eq!(replayed.len(), 1);
396            assert_eq!(replayed[0].kind, "user_msg");
397            assert_eq!(replayed[0].payload, b"persist me".to_vec());
398        });
399    }
400
401    /// Determinism / reproducibility: two independent deterministic runs with
402    /// the same seeded program produce the same auditor state. This is the
403    /// property replay tests rely on (mirrors the runtime spike's
404    /// `auditor().state()` assertion).
405    #[test]
406    fn deterministic_runs_are_reproducible() {
407        fn run() -> String {
408            let executor = deterministic::Runner::default();
409            executor.start(|context| async move {
410                // `Context` is no longer `Clone` in 2026.5 — use `child`
411                // to produce a sibling context for the log while keeping
412                // the parent available for the `auditor()` read at the end.
413                let log =
414                    EventLog::open(context.child("det"), EventLogConfig::for_partition("det"))
415                        .await
416                        .expect("open");
417                for i in 0..6u8 {
418                    log.append(&Event::new(format!("kind-{i}"), vec![i; i as usize]))
419                        .await
420                        .expect("append");
421                }
422                log.commit().await.expect("commit");
423                let _ = log.replay().await.expect("replay");
424                context.auditor().state()
425            })
426        }
427
428        let first = run();
429        let second = run();
430        assert_eq!(first, second, "deterministic runtime must be reproducible");
431    }
432}