meerkat_mobkit/unified_runtime/
event_log.rs1use 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
14pub type EventLogError = Box<dyn std::error::Error + Send>;
16
17type EventFilter = Box<dyn Fn(&UnifiedEvent) -> bool + Send + Sync>;
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct PersistedEvent {
27 pub id: String,
29 pub seq: u64,
32 pub timestamp_ms: u64,
34 pub member_id: Option<String>,
36 pub event: UnifiedEvent,
38}
39
40#[derive(Debug, Clone, Default, Serialize, Deserialize)]
52pub struct EventQuery {
53 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub since_ms: Option<u64>,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub until_ms: Option<u64>,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub member_id: Option<String>,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub identity: Option<String>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub mob_id: Option<String>,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub run_id: Option<String>,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub step_id: Option<String>,
74 #[serde(default, skip_serializing_if = "Vec::is_empty")]
76 pub event_types: Vec<String>,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub limit: Option<usize>,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub after_seq: Option<u64>,
83}
84
85pub trait EventLogStore: Send + Sync {
96 fn append_batch(
101 &self,
102 events: Vec<PersistedEvent>,
103 ) -> Pin<Box<dyn Future<Output = Result<(), EventLogError>> + Send + '_>>;
104
105 fn query(
107 &self,
108 query: EventQuery,
109 ) -> Pin<Box<dyn Future<Output = Result<Vec<PersistedEvent>, EventLogError>> + Send + '_>>;
110}
111
112pub struct EventLogConfig {
118 pub store: Box<dyn EventLogStore>,
120 pub filter: Option<EventFilter>,
123 pub batch_size: usize,
126 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#[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
167pub(crate) struct EventLogHandle {
173 store: Arc<dyn EventLogStore>,
174 ingress_tx: mpsc::Sender<EventEnvelope<UnifiedEvent>>,
177}
178
179impl EventLogHandle {
180 pub fn store(&self) -> std::sync::Arc<dyn EventLogStore> {
182 self.store.clone()
183 }
184
185 pub fn ingest(&self, event: EventEnvelope<UnifiedEvent>) {
187 let _ = self.ingress_tx.try_send(event);
189 }
190}
191
192const EVENT_LOG_RETRY_BUFFER_CAP: usize = 4096;
196
197pub(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 let batch_size = config.batch_size.max(1);
209 let flush_interval = config.flush_interval.max(Duration::from_millis(1));
214 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 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
287fn 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 let mut restored = events;
325 restored.append(batch); 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 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 #[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 flush_batch(&store, &mut batch, &None).await;
423 assert_eq!(batch.len(), 2, "events must be retained on flush failure");
424
425 flush_batch(&store, &mut batch, &None).await;
427 assert_eq!(batch.len(), 2);
428
429 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 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 #[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 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 assert_eq!(batch.first().expect("first").id, "evt-100");
509 }
510}