Skip to main content

meerkat_mobkit/unified_runtime/
event_log.rs

1//! Persistent operational event log with buffered ingestion and pluggable storage.
2
3use std::future::Future;
4use std::pin::Pin;
5use std::sync::Arc;
6use std::sync::atomic::{AtomicU64, Ordering};
7use std::time::Duration;
8
9use serde::{Deserialize, Serialize};
10use tokio::sync::mpsc;
11
12use crate::types::{EventEnvelope, UnifiedEvent};
13
14/// Boxed error type returned by [`EventLogStore`] methods.
15pub type EventLogError = Box<dyn std::error::Error + Send>;
16
17/// Optional event filter predicate.
18type EventFilter = Box<dyn Fn(&UnifiedEvent) -> bool + Send + Sync>;
19
20// ---------------------------------------------------------------------------
21// Persisted event model
22// ---------------------------------------------------------------------------
23
24/// A persisted operational event with monotonic ordering.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct PersistedEvent {
27    /// Unique event ID (from the original event envelope).
28    pub id: String,
29    /// Monotonic sequence number assigned at ingestion time.
30    /// Deterministic ordering within and across batches.
31    pub seq: u64,
32    /// Millisecond timestamp from the original event.
33    pub timestamp_ms: u64,
34    /// Member/agent ID. `None` for module events.
35    pub member_id: Option<String>,
36    /// The full event payload.
37    pub event: UnifiedEvent,
38}
39
40// ---------------------------------------------------------------------------
41// Query model
42// ---------------------------------------------------------------------------
43
44/// Query parameters for historical event retrieval.
45///
46/// `after_seq` IS the cursor for pagination. Stores that surface a
47/// monotonic sequence (`PersistedEvent::seq`, `MobStructuralEventEnvelope::cursor`)
48/// MUST treat it as an exclusive lower bound and return events with
49/// `seq > after_seq`. Callers paginate by passing the last-seen `seq` as
50/// `after_seq` on the next query.
51#[derive(Debug, Clone, Default, Serialize, Deserialize)]
52pub struct EventQuery {
53    /// Only events after this timestamp (inclusive).
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub since_ms: Option<u64>,
56    /// Only events before this timestamp (exclusive).
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub until_ms: Option<u64>,
59    /// Filter to events from a specific member/agent.
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub member_id: Option<String>,
62    /// Filter to events from a specific identity when the store supports identity-native rows.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub identity: Option<String>,
65    /// Filter to events from a specific mob (structural event surface).
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub mob_id: Option<String>,
68    /// Filter to events scoped to a specific flow run.
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub run_id: Option<String>,
71    /// Filter to events scoped to a specific flow step.
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub step_id: Option<String>,
74    /// Filter to specific event types (e.g. "run_completed", "run_failed").
75    #[serde(default, skip_serializing_if = "Vec::is_empty")]
76    pub event_types: Vec<String>,
77    /// Maximum number of events to return.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub limit: Option<usize>,
80    /// Resume after this sequence number (exclusive; the cursor for pagination).
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub after_seq: Option<u64>,
83}
84
85// ---------------------------------------------------------------------------
86// Storage trait
87// ---------------------------------------------------------------------------
88
89/// Trait for persisting and querying operational events.
90///
91/// MobKit defines the contract; apps provide the implementation for their
92/// storage backend (BigQuery, Postgres, SQLite, in-memory, etc.).
93///
94/// Same pattern as `Discovery`, `EdgeDiscovery`, `SessionAgentBuilder`.
95pub trait EventLogStore: Send + Sync {
96    /// Persist a batch of events. Called periodically by the ingestion engine.
97    ///
98    /// Must be idempotent — duplicate events (same `id`) should be ignored.
99    /// Failures are logged via the error hook but never block agent execution.
100    fn append_batch(
101        &self,
102        events: Vec<PersistedEvent>,
103    ) -> Pin<Box<dyn Future<Output = Result<(), EventLogError>> + Send + '_>>;
104
105    /// Query historical events matching the given criteria.
106    fn query(
107        &self,
108        query: EventQuery,
109    ) -> Pin<Box<dyn Future<Output = Result<Vec<PersistedEvent>, EventLogError>> + Send + '_>>;
110}
111
112// ---------------------------------------------------------------------------
113// Configuration
114// ---------------------------------------------------------------------------
115
116/// Configuration for the event log ingestion engine.
117pub struct EventLogConfig {
118    /// App-provided storage backend.
119    pub store: Box<dyn EventLogStore>,
120    /// Optional filter — return `true` to persist, `false` to skip.
121    /// If `None`, all events are persisted.
122    pub filter: Option<EventFilter>,
123    /// Number of events to buffer before flushing to storage.
124    /// Default: 64.
125    pub batch_size: usize,
126    /// Maximum time between flushes, even if batch is not full.
127    /// Default: 1 second.
128    pub flush_interval: Duration,
129}
130
131impl Default for EventLogConfig {
132    fn default() -> Self {
133        Self {
134            store: Box::new(NullEventLogStore),
135            filter: None,
136            batch_size: 64,
137            flush_interval: Duration::from_secs(1),
138        }
139    }
140}
141
142/// No-op store that drops every event and serves empty queries.
143///
144/// A legitimate **declared** choice (tests, demos, gateways answering
145/// `runtime_options.event_log = {"storage": "null"}`), never an unconfigured
146/// default the composition falls back to silently — the silent case is the
147/// absence of event-log configuration, which means no ingestion at all.
148#[derive(Debug, Default, Clone, Copy)]
149pub struct NullEventLogStore;
150
151impl EventLogStore for NullEventLogStore {
152    fn append_batch(
153        &self,
154        _events: Vec<PersistedEvent>,
155    ) -> Pin<Box<dyn Future<Output = Result<(), EventLogError>> + Send + '_>> {
156        Box::pin(async { Ok(()) })
157    }
158
159    fn query(
160        &self,
161        _query: EventQuery,
162    ) -> Pin<Box<dyn Future<Output = Result<Vec<PersistedEvent>, EventLogError>> + Send + '_>> {
163        Box::pin(async { Ok(Vec::new()) })
164    }
165}
166
167// ---------------------------------------------------------------------------
168// Ingestion engine
169// ---------------------------------------------------------------------------
170
171/// Shared handle to the event log, held by UnifiedRuntime.
172pub(crate) struct EventLogHandle {
173    store: Arc<dyn EventLogStore>,
174    /// Sender for the ingestion buffer. Events are sent here and flushed
175    /// in batches by a background task.
176    ingress_tx: mpsc::Sender<EventEnvelope<UnifiedEvent>>,
177}
178
179impl EventLogHandle {
180    /// Return a cloned reference to the underlying store.
181    pub fn store(&self) -> std::sync::Arc<dyn EventLogStore> {
182        self.store.clone()
183    }
184
185    /// Ingest an event into the log (non-blocking, buffered).
186    pub fn ingest(&self, event: EventEnvelope<UnifiedEvent>) {
187        // Non-blocking: drop if the buffer is full (backpressure protection)
188        let _ = self.ingress_tx.try_send(event);
189    }
190}
191
192/// Hard cap on retained events while the store is unavailable. Beyond
193/// this, the oldest events in the retry buffer are dropped — bounded
194/// loss is preferable to OOM.
195const EVENT_LOG_RETRY_BUFFER_CAP: usize = 4096;
196
197/// Start the event log ingestion engine. Returns a handle for the runtime
198/// and spawns a background flush task.
199pub(crate) fn start_event_log(
200    config: EventLogConfig,
201    error_hook: Option<super::ErrorHook>,
202) -> EventLogHandle {
203    let store: Arc<dyn EventLogStore> = Arc::from(config.store);
204    let seq = Arc::new(AtomicU64::new(1));
205    // Clamp batch_size — `mpsc::channel(0)` panics, and `batch_size = 0`
206    // would also defeat batching by triggering an immediate flush per
207    // event.
208    let batch_size = config.batch_size.max(1);
209    // Clamp flush_interval — `tokio::time::interval` panics on a zero
210    // period, which would kill the ingestion task. The gateway rejects
211    // zero at the wire; this guards embedders constructing
212    // `EventLogConfig` directly.
213    let flush_interval = config.flush_interval.max(Duration::from_millis(1));
214    // Buffer capacity: 4x batch size to absorb bursts; floor at 4 so a
215    // tiny batch_size doesn't starve.
216    let channel_capacity = (batch_size * 4).max(4);
217    let (ingress_tx, ingress_rx) = mpsc::channel(channel_capacity);
218
219    let handle = EventLogHandle {
220        store: store.clone(),
221        ingress_tx,
222    };
223
224    tokio::spawn(run_flush_loop(
225        ingress_rx,
226        store,
227        seq,
228        config.filter,
229        batch_size,
230        flush_interval,
231        error_hook,
232    ));
233
234    handle
235}
236
237async fn run_flush_loop(
238    mut rx: mpsc::Receiver<EventEnvelope<UnifiedEvent>>,
239    store: Arc<dyn EventLogStore>,
240    seq: Arc<AtomicU64>,
241    filter: Option<EventFilter>,
242    batch_size: usize,
243    flush_interval: Duration,
244    error_hook: Option<super::ErrorHook>,
245) {
246    let mut batch: Vec<PersistedEvent> = Vec::with_capacity(batch_size);
247    let mut interval = tokio::time::interval(flush_interval);
248    interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
249
250    loop {
251        tokio::select! {
252            maybe_event = rx.recv() => {
253                match maybe_event {
254                    Some(envelope) => {
255                        if let Some(ref f) = filter
256                            && !f(&envelope.event)
257                        {
258                            continue;
259                        }
260                        let persisted = to_persisted(&seq, &envelope);
261                        batch.push(persisted);
262                        if batch.len() >= batch_size {
263                            flush_batch(&store, &mut batch, &error_hook).await;
264                        }
265                    }
266                    None => {
267                        // Channel closed — best-effort final flush and exit.
268                        // If this final flush fails, events on the floor
269                        // are unrecoverable; the error hook fires so
270                        // operators see it.
271                        if !batch.is_empty() {
272                            flush_batch(&store, &mut batch, &error_hook).await;
273                        }
274                        break;
275                    }
276                }
277            }
278            _ = interval.tick() => {
279                if !batch.is_empty() {
280                    flush_batch(&store, &mut batch, &error_hook).await;
281                }
282            }
283        }
284    }
285}
286
287/// Drop the oldest events from a retry buffer when it exceeds
288/// [`EVENT_LOG_RETRY_BUFFER_CAP`]. Returns the count dropped. Bounded
289/// loss is preferable to OOM under sustained store failure.
290fn enforce_retry_cap(batch: &mut Vec<PersistedEvent>) -> usize {
291    if batch.len() <= EVENT_LOG_RETRY_BUFFER_CAP {
292        return 0;
293    }
294    let drop = batch.len() - EVENT_LOG_RETRY_BUFFER_CAP;
295    batch.drain(0..drop);
296    drop
297}
298
299fn to_persisted(seq: &AtomicU64, envelope: &EventEnvelope<UnifiedEvent>) -> PersistedEvent {
300    let member_id = match &envelope.event {
301        UnifiedEvent::Agent { agent_id, .. } => Some(agent_id.clone()),
302        UnifiedEvent::Module(_) => None,
303    };
304    PersistedEvent {
305        id: envelope.event_id.clone(),
306        seq: seq.fetch_add(1, Ordering::Relaxed),
307        timestamp_ms: envelope.timestamp_ms,
308        member_id,
309        event: envelope.event.clone(),
310    }
311}
312
313async fn flush_batch(
314    store: &Arc<dyn EventLogStore>,
315    batch: &mut Vec<PersistedEvent>,
316    error_hook: &Option<super::ErrorHook>,
317) {
318    let events = std::mem::take(batch);
319    if let Err(err) = store.append_batch(events.clone()).await {
320        // Restore the failed batch so the next tick / arrival retries
321        // — silent loss on transient store errors was the prior bug.
322        // Bound the retry buffer so a persistently-failing store can't
323        // drive mobkit OOM.
324        let mut restored = events;
325        restored.append(batch); // events that arrived after the failure
326        let dropped = enforce_retry_cap(&mut restored);
327        *batch = restored;
328
329        if let Some(hook) = error_hook {
330            let hook = hook.clone();
331            let msg = if dropped > 0 {
332                format!(
333                    "event log flush failed: {err}; dropped {dropped} oldest events to bound the retry buffer at {EVENT_LOG_RETRY_BUFFER_CAP}"
334                )
335            } else {
336                format!("event log flush failed: {err}; will retry")
337            };
338            tokio::spawn(async move {
339                let () = hook(super::types::ErrorEvent::EventLogFlushFailure { error: msg }).await;
340            });
341        }
342    }
343}
344
345#[cfg(test)]
346#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
347mod tests {
348    use super::*;
349    use std::sync::Mutex;
350
351    /// Stub store that fails its first N `append_batch` calls and
352    /// succeeds thereafter, recording every event it eventually accepts.
353    struct FlakyStore {
354        failures_remaining: Mutex<usize>,
355        persisted: Mutex<Vec<PersistedEvent>>,
356        attempts: Mutex<usize>,
357    }
358
359    impl EventLogStore for FlakyStore {
360        fn append_batch(
361            &self,
362            events: Vec<PersistedEvent>,
363        ) -> Pin<Box<dyn Future<Output = Result<(), EventLogError>> + Send + '_>> {
364            Box::pin(async move {
365                *self.attempts.lock().expect("attempts") += 1;
366                let mut left = self.failures_remaining.lock().expect("failures");
367                if *left > 0 {
368                    *left -= 1;
369                    #[derive(Debug)]
370                    struct Transient;
371                    impl std::fmt::Display for Transient {
372                        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
373                            write!(f, "transient")
374                        }
375                    }
376                    impl std::error::Error for Transient {}
377                    return Err(Box::new(Transient) as Box<dyn std::error::Error + Send>);
378                }
379                self.persisted.lock().expect("persisted").extend(events);
380                Ok(())
381            })
382        }
383
384        fn query(
385            &self,
386            _query: EventQuery,
387        ) -> Pin<Box<dyn Future<Output = Result<Vec<PersistedEvent>, EventLogError>> + Send + '_>>
388        {
389            Box::pin(async { Ok(Vec::new()) })
390        }
391    }
392
393    fn sample_event(id: &str) -> PersistedEvent {
394        PersistedEvent {
395            id: id.to_string(),
396            seq: 0,
397            timestamp_ms: 0,
398            member_id: None,
399            event: UnifiedEvent::Module(crate::types::ModuleEvent {
400                module: "test-module".into(),
401                event_type: "x".into(),
402                payload: serde_json::Value::Null,
403            }),
404        }
405    }
406
407    /// Regression: pre-fix, `flush_batch` did `mem::take(batch)` and
408    /// dropped the events on store error. After the fix, the events
409    /// stay in the batch until a subsequent flush succeeds.
410    #[tokio::test]
411    async fn flush_failure_retries_instead_of_dropping_events() {
412        let flaky = Arc::new(FlakyStore {
413            failures_remaining: Mutex::new(2),
414            persisted: Mutex::new(Vec::new()),
415            attempts: Mutex::new(0),
416        });
417        let store: Arc<dyn EventLogStore> = flaky.clone();
418        let mut batch = vec![sample_event("a"), sample_event("b")];
419
420        // First flush — fails. Batch must remain non-empty (events
421        // pushed back into the retry buffer).
422        flush_batch(&store, &mut batch, &None).await;
423        assert_eq!(batch.len(), 2, "events must be retained on flush failure");
424
425        // Second flush — fails again. Still retained.
426        flush_batch(&store, &mut batch, &None).await;
427        assert_eq!(batch.len(), 2);
428
429        // Third flush — succeeds. Batch drained, events persisted.
430        flush_batch(&store, &mut batch, &None).await;
431        assert!(batch.is_empty(), "batch must drain on successful flush");
432
433        assert_eq!(*flaky.attempts.lock().expect("attempts"), 3);
434        assert_eq!(flaky.persisted.lock().expect("persisted").len(), 2);
435    }
436
437    /// Delegating wrapper so a test can hand the engine a boxed store
438    /// while keeping a shared handle for inspection.
439    struct SharedStore(Arc<FlakyStore>);
440
441    impl EventLogStore for SharedStore {
442        fn append_batch(
443            &self,
444            events: Vec<PersistedEvent>,
445        ) -> Pin<Box<dyn Future<Output = Result<(), EventLogError>> + Send + '_>> {
446            self.0.append_batch(events)
447        }
448
449        fn query(
450            &self,
451            query: EventQuery,
452        ) -> Pin<Box<dyn Future<Output = Result<Vec<PersistedEvent>, EventLogError>> + Send + '_>>
453        {
454            self.0.query(query)
455        }
456    }
457
458    /// Regression: `tokio::time::interval(Duration::ZERO)` panics, which
459    /// killed the ingestion task before it processed a single event. The
460    /// engine clamps the interval, so a zero-interval config still flushes.
461    #[tokio::test]
462    async fn zero_flush_interval_does_not_kill_the_flush_loop() {
463        let flaky = Arc::new(FlakyStore {
464            failures_remaining: Mutex::new(0),
465            persisted: Mutex::new(Vec::new()),
466            attempts: Mutex::new(0),
467        });
468        // batch_size > 1 so only the interval tick can flush the single
469        // ingested event — the path the zero interval used to kill.
470        let handle = start_event_log(
471            EventLogConfig {
472                store: Box::new(SharedStore(flaky.clone())),
473                filter: None,
474                batch_size: 64,
475                flush_interval: Duration::ZERO,
476            },
477            None,
478        );
479        handle.ingest(EventEnvelope {
480            event_id: "evt-zero-interval".to_string(),
481            source: "test".to_string(),
482            timestamp_ms: 0,
483            event: UnifiedEvent::Module(crate::types::ModuleEvent {
484                module: "test-module".into(),
485                event_type: "x".into(),
486                payload: serde_json::Value::Null,
487            }),
488        });
489
490        for _ in 0..400 {
491            if !flaky.persisted.lock().expect("persisted").is_empty() {
492                return;
493            }
494            tokio::time::sleep(Duration::from_millis(5)).await;
495        }
496        panic!("event never flushed: zero flush_interval killed the ingestion task");
497    }
498
499    #[test]
500    fn enforce_retry_cap_drops_oldest() {
501        let mut batch: Vec<PersistedEvent> = (0..(EVENT_LOG_RETRY_BUFFER_CAP + 100))
502            .map(|i| sample_event(&format!("evt-{i}")))
503            .collect();
504        let dropped = enforce_retry_cap(&mut batch);
505        assert_eq!(dropped, 100);
506        assert_eq!(batch.len(), EVENT_LOG_RETRY_BUFFER_CAP);
507        // Newest 4096 retained — first remaining id is evt-100.
508        assert_eq!(batch.first().expect("first").id, "evt-100");
509    }
510}