1#[cfg(feature = "server")]
2use crate::application::services::webhook::WebhookRegistry;
3#[cfg(feature = "server")]
4use crate::infrastructure::observability::metrics::MetricsRegistry;
5#[cfg(feature = "server")]
6use crate::infrastructure::web::websocket::WebSocketManager;
7use crate::{
8 application::{
9 dto::QueryEventsRequest,
10 services::{
11 consumer::ConsumerRegistry,
12 exactly_once::{ExactlyOnceConfig, ExactlyOnceRegistry},
13 pipeline::PipelineManager,
14 projection::{EntitySnapshotProjection, EventCounterProjection, ProjectionManager},
15 replay::ReplayManager,
16 schema::{SchemaRegistry, SchemaRegistryConfig},
17 schema_evolution::SchemaEvolutionManager,
18 },
19 },
20 domain::entities::Event,
21 error::{AllSourceError, Result},
22 infrastructure::{
23 persistence::{
24 compaction::{CompactionConfig, CompactionManager},
25 index::{EventIndex, IndexEntry},
26 snapshot::{SnapshotConfig, SnapshotManager, SnapshotType},
27 storage::ParquetStorage,
28 tenant_loader::TenantLoader,
29 wal::{WALConfig, WriteAheadLog},
30 },
31 query::geospatial::GeoIndex,
32 },
33};
34use chrono::{DateTime, Utc};
35use dashmap::DashMap;
36use parking_lot::RwLock;
37use std::{path::PathBuf, sync::Arc};
38#[cfg(feature = "server")]
39use tokio::sync::mpsc;
40
41pub struct EventStore {
43 events: Arc<RwLock<Vec<Event>>>,
45
46 index: Arc<EventIndex>,
48
49 pub(crate) projections: Arc<RwLock<ProjectionManager>>,
51
52 storage: Option<Arc<RwLock<ParquetStorage>>>,
54
55 #[cfg(feature = "server")]
57 websocket_manager: Arc<WebSocketManager>,
58
59 snapshot_manager: Arc<SnapshotManager>,
61
62 wal: Option<Arc<WriteAheadLog>>,
64
65 compaction_manager: Option<Arc<CompactionManager>>,
67
68 schema_registry: Arc<SchemaRegistry>,
70
71 replay_manager: Arc<ReplayManager>,
73
74 pipeline_manager: Arc<PipelineManager>,
76
77 #[cfg(feature = "server")]
79 metrics: Arc<MetricsRegistry>,
80
81 total_ingested: Arc<RwLock<u64>>,
83
84 projection_state_cache: Arc<DashMap<String, serde_json::Value>>,
88
89 projection_status: Arc<DashMap<String, String>>,
92
93 #[cfg(feature = "server")]
95 webhook_registry: Arc<WebhookRegistry>,
96
97 #[cfg(feature = "server")]
99 webhook_tx: Arc<RwLock<Option<mpsc::UnboundedSender<WebhookDeliveryTask>>>>,
100
101 geo_index: Arc<GeoIndex>,
103
104 exactly_once: Arc<ExactlyOnceRegistry>,
106
107 schema_evolution: Arc<SchemaEvolutionManager>,
109
110 entity_versions: Arc<DashMap<String, u64>>,
113
114 consumer_registry: Arc<ConsumerRegistry>,
116
117 event_broadcast_tx: tokio::sync::broadcast::Sender<Arc<Event>>,
121
122 tenant_loader: Arc<TenantLoader>,
128
129 checkpoint_interval_secs: Option<u64>,
135}
136
137#[cfg(feature = "server")]
139#[derive(Debug, Clone)]
140pub struct WebhookDeliveryTask {
141 pub webhook: crate::application::services::webhook::WebhookSubscription,
142 pub event: Event,
143}
144
145impl EventStore {
146 pub fn new() -> Self {
148 Self::with_config(EventStoreConfig::default())
149 }
150
151 pub fn with_config(config: EventStoreConfig) -> Self {
153 let mut projections = ProjectionManager::new();
154
155 projections.register(Arc::new(EntitySnapshotProjection::new("entity_snapshots")));
157 projections.register(Arc::new(EventCounterProjection::new("event_counters")));
158
159 let storage = config
161 .storage_dir
162 .as_ref()
163 .and_then(|dir| match ParquetStorage::new(dir) {
164 Ok(storage) => {
165 tracing::info!("✅ Parquet persistence enabled at: {}", dir.display());
166 Some(Arc::new(RwLock::new(storage)))
167 }
168 Err(e) => {
169 tracing::error!("❌ Failed to initialize Parquet storage: {}", e);
170 None
171 }
172 });
173
174 let wal = config.wal_dir.as_ref().and_then(|dir| {
176 match WriteAheadLog::new(dir, config.wal_config.clone()) {
177 Ok(wal) => {
178 tracing::info!("✅ WAL enabled at: {}", dir.display());
179 Some(Arc::new(wal))
180 }
181 Err(e) => {
182 tracing::error!("❌ Failed to initialize WAL: {}", e);
183 None
184 }
185 }
186 });
187
188 let compaction_manager = config.storage_dir.as_ref().map(|dir| {
190 let manager = CompactionManager::new(dir, config.compaction_config.clone());
191 Arc::new(manager)
192 });
193
194 let schema_registry = Arc::new(SchemaRegistry::new(config.schema_registry_config.clone()));
196 tracing::info!("✅ Schema registry enabled");
197
198 let replay_manager = Arc::new(ReplayManager::new());
200 tracing::info!("✅ Replay manager enabled");
201
202 let pipeline_manager = Arc::new(PipelineManager::new());
204 tracing::info!("✅ Pipeline manager enabled");
205
206 #[cfg(feature = "server")]
208 let metrics = {
209 let m = MetricsRegistry::new();
210 tracing::info!("✅ Prometheus metrics registry initialized");
211 m
212 };
213
214 let projection_state_cache = Arc::new(DashMap::new());
216 tracing::info!("✅ Projection state cache initialized");
217
218 #[cfg(feature = "server")]
220 let webhook_registry = {
221 let w = Arc::new(WebhookRegistry::new());
222 tracing::info!("✅ Webhook registry initialized");
223 w
224 };
225
226 let (event_broadcast_tx, _) = tokio::sync::broadcast::channel(1024);
229
230 let store = Self {
231 events: Arc::new(RwLock::new(Vec::new())),
232 index: Arc::new(EventIndex::new()),
233 projections: Arc::new(RwLock::new(projections)),
234 storage,
235 #[cfg(feature = "server")]
236 websocket_manager: Arc::new(WebSocketManager::new()),
237 snapshot_manager: Arc::new(SnapshotManager::new(config.snapshot_config)),
238 wal,
239 compaction_manager,
240 schema_registry,
241 replay_manager,
242 pipeline_manager,
243 #[cfg(feature = "server")]
244 metrics,
245 total_ingested: Arc::new(RwLock::new(0)),
246 projection_state_cache,
247 projection_status: Arc::new(DashMap::new()),
248 #[cfg(feature = "server")]
249 webhook_registry,
250 #[cfg(feature = "server")]
251 webhook_tx: Arc::new(RwLock::new(None)),
252 geo_index: Arc::new(GeoIndex::new()),
253 exactly_once: Arc::new(ExactlyOnceRegistry::new(ExactlyOnceConfig::default())),
254 schema_evolution: Arc::new(SchemaEvolutionManager::new()),
255 entity_versions: Arc::new(DashMap::new()),
256 consumer_registry: Arc::new(ConsumerRegistry::new()),
257 event_broadcast_tx,
258 tenant_loader: {
259 let loader = TenantLoader::new();
260 if let Some(budget) = config.cache_byte_budget {
261 loader.set_byte_budget(budget);
262 tracing::info!(
263 "✅ Cache byte budget set to {} bytes ({:.2} GiB) — LRU eviction enabled",
264 budget,
265 budget as f64 / (1024.0 * 1024.0 * 1024.0)
266 );
267 } else {
268 tracing::info!(
269 "✅ Cache budget unset — every loaded tenant stays resident \
270 (set ALLSOURCE_CACHE_BYTES to enable eviction)"
271 );
272 }
273 Arc::new(loader)
274 },
275 checkpoint_interval_secs: config.checkpoint_interval_secs,
276 };
277
278 if let Some(ref wal) = store.wal {
300 match wal.recover() {
301 Ok(recovered_events) if !recovered_events.is_empty() => {
302 let mut wal_new = 0usize;
303 for event in recovered_events {
304 let offset = store.events.read().len();
305 if let Err(e) = store.index.index_event(
306 event.id,
307 event.entity_id_str(),
308 event.event_type_str(),
309 event.timestamp,
310 offset,
311 ) {
312 tracing::error!("Failed to re-index WAL event {}: {}", event.id, e);
313 }
314
315 if let Err(e) = store.projections.read().process_event(&event) {
316 tracing::error!("Failed to re-process WAL event {}: {}", event.id, e);
317 }
318
319 *store
320 .entity_versions
321 .entry(event.entity_id_str().to_string())
322 .or_insert(0) += 1;
323
324 store.events.write().push(event);
325 wal_new += 1;
326 }
327
328 #[cfg(feature = "server")]
333 store.metrics.wal_replay_events_total.set(wal_new as i64);
334
335 if wal_new > 0 {
336 let total = store.events.read().len();
337 *store.total_ingested.write() = total as u64;
345 tracing::info!(
346 "✅ Recovered {} events from WAL (Parquet data stays cold until \
347 first per-tenant query)",
348 wal_new
349 );
350
351 if let Some(ref storage) = store.storage {
359 tracing::info!(
360 "📸 Checkpointing {} WAL events to Parquet storage...",
361 wal_new
362 );
363 let parquet = storage.read();
364 let events = store.events.read();
365 let mut buffered = 0usize;
366 for event in events.iter().skip(events.len() - wal_new) {
367 if let Err(e) = parquet.append_event(event.clone()) {
368 tracing::error!(
369 "Failed to buffer WAL event for Parquet: {}",
370 e
371 );
372 } else {
373 buffered += 1;
374 }
375 }
376 drop(events);
377 drop(parquet);
378
379 if buffered > 0 {
380 if let Err(e) = store.flush_storage() {
381 tracing::error!("Failed to checkpoint to Parquet: {}", e);
382 } else if let Err(e) = wal.truncate() {
383 tracing::error!(
384 "Failed to truncate WAL after checkpoint: {}",
385 e
386 );
387 } else {
388 tracing::info!(
389 "✅ WAL checkpointed and truncated ({} events)",
390 buffered
391 );
392 }
393 }
394 }
395 }
396 }
397 Ok(_) => {
398 tracing::debug!("No events to recover from WAL");
399 #[cfg(feature = "server")]
400 store.metrics.wal_replay_events_total.set(0);
401 }
402 Err(e) => {
403 tracing::error!("❌ WAL recovery failed: {}", e);
404 }
405 }
406 } else if store.storage.is_some() {
407 tracing::info!(
408 "📂 Boot complete (lazy-load mode): Parquet data stays on disk until first \
409 per-tenant query"
410 );
411 }
412
413 store
414 }
415
416 #[cfg_attr(feature = "hotpath", hotpath::measure)]
424 pub fn ingest_with_expected_version(
425 &self,
426 event: &Event,
427 expected_version: Option<u64>,
428 ) -> Result<u64> {
429 self.validate_event(event)?;
431
432 let entity_id = event.entity_id_str().to_string();
433
434 let new_version = {
437 let mut version_entry = self.entity_versions.entry(entity_id.clone()).or_insert(0);
438 let current = *version_entry;
439
440 if let Some(expected) = expected_version
441 && current != expected
442 {
443 return Err(crate::error::AllSourceError::VersionConflict { expected, current });
444 }
445
446 if let Some(ref wal) = self.wal {
448 wal.append(event.clone())?;
449 }
450
451 *version_entry += 1;
452 *version_entry
453 };
454
455 self.ingest_post_wal(event)?;
458
459 Ok(new_version)
460 }
461
462 #[cfg_attr(feature = "hotpath", hotpath::measure)]
465 fn ingest_post_wal(&self, event: &Event) -> Result<()> {
466 #[cfg(feature = "server")]
467 let timer = self.metrics.ingestion_duration_seconds.start_timer();
468
469 let mut events = self.events.write();
470 let offset = events.len();
471
472 self.index.index_event(
474 event.id,
475 event.entity_id_str(),
476 event.event_type_str(),
477 event.timestamp,
478 offset,
479 )?;
480
481 let projections = self.projections.read();
483 projections.process_event(event)?;
484 drop(projections);
485
486 let pipeline_results = self.pipeline_manager.process_event(event);
488 if !pipeline_results.is_empty() {
489 tracing::debug!(
490 "Event {} processed by {} pipeline(s)",
491 event.id,
492 pipeline_results.len()
493 );
494 for (pipeline_id, result) in pipeline_results {
495 tracing::trace!("Pipeline {} result: {:?}", pipeline_id, result);
496 }
497 }
498
499 if let Some(ref storage) = self.storage {
501 let storage = storage.read();
502 storage.append_event(event.clone())?;
503 }
504
505 events.push(event.clone());
507 let total_events = events.len();
508 drop(events);
509
510 let event_arc = Arc::new(event.clone());
512 let _ = self.event_broadcast_tx.send(Arc::clone(&event_arc));
513 #[cfg(feature = "server")]
514 self.websocket_manager.broadcast_event(event_arc);
515
516 #[cfg(feature = "server")]
518 self.dispatch_webhooks(event);
519
520 self.geo_index.index_event(event);
522
523 self.schema_evolution
525 .analyze_event(event.event_type_str(), &event.payload);
526
527 self.check_auto_snapshot(event.entity_id_str(), event);
529
530 #[cfg(feature = "server")]
532 {
533 self.metrics.events_ingested_total.inc();
534 self.metrics
535 .events_ingested_by_type
536 .with_label_values(&[event.event_type_str()])
537 .inc();
538 self.metrics.storage_events_total.set(total_events as i64);
539 }
540
541 let mut total = self.total_ingested.write();
543 *total += 1;
544
545 #[cfg(feature = "server")]
546 timer.observe_duration();
547
548 tracing::debug!("Event ingested: {} (offset: {})", event.id, offset);
549
550 Ok(())
551 }
552
553 #[cfg_attr(feature = "hotpath", hotpath::measure)]
555 pub fn ingest(&self, event: &Event) -> Result<()> {
556 #[cfg(feature = "server")]
558 let timer = self.metrics.ingestion_duration_seconds.start_timer();
559
560 let validation_result = self.validate_event(event);
562 if let Err(e) = validation_result {
563 #[cfg(feature = "server")]
564 {
565 self.metrics.ingestion_errors_total.inc();
566 timer.observe_duration();
567 }
568 return Err(e);
569 }
570
571 if let Some(ref wal) = self.wal
574 && let Err(e) = wal.append(event.clone())
575 {
576 #[cfg(feature = "server")]
577 {
578 self.metrics.ingestion_errors_total.inc();
579 timer.observe_duration();
580 }
581 return Err(e);
582 }
583
584 *self
586 .entity_versions
587 .entry(event.entity_id_str().to_string())
588 .or_insert(0) += 1;
589
590 let mut events = self.events.write();
591 let offset = events.len();
592
593 self.index.index_event(
595 event.id,
596 event.entity_id_str(),
597 event.event_type_str(),
598 event.timestamp,
599 offset,
600 )?;
601
602 let projections = self.projections.read();
604 projections.process_event(event)?;
605 drop(projections); let pipeline_results = self.pipeline_manager.process_event(event);
610 if !pipeline_results.is_empty() {
611 tracing::debug!(
612 "Event {} processed by {} pipeline(s)",
613 event.id,
614 pipeline_results.len()
615 );
616 for (pipeline_id, result) in pipeline_results {
619 tracing::trace!("Pipeline {} result: {:?}", pipeline_id, result);
620 }
621 }
622
623 if let Some(ref storage) = self.storage {
625 let storage = storage.read();
626 storage.append_event(event.clone())?;
627 }
628
629 events.push(event.clone());
631 let total_events = events.len();
632 drop(events); let event_arc = Arc::new(event.clone());
636 let _ = self.event_broadcast_tx.send(Arc::clone(&event_arc));
637 #[cfg(feature = "server")]
638 self.websocket_manager.broadcast_event(event_arc);
639
640 #[cfg(feature = "server")]
642 self.dispatch_webhooks(event);
643
644 self.geo_index.index_event(event);
646
647 self.schema_evolution
649 .analyze_event(event.event_type_str(), &event.payload);
650
651 self.check_auto_snapshot(event.entity_id_str(), event);
653
654 #[cfg(feature = "server")]
656 {
657 self.metrics.events_ingested_total.inc();
658 self.metrics
659 .events_ingested_by_type
660 .with_label_values(&[event.event_type_str()])
661 .inc();
662 self.metrics.storage_events_total.set(total_events as i64);
663 }
664
665 let mut total = self.total_ingested.write();
667 *total += 1;
668
669 #[cfg(feature = "server")]
670 timer.observe_duration();
671
672 tracing::debug!("Event ingested: {} (offset: {})", event.id, offset);
673
674 Ok(())
675 }
676
677 #[cfg_attr(feature = "hotpath", hotpath::measure)]
684 pub fn ingest_batch(&self, batch: Vec<Event>) -> Result<()> {
685 if batch.is_empty() {
686 return Ok(());
687 }
688
689 for event in &batch {
691 self.validate_event(event)?;
692 }
693
694 if let Some(ref wal) = self.wal {
696 for event in &batch {
697 wal.append(event.clone())?;
698 }
699 }
700
701 let mut events = self.events.write();
703 let projections = self.projections.read();
704
705 for event in batch {
706 let offset = events.len();
707
708 self.index.index_event(
709 event.id,
710 event.entity_id_str(),
711 event.event_type_str(),
712 event.timestamp,
713 offset,
714 )?;
715
716 projections.process_event(&event)?;
717 self.pipeline_manager.process_event(&event);
718
719 if let Some(ref storage) = self.storage {
720 let storage = storage.read();
721 storage.append_event(event.clone())?;
722 }
723
724 self.geo_index.index_event(&event);
725 self.schema_evolution
726 .analyze_event(event.event_type_str(), &event.payload);
727
728 *self
730 .entity_versions
731 .entry(event.entity_id_str().to_string())
732 .or_insert(0) += 1;
733
734 let _ = self.event_broadcast_tx.send(Arc::new(event.clone()));
736
737 events.push(event);
738 }
739
740 let total_events = events.len();
741 drop(projections);
742 drop(events);
743
744 let mut total = self.total_ingested.write();
745 *total += total_events as u64;
746
747 Ok(())
748 }
749
750 #[cfg_attr(feature = "hotpath", hotpath::measure)]
757 pub fn ingest_replicated(&self, event: &Event) -> Result<()> {
758 #[cfg(feature = "server")]
759 let timer = self.metrics.ingestion_duration_seconds.start_timer();
760
761 let mut events = self.events.write();
762 let offset = events.len();
763
764 self.index.index_event(
766 event.id,
767 event.entity_id_str(),
768 event.event_type_str(),
769 event.timestamp,
770 offset,
771 )?;
772
773 let projections = self.projections.read();
775 projections.process_event(event)?;
776 drop(projections);
777
778 let pipeline_results = self.pipeline_manager.process_event(event);
780 if !pipeline_results.is_empty() {
781 tracing::debug!(
782 "Replicated event {} processed by {} pipeline(s)",
783 event.id,
784 pipeline_results.len()
785 );
786 }
787
788 *self
790 .entity_versions
791 .entry(event.entity_id_str().to_string())
792 .or_insert(0) += 1;
793
794 events.push(event.clone());
796 let total_events = events.len();
797 drop(events);
798
799 let event_arc = Arc::new(event.clone());
801 let _ = self.event_broadcast_tx.send(Arc::clone(&event_arc));
802 #[cfg(feature = "server")]
803 self.websocket_manager.broadcast_event(event_arc);
804
805 #[cfg(feature = "server")]
807 {
808 self.metrics.events_ingested_total.inc();
809 self.metrics
810 .events_ingested_by_type
811 .with_label_values(&[event.event_type_str()])
812 .inc();
813 self.metrics.storage_events_total.set(total_events as i64);
814 }
815
816 let mut total = self.total_ingested.write();
817 *total += 1;
818
819 #[cfg(feature = "server")]
820 timer.observe_duration();
821
822 tracing::debug!(
823 "Replicated event ingested: {} (offset: {})",
824 event.id,
825 offset
826 );
827
828 Ok(())
829 }
830
831 #[cfg_attr(feature = "hotpath", hotpath::measure)]
834 pub fn get_entity_version(&self, entity_id: &str) -> u64 {
835 self.entity_versions.get(entity_id).map_or(0, |v| *v)
836 }
837
838 pub fn consumer_registry(&self) -> &ConsumerRegistry {
840 &self.consumer_registry
841 }
842
843 pub fn subscribe_events(&self) -> tokio::sync::broadcast::Receiver<Arc<Event>> {
849 self.event_broadcast_tx.subscribe()
850 }
851
852 pub fn set_consumer_registry(&mut self, registry: Arc<ConsumerRegistry>) {
857 self.consumer_registry = registry;
858 }
859
860 pub fn total_events(&self) -> usize {
862 self.events.read().len()
863 }
864
865 pub fn events_after_offset(
868 &self,
869 offset: u64,
870 filters: &[String],
871 limit: usize,
872 ) -> Vec<(u64, Event)> {
873 let events = self.events.read();
874 let start = offset as usize;
875 if start >= events.len() {
876 return vec![];
877 }
878
879 events[start..]
880 .iter()
881 .enumerate()
882 .filter(|(_, event)| ConsumerRegistry::matches_filters(event.event_type_str(), filters))
883 .take(limit)
884 .map(|(i, event)| ((start + i + 1) as u64, event.clone()))
885 .collect()
886 }
887
888 #[cfg(feature = "server")]
890 pub fn websocket_manager(&self) -> Arc<WebSocketManager> {
891 Arc::clone(&self.websocket_manager)
892 }
893
894 pub fn snapshot_manager(&self) -> Arc<SnapshotManager> {
896 Arc::clone(&self.snapshot_manager)
897 }
898
899 pub fn compaction_manager(&self) -> Option<Arc<CompactionManager>> {
901 self.compaction_manager.as_ref().map(Arc::clone)
902 }
903
904 pub fn schema_registry(&self) -> Arc<SchemaRegistry> {
906 Arc::clone(&self.schema_registry)
907 }
908
909 pub fn replay_manager(&self) -> Arc<ReplayManager> {
911 Arc::clone(&self.replay_manager)
912 }
913
914 pub fn pipeline_manager(&self) -> Arc<PipelineManager> {
916 Arc::clone(&self.pipeline_manager)
917 }
918
919 #[cfg(feature = "server")]
921 pub fn metrics(&self) -> Arc<MetricsRegistry> {
922 Arc::clone(&self.metrics)
923 }
924
925 pub fn projection_manager(&self) -> parking_lot::RwLockReadGuard<'_, ProjectionManager> {
927 self.projections.read()
928 }
929
930 pub fn register_projection(
939 &self,
940 projection: Arc<dyn crate::application::services::projection::Projection>,
941 ) {
942 let mut pm = self.projections.write();
943 pm.register(projection);
944 }
945
946 pub fn register_projection_with_backfill(
958 &self,
959 projection: &Arc<dyn crate::application::services::projection::Projection>,
960 ) -> Result<()> {
961 {
963 let mut pm = self.projections.write();
964 pm.register(Arc::clone(projection));
965 }
966
967 let events = self.events.read();
969 let mut ordered: Vec<&Event> = events.iter().collect();
970 ordered.sort_by(|a, b| {
971 a.timestamp
972 .cmp(&b.timestamp)
973 .then_with(|| a.version.cmp(&b.version))
974 });
975 for event in ordered {
976 projection.process(event)?;
977 }
978
979 Ok(())
980 }
981
982 pub fn hydrate_all_from_storage(&self) -> Result<usize> {
1000 let Some(storage) = self.storage.as_ref().map(Arc::clone) else {
1001 return Ok(0);
1002 };
1003
1004 let events = storage.read().load_all_events()?;
1005 let read_count = events.len();
1006 let before = self.events.read().len();
1007 for event in events {
1008 self.append_loaded_event(event);
1009 }
1010 let applied = self.events.read().len() - before;
1011
1012 *self.total_ingested.write() = self.events.read().len() as u64;
1014
1015 tracing::info!(
1016 read = read_count,
1017 applied = applied,
1018 "🔄 hydrate_all_from_storage: in-memory pile reconstructed from Parquet"
1019 );
1020 Ok(applied)
1021 }
1022
1023 pub fn projection_state_cache(&self) -> Arc<DashMap<String, serde_json::Value>> {
1026 Arc::clone(&self.projection_state_cache)
1027 }
1028
1029 pub fn projection_status(&self) -> Arc<DashMap<String, String>> {
1031 Arc::clone(&self.projection_status)
1032 }
1033
1034 pub fn geo_index(&self) -> Arc<GeoIndex> {
1037 self.geo_index.clone()
1038 }
1039
1040 pub fn exactly_once(&self) -> Arc<ExactlyOnceRegistry> {
1042 self.exactly_once.clone()
1043 }
1044
1045 pub fn schema_evolution(&self) -> Arc<SchemaEvolutionManager> {
1047 self.schema_evolution.clone()
1048 }
1049
1050 pub fn snapshot_events(&self) -> Vec<Event> {
1056 self.events.read().clone()
1057 }
1058
1059 pub fn compact_entity_tokens(
1081 &self,
1082 entity_id: &str,
1083 token_event_type: &str,
1084 merged_event: Event,
1085 ) -> Result<bool> {
1086 {
1088 let events = self.events.read();
1089 let has_tokens = events
1090 .iter()
1091 .any(|e| e.entity_id_str() == entity_id && e.event_type_str() == token_event_type);
1092 if !has_tokens {
1093 return Ok(false);
1094 }
1095 }
1096
1097 let projections = self.projections.read();
1099 projections.process_event(&merged_event)?;
1100 drop(projections);
1101
1102 let mut events = self.events.write();
1104
1105 events.retain(|e| {
1106 !(e.entity_id_str() == entity_id && e.event_type_str() == token_event_type)
1107 });
1108
1109 events.push(merged_event.clone());
1110
1111 if let Some(ref wal) = self.wal {
1115 wal.append(merged_event)?;
1116 }
1117
1118 self.index.clear();
1123 for (offset, event) in events.iter().enumerate() {
1124 if let Err(e) = self.index.index_event(
1125 event.id,
1126 event.entity_id_str(),
1127 event.event_type_str(),
1128 event.timestamp,
1129 offset,
1130 ) {
1131 tracing::warn!(
1132 event_id = %event.id,
1133 offset,
1134 "Failed to re-index event during compaction: {e}"
1135 );
1136 }
1137 }
1138
1139 Ok(true)
1140 }
1141
1142 #[cfg(feature = "server")]
1143 pub fn webhook_registry(&self) -> Arc<WebhookRegistry> {
1144 Arc::clone(&self.webhook_registry)
1145 }
1146
1147 #[cfg(feature = "server")]
1150 pub fn set_webhook_tx(&self, tx: mpsc::UnboundedSender<WebhookDeliveryTask>) {
1151 *self.webhook_tx.write() = Some(tx);
1152 tracing::info!("Webhook delivery channel connected");
1153 }
1154
1155 #[cfg(feature = "server")]
1157 fn dispatch_webhooks(&self, event: &Event) {
1158 let matching = self.webhook_registry.find_matching(event);
1159 if matching.is_empty() {
1160 return;
1161 }
1162
1163 let tx_guard = self.webhook_tx.read();
1164 if let Some(ref tx) = *tx_guard {
1165 for webhook in matching {
1166 let task = WebhookDeliveryTask {
1167 webhook,
1168 event: event.clone(),
1169 };
1170 if let Err(e) = tx.send(task) {
1171 tracing::warn!("Failed to queue webhook delivery: {}", e);
1172 }
1173 }
1174 }
1175 }
1176
1177 pub fn flush_storage(&self) -> Result<()> {
1179 if let Some(ref storage) = self.storage {
1180 let storage = storage.read();
1181 storage.flush()?;
1182 tracing::info!("✅ Flushed events to persistent storage");
1183 }
1184 Ok(())
1185 }
1186
1187 pub fn checkpoint(&self) -> Result<()> {
1208 let Some(ref wal) = self.wal else {
1209 return Ok(());
1210 };
1211
1212 self.flush_storage()?;
1217 wal.truncate()?;
1218 tracing::debug!("✅ Checkpoint complete: Parquet flushed, WAL truncated");
1219 Ok(())
1220 }
1221
1222 pub fn checkpoint_interval(&self) -> Option<std::time::Duration> {
1224 self.checkpoint_interval_secs
1225 .map(std::time::Duration::from_secs)
1226 }
1227
1228 pub fn ensure_tenant_loaded(&self, tenant_id: &str) -> Result<()> {
1252 if self.tenant_loader.is_loaded(tenant_id) {
1254 return Ok(());
1255 }
1256
1257 let Some(storage) = self.storage.as_ref().map(Arc::clone) else {
1258 self.tenant_loader.mark_loaded(tenant_id);
1261 return Ok(());
1262 };
1263
1264 let lock = self.tenant_loader.lock_for(tenant_id);
1267 let timeout = self.tenant_loader.load_timeout();
1268 let _guard = lock.try_lock_for(timeout).ok_or_else(|| {
1269 AllSourceError::StorageError(format!(
1270 "ensure_tenant_loaded timed out after {timeout:?} waiting for in-flight load of \
1271 tenant {tenant_id:?}"
1272 ))
1273 })?;
1274
1275 if self.tenant_loader.is_loaded(tenant_id) {
1278 return Ok(());
1279 }
1280
1281 let started = std::time::Instant::now();
1282 let events = storage.read().load_events_for_tenant(tenant_id)?;
1283 let read_count = events.len();
1284
1285 let before = self.events.read().len();
1286 for event in events {
1287 self.append_loaded_event(event);
1288 }
1289 let applied = self.events.read().len() - before;
1290
1291 *self.total_ingested.write() += applied as u64;
1295 self.tenant_loader.mark_loaded(tenant_id);
1296
1297 tracing::info!(
1298 tenant_id = tenant_id,
1299 read = read_count,
1300 applied = applied,
1301 elapsed_ms = started.elapsed().as_millis() as u64,
1302 "ensure_tenant_loaded: tenant hydrated"
1303 );
1304
1305 self.enforce_cache_budget(tenant_id);
1313
1314 #[cfg(feature = "server")]
1317 self.metrics
1318 .cache_bytes
1319 .set(self.tenant_loader.total_bytes() as i64);
1320
1321 Ok(())
1322 }
1323
1324 fn enforce_cache_budget(&self, recently_touched: &str) {
1335 if !self.tenant_loader.over_budget() {
1336 return;
1337 }
1338 loop {
1339 let Some(victim) = self.tenant_loader.pick_lru_excluding(recently_touched) else {
1340 tracing::warn!(
1341 cache_bytes = self.tenant_loader.total_bytes(),
1342 budget = self.tenant_loader.byte_budget(),
1343 recently_touched = recently_touched,
1344 "cache over budget but no other tenant available to evict — \
1345 a single tenant exceeds the budget; consider raising it"
1346 );
1347 return;
1348 };
1349 self.evict_tenant(&victim);
1350 if !self.tenant_loader.over_budget() {
1351 return;
1352 }
1353 }
1354 }
1355
1356 pub fn is_tenant_loaded(&self, tenant_id: &str) -> bool {
1359 self.tenant_loader.is_loaded(tenant_id)
1360 }
1361
1362 pub fn evict_tenant(&self, tenant_id: &str) {
1390 let mut events = self.events.write();
1391 let before = events.len();
1392 let evicted_bytes = self.tenant_loader.bytes_for(tenant_id);
1393
1394 events.retain(|e| e.tenant_id_str() != tenant_id);
1395 let after = events.len();
1396 let dropped = before - after;
1397
1398 if dropped == 0 {
1399 drop(events);
1403 self.tenant_loader.mark_unloaded(tenant_id);
1404 return;
1405 }
1406
1407 self.index.clear();
1411 self.entity_versions.clear();
1412 for (offset, event) in events.iter().enumerate() {
1413 if let Err(e) = self.index.index_event(
1414 event.id,
1415 event.entity_id_str(),
1416 event.event_type_str(),
1417 event.timestamp,
1418 offset,
1419 ) {
1420 tracing::error!(
1421 "Failed to re-index event during eviction of {}: {}",
1422 tenant_id,
1423 e
1424 );
1425 }
1426 *self
1427 .entity_versions
1428 .entry(event.entity_id_str().to_string())
1429 .or_insert(0) += 1;
1430 }
1431 drop(events);
1432
1433 self.tenant_loader.mark_unloaded(tenant_id);
1434
1435 let mut t = self.total_ingested.write();
1438 *t = t.saturating_sub(dropped as u64);
1439 drop(t);
1440
1441 #[cfg(feature = "server")]
1444 {
1445 self.metrics.cache_evictions_total.inc();
1446 self.metrics
1447 .cache_bytes
1448 .set(self.tenant_loader.total_bytes() as i64);
1449 }
1450
1451 tracing::info!(
1452 tenant_id = tenant_id,
1453 events_dropped = dropped,
1454 bytes_freed = evicted_bytes,
1455 "evicted tenant from memory cache"
1456 );
1457 }
1458
1459 pub fn tenant_resident_bytes(&self, tenant_id: &str) -> u64 {
1463 self.tenant_loader.bytes_for(tenant_id)
1464 }
1465
1466 pub fn cache_resident_bytes(&self) -> u64 {
1469 self.tenant_loader.total_bytes()
1470 }
1471
1472 fn append_loaded_event(&self, event: Event) {
1494 if self.index.get_by_id(&event.id).is_some() {
1495 return;
1496 }
1497
1498 let event_bytes = event.estimated_size_bytes();
1499 let tenant = event.tenant_id_str().to_string();
1500
1501 let mut events = self.events.write();
1502 let offset = events.len();
1503
1504 if let Err(e) = self.index.index_event(
1505 event.id,
1506 event.entity_id_str(),
1507 event.event_type_str(),
1508 event.timestamp,
1509 offset,
1510 ) {
1511 tracing::error!("Failed to index loaded event {}: {}", event.id, e);
1512 }
1513
1514 if let Err(e) = self.projections.read().process_event(&event) {
1515 tracing::error!("Failed to project loaded event {}: {}", event.id, e);
1516 }
1517
1518 *self
1519 .entity_versions
1520 .entry(event.entity_id_str().to_string())
1521 .or_insert(0) += 1;
1522
1523 events.push(event);
1524 self.tenant_loader.add_bytes(&tenant, event_bytes);
1528 }
1529
1530 pub fn create_snapshot(&self, entity_id: &str) -> Result<()> {
1532 let events = self.query(&QueryEventsRequest {
1534 entity_id: Some(entity_id.to_string()),
1535 event_type: None,
1536 tenant_id: None,
1537 as_of: None,
1538 since: None,
1539 until: None,
1540 limit: None,
1541 event_type_prefix: None,
1542 payload_filter: None,
1543 })?;
1544
1545 if events.is_empty() {
1546 return Err(AllSourceError::EntityNotFound(entity_id.to_string()));
1547 }
1548
1549 let mut state = serde_json::json!({});
1551 for event in &events {
1552 if let serde_json::Value::Object(ref mut state_map) = state
1553 && let serde_json::Value::Object(ref payload_map) = event.payload
1554 {
1555 for (key, value) in payload_map {
1556 state_map.insert(key.clone(), value.clone());
1557 }
1558 }
1559 }
1560
1561 let last_event = events.last().unwrap();
1562 self.snapshot_manager.create_snapshot(
1563 entity_id,
1564 state,
1565 last_event.timestamp,
1566 events.len(),
1567 SnapshotType::Manual,
1568 )?;
1569
1570 Ok(())
1571 }
1572
1573 fn check_auto_snapshot(&self, entity_id: &str, event: &Event) {
1575 let entity_event_count = self
1577 .index
1578 .get_by_entity(entity_id)
1579 .map_or(0, |entries| entries.len());
1580
1581 if self.snapshot_manager.should_create_snapshot(
1582 entity_id,
1583 entity_event_count,
1584 event.timestamp,
1585 ) {
1586 if let Err(e) = self.create_snapshot(entity_id) {
1588 tracing::warn!(
1589 "Failed to create automatic snapshot for {}: {}",
1590 entity_id,
1591 e
1592 );
1593 }
1594 }
1595 }
1596
1597 fn validate_event(&self, event: &Event) -> Result<()> {
1599 if event.entity_id_str().is_empty() {
1602 return Err(AllSourceError::ValidationError(
1603 "entity_id cannot be empty".to_string(),
1604 ));
1605 }
1606
1607 if event.event_type_str().is_empty() {
1608 return Err(AllSourceError::ValidationError(
1609 "event_type cannot be empty".to_string(),
1610 ));
1611 }
1612
1613 if event.event_type().is_system() {
1616 return Err(AllSourceError::ValidationError(
1617 "Event types starting with '_system.' are reserved for internal use".to_string(),
1618 ));
1619 }
1620
1621 Ok(())
1622 }
1623
1624 pub fn reset_projection(&self, name: &str) -> Result<usize> {
1626 let projection_manager = self.projections.read();
1627 let projection = projection_manager.get_projection(name).ok_or_else(|| {
1628 AllSourceError::EntityNotFound(format!("Projection '{name}' not found"))
1629 })?;
1630
1631 projection.clear();
1633
1634 let prefix = format!("{name}:");
1636 let keys_to_remove: Vec<String> = self
1637 .projection_state_cache
1638 .iter()
1639 .filter(|entry| entry.key().starts_with(&prefix))
1640 .map(|entry| entry.key().clone())
1641 .collect();
1642 for key in keys_to_remove {
1643 self.projection_state_cache.remove(&key);
1644 }
1645
1646 let events = self.events.read();
1648 let mut reprocessed = 0usize;
1649 for event in events.iter() {
1650 if projection.process(event).is_ok() {
1651 reprocessed += 1;
1652 }
1653 }
1654
1655 Ok(reprocessed)
1656 }
1657
1658 pub fn get_event_by_id(&self, event_id: &uuid::Uuid) -> Result<Option<Event>> {
1660 if let Some(offset) = self.index.get_by_id(event_id) {
1661 let events = self.events.read();
1662 Ok(events.get(offset).cloned())
1663 } else {
1664 Ok(None)
1665 }
1666 }
1667
1668 #[cfg_attr(feature = "hotpath", hotpath::measure)]
1670 pub fn query(&self, request: &QueryEventsRequest) -> Result<Vec<Event>> {
1671 if let Some(ref tenant_id) = request.tenant_id {
1690 self.ensure_tenant_loaded(tenant_id)?;
1691 self.tenant_loader.touch(tenant_id);
1695 }
1696
1697 let query_type = if request.entity_id.is_some() {
1699 "entity"
1700 } else if request.event_type.is_some() {
1701 "type"
1702 } else if request.event_type_prefix.is_some() {
1703 "type_prefix"
1704 } else {
1705 "full_scan"
1706 };
1707
1708 #[cfg(feature = "server")]
1710 let timer = self
1711 .metrics
1712 .query_duration_seconds
1713 .with_label_values(&[query_type])
1714 .start_timer();
1715
1716 #[cfg(feature = "server")]
1718 self.metrics
1719 .queries_total
1720 .with_label_values(&[query_type])
1721 .inc();
1722
1723 let events = self.events.read();
1724
1725 let offsets: Vec<usize> = if let Some(entity_id) = &request.entity_id {
1727 self.index
1729 .get_by_entity(entity_id)
1730 .map(|entries| self.filter_entries(entries, request))
1731 .unwrap_or_default()
1732 } else if let Some(event_type) = &request.event_type {
1733 self.index
1735 .get_by_type(event_type)
1736 .map(|entries| self.filter_entries(entries, request))
1737 .unwrap_or_default()
1738 } else if let Some(prefix) = &request.event_type_prefix {
1739 let entries = self.index.get_by_type_prefix(prefix);
1741 self.filter_entries(entries, request)
1742 } else {
1743 (0..events.len()).collect()
1745 };
1746
1747 let mut results: Vec<Event> = offsets
1749 .iter()
1750 .filter_map(|&offset| events.get(offset).cloned())
1751 .filter(|event| self.apply_filters(event, request))
1752 .collect();
1753
1754 results.sort_by(|a, b| {
1759 a.timestamp
1760 .cmp(&b.timestamp)
1761 .then_with(|| a.version.cmp(&b.version))
1762 });
1763
1764 if let Some(limit) = request.limit {
1766 results.truncate(limit);
1767 }
1768
1769 #[cfg(feature = "server")]
1771 {
1772 self.metrics
1773 .query_results_total
1774 .with_label_values(&[query_type])
1775 .inc_by(results.len() as u64);
1776 timer.observe_duration();
1777 }
1778
1779 Ok(results)
1780 }
1781
1782 #[cfg_attr(feature = "hotpath", hotpath::measure)]
1784 fn filter_entries(&self, entries: Vec<IndexEntry>, request: &QueryEventsRequest) -> Vec<usize> {
1785 entries
1786 .into_iter()
1787 .filter(|entry| {
1788 if let Some(as_of) = request.as_of
1790 && entry.timestamp > as_of
1791 {
1792 return false;
1793 }
1794 if let Some(since) = request.since
1795 && entry.timestamp < since
1796 {
1797 return false;
1798 }
1799 if let Some(until) = request.until
1800 && entry.timestamp > until
1801 {
1802 return false;
1803 }
1804 true
1805 })
1806 .map(|entry| entry.offset)
1807 .collect()
1808 }
1809
1810 #[cfg_attr(feature = "hotpath", hotpath::measure)]
1812 fn apply_filters(&self, event: &Event, request: &QueryEventsRequest) -> bool {
1813 if let Some(ref tid) = request.tenant_id
1815 && event.tenant_id_str() != tid
1816 {
1817 return false;
1818 }
1819
1820 if request.entity_id.is_some()
1822 && let Some(ref event_type) = request.event_type
1823 && event.event_type_str() != event_type
1824 {
1825 return false;
1826 }
1827
1828 if request.entity_id.is_some()
1830 && let Some(ref prefix) = request.event_type_prefix
1831 && !event.event_type_str().starts_with(prefix)
1832 {
1833 return false;
1834 }
1835
1836 if let Some(ref filter_str) = request.payload_filter
1838 && let Ok(filter_obj) =
1839 serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(filter_str)
1840 {
1841 let payload = event.payload();
1842 for (key, expected_value) in &filter_obj {
1843 match payload.get(key) {
1844 Some(actual_value) if actual_value == expected_value => {}
1845 _ => return false,
1846 }
1847 }
1848 }
1849
1850 true
1851 }
1852
1853 #[cfg_attr(feature = "hotpath", hotpath::measure)]
1856 pub fn reconstruct_state(
1857 &self,
1858 entity_id: &str,
1859 as_of: Option<DateTime<Utc>>,
1860 ) -> Result<serde_json::Value> {
1861 let (merged_state, since_timestamp) = if let Some(as_of_time) = as_of {
1863 if let Some(snapshot) = self
1865 .snapshot_manager
1866 .get_snapshot_as_of(entity_id, as_of_time)
1867 {
1868 tracing::debug!(
1869 "Using snapshot from {} for entity {} (saved {} events)",
1870 snapshot.as_of,
1871 entity_id,
1872 snapshot.event_count
1873 );
1874 (snapshot.state.clone(), Some(snapshot.as_of))
1875 } else {
1876 (serde_json::json!({}), None)
1877 }
1878 } else {
1879 if let Some(snapshot) = self.snapshot_manager.get_latest_snapshot(entity_id) {
1881 tracing::debug!(
1882 "Using latest snapshot from {} for entity {}",
1883 snapshot.as_of,
1884 entity_id
1885 );
1886 (snapshot.state.clone(), Some(snapshot.as_of))
1887 } else {
1888 (serde_json::json!({}), None)
1889 }
1890 };
1891
1892 let events = self.query(&QueryEventsRequest {
1894 entity_id: Some(entity_id.to_string()),
1895 event_type: None,
1896 tenant_id: None,
1897 as_of,
1898 since: since_timestamp,
1899 until: None,
1900 limit: None,
1901 event_type_prefix: None,
1902 payload_filter: None,
1903 })?;
1904
1905 if events.is_empty() && since_timestamp.is_none() {
1907 return Err(AllSourceError::EntityNotFound(entity_id.to_string()));
1908 }
1909
1910 let mut merged_state = merged_state;
1912 for event in &events {
1913 if let serde_json::Value::Object(ref mut state_map) = merged_state
1914 && let serde_json::Value::Object(ref payload_map) = event.payload
1915 {
1916 for (key, value) in payload_map {
1917 state_map.insert(key.clone(), value.clone());
1918 }
1919 }
1920 }
1921
1922 let state = serde_json::json!({
1924 "entity_id": entity_id,
1925 "last_updated": events.last().map(|e| e.timestamp),
1926 "event_count": events.len(),
1927 "as_of": as_of,
1928 "current_state": merged_state,
1929 "history": events.iter().map(|e| {
1930 serde_json::json!({
1931 "event_id": e.id,
1932 "type": e.event_type,
1933 "timestamp": e.timestamp,
1934 "payload": e.payload
1935 })
1936 }).collect::<Vec<_>>()
1937 });
1938
1939 Ok(state)
1940 }
1941
1942 pub fn get_snapshot(&self, entity_id: &str) -> Result<serde_json::Value> {
1944 let projections = self.projections.read();
1945
1946 if let Some(snapshot_projection) = projections.get_projection("entity_snapshots")
1947 && let Some(state) = snapshot_projection.get_state(entity_id)
1948 {
1949 return Ok(serde_json::json!({
1950 "entity_id": entity_id,
1951 "snapshot": state,
1952 "from_projection": "entity_snapshots"
1953 }));
1954 }
1955
1956 Err(AllSourceError::EntityNotFound(entity_id.to_string()))
1957 }
1958
1959 pub fn stats(&self) -> StoreStats {
1961 let events = self.events.read();
1962 let index_stats = self.index.stats();
1963
1964 StoreStats {
1965 total_events: events.len(),
1966 total_entities: index_stats.total_entities,
1967 total_event_types: index_stats.total_event_types,
1968 total_ingested: *self.total_ingested.read(),
1969 }
1970 }
1971
1972 pub fn list_streams(&self) -> Vec<StreamInfo> {
1974 self.index
1975 .get_all_entities()
1976 .into_iter()
1977 .map(|entity_id| {
1978 let event_count = self
1979 .index
1980 .get_by_entity(&entity_id)
1981 .map_or(0, |entries| entries.len());
1982 let last_event_at = self
1983 .index
1984 .get_by_entity(&entity_id)
1985 .and_then(|entries| entries.last().map(|e| e.timestamp));
1986 StreamInfo {
1987 stream_id: entity_id,
1988 event_count,
1989 last_event_at,
1990 }
1991 })
1992 .collect()
1993 }
1994
1995 pub fn list_event_types(&self) -> Vec<EventTypeInfo> {
1997 self.index
1998 .get_all_types()
1999 .into_iter()
2000 .map(|event_type| {
2001 let event_count = self
2002 .index
2003 .get_by_type(&event_type)
2004 .map_or(0, |entries| entries.len());
2005 let last_event_at = self
2006 .index
2007 .get_by_type(&event_type)
2008 .and_then(|entries| entries.last().map(|e| e.timestamp));
2009 EventTypeInfo {
2010 event_type,
2011 event_count,
2012 last_event_at,
2013 }
2014 })
2015 .collect()
2016 }
2017
2018 pub fn enable_wal_replication(
2025 &self,
2026 tx: tokio::sync::broadcast::Sender<crate::infrastructure::persistence::wal::WALEntry>,
2027 ) {
2028 if let Some(ref wal_arc) = self.wal {
2029 wal_arc.set_replication_tx(tx);
2030 tracing::info!("WAL replication broadcast enabled");
2031 } else {
2032 tracing::warn!("Cannot enable WAL replication: WAL is not configured");
2033 }
2034 }
2035
2036 pub fn wal(&self) -> Option<&Arc<WriteAheadLog>> {
2039 self.wal.as_ref()
2040 }
2041
2042 pub fn parquet_storage(&self) -> Option<&Arc<RwLock<ParquetStorage>>> {
2045 self.storage.as_ref()
2046 }
2047}
2048
2049#[derive(Debug, Clone, Default)]
2051pub struct EventStoreConfig {
2052 pub storage_dir: Option<PathBuf>,
2054
2055 pub snapshot_config: SnapshotConfig,
2057
2058 pub wal_dir: Option<PathBuf>,
2060
2061 pub wal_config: WALConfig,
2063
2064 pub compaction_config: CompactionConfig,
2066
2067 pub schema_registry_config: SchemaRegistryConfig,
2069
2070 pub system_data_dir: Option<PathBuf>,
2075
2076 pub bootstrap_tenant: Option<String>,
2078
2079 pub cache_byte_budget: Option<u64>,
2086
2087 pub checkpoint_interval_secs: Option<u64>,
2099}
2100
2101impl EventStoreConfig {
2102 pub fn with_persistence(storage_dir: impl Into<PathBuf>) -> Self {
2104 Self {
2105 storage_dir: Some(storage_dir.into()),
2106 ..Self::default()
2107 }
2108 }
2109
2110 pub fn with_snapshots(snapshot_config: SnapshotConfig) -> Self {
2112 Self {
2113 snapshot_config,
2114 ..Self::default()
2115 }
2116 }
2117
2118 pub fn with_wal(wal_dir: impl Into<PathBuf>, wal_config: WALConfig) -> Self {
2120 Self {
2121 wal_dir: Some(wal_dir.into()),
2122 wal_config,
2123 ..Self::default()
2124 }
2125 }
2126
2127 pub fn with_all(storage_dir: impl Into<PathBuf>, snapshot_config: SnapshotConfig) -> Self {
2129 Self {
2130 storage_dir: Some(storage_dir.into()),
2131 snapshot_config,
2132 ..Self::default()
2133 }
2134 }
2135
2136 pub fn production(
2138 storage_dir: impl Into<PathBuf>,
2139 wal_dir: impl Into<PathBuf>,
2140 snapshot_config: SnapshotConfig,
2141 wal_config: WALConfig,
2142 compaction_config: CompactionConfig,
2143 ) -> Self {
2144 let storage_dir = storage_dir.into();
2145 let system_data_dir = storage_dir.join("__system");
2146 Self {
2147 storage_dir: Some(storage_dir),
2148 snapshot_config,
2149 wal_dir: Some(wal_dir.into()),
2150 wal_config,
2151 compaction_config,
2152 system_data_dir: Some(system_data_dir),
2153 ..Self::default()
2154 }
2155 }
2156
2157 pub fn effective_system_data_dir(&self) -> Option<PathBuf> {
2162 self.system_data_dir
2163 .clone()
2164 .or_else(|| self.storage_dir.as_ref().map(|d| d.join("__system")))
2165 }
2166
2167 pub fn from_env() -> (Self, &'static str) {
2175 Self::from_env_vars(
2176 std::env::var("ALLSOURCE_DATA_DIR")
2177 .ok()
2178 .filter(|s| !s.is_empty()),
2179 std::env::var("ALLSOURCE_STORAGE_DIR")
2180 .ok()
2181 .filter(|s| !s.is_empty()),
2182 std::env::var("ALLSOURCE_WAL_DIR")
2183 .ok()
2184 .filter(|s| !s.is_empty()),
2185 std::env::var("ALLSOURCE_WAL_ENABLED").ok(),
2186 std::env::var("ALLSOURCE_CACHE_BYTES").ok(),
2187 std::env::var("ALLSOURCE_SNAPSHOT_INTERVAL_SECONDS").ok(),
2188 std::env::var("ALLSOURCE_RETENTION_SYSTEM_DAYS").ok(),
2189 std::env::var("ALLSOURCE_CHECKPOINT_INTERVAL_SECONDS").ok(),
2190 )
2191 }
2192
2193 pub fn from_env_vars(
2195 data_dir: Option<String>,
2196 explicit_storage_dir: Option<String>,
2197 explicit_wal_dir: Option<String>,
2198 wal_enabled_var: Option<String>,
2199 cache_bytes_var: Option<String>,
2200 snapshot_interval_var: Option<String>,
2201 retention_system_days_var: Option<String>,
2202 checkpoint_interval_var: Option<String>,
2203 ) -> (Self, &'static str) {
2204 let data_dir = data_dir.filter(|s| !s.is_empty());
2205 let storage_dir = explicit_storage_dir
2206 .filter(|s| !s.is_empty())
2207 .or_else(|| data_dir.as_ref().map(|d| format!("{d}/storage")));
2208 let wal_dir = explicit_wal_dir
2209 .filter(|s| !s.is_empty())
2210 .or_else(|| data_dir.as_ref().map(|d| format!("{d}/wal")));
2211 let wal_enabled = wal_enabled_var.is_none_or(|v| v == "true");
2212 let cache_byte_budget =
2217 cache_bytes_var
2218 .filter(|s| !s.is_empty())
2219 .and_then(|s| match s.parse::<u64>() {
2220 Ok(v) => Some(v),
2221 Err(e) => {
2222 tracing::warn!(
2223 "ALLSOURCE_CACHE_BYTES={s:?} could not be parsed as u64: {e}; \
2224 cache budget disabled"
2225 );
2226 None
2227 }
2228 });
2229 let compaction_config =
2230 CompactionConfig::from_env_vars(snapshot_interval_var, retention_system_days_var);
2231
2232 let checkpoint_interval_secs = if wal_enabled {
2237 checkpoint_interval_var
2238 .filter(|s| !s.is_empty())
2239 .map(|s| match s.parse::<u64>() {
2240 Ok(v) => v,
2241 Err(e) => {
2242 tracing::warn!(
2243 "ALLSOURCE_CHECKPOINT_INTERVAL_SECONDS={s:?} could not be parsed as \
2244 u64: {e}; falling back to default 60s"
2245 );
2246 60
2247 }
2248 })
2249 .or(Some(60))
2250 } else {
2251 None
2252 };
2253
2254 let mut config = match (&storage_dir, &wal_dir) {
2255 (Some(sd), Some(wd)) if wal_enabled => Self::production(
2256 sd,
2257 wd,
2258 SnapshotConfig::default(),
2259 WALConfig::default(),
2260 compaction_config,
2261 ),
2262 (Some(sd), _) => Self::with_persistence(sd),
2263 (_, Some(wd)) if wal_enabled => Self::with_wal(wd, WALConfig::default()),
2264 _ => Self::default(),
2265 };
2266 config.cache_byte_budget = cache_byte_budget;
2267 config.checkpoint_interval_secs = checkpoint_interval_secs;
2268
2269 let mode = match (&storage_dir, &wal_dir) {
2270 (Some(_), Some(_)) if wal_enabled => "wal+parquet",
2271 (Some(_), _) => "parquet-only",
2272 (_, Some(_)) if wal_enabled => "wal-only",
2273 _ => "in-memory",
2274 };
2275 (config, mode)
2276 }
2277}
2278
2279#[derive(Debug, serde::Serialize)]
2280pub struct StoreStats {
2281 pub total_events: usize,
2282 pub total_entities: usize,
2283 pub total_event_types: usize,
2284 pub total_ingested: u64,
2285}
2286
2287#[derive(Debug, Clone, serde::Serialize)]
2289pub struct StreamInfo {
2290 pub stream_id: String,
2292 pub event_count: usize,
2294 pub last_event_at: Option<chrono::DateTime<chrono::Utc>>,
2296}
2297
2298#[derive(Debug, Clone, serde::Serialize)]
2300pub struct EventTypeInfo {
2301 pub event_type: String,
2303 pub event_count: usize,
2305 pub last_event_at: Option<chrono::DateTime<chrono::Utc>>,
2307}
2308
2309impl Default for EventStore {
2310 fn default() -> Self {
2311 Self::new()
2312 }
2313}
2314
2315#[cfg(test)]
2316mod tests {
2317 use super::*;
2318 use crate::domain::entities::Event;
2319 use tempfile::TempDir;
2320
2321 fn find_parquet_files(dir: &std::path::Path) -> Vec<std::path::PathBuf> {
2326 let mut out = Vec::new();
2327 let mut stack = vec![dir.to_path_buf()];
2328 while let Some(d) = stack.pop() {
2329 let Ok(entries) = std::fs::read_dir(&d) else {
2330 continue;
2331 };
2332 for e in entries.flatten() {
2333 let p = e.path();
2334 if p.is_dir() {
2335 stack.push(p);
2336 } else if p.extension().and_then(|s| s.to_str()) == Some("parquet") {
2337 out.push(p);
2338 }
2339 }
2340 }
2341 out
2342 }
2343
2344 fn create_test_event(entity_id: &str, event_type: &str) -> Event {
2345 Event::from_strings(
2346 event_type.to_string(),
2347 entity_id.to_string(),
2348 "default".to_string(),
2349 serde_json::json!({"name": "Test", "value": 42}),
2350 None,
2351 )
2352 .unwrap()
2353 }
2354
2355 fn create_test_event_with_payload(
2356 entity_id: &str,
2357 event_type: &str,
2358 payload: serde_json::Value,
2359 ) -> Event {
2360 Event::from_strings(
2361 event_type.to_string(),
2362 entity_id.to_string(),
2363 "default".to_string(),
2364 payload,
2365 None,
2366 )
2367 .unwrap()
2368 }
2369
2370 #[test]
2371 fn test_event_store_new() {
2372 let store = EventStore::new();
2373 assert_eq!(store.stats().total_events, 0);
2374 assert_eq!(store.stats().total_entities, 0);
2375 }
2376
2377 #[test]
2384 fn test_ensure_tenant_loaded_no_storage_is_a_noop() {
2385 let store = EventStore::new();
2389 assert!(!store.is_tenant_loaded("alice"));
2390 store.ensure_tenant_loaded("alice").unwrap();
2391 assert!(store.is_tenant_loaded("alice"));
2392 assert!(!store.is_tenant_loaded("bob"));
2394 }
2395
2396 #[test]
2397 fn test_ensure_tenant_loaded_warm_path_is_idempotent() {
2398 let store = EventStore::new();
2399 store.ensure_tenant_loaded("alice").unwrap();
2400 store.ensure_tenant_loaded("alice").unwrap();
2402 }
2403
2404 #[test]
2405 fn test_ensure_tenant_loaded_rejects_unsafe_tenant_id() {
2406 let temp_dir = TempDir::new().unwrap();
2412 let store = EventStore::with_config(EventStoreConfig::with_persistence(temp_dir.path()));
2413 for unsafe_tid in ["..", "a/b", "a\\b", ""] {
2414 let result = store.ensure_tenant_loaded(unsafe_tid);
2415 assert!(
2416 result.is_err(),
2417 "tenant_id {unsafe_tid:?} should have been rejected"
2418 );
2419 assert!(
2420 !store.is_tenant_loaded(unsafe_tid),
2421 "rejected tenant {unsafe_tid:?} must not be marked loaded"
2422 );
2423 }
2424 }
2425
2426 #[test]
2427 fn test_ensure_tenant_loaded_no_subtree_marks_loaded_with_zero_events() {
2428 let temp_dir = TempDir::new().unwrap();
2433 let store = EventStore::with_config(EventStoreConfig::with_persistence(temp_dir.path()));
2434 assert!(!store.is_tenant_loaded("never-existed"));
2435 store.ensure_tenant_loaded("never-existed").unwrap();
2436 assert!(store.is_tenant_loaded("never-existed"));
2437 }
2438
2439 #[test]
2440 fn test_evict_tenant_drops_events_and_resets_bytes() {
2441 let temp_dir = TempDir::new().unwrap();
2445 let storage_dir = temp_dir.path().to_path_buf();
2446
2447 {
2448 let store = EventStore::with_config(EventStoreConfig::with_persistence(&storage_dir));
2449 for i in 0..3 {
2450 store
2451 .ingest(
2452 &Event::from_strings(
2453 "test.event".to_string(),
2454 format!("a-{i}"),
2455 "alice".to_string(),
2456 serde_json::json!({"i": i}),
2457 None,
2458 )
2459 .unwrap(),
2460 )
2461 .unwrap();
2462 }
2463 for i in 0..2 {
2464 store
2465 .ingest(
2466 &Event::from_strings(
2467 "test.event".to_string(),
2468 format!("b-{i}"),
2469 "bob".to_string(),
2470 serde_json::json!({"i": i}),
2471 None,
2472 )
2473 .unwrap(),
2474 )
2475 .unwrap();
2476 }
2477 store.flush_storage().unwrap();
2478 }
2479
2480 let store = EventStore::with_config(EventStoreConfig::with_persistence(&storage_dir));
2481 store.ensure_tenant_loaded("alice").unwrap();
2482 store.ensure_tenant_loaded("bob").unwrap();
2483 assert_eq!(store.stats().total_events, 5);
2484 let alice_bytes = store.tenant_resident_bytes("alice");
2485 let bob_bytes = store.tenant_resident_bytes("bob");
2486 assert!(alice_bytes > 0 && bob_bytes > 0);
2487
2488 store.evict_tenant("alice");
2489
2490 assert!(!store.is_tenant_loaded("alice"));
2491 assert!(store.is_tenant_loaded("bob"));
2492 assert_eq!(store.tenant_resident_bytes("alice"), 0);
2493 assert_eq!(store.tenant_resident_bytes("bob"), bob_bytes);
2494 assert_eq!(store.stats().total_events, 2, "only bob's 2 events remain");
2495 }
2496
2497 #[test]
2498 fn test_evict_tenant_then_query_re_loads_from_disk() {
2499 let temp_dir = TempDir::new().unwrap();
2503 let storage_dir = temp_dir.path().to_path_buf();
2504
2505 {
2506 let store = EventStore::with_config(EventStoreConfig::with_persistence(&storage_dir));
2507 for i in 0..4 {
2508 store
2509 .ingest(
2510 &Event::from_strings(
2511 "test.event".to_string(),
2512 format!("a-{i}"),
2513 "alice".to_string(),
2514 serde_json::json!({"i": i}),
2515 None,
2516 )
2517 .unwrap(),
2518 )
2519 .unwrap();
2520 }
2521 store.flush_storage().unwrap();
2522 }
2523
2524 let store = EventStore::with_config(EventStoreConfig::with_persistence(&storage_dir));
2525 store.ensure_tenant_loaded("alice").unwrap();
2526 store.evict_tenant("alice");
2527 assert_eq!(store.stats().total_events, 0);
2528
2529 let results = store
2531 .query(&QueryEventsRequest {
2532 entity_id: None,
2533 event_type: None,
2534 tenant_id: Some("alice".to_string()),
2535 as_of: None,
2536 since: None,
2537 until: None,
2538 limit: None,
2539 event_type_prefix: None,
2540 payload_filter: None,
2541 })
2542 .unwrap();
2543 assert_eq!(results.len(), 4);
2544 assert!(store.is_tenant_loaded("alice"));
2545 }
2546
2547 #[test]
2548 fn test_evict_tenant_rebuilds_index_with_new_offsets() {
2549 let temp_dir = TempDir::new().unwrap();
2556 let store = EventStore::with_config(EventStoreConfig::with_persistence(temp_dir.path()));
2557
2558 for i in 0..3 {
2562 store
2563 .ingest(
2564 &Event::from_strings(
2565 "test.event".to_string(),
2566 format!("a-{i}"),
2567 "alice".to_string(),
2568 serde_json::json!({"i": i}),
2569 None,
2570 )
2571 .unwrap(),
2572 )
2573 .unwrap();
2574 if i < 2 {
2575 store
2576 .ingest(
2577 &Event::from_strings(
2578 "test.event".to_string(),
2579 format!("b-{i}"),
2580 "bob".to_string(),
2581 serde_json::json!({"i": i}),
2582 None,
2583 )
2584 .unwrap(),
2585 )
2586 .unwrap();
2587 }
2588 }
2589 store.tenant_loader.mark_loaded("alice");
2591 store.tenant_loader.mark_loaded("bob");
2592
2593 store.evict_tenant("alice");
2594
2595 let bob_results = store
2596 .query(&QueryEventsRequest {
2597 entity_id: None,
2598 event_type: None,
2599 tenant_id: Some("bob".to_string()),
2600 as_of: None,
2601 since: None,
2602 until: None,
2603 limit: None,
2604 event_type_prefix: None,
2605 payload_filter: None,
2606 })
2607 .unwrap();
2608 assert_eq!(bob_results.len(), 2);
2609 for e in &bob_results {
2610 assert_eq!(e.tenant_id_str(), "bob");
2611 }
2612 }
2613
2614 #[test]
2615 fn test_budget_eviction_keeps_resident_set_bounded() {
2616 let temp_dir = TempDir::new().unwrap();
2620 let storage_dir = temp_dir.path().to_path_buf();
2621
2622 let big_payload = serde_json::json!({"data": "x".repeat(1000)});
2625 {
2626 let store = EventStore::with_config(EventStoreConfig::with_persistence(&storage_dir));
2627 for tenant in ["alice", "bob", "carol"] {
2628 for i in 0..5 {
2629 store
2630 .ingest(
2631 &Event::from_strings(
2632 "test.event".to_string(),
2633 format!("{tenant}-{i}"),
2634 tenant.to_string(),
2635 big_payload.clone(),
2636 None,
2637 )
2638 .unwrap(),
2639 )
2640 .unwrap();
2641 }
2642 }
2643 store.flush_storage().unwrap();
2644 }
2645
2646 let mut config = EventStoreConfig::with_persistence(&storage_dir);
2649 config.cache_byte_budget = Some(12_000);
2650 let store = EventStore::with_config(config);
2651
2652 store.ensure_tenant_loaded("alice").unwrap();
2654 assert!(store.is_tenant_loaded("alice"));
2655
2656 store.tenant_loader.touch("alice");
2661 std::thread::sleep(std::time::Duration::from_millis(10));
2662 store.ensure_tenant_loaded("bob").unwrap();
2663 assert!(store.is_tenant_loaded("bob"));
2664
2665 store.tenant_loader.touch("bob");
2669 std::thread::sleep(std::time::Duration::from_millis(10));
2670 store.ensure_tenant_loaded("carol").unwrap();
2671 assert!(store.is_tenant_loaded("carol"));
2672
2673 let resident = store.cache_resident_bytes();
2678 let budget = 12_000u64;
2679
2680 if resident > budget {
2683 let loaded_count = ["alice", "bob", "carol"]
2684 .iter()
2685 .filter(|t| store.is_tenant_loaded(t))
2686 .count();
2687 assert_eq!(
2688 loaded_count, 1,
2689 "over budget but more than one tenant loaded — eviction policy didn't fire"
2690 );
2691 }
2692
2693 assert!(store.is_tenant_loaded("carol"));
2696 }
2697
2698 #[test]
2699 fn test_query_after_eviction_re_loads_transparently() {
2700 let temp_dir = TempDir::new().unwrap();
2703 let storage_dir = temp_dir.path().to_path_buf();
2704
2705 let big_payload = serde_json::json!({"data": "x".repeat(2000)});
2706 {
2707 let store = EventStore::with_config(EventStoreConfig::with_persistence(&storage_dir));
2708 for tenant in ["alice", "bob"] {
2709 for i in 0..3 {
2710 store
2711 .ingest(
2712 &Event::from_strings(
2713 "test.event".to_string(),
2714 format!("{tenant}-{i}"),
2715 tenant.to_string(),
2716 big_payload.clone(),
2717 None,
2718 )
2719 .unwrap(),
2720 )
2721 .unwrap();
2722 }
2723 }
2724 store.flush_storage().unwrap();
2725 }
2726
2727 let mut config = EventStoreConfig::with_persistence(&storage_dir);
2729 config.cache_byte_budget = Some(5_000);
2730 let store = EventStore::with_config(config);
2731
2732 let alice_first = store
2736 .query(&QueryEventsRequest {
2737 entity_id: None,
2738 event_type: None,
2739 tenant_id: Some("alice".to_string()),
2740 as_of: None,
2741 since: None,
2742 until: None,
2743 limit: None,
2744 event_type_prefix: None,
2745 payload_filter: None,
2746 })
2747 .unwrap();
2748 assert_eq!(alice_first.len(), 3);
2749
2750 std::thread::sleep(std::time::Duration::from_millis(15));
2752 let _bob = store
2754 .query(&QueryEventsRequest {
2755 entity_id: None,
2756 event_type: None,
2757 tenant_id: Some("bob".to_string()),
2758 as_of: None,
2759 since: None,
2760 until: None,
2761 limit: None,
2762 event_type_prefix: None,
2763 payload_filter: None,
2764 })
2765 .unwrap();
2766 assert!(
2767 !store.is_tenant_loaded("alice"),
2768 "alice should have been evicted"
2769 );
2770
2771 let alice_second = store
2773 .query(&QueryEventsRequest {
2774 entity_id: None,
2775 event_type: None,
2776 tenant_id: Some("alice".to_string()),
2777 as_of: None,
2778 since: None,
2779 until: None,
2780 limit: None,
2781 event_type_prefix: None,
2782 payload_filter: None,
2783 })
2784 .unwrap();
2785 assert_eq!(
2786 alice_second.len(),
2787 3,
2788 "alice's events come back via re-load"
2789 );
2790 assert!(store.is_tenant_loaded("alice"));
2791 }
2792
2793 #[test]
2794 #[cfg(feature = "server")]
2795 fn test_cache_metrics_track_evictions_and_bytes() {
2796 let temp_dir = TempDir::new().unwrap();
2800 let storage_dir = temp_dir.path().to_path_buf();
2801
2802 let big_payload = serde_json::json!({"data": "x".repeat(2000)});
2803 {
2804 let store = EventStore::with_config(EventStoreConfig::with_persistence(&storage_dir));
2805 for tenant in ["alice", "bob"] {
2806 for i in 0..3 {
2807 store
2808 .ingest(
2809 &Event::from_strings(
2810 "test.event".to_string(),
2811 format!("{tenant}-{i}"),
2812 tenant.to_string(),
2813 big_payload.clone(),
2814 None,
2815 )
2816 .unwrap(),
2817 )
2818 .unwrap();
2819 }
2820 }
2821 store.flush_storage().unwrap();
2822 }
2823
2824 let mut config = EventStoreConfig::with_persistence(&storage_dir);
2825 config.cache_byte_budget = Some(5_000); let store = EventStore::with_config(config);
2827
2828 assert_eq!(store.metrics.cache_evictions_total.get(), 0);
2829 assert_eq!(store.metrics.cache_bytes.get(), 0);
2830
2831 store.ensure_tenant_loaded("alice").unwrap();
2832 let after_alice = store.metrics.cache_bytes.get();
2834 assert!(after_alice > 0, "gauge should reflect alice's bytes");
2835 assert_eq!(store.metrics.cache_evictions_total.get(), 0);
2837
2838 std::thread::sleep(std::time::Duration::from_millis(10));
2839 store.ensure_tenant_loaded("bob").unwrap();
2840
2841 assert_eq!(
2844 store.metrics.cache_evictions_total.get(),
2845 1,
2846 "exactly one tenant evicted after bob's load"
2847 );
2848 let after_bob = store.metrics.cache_bytes.get();
2850 assert!(after_bob > 0);
2851 assert!(after_bob <= after_alice, "gauge dropped after eviction");
2852 }
2853
2854 #[test]
2855 fn test_stress_resident_set_stays_near_budget_under_rolling_queries() {
2856 let temp_dir = TempDir::new().unwrap();
2863 let storage_dir = temp_dir.path().to_path_buf();
2864
2865 const TENANT_COUNT: usize = 10;
2866 const EVENTS_PER_TENANT: usize = 50;
2867 let big_payload = serde_json::json!({"data": "x".repeat(10_000)});
2869
2870 {
2873 let store = EventStore::with_config(EventStoreConfig::with_persistence(&storage_dir));
2874 for t in 0..TENANT_COUNT {
2875 let tenant = format!("tenant-{t}");
2876 for i in 0..EVENTS_PER_TENANT {
2877 store
2878 .ingest(
2879 &Event::from_strings(
2880 "test.event".to_string(),
2881 format!("{tenant}-{i}"),
2882 tenant.clone(),
2883 big_payload.clone(),
2884 None,
2885 )
2886 .unwrap(),
2887 )
2888 .unwrap();
2889 }
2890 }
2891 store.flush_storage().unwrap();
2892 }
2893
2894 const BUDGET: u64 = 1_048_576;
2898 let mut config = EventStoreConfig::with_persistence(&storage_dir);
2899 config.cache_byte_budget = Some(BUDGET);
2900 let store = EventStore::with_config(config);
2901
2902 let mut peak_resident: u64 = 0;
2906 for t in 0..TENANT_COUNT {
2907 let tenant = format!("tenant-{t}");
2908 let results = store
2909 .query(&QueryEventsRequest {
2910 entity_id: None,
2911 event_type: None,
2912 tenant_id: Some(tenant.clone()),
2913 as_of: None,
2914 since: None,
2915 until: None,
2916 limit: None,
2917 event_type_prefix: None,
2918 payload_filter: None,
2919 })
2920 .unwrap();
2921 assert_eq!(
2922 results.len(),
2923 EVENTS_PER_TENANT,
2924 "every per-tenant query must return all of that tenant's events"
2925 );
2926 let resident = store.cache_resident_bytes();
2928 if resident > peak_resident {
2929 peak_resident = resident;
2930 }
2931 }
2932
2933 let final_resident = store.cache_resident_bytes();
2934
2935 let tolerance = BUDGET; assert!(
2941 peak_resident <= BUDGET + tolerance,
2942 "peak resident {peak_resident} exceeds budget {BUDGET} by more than {tolerance} \
2943 — eviction policy not keeping up with the working-set churn"
2944 );
2945 assert!(
2946 final_resident <= BUDGET + tolerance,
2947 "final resident {final_resident} exceeds budget {BUDGET} by more than {tolerance}"
2948 );
2949
2950 let last_tenant = format!("tenant-{}", TENANT_COUNT - 1);
2953 assert!(
2954 store.is_tenant_loaded(&last_tenant),
2955 "the most-recent tenant must remain loaded after the sweep"
2956 );
2957
2958 let still_loaded = (0..TENANT_COUNT)
2961 .filter(|t| store.is_tenant_loaded(&format!("tenant-{t}")))
2962 .count();
2963 assert!(
2964 still_loaded < TENANT_COUNT,
2965 "no tenants evicted ({still_loaded}/{TENANT_COUNT} still loaded) — \
2966 budget enforcement didn't engage"
2967 );
2968 }
2969
2970 #[test]
2971 fn test_evict_tenant_when_not_loaded_is_a_noop() {
2972 let store = EventStore::new();
2975 store.evict_tenant("nobody"); assert!(!store.is_tenant_loaded("nobody"));
2977 }
2978
2979 #[test]
2980 fn test_lazy_load_accounts_bytes_per_tenant() {
2981 let temp_dir = TempDir::new().unwrap();
2985 let storage_dir = temp_dir.path().to_path_buf();
2986
2987 {
2989 let store = EventStore::with_config(EventStoreConfig::with_persistence(&storage_dir));
2990 for i in 0..5 {
2991 store
2992 .ingest(
2993 &Event::from_strings(
2994 "test.event".to_string(),
2995 format!("a-{i}"),
2996 "alice".to_string(),
2997 serde_json::json!({"data": "x".repeat(1000)}),
2998 None,
2999 )
3000 .unwrap(),
3001 )
3002 .unwrap();
3003 }
3004 store.flush_storage().unwrap();
3005 }
3006
3007 let store = EventStore::with_config(EventStoreConfig::with_persistence(&storage_dir));
3008 assert_eq!(store.tenant_resident_bytes("alice"), 0);
3010 assert_eq!(store.cache_resident_bytes(), 0);
3011
3012 store.ensure_tenant_loaded("alice").unwrap();
3013
3014 let alice_bytes = store.tenant_resident_bytes("alice");
3017 assert!(
3018 alice_bytes >= 5 * 1000,
3019 "alice should have at least 5 KiB resident; got {alice_bytes}"
3020 );
3021 assert_eq!(store.tenant_resident_bytes("bob"), 0);
3023 assert_eq!(store.cache_resident_bytes(), alice_bytes);
3025 }
3026
3027 #[test]
3028 fn test_query_lazy_loads_tenant_on_first_call() {
3029 let temp_dir = TempDir::new().unwrap();
3033 let storage_dir = temp_dir.path().to_path_buf();
3034
3035 {
3037 let store = EventStore::with_config(EventStoreConfig::with_persistence(&storage_dir));
3038 for i in 0..3 {
3039 let event = Event::from_strings(
3040 "test.event".to_string(),
3041 format!("e-{i}"),
3042 "alice".to_string(),
3043 serde_json::json!({"i": i}),
3044 None,
3045 )
3046 .unwrap();
3047 store.ingest(&event).unwrap();
3048 }
3049 store.flush_storage().unwrap();
3050 }
3051
3052 let store = EventStore::with_config(EventStoreConfig::with_persistence(&storage_dir));
3054 assert_eq!(
3055 store.stats().total_events,
3056 0,
3057 "boot must be O(1) — no Parquet pre-load"
3058 );
3059 assert!(!store.is_tenant_loaded("alice"));
3060 assert!(!store.is_tenant_loaded("bob"));
3061
3062 let results = store
3064 .query(&QueryEventsRequest {
3065 entity_id: None,
3066 event_type: None,
3067 tenant_id: Some("alice".to_string()),
3068 as_of: None,
3069 since: None,
3070 until: None,
3071 limit: None,
3072 event_type_prefix: None,
3073 payload_filter: None,
3074 })
3075 .unwrap();
3076 assert_eq!(results.len(), 3, "alice's 3 events are returned");
3077 assert!(store.is_tenant_loaded("alice"), "alice now warm");
3078 assert!(!store.is_tenant_loaded("bob"), "bob still cold");
3081 }
3082
3083 #[test]
3084 fn test_query_invalid_tenant_id_returns_error_no_hang() {
3085 let temp_dir = TempDir::new().unwrap();
3089 let store = EventStore::with_config(EventStoreConfig::with_persistence(temp_dir.path()));
3090
3091 let result = store.query(&QueryEventsRequest {
3092 entity_id: None,
3093 event_type: None,
3094 tenant_id: Some("../etc".to_string()),
3095 as_of: None,
3096 since: None,
3097 until: None,
3098 limit: None,
3099 event_type_prefix: None,
3100 payload_filter: None,
3101 });
3102 assert!(result.is_err(), "unsafe tenant_id must surface as error");
3103 }
3104
3105 #[test]
3106 fn test_query_concurrent_first_queries_for_same_tenant_all_succeed() {
3107 let temp_dir = TempDir::new().unwrap();
3115 let storage_dir = temp_dir.path().to_path_buf();
3116
3117 {
3119 let store = EventStore::with_config(EventStoreConfig::with_persistence(&storage_dir));
3120 for i in 0..25 {
3121 let event = Event::from_strings(
3122 "test.event".to_string(),
3123 format!("e-{i}"),
3124 "alice".to_string(),
3125 serde_json::json!({"i": i}),
3126 None,
3127 )
3128 .unwrap();
3129 store.ingest(&event).unwrap();
3130 }
3131 store.flush_storage().unwrap();
3132 }
3133
3134 let store = Arc::new(EventStore::with_config(EventStoreConfig::with_persistence(
3136 &storage_dir,
3137 )));
3138 assert!(!store.is_tenant_loaded("alice"));
3139
3140 let mut handles = Vec::new();
3141 for _ in 0..8 {
3142 let s = store.clone();
3143 handles.push(std::thread::spawn(move || {
3144 s.query(&QueryEventsRequest {
3145 entity_id: None,
3146 event_type: None,
3147 tenant_id: Some("alice".to_string()),
3148 as_of: None,
3149 since: None,
3150 until: None,
3151 limit: None,
3152 event_type_prefix: None,
3153 payload_filter: None,
3154 })
3155 }));
3156 }
3157
3158 for h in handles {
3159 let result = h.join().unwrap().unwrap();
3160 assert_eq!(
3161 result.len(),
3162 25,
3163 "every concurrent caller must see all 25 events"
3164 );
3165 }
3166 assert!(store.is_tenant_loaded("alice"));
3167 assert_eq!(store.stats().total_events, 25);
3169 }
3170
3171 #[test]
3172 fn test_query_two_cold_tenants_load_independently() {
3173 let temp_dir = TempDir::new().unwrap();
3177 let storage_dir = temp_dir.path().to_path_buf();
3178
3179 {
3180 let store = EventStore::with_config(EventStoreConfig::with_persistence(&storage_dir));
3181 for i in 0..3 {
3182 store
3183 .ingest(
3184 &Event::from_strings(
3185 "test.event".to_string(),
3186 format!("a-{i}"),
3187 "alice".to_string(),
3188 serde_json::json!({"i": i}),
3189 None,
3190 )
3191 .unwrap(),
3192 )
3193 .unwrap();
3194 }
3195 for i in 0..5 {
3196 store
3197 .ingest(
3198 &Event::from_strings(
3199 "test.event".to_string(),
3200 format!("b-{i}"),
3201 "bob".to_string(),
3202 serde_json::json!({"i": i}),
3203 None,
3204 )
3205 .unwrap(),
3206 )
3207 .unwrap();
3208 }
3209 store.flush_storage().unwrap();
3210 }
3211
3212 let store = EventStore::with_config(EventStoreConfig::with_persistence(&storage_dir));
3213 assert_eq!(store.stats().total_events, 0);
3214
3215 let alice = store
3217 .query(&QueryEventsRequest {
3218 entity_id: None,
3219 event_type: None,
3220 tenant_id: Some("alice".to_string()),
3221 as_of: None,
3222 since: None,
3223 until: None,
3224 limit: None,
3225 event_type_prefix: None,
3226 payload_filter: None,
3227 })
3228 .unwrap();
3229 assert_eq!(alice.len(), 3);
3230 assert!(store.is_tenant_loaded("alice"));
3231 assert!(!store.is_tenant_loaded("bob"));
3232 assert_eq!(store.stats().total_events, 3);
3233
3234 let bob = store
3236 .query(&QueryEventsRequest {
3237 entity_id: None,
3238 event_type: None,
3239 tenant_id: Some("bob".to_string()),
3240 as_of: None,
3241 since: None,
3242 until: None,
3243 limit: None,
3244 event_type_prefix: None,
3245 payload_filter: None,
3246 })
3247 .unwrap();
3248 assert_eq!(bob.len(), 5);
3249 assert!(store.is_tenant_loaded("bob"));
3250 assert_eq!(store.stats().total_events, 8);
3251 }
3252
3253 #[test]
3254 fn test_boot_with_persisted_data_is_o1() {
3255 let temp_dir = TempDir::new().unwrap();
3268 let storage_dir = temp_dir.path().to_path_buf();
3269
3270 {
3271 let store = EventStore::with_config(EventStoreConfig::with_persistence(&storage_dir));
3272 for tenant in ["alice", "bob", "carol"] {
3273 for i in 0..50 / 3 {
3274 store
3275 .ingest(
3276 &Event::from_strings(
3277 "test.event".to_string(),
3278 format!("{tenant}-{i}"),
3279 tenant.to_string(),
3280 serde_json::json!({"i": i}),
3281 None,
3282 )
3283 .unwrap(),
3284 )
3285 .unwrap();
3286 }
3287 }
3288 store.flush_storage().unwrap();
3289 }
3290
3291 let on_disk = find_parquet_files(&storage_dir);
3293 assert!(
3294 !on_disk.is_empty(),
3295 "session 1 should have produced parquet files; pre-condition for the test"
3296 );
3297
3298 let started = std::time::Instant::now();
3299 let store = EventStore::with_config(EventStoreConfig::with_persistence(&storage_dir));
3300 let boot_elapsed = started.elapsed();
3301
3302 assert_eq!(
3303 store.stats().total_events,
3304 0,
3305 "boot must not pre-load any Parquet events"
3306 );
3307
3308 assert!(
3312 boot_elapsed < std::time::Duration::from_secs(2),
3313 "boot took {boot_elapsed:?} — Step 2 boot should be O(1)"
3314 );
3315 }
3316
3317 #[test]
3318 fn test_query_warm_tenant_does_not_re_read_disk() {
3319 let temp_dir = TempDir::new().unwrap();
3325 let storage_dir = temp_dir.path().to_path_buf();
3326
3327 let store = EventStore::with_config(EventStoreConfig::with_persistence(&storage_dir));
3328 for i in 0..3 {
3329 let event = Event::from_strings(
3330 "test.event".to_string(),
3331 format!("e-{i}"),
3332 "alice".to_string(),
3333 serde_json::json!({"i": i}),
3334 None,
3335 )
3336 .unwrap();
3337 store.ingest(&event).unwrap();
3338 }
3339 store.flush_storage().unwrap();
3340
3341 let _ = store
3343 .query(&QueryEventsRequest {
3344 entity_id: None,
3345 event_type: None,
3346 tenant_id: Some("alice".to_string()),
3347 as_of: None,
3348 since: None,
3349 until: None,
3350 limit: None,
3351 event_type_prefix: None,
3352 payload_filter: None,
3353 })
3354 .unwrap();
3355 assert!(store.is_tenant_loaded("alice"));
3356
3357 let parquet_files = find_parquet_files(&storage_dir);
3360 for f in parquet_files {
3361 std::fs::remove_file(&f).unwrap();
3362 }
3363
3364 let results = store
3365 .query(&QueryEventsRequest {
3366 entity_id: None,
3367 event_type: None,
3368 tenant_id: Some("alice".to_string()),
3369 as_of: None,
3370 since: None,
3371 until: None,
3372 limit: None,
3373 event_type_prefix: None,
3374 payload_filter: None,
3375 })
3376 .unwrap();
3377 assert_eq!(
3378 results.len(),
3379 3,
3380 "warm tenant query must not need disk; got {} events from a deleted parquet",
3381 results.len()
3382 );
3383 }
3384
3385 #[test]
3386 fn test_event_store_default() {
3387 let store = EventStore::default();
3388 assert_eq!(store.stats().total_events, 0);
3389 }
3390
3391 #[test]
3392 fn test_ingest_single_event() {
3393 let store = EventStore::new();
3394 let event = create_test_event("entity-1", "user.created");
3395
3396 store.ingest(&event).unwrap();
3397
3398 assert_eq!(store.stats().total_events, 1);
3399 assert_eq!(store.stats().total_ingested, 1);
3400 }
3401
3402 #[test]
3403 fn test_ingest_multiple_events() {
3404 let store = EventStore::new();
3405
3406 for i in 0..10 {
3407 let event = create_test_event(&format!("entity-{i}"), "user.created");
3408 store.ingest(&event).unwrap();
3409 }
3410
3411 assert_eq!(store.stats().total_events, 10);
3412 assert_eq!(store.stats().total_ingested, 10);
3413 }
3414
3415 #[test]
3416 fn test_query_by_entity_id() {
3417 let store = EventStore::new();
3418
3419 store
3420 .ingest(&create_test_event("entity-1", "user.created"))
3421 .unwrap();
3422 store
3423 .ingest(&create_test_event("entity-2", "user.created"))
3424 .unwrap();
3425 store
3426 .ingest(&create_test_event("entity-1", "user.updated"))
3427 .unwrap();
3428
3429 let results = store
3430 .query(&QueryEventsRequest {
3431 entity_id: Some("entity-1".to_string()),
3432 event_type: None,
3433 tenant_id: None,
3434 as_of: None,
3435 since: None,
3436 until: None,
3437 limit: None,
3438 event_type_prefix: None,
3439 payload_filter: None,
3440 })
3441 .unwrap();
3442
3443 assert_eq!(results.len(), 2);
3444 }
3445
3446 #[test]
3447 fn test_query_by_event_type() {
3448 let store = EventStore::new();
3449
3450 store
3451 .ingest(&create_test_event("entity-1", "user.created"))
3452 .unwrap();
3453 store
3454 .ingest(&create_test_event("entity-2", "user.updated"))
3455 .unwrap();
3456 store
3457 .ingest(&create_test_event("entity-3", "user.created"))
3458 .unwrap();
3459
3460 let results = store
3461 .query(&QueryEventsRequest {
3462 entity_id: None,
3463 event_type: Some("user.created".to_string()),
3464 tenant_id: None,
3465 as_of: None,
3466 since: None,
3467 until: None,
3468 limit: None,
3469 event_type_prefix: None,
3470 payload_filter: None,
3471 })
3472 .unwrap();
3473
3474 assert_eq!(results.len(), 2);
3475 }
3476
3477 #[test]
3478 fn test_query_with_limit() {
3479 let store = EventStore::new();
3480
3481 for i in 0..10 {
3482 let event = create_test_event(&format!("entity-{i}"), "user.created");
3483 store.ingest(&event).unwrap();
3484 }
3485
3486 let results = store
3487 .query(&QueryEventsRequest {
3488 entity_id: None,
3489 event_type: None,
3490 tenant_id: None,
3491 as_of: None,
3492 since: None,
3493 until: None,
3494 limit: Some(5),
3495 event_type_prefix: None,
3496 payload_filter: None,
3497 })
3498 .unwrap();
3499
3500 assert_eq!(results.len(), 5);
3501 }
3502
3503 #[test]
3504 fn test_query_empty_store() {
3505 let store = EventStore::new();
3506
3507 let results = store
3508 .query(&QueryEventsRequest {
3509 entity_id: Some("non-existent".to_string()),
3510 event_type: None,
3511 tenant_id: None,
3512 as_of: None,
3513 since: None,
3514 until: None,
3515 limit: None,
3516 event_type_prefix: None,
3517 payload_filter: None,
3518 })
3519 .unwrap();
3520
3521 assert!(results.is_empty());
3522 }
3523
3524 #[test]
3525 fn test_reconstruct_state() {
3526 let store = EventStore::new();
3527
3528 store
3529 .ingest(&create_test_event("entity-1", "user.created"))
3530 .unwrap();
3531
3532 let state = store.reconstruct_state("entity-1", None).unwrap();
3533 assert_eq!(state["current_state"]["name"], "Test");
3535 assert_eq!(state["current_state"]["value"], 42);
3536 }
3537
3538 #[test]
3539 fn test_reconstruct_state_not_found() {
3540 let store = EventStore::new();
3541
3542 let result = store.reconstruct_state("non-existent", None);
3543 assert!(result.is_err());
3544 }
3545
3546 #[test]
3547 fn test_get_snapshot_empty() {
3548 let store = EventStore::new();
3549
3550 let result = store.get_snapshot("non-existent");
3551 assert!(result.is_err());
3553 }
3554
3555 #[test]
3556 fn test_create_snapshot() {
3557 let store = EventStore::new();
3558
3559 store
3560 .ingest(&create_test_event("entity-1", "user.created"))
3561 .unwrap();
3562
3563 store.create_snapshot("entity-1").unwrap();
3564
3565 let snapshot = store.get_snapshot("entity-1").unwrap();
3567 assert!(snapshot != serde_json::json!(null));
3568 }
3569
3570 #[test]
3571 fn test_create_snapshot_entity_not_found() {
3572 let store = EventStore::new();
3573
3574 let result = store.create_snapshot("non-existent");
3575 assert!(result.is_err());
3576 }
3577
3578 #[test]
3579 fn test_websocket_manager() {
3580 let store = EventStore::new();
3581 let manager = store.websocket_manager();
3582 assert!(Arc::strong_count(&manager) >= 1);
3584 }
3585
3586 #[test]
3587 fn test_snapshot_manager() {
3588 let store = EventStore::new();
3589 let manager = store.snapshot_manager();
3590 assert!(Arc::strong_count(&manager) >= 1);
3591 }
3592
3593 #[test]
3594 fn test_compaction_manager_none() {
3595 let store = EventStore::new();
3596 assert!(store.compaction_manager().is_none());
3598 }
3599
3600 #[test]
3601 fn test_schema_registry() {
3602 let store = EventStore::new();
3603 let registry = store.schema_registry();
3604 assert!(Arc::strong_count(®istry) >= 1);
3605 }
3606
3607 #[test]
3608 fn test_replay_manager() {
3609 let store = EventStore::new();
3610 let manager = store.replay_manager();
3611 assert!(Arc::strong_count(&manager) >= 1);
3612 }
3613
3614 #[test]
3615 fn test_pipeline_manager() {
3616 let store = EventStore::new();
3617 let manager = store.pipeline_manager();
3618 assert!(Arc::strong_count(&manager) >= 1);
3619 }
3620
3621 #[test]
3622 fn test_projection_manager() {
3623 let store = EventStore::new();
3624 let manager = store.projection_manager();
3625 let projections = manager.list_projections();
3627 assert!(projections.len() >= 2); }
3629
3630 #[test]
3631 fn test_projection_state_cache() {
3632 let store = EventStore::new();
3633 let cache = store.projection_state_cache();
3634
3635 cache.insert("test:key".to_string(), serde_json::json!({"value": 123}));
3636 assert_eq!(cache.len(), 1);
3637
3638 let value = cache.get("test:key").unwrap();
3639 assert_eq!(value["value"], 123);
3640 }
3641
3642 #[test]
3643 fn test_metrics() {
3644 let store = EventStore::new();
3645 let metrics = store.metrics();
3646 assert!(Arc::strong_count(&metrics) >= 1);
3647 }
3648
3649 #[test]
3650 fn test_store_stats() {
3651 let store = EventStore::new();
3652
3653 store
3654 .ingest(&create_test_event("entity-1", "user.created"))
3655 .unwrap();
3656 store
3657 .ingest(&create_test_event("entity-2", "order.placed"))
3658 .unwrap();
3659
3660 let stats = store.stats();
3661 assert_eq!(stats.total_events, 2);
3662 assert_eq!(stats.total_entities, 2);
3663 assert_eq!(stats.total_event_types, 2);
3664 assert_eq!(stats.total_ingested, 2);
3665 }
3666
3667 #[test]
3668 fn test_event_store_config_default() {
3669 let config = EventStoreConfig::default();
3670 assert!(config.storage_dir.is_none());
3671 assert!(config.wal_dir.is_none());
3672 }
3673
3674 #[test]
3675 fn test_event_store_config_with_persistence() {
3676 let temp_dir = TempDir::new().unwrap();
3677 let config = EventStoreConfig::with_persistence(temp_dir.path());
3678
3679 assert!(config.storage_dir.is_some());
3680 assert!(config.wal_dir.is_none());
3681 }
3682
3683 #[test]
3684 fn test_event_store_config_with_wal() {
3685 let temp_dir = TempDir::new().unwrap();
3686 let config = EventStoreConfig::with_wal(temp_dir.path(), WALConfig::default());
3687
3688 assert!(config.storage_dir.is_none());
3689 assert!(config.wal_dir.is_some());
3690 }
3691
3692 #[test]
3693 fn test_event_store_config_with_all() {
3694 let temp_dir = TempDir::new().unwrap();
3695 let config = EventStoreConfig::with_all(temp_dir.path(), SnapshotConfig::default());
3696
3697 assert!(config.storage_dir.is_some());
3698 }
3699
3700 #[test]
3701 fn test_event_store_config_production() {
3702 let storage_dir = TempDir::new().unwrap();
3703 let wal_dir = TempDir::new().unwrap();
3704 let config = EventStoreConfig::production(
3705 storage_dir.path(),
3706 wal_dir.path(),
3707 SnapshotConfig::default(),
3708 WALConfig::default(),
3709 CompactionConfig::default(),
3710 );
3711
3712 assert!(config.storage_dir.is_some());
3713 assert!(config.wal_dir.is_some());
3714 }
3715
3716 #[test]
3722 fn test_from_env_vars_data_dir_enables_full_persistence() {
3723 let (config, mode) = EventStoreConfig::from_env_vars(
3724 Some("/app/data".to_string()),
3725 None,
3726 None,
3727 None,
3728 None,
3729 None,
3730 None,
3731 None,
3732 );
3733 assert_eq!(mode, "wal+parquet");
3734 assert_eq!(
3735 config.storage_dir.unwrap().to_str().unwrap(),
3736 "/app/data/storage"
3737 );
3738 assert_eq!(config.wal_dir.unwrap().to_str().unwrap(), "/app/data/wal");
3739 }
3740
3741 #[test]
3742 fn test_from_env_vars_explicit_dirs() {
3743 let (config, mode) = EventStoreConfig::from_env_vars(
3744 None,
3745 Some("/custom/storage".to_string()),
3746 Some("/custom/wal".to_string()),
3747 None,
3748 None,
3749 None,
3750 None,
3751 None,
3752 );
3753 assert_eq!(mode, "wal+parquet");
3754 assert_eq!(
3755 config.storage_dir.unwrap().to_str().unwrap(),
3756 "/custom/storage"
3757 );
3758 assert_eq!(config.wal_dir.unwrap().to_str().unwrap(), "/custom/wal");
3759 }
3760
3761 #[test]
3762 fn test_from_env_vars_wal_disabled() {
3763 let (config, mode) = EventStoreConfig::from_env_vars(
3764 Some("/app/data".to_string()),
3765 None,
3766 None,
3767 Some("false".to_string()),
3768 None,
3769 None,
3770 None,
3771 None,
3772 );
3773 assert_eq!(mode, "parquet-only");
3774 assert!(config.storage_dir.is_some());
3775 assert!(config.wal_dir.is_none());
3776 }
3777
3778 #[test]
3779 fn test_from_env_vars_no_dirs_is_in_memory() {
3780 let (config, mode) =
3781 EventStoreConfig::from_env_vars(None, None, None, None, None, None, None, None);
3782 assert_eq!(mode, "in-memory");
3783 assert!(config.storage_dir.is_none());
3784 assert!(config.wal_dir.is_none());
3785 }
3786
3787 #[test]
3788 fn test_from_env_vars_empty_strings_treated_as_none() {
3789 let (_, mode) = EventStoreConfig::from_env_vars(
3790 Some(String::new()),
3791 Some(String::new()),
3792 Some(String::new()),
3793 None,
3794 None,
3795 None,
3796 None,
3797 None,
3798 );
3799 assert_eq!(mode, "in-memory");
3800 }
3801
3802 #[test]
3803 fn test_from_env_vars_explicit_overrides_data_dir() {
3804 let (config, mode) = EventStoreConfig::from_env_vars(
3805 Some("/app/data".to_string()),
3806 Some("/override/storage".to_string()),
3807 Some("/override/wal".to_string()),
3808 None,
3809 None,
3810 None,
3811 None,
3812 None,
3813 );
3814 assert_eq!(mode, "wal+parquet");
3815 assert_eq!(
3816 config.storage_dir.unwrap().to_str().unwrap(),
3817 "/override/storage"
3818 );
3819 assert_eq!(config.wal_dir.unwrap().to_str().unwrap(), "/override/wal");
3820 }
3821
3822 #[test]
3823 fn test_from_env_vars_wal_only() {
3824 let (config, mode) = EventStoreConfig::from_env_vars(
3825 None,
3826 None,
3827 Some("/wal/only".to_string()),
3828 None,
3829 None,
3830 None,
3831 None,
3832 None,
3833 );
3834 assert_eq!(mode, "wal-only");
3835 assert!(config.storage_dir.is_none());
3836 assert_eq!(config.wal_dir.unwrap().to_str().unwrap(), "/wal/only");
3837 }
3838
3839 #[test]
3840 fn test_from_env_vars_cache_bytes_parses_decimal() {
3841 let (config, _) = EventStoreConfig::from_env_vars(
3842 Some("/app/data".to_string()),
3843 None,
3844 None,
3845 None,
3846 Some("536870912".to_string()),
3847 None,
3849 None,
3850 None,
3851 );
3852 assert_eq!(config.cache_byte_budget, Some(536_870_912));
3853 }
3854
3855 #[test]
3856 fn test_from_env_vars_cache_bytes_unparseable_disables_budget() {
3857 let (config, _) = EventStoreConfig::from_env_vars(
3861 Some("/app/data".to_string()),
3862 None,
3863 None,
3864 None,
3865 Some("not-a-number".to_string()),
3866 None,
3867 None,
3868 None,
3869 );
3870 assert_eq!(config.cache_byte_budget, None);
3871 }
3872
3873 #[test]
3874 fn test_from_env_vars_cache_bytes_empty_disables_budget() {
3875 let (config, _) = EventStoreConfig::from_env_vars(
3876 Some("/app/data".to_string()),
3877 None,
3878 None,
3879 None,
3880 Some(String::new()),
3881 None,
3882 None,
3883 None,
3884 );
3885 assert_eq!(config.cache_byte_budget, None);
3886 }
3887
3888 #[test]
3889 fn test_from_env_vars_snapshot_interval_overrides_default() {
3890 let (config, _) = EventStoreConfig::from_env_vars(
3894 Some("/app/data".to_string()),
3895 None,
3896 None,
3897 None,
3898 None,
3899 Some("60".to_string()),
3900 None,
3901 None,
3902 );
3903 assert_eq!(config.compaction_config.compaction_interval_seconds, 60);
3904 }
3905
3906 #[test]
3907 fn test_from_env_vars_snapshot_interval_default_is_hourly() {
3908 let (config, _) = EventStoreConfig::from_env_vars(
3909 Some("/app/data".to_string()),
3910 None,
3911 None,
3912 None,
3913 None,
3914 None,
3915 None,
3916 None,
3917 );
3918 assert_eq!(config.compaction_config.compaction_interval_seconds, 3600);
3919 }
3920
3921 #[test]
3922 fn test_from_env_vars_snapshot_interval_unparseable_falls_back() {
3923 let (config, _) = EventStoreConfig::from_env_vars(
3924 Some("/app/data".to_string()),
3925 None,
3926 None,
3927 None,
3928 None,
3929 Some("not-a-number".to_string()),
3930 None,
3931 None,
3932 );
3933 assert_eq!(config.compaction_config.compaction_interval_seconds, 3600);
3934 }
3935
3936 #[test]
3937 fn test_from_env_vars_retention_system_days_overrides_default() {
3938 let (config, _) = EventStoreConfig::from_env_vars(
3941 Some("/app/data".to_string()),
3942 None,
3943 None,
3944 None,
3945 None,
3946 None,
3947 Some("7".to_string()),
3948 None,
3949 );
3950 let ttl = config
3951 .compaction_config
3952 .retention
3953 .ttl_for("system")
3954 .unwrap();
3955 assert_eq!(ttl.as_secs(), 7 * 24 * 3600);
3956 }
3957
3958 #[test]
3959 fn test_from_env_vars_retention_default_is_30_days_for_system() {
3960 let (config, _) = EventStoreConfig::from_env_vars(
3961 Some("/app/data".to_string()),
3962 None,
3963 None,
3964 None,
3965 None,
3966 None,
3967 None,
3968 None,
3969 );
3970 let ttl = config
3971 .compaction_config
3972 .retention
3973 .ttl_for("system")
3974 .unwrap();
3975 assert_eq!(ttl.as_secs(), 30 * 24 * 3600);
3976 assert!(config.compaction_config.retention.ttl_for("acme").is_none());
3978 }
3979
3980 #[test]
3981 fn test_store_stats_serde() {
3982 let stats = StoreStats {
3983 total_events: 100,
3984 total_entities: 50,
3985 total_event_types: 10,
3986 total_ingested: 100,
3987 };
3988
3989 let json = serde_json::to_string(&stats).unwrap();
3990 assert!(json.contains("\"total_events\":100"));
3991 assert!(json.contains("\"total_entities\":50"));
3992 }
3993
3994 #[test]
3995 fn test_query_with_entity_and_type() {
3996 let store = EventStore::new();
3997
3998 store
3999 .ingest(&create_test_event("entity-1", "user.created"))
4000 .unwrap();
4001 store
4002 .ingest(&create_test_event("entity-1", "user.updated"))
4003 .unwrap();
4004 store
4005 .ingest(&create_test_event("entity-2", "user.created"))
4006 .unwrap();
4007
4008 let results = store
4009 .query(&QueryEventsRequest {
4010 entity_id: Some("entity-1".to_string()),
4011 event_type: Some("user.created".to_string()),
4012 tenant_id: None,
4013 as_of: None,
4014 since: None,
4015 until: None,
4016 limit: None,
4017 event_type_prefix: None,
4018 payload_filter: None,
4019 })
4020 .unwrap();
4021
4022 assert_eq!(results.len(), 1);
4023 assert_eq!(results[0].event_type_str(), "user.created");
4024 }
4025
4026 #[test]
4027 fn test_query_by_event_type_prefix() {
4028 let store = EventStore::new();
4029
4030 store
4032 .ingest(&create_test_event("entity-1", "index.created"))
4033 .unwrap();
4034 store
4035 .ingest(&create_test_event("entity-2", "index.updated"))
4036 .unwrap();
4037 store
4038 .ingest(&create_test_event("entity-3", "trade.created"))
4039 .unwrap();
4040 store
4041 .ingest(&create_test_event("entity-4", "trade.completed"))
4042 .unwrap();
4043 store
4044 .ingest(&create_test_event("entity-5", "balance.updated"))
4045 .unwrap();
4046
4047 let results = store
4049 .query(&QueryEventsRequest {
4050 entity_id: None,
4051 event_type: None,
4052 tenant_id: None,
4053 as_of: None,
4054 since: None,
4055 until: None,
4056 limit: None,
4057 event_type_prefix: Some("index.".to_string()),
4058 payload_filter: None,
4059 })
4060 .unwrap();
4061
4062 assert_eq!(results.len(), 2);
4063 assert!(
4064 results
4065 .iter()
4066 .all(|e| e.event_type_str().starts_with("index."))
4067 );
4068 }
4069
4070 #[test]
4071 fn test_query_by_event_type_prefix_empty_returns_all() {
4072 let store = EventStore::new();
4073
4074 store
4075 .ingest(&create_test_event("entity-1", "index.created"))
4076 .unwrap();
4077 store
4078 .ingest(&create_test_event("entity-2", "trade.created"))
4079 .unwrap();
4080
4081 let results = store
4083 .query(&QueryEventsRequest {
4084 entity_id: None,
4085 event_type: None,
4086 tenant_id: None,
4087 as_of: None,
4088 since: None,
4089 until: None,
4090 limit: None,
4091 event_type_prefix: Some(String::new()),
4092 payload_filter: None,
4093 })
4094 .unwrap();
4095
4096 assert_eq!(results.len(), 2);
4097 }
4098
4099 #[test]
4100 fn test_query_by_event_type_prefix_no_match() {
4101 let store = EventStore::new();
4102
4103 store
4104 .ingest(&create_test_event("entity-1", "index.created"))
4105 .unwrap();
4106
4107 let results = store
4108 .query(&QueryEventsRequest {
4109 entity_id: None,
4110 event_type: None,
4111 tenant_id: None,
4112 as_of: None,
4113 since: None,
4114 until: None,
4115 limit: None,
4116 event_type_prefix: Some("nonexistent.".to_string()),
4117 payload_filter: None,
4118 })
4119 .unwrap();
4120
4121 assert!(results.is_empty());
4122 }
4123
4124 #[test]
4125 fn test_query_by_entity_with_type_prefix() {
4126 let store = EventStore::new();
4127
4128 store
4129 .ingest(&create_test_event("entity-1", "index.created"))
4130 .unwrap();
4131 store
4132 .ingest(&create_test_event("entity-1", "trade.created"))
4133 .unwrap();
4134 store
4135 .ingest(&create_test_event("entity-2", "index.updated"))
4136 .unwrap();
4137
4138 let results = store
4140 .query(&QueryEventsRequest {
4141 entity_id: Some("entity-1".to_string()),
4142 event_type: None,
4143 tenant_id: None,
4144 as_of: None,
4145 since: None,
4146 until: None,
4147 limit: None,
4148 event_type_prefix: Some("index.".to_string()),
4149 payload_filter: None,
4150 })
4151 .unwrap();
4152
4153 assert_eq!(results.len(), 1);
4154 assert_eq!(results[0].event_type_str(), "index.created");
4155 }
4156
4157 #[test]
4158 fn test_query_prefix_with_limit() {
4159 let store = EventStore::new();
4160
4161 for i in 0..5 {
4162 store
4163 .ingest(&create_test_event(&format!("entity-{i}"), "index.created"))
4164 .unwrap();
4165 }
4166
4167 let results = store
4168 .query(&QueryEventsRequest {
4169 entity_id: None,
4170 event_type: None,
4171 tenant_id: None,
4172 as_of: None,
4173 since: None,
4174 until: None,
4175 limit: Some(3),
4176 event_type_prefix: Some("index.".to_string()),
4177 payload_filter: None,
4178 })
4179 .unwrap();
4180
4181 assert_eq!(results.len(), 3);
4182 }
4183
4184 #[test]
4185 fn test_query_prefix_alongside_existing_filters() {
4186 let store = EventStore::new();
4187
4188 store
4189 .ingest(&create_test_event("entity-1", "index.created"))
4190 .unwrap();
4191 std::thread::sleep(std::time::Duration::from_millis(10));
4193 store
4194 .ingest(&create_test_event("entity-2", "index.strategy.updated"))
4195 .unwrap();
4196 std::thread::sleep(std::time::Duration::from_millis(10));
4197 store
4198 .ingest(&create_test_event("entity-3", "index.deleted"))
4199 .unwrap();
4200
4201 let results = store
4203 .query(&QueryEventsRequest {
4204 entity_id: None,
4205 event_type: None,
4206 tenant_id: None,
4207 as_of: None,
4208 since: None,
4209 until: None,
4210 limit: Some(2),
4211 event_type_prefix: Some("index.".to_string()),
4212 payload_filter: None,
4213 })
4214 .unwrap();
4215
4216 assert_eq!(results.len(), 2);
4217 }
4218
4219 #[test]
4220 fn test_query_with_payload_filter() {
4221 let store = EventStore::new();
4222
4223 for i in 0..5 {
4225 store
4226 .ingest(&create_test_event_with_payload(
4227 &format!("entity-{i}"),
4228 "user.action",
4229 serde_json::json!({"user_id": "alice", "action": "click"}),
4230 ))
4231 .unwrap();
4232 }
4233 for i in 5..10 {
4235 store
4236 .ingest(&create_test_event_with_payload(
4237 &format!("entity-{i}"),
4238 "user.action",
4239 serde_json::json!({"user_id": "bob", "action": "view"}),
4240 ))
4241 .unwrap();
4242 }
4243
4244 let results = store
4246 .query(&QueryEventsRequest {
4247 entity_id: None,
4248 event_type: Some("user.action".to_string()),
4249 tenant_id: None,
4250 as_of: None,
4251 since: None,
4252 until: None,
4253 limit: None,
4254 event_type_prefix: None,
4255 payload_filter: Some(r#"{"user_id":"alice"}"#.to_string()),
4256 })
4257 .unwrap();
4258
4259 assert_eq!(results.len(), 5);
4260 }
4261
4262 #[test]
4263 fn test_query_payload_filter_non_existent_field() {
4264 let store = EventStore::new();
4265
4266 store
4267 .ingest(&create_test_event_with_payload(
4268 "entity-1",
4269 "user.action",
4270 serde_json::json!({"user_id": "alice"}),
4271 ))
4272 .unwrap();
4273
4274 let results = store
4276 .query(&QueryEventsRequest {
4277 entity_id: None,
4278 event_type: None,
4279 tenant_id: None,
4280 as_of: None,
4281 since: None,
4282 until: None,
4283 limit: None,
4284 event_type_prefix: None,
4285 payload_filter: Some(r#"{"nonexistent":"value"}"#.to_string()),
4286 })
4287 .unwrap();
4288
4289 assert!(results.is_empty());
4290 }
4291
4292 #[test]
4293 fn test_query_payload_filter_with_prefix() {
4294 let store = EventStore::new();
4295
4296 store
4297 .ingest(&create_test_event_with_payload(
4298 "entity-1",
4299 "index.created",
4300 serde_json::json!({"status": "active"}),
4301 ))
4302 .unwrap();
4303 store
4304 .ingest(&create_test_event_with_payload(
4305 "entity-2",
4306 "index.created",
4307 serde_json::json!({"status": "inactive"}),
4308 ))
4309 .unwrap();
4310 store
4311 .ingest(&create_test_event_with_payload(
4312 "entity-3",
4313 "trade.created",
4314 serde_json::json!({"status": "active"}),
4315 ))
4316 .unwrap();
4317
4318 let results = store
4320 .query(&QueryEventsRequest {
4321 entity_id: None,
4322 event_type: None,
4323 tenant_id: None,
4324 as_of: None,
4325 since: None,
4326 until: None,
4327 limit: None,
4328 event_type_prefix: Some("index.".to_string()),
4329 payload_filter: Some(r#"{"status":"active"}"#.to_string()),
4330 })
4331 .unwrap();
4332
4333 assert_eq!(results.len(), 1);
4334 assert_eq!(results[0].entity_id().to_string(), "entity-1");
4335 }
4336
4337 #[test]
4338 fn test_flush_storage_no_storage() {
4339 let store = EventStore::new();
4340 let result = store.flush_storage();
4342 assert!(result.is_ok());
4343 }
4344
4345 #[test]
4346 fn test_state_evolution() {
4347 let store = EventStore::new();
4348
4349 store
4351 .ingest(
4352 &Event::from_strings(
4353 "user.created".to_string(),
4354 "user-1".to_string(),
4355 "default".to_string(),
4356 serde_json::json!({"name": "Alice", "age": 25}),
4357 None,
4358 )
4359 .unwrap(),
4360 )
4361 .unwrap();
4362
4363 store
4365 .ingest(
4366 &Event::from_strings(
4367 "user.updated".to_string(),
4368 "user-1".to_string(),
4369 "default".to_string(),
4370 serde_json::json!({"age": 26}),
4371 None,
4372 )
4373 .unwrap(),
4374 )
4375 .unwrap();
4376
4377 let state = store.reconstruct_state("user-1", None).unwrap();
4378 assert_eq!(state["current_state"]["name"], "Alice");
4380 assert_eq!(state["current_state"]["age"], 26);
4381 }
4382
4383 #[test]
4384 fn test_reject_system_event_types() {
4385 let store = EventStore::new();
4386
4387 let event = Event::reconstruct_from_strings(
4389 uuid::Uuid::new_v4(),
4390 "_system.tenant.created".to_string(),
4391 "_system:tenant:acme".to_string(),
4392 "_system".to_string(),
4393 serde_json::json!({"name": "ACME"}),
4394 chrono::Utc::now(),
4395 None,
4396 1,
4397 );
4398
4399 let result = store.ingest(&event);
4400 assert!(result.is_err());
4401 let err = result.unwrap_err();
4402 assert!(
4403 err.to_string().contains("reserved for internal use"),
4404 "Expected system namespace rejection, got: {err}"
4405 );
4406 }
4407
4408 #[test]
4416 fn test_wal_recovery_checkpoints_to_parquet() {
4417 let data_dir = TempDir::new().unwrap();
4418 let storage_dir = data_dir.path().join("storage");
4419 let wal_dir = data_dir.path().join("wal");
4420
4421 {
4423 let config = EventStoreConfig::production(
4424 &storage_dir,
4425 &wal_dir,
4426 SnapshotConfig::default(),
4427 WALConfig {
4428 sync_on_write: true,
4429 ..WALConfig::default()
4430 },
4431 CompactionConfig::default(),
4432 );
4433 let store = EventStore::with_config(config);
4434
4435 for i in 0..5 {
4436 let event = Event::from_strings(
4437 "test.created".to_string(),
4438 format!("entity-{i}"),
4439 "default".to_string(),
4440 serde_json::json!({"index": i}),
4441 None,
4442 )
4443 .unwrap();
4444 store.ingest(&event).unwrap();
4445 }
4446
4447 assert_eq!(store.stats().total_events, 5);
4448
4449 }
4452
4453 let wal_files: Vec<_> = std::fs::read_dir(&wal_dir)
4455 .unwrap()
4456 .filter_map(std::result::Result::ok)
4457 .filter(|e| e.path().extension().is_some_and(|ext| ext == "log"))
4458 .collect();
4459 assert!(!wal_files.is_empty(), "WAL file should exist");
4460 let wal_size = wal_files[0].metadata().unwrap().len();
4461 assert!(wal_size > 0, "WAL file should have data (got 0 bytes)");
4462
4463 {
4465 let config = EventStoreConfig::production(
4466 &storage_dir,
4467 &wal_dir,
4468 SnapshotConfig::default(),
4469 WALConfig {
4470 sync_on_write: true,
4471 ..WALConfig::default()
4472 },
4473 CompactionConfig::default(),
4474 );
4475 let store = EventStore::with_config(config);
4476
4477 assert_eq!(
4479 store.stats().total_events,
4480 5,
4481 "Session 2 should have all 5 events after WAL recovery"
4482 );
4483
4484 let parquet_files = find_parquet_files(&storage_dir);
4488 assert!(
4489 !parquet_files.is_empty(),
4490 "Parquet file should exist after WAL checkpoint"
4491 );
4492 }
4493
4494 {
4497 let config = EventStoreConfig::production(
4498 &storage_dir,
4499 &wal_dir,
4500 SnapshotConfig::default(),
4501 WALConfig {
4502 sync_on_write: true,
4503 ..WALConfig::default()
4504 },
4505 CompactionConfig::default(),
4506 );
4507 let store = EventStore::with_config(config);
4508
4509 assert_eq!(
4513 store.stats().total_events,
4514 0,
4515 "Session 3 boot should not pre-load Parquet (lazy-load mode)"
4516 );
4517
4518 store.ensure_tenant_loaded("default").unwrap();
4521 assert_eq!(
4522 store.stats().total_events,
4523 5,
4524 "Session 3 should have all 5 events after ensure_tenant_loaded"
4525 );
4526 }
4527 }
4528
4529 #[test]
4530 fn test_parquet_restore_surfaces_errors_not_silent() {
4531 let data_dir = TempDir::new().unwrap();
4535 let storage_dir = data_dir.path().join("storage");
4536 let wal_dir = data_dir.path().join("wal");
4537
4538 {
4540 let config = EventStoreConfig::production(
4541 &storage_dir,
4542 &wal_dir,
4543 SnapshotConfig::default(),
4544 WALConfig {
4545 sync_on_write: true,
4546 ..WALConfig::default()
4547 },
4548 CompactionConfig::default(),
4549 );
4550 let store = EventStore::with_config(config);
4551
4552 for i in 0..3 {
4553 let event = Event::from_strings(
4554 "test.created".to_string(),
4555 format!("entity-{i}"),
4556 "default".to_string(),
4557 serde_json::json!({"i": i}),
4558 None,
4559 )
4560 .unwrap();
4561 store.ingest(&event).unwrap();
4562 }
4563
4564 store.flush_storage().unwrap();
4565 assert_eq!(store.stats().total_events, 3);
4566 }
4567
4568 let parquet_files = find_parquet_files(&storage_dir);
4571 assert!(!parquet_files.is_empty(), "Parquet file must exist");
4572
4573 std::fs::write(&parquet_files[0], b"corrupted data").unwrap();
4575
4576 for entry in std::fs::read_dir(&wal_dir).unwrap().flatten() {
4578 std::fs::write(entry.path(), b"").unwrap();
4579 }
4580
4581 {
4588 let config = EventStoreConfig::production(
4589 &storage_dir,
4590 &wal_dir,
4591 SnapshotConfig::default(),
4592 WALConfig::default(),
4593 CompactionConfig::default(),
4594 );
4595 let store = EventStore::with_config(config);
4596
4597 assert_eq!(store.stats().total_events, 0);
4600 }
4601 }
4602
4603 fn count_wal_entries(wal_dir: &std::path::Path) -> usize {
4613 use std::io::{BufRead, BufReader};
4614 let mut total = 0usize;
4615 let Ok(entries) = std::fs::read_dir(wal_dir) else {
4616 return 0;
4617 };
4618 for entry in entries.flatten() {
4619 let path = entry.path();
4620 if path.extension().is_none_or(|e| e != "log") {
4621 continue;
4622 }
4623 let Ok(file) = std::fs::File::open(&path) else {
4624 continue;
4625 };
4626 for line in BufReader::new(file)
4627 .lines()
4628 .map_while(std::result::Result::ok)
4629 {
4630 if !line.trim().is_empty() {
4631 total += 1;
4632 }
4633 }
4634 }
4635 total
4636 }
4637
4638 #[test]
4639 fn test_checkpoint_truncates_wal_after_flush() {
4640 let data_dir = TempDir::new().unwrap();
4645 let storage_dir = data_dir.path().join("storage");
4646 let wal_dir = data_dir.path().join("wal");
4647
4648 let config = EventStoreConfig::production(
4649 &storage_dir,
4650 &wal_dir,
4651 SnapshotConfig::default(),
4652 WALConfig {
4653 sync_on_write: true,
4654 ..WALConfig::default()
4655 },
4656 CompactionConfig::default(),
4657 );
4658 let store = EventStore::with_config(config);
4659
4660 for i in 0..10 {
4661 let event = Event::from_strings(
4662 "test.created".to_string(),
4663 format!("entity-{i}"),
4664 "default".to_string(),
4665 serde_json::json!({"i": i}),
4666 None,
4667 )
4668 .unwrap();
4669 store.ingest(&event).unwrap();
4670 }
4671
4672 assert_eq!(
4674 count_wal_entries(&wal_dir),
4675 10,
4676 "WAL should have 10 events before checkpoint"
4677 );
4678
4679 store.checkpoint().unwrap();
4680
4681 assert_eq!(
4682 count_wal_entries(&wal_dir),
4683 0,
4684 "WAL should be empty after successful checkpoint"
4685 );
4686 let parquet_files = find_parquet_files(&storage_dir);
4687 assert!(!parquet_files.is_empty(), "Parquet should hold the events");
4688 }
4689
4690 #[test]
4691 fn test_replay_only_post_checkpoint_events_after_crash() {
4692 let data_dir = TempDir::new().unwrap();
4699 let storage_dir = data_dir.path().join("storage");
4700 let wal_dir = data_dir.path().join("wal");
4701
4702 let config_factory = || {
4703 EventStoreConfig::production(
4704 &storage_dir,
4705 &wal_dir,
4706 SnapshotConfig::default(),
4707 WALConfig {
4708 sync_on_write: true,
4709 ..WALConfig::default()
4710 },
4711 CompactionConfig::default(),
4712 )
4713 };
4714
4715 const N: usize = 50;
4718 const K: usize = 5;
4719 {
4720 let store = EventStore::with_config(config_factory());
4721 for i in 0..N {
4722 store
4723 .ingest(
4724 &Event::from_strings(
4725 "pre.checkpoint".to_string(),
4726 format!("e-{i}"),
4727 "default".to_string(),
4728 serde_json::json!({"i": i}),
4729 None,
4730 )
4731 .unwrap(),
4732 )
4733 .unwrap();
4734 }
4735 store.checkpoint().unwrap();
4736 assert_eq!(
4737 count_wal_entries(&wal_dir),
4738 0,
4739 "WAL should be empty immediately after checkpoint"
4740 );
4741
4742 for i in 0..K {
4743 store
4744 .ingest(
4745 &Event::from_strings(
4746 "post.checkpoint".to_string(),
4747 format!("p-{i}"),
4748 "default".to_string(),
4749 serde_json::json!({"i": i}),
4750 None,
4751 )
4752 .unwrap(),
4753 )
4754 .unwrap();
4755 }
4756 assert_eq!(
4757 count_wal_entries(&wal_dir),
4758 K,
4759 "WAL should hold only post-checkpoint events"
4760 );
4761 }
4763
4764 {
4768 let store = EventStore::with_config(config_factory());
4769 assert_eq!(
4773 store.stats().total_events,
4774 K,
4775 "Boot should replay exactly K events from WAL (the post-checkpoint window), not N+K"
4776 );
4777
4778 store.ensure_tenant_loaded("default").unwrap();
4780 assert_eq!(
4781 store.stats().total_events,
4782 N + K,
4783 "After lazy-load, both pre- and post-checkpoint events should be reachable"
4784 );
4785 }
4786 }
4787
4788 #[test]
4789 fn test_checkpoint_is_idempotent() {
4790 let data_dir = TempDir::new().unwrap();
4793 let storage_dir = data_dir.path().join("storage");
4794 let wal_dir = data_dir.path().join("wal");
4795
4796 let store = EventStore::with_config(EventStoreConfig::production(
4797 &storage_dir,
4798 &wal_dir,
4799 SnapshotConfig::default(),
4800 WALConfig::default(),
4801 CompactionConfig::default(),
4802 ));
4803
4804 for i in 0..5 {
4805 store
4806 .ingest(
4807 &Event::from_strings(
4808 "x".to_string(),
4809 format!("e-{i}"),
4810 "default".to_string(),
4811 serde_json::json!({}),
4812 None,
4813 )
4814 .unwrap(),
4815 )
4816 .unwrap();
4817 }
4818
4819 store.checkpoint().unwrap();
4820 store.checkpoint().unwrap();
4822 assert_eq!(count_wal_entries(&wal_dir), 0);
4823 }
4824
4825 #[test]
4826 fn test_checkpoint_noop_in_memory_only_mode() {
4827 let store = EventStore::new();
4829 store.checkpoint().unwrap();
4830 }
4831
4832 #[test]
4833 fn test_checkpoint_interval_from_env_defaults_to_60s_when_wal_enabled() {
4834 let (config, _) = EventStoreConfig::from_env_vars(
4835 Some("/app/data".to_string()),
4836 None,
4837 None,
4838 None,
4839 None,
4840 None,
4841 None,
4842 None,
4843 );
4844 assert_eq!(config.checkpoint_interval_secs, Some(60));
4845 }
4846
4847 #[test]
4848 fn test_checkpoint_interval_from_env_overrides_default() {
4849 let (config, _) = EventStoreConfig::from_env_vars(
4850 Some("/app/data".to_string()),
4851 None,
4852 None,
4853 None,
4854 None,
4855 None,
4856 None,
4857 Some("15".to_string()),
4858 );
4859 assert_eq!(config.checkpoint_interval_secs, Some(15));
4860 }
4861
4862 #[test]
4863 fn test_checkpoint_interval_disabled_when_wal_disabled() {
4864 let (config, _) = EventStoreConfig::from_env_vars(
4866 Some("/app/data".to_string()),
4867 None,
4868 None,
4869 Some("false".to_string()),
4870 None,
4871 None,
4872 None,
4873 Some("15".to_string()),
4874 );
4875 assert_eq!(config.checkpoint_interval_secs, None);
4876 }
4877
4878 #[test]
4879 fn test_checkpoint_interval_unparseable_falls_back_to_default() {
4880 let (config, _) = EventStoreConfig::from_env_vars(
4881 Some("/app/data".to_string()),
4882 None,
4883 None,
4884 None,
4885 None,
4886 None,
4887 None,
4888 Some("not-a-number".to_string()),
4889 );
4890 assert_eq!(config.checkpoint_interval_secs, Some(60));
4891 }
4892}