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}