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 read_only: bool,
143}
144
145#[cfg(feature = "server")]
147#[derive(Debug, Clone)]
148pub struct WebhookDeliveryTask {
149 pub webhook: crate::application::services::webhook::WebhookSubscription,
150 pub event: Event,
151}
152
153impl EventStore {
154 pub fn new() -> Self {
156 Self::with_config(EventStoreConfig::default())
157 }
158
159 pub fn with_config(config: EventStoreConfig) -> Self {
161 let mut projections = ProjectionManager::new();
162
163 projections.register(Arc::new(EntitySnapshotProjection::new("entity_snapshots")));
165 projections.register(Arc::new(EventCounterProjection::new("event_counters")));
166
167 let storage = config
169 .storage_dir
170 .as_ref()
171 .and_then(|dir| match ParquetStorage::new(dir) {
172 Ok(storage) => {
173 tracing::info!("✅ Parquet persistence enabled at: {}", dir.display());
174 Some(Arc::new(RwLock::new(storage)))
175 }
176 Err(e) => {
177 tracing::error!("❌ Failed to initialize Parquet storage: {}", e);
178 None
179 }
180 });
181
182 let wal = config.wal_dir.as_ref().and_then(|dir| {
184 match WriteAheadLog::new(dir, config.wal_config.clone()) {
185 Ok(wal) => {
186 tracing::info!("✅ WAL enabled at: {}", dir.display());
187 Some(Arc::new(wal))
188 }
189 Err(e) => {
190 tracing::error!("❌ Failed to initialize WAL: {}", e);
191 None
192 }
193 }
194 });
195
196 let compaction_manager = config.storage_dir.as_ref().map(|dir| {
198 let manager = CompactionManager::new(dir, config.compaction_config.clone());
199 Arc::new(manager)
200 });
201
202 let schema_registry = Arc::new(SchemaRegistry::new(config.schema_registry_config.clone()));
204 tracing::info!("✅ Schema registry enabled");
205
206 let replay_manager = Arc::new(ReplayManager::new());
208 tracing::info!("✅ Replay manager enabled");
209
210 let pipeline_manager = Arc::new(PipelineManager::new());
212 tracing::info!("✅ Pipeline manager enabled");
213
214 #[cfg(feature = "server")]
216 let metrics = {
217 let m = MetricsRegistry::new();
218 tracing::info!("✅ Prometheus metrics registry initialized");
219 m
220 };
221
222 let projection_state_cache = Arc::new(DashMap::new());
224 tracing::info!("✅ Projection state cache initialized");
225
226 #[cfg(feature = "server")]
228 let webhook_registry = {
229 let w = Arc::new(WebhookRegistry::new());
230 tracing::info!("✅ Webhook registry initialized");
231 w
232 };
233
234 let (event_broadcast_tx, _) = tokio::sync::broadcast::channel(1024);
237
238 let store = Self {
239 events: Arc::new(RwLock::new(Vec::new())),
240 index: Arc::new(EventIndex::new()),
241 projections: Arc::new(RwLock::new(projections)),
242 storage,
243 #[cfg(feature = "server")]
244 websocket_manager: Arc::new(WebSocketManager::new()),
245 snapshot_manager: Arc::new(SnapshotManager::new(config.snapshot_config)),
246 wal,
247 compaction_manager,
248 schema_registry,
249 replay_manager,
250 pipeline_manager,
251 #[cfg(feature = "server")]
252 metrics,
253 total_ingested: Arc::new(RwLock::new(0)),
254 projection_state_cache,
255 projection_status: Arc::new(DashMap::new()),
256 #[cfg(feature = "server")]
257 webhook_registry,
258 #[cfg(feature = "server")]
259 webhook_tx: Arc::new(RwLock::new(None)),
260 geo_index: Arc::new(GeoIndex::new()),
261 exactly_once: Arc::new(ExactlyOnceRegistry::new(ExactlyOnceConfig::default())),
262 schema_evolution: Arc::new(SchemaEvolutionManager::new()),
263 entity_versions: Arc::new(DashMap::new()),
264 consumer_registry: Arc::new(ConsumerRegistry::new()),
265 event_broadcast_tx,
266 tenant_loader: {
267 let loader = TenantLoader::new();
268 if let Some(budget) = config.cache_byte_budget {
269 loader.set_byte_budget(budget);
270 tracing::info!(
271 "✅ Cache byte budget set to {} bytes ({:.2} GiB) — LRU eviction enabled",
272 budget,
273 budget as f64 / (1024.0 * 1024.0 * 1024.0)
274 );
275 } else {
276 tracing::info!(
277 "✅ Cache budget unset — every loaded tenant stays resident \
278 (set ALLSOURCE_CACHE_BYTES to enable eviction)"
279 );
280 }
281 Arc::new(loader)
282 },
283 checkpoint_interval_secs: config.checkpoint_interval_secs,
284 read_only: config.read_only,
285 };
286
287 if config.read_only {
288 tracing::info!(
289 "📖 EventStore opened READ-ONLY (replica): WAL will be replayed for reads but \
290 not truncated; writes are rejected"
291 );
292 }
293
294 if let Some(ref wal) = store.wal {
316 match wal.recover() {
317 Ok(recovered_events) if !recovered_events.is_empty() => {
318 let mut wal_new = 0usize;
319 for event in recovered_events {
320 let offset = store.events.read().len();
321 if let Err(e) = store.index.index_event(
322 event.id,
323 event.entity_id_str(),
324 event.event_type_str(),
325 event.timestamp,
326 offset,
327 ) {
328 tracing::error!("Failed to re-index WAL event {}: {}", event.id, e);
329 }
330
331 if let Err(e) = store.projections.read().process_event(&event) {
332 tracing::error!("Failed to re-process WAL event {}: {}", event.id, e);
333 }
334
335 *store
336 .entity_versions
337 .entry(event.entity_id_str().to_string())
338 .or_insert(0) += 1;
339
340 store.events.write().push(event);
341 wal_new += 1;
342 }
343
344 #[cfg(feature = "server")]
349 store.metrics.wal_replay_events_total.set(wal_new as i64);
350
351 if wal_new > 0 {
352 let total = store.events.read().len();
353 *store.total_ingested.write() = total as u64;
361 tracing::info!(
362 "✅ Recovered {} events from WAL (Parquet data stays cold until \
363 first per-tenant query)",
364 wal_new
365 );
366
367 if let Some(ref storage) = store.storage
382 && !config.read_only
383 {
384 tracing::info!(
385 "📸 Checkpointing {} WAL events to Parquet storage...",
386 wal_new
387 );
388 let parquet = storage.read();
389 let events = store.events.read();
390 let mut buffered = 0usize;
391 for event in events.iter().skip(events.len() - wal_new) {
392 if let Err(e) = parquet.append_event(event.clone()) {
393 tracing::error!(
394 "Failed to buffer WAL event for Parquet: {}",
395 e
396 );
397 } else {
398 buffered += 1;
399 }
400 }
401 drop(events);
402 drop(parquet);
403
404 if buffered > 0 {
405 if let Err(e) = store.flush_storage() {
406 tracing::error!("Failed to checkpoint to Parquet: {}", e);
407 } else if let Err(e) = wal.truncate() {
408 tracing::error!(
409 "Failed to truncate WAL after checkpoint: {}",
410 e
411 );
412 } else {
413 tracing::info!(
414 "✅ WAL checkpointed and truncated ({} events)",
415 buffered
416 );
417 }
418 }
419 }
420 }
421 }
422 Ok(_) => {
423 tracing::debug!("No events to recover from WAL");
424 #[cfg(feature = "server")]
425 store.metrics.wal_replay_events_total.set(0);
426 }
427 Err(e) => {
428 tracing::error!("❌ WAL recovery failed: {}", e);
429 }
430 }
431 } else if store.storage.is_some() {
432 tracing::info!(
433 "📂 Boot complete (lazy-load mode): Parquet data stays on disk until first \
434 per-tenant query"
435 );
436 }
437
438 store
439 }
440
441 pub fn is_read_only(&self) -> bool {
443 self.read_only
444 }
445
446 fn ensure_writable(&self) -> Result<()> {
450 if self.read_only {
451 return Err(crate::error::AllSourceError::ReadOnly(
452 "this AllSource instance is a read-only replica — the data directory is owned by \
453 another running process. Stop the other process, or run a single shared writer \
454 (e.g. Prime in --mode http) and point clients at it."
455 .to_string(),
456 ));
457 }
458 Ok(())
459 }
460
461 #[cfg_attr(feature = "hotpath", hotpath::measure)]
469 pub fn ingest_with_expected_version(
470 &self,
471 event: &Event,
472 expected_version: Option<u64>,
473 ) -> Result<u64> {
474 self.ensure_writable()?;
476
477 self.validate_event(event)?;
479
480 let entity_id = event.entity_id_str().to_string();
481
482 let new_version = {
485 let mut version_entry = self.entity_versions.entry(entity_id.clone()).or_insert(0);
486 let current = *version_entry;
487
488 if let Some(expected) = expected_version
489 && current != expected
490 {
491 return Err(crate::error::AllSourceError::VersionConflict { expected, current });
492 }
493
494 if let Some(ref wal) = self.wal {
496 wal.append(event.clone())?;
497 }
498
499 *version_entry += 1;
500 *version_entry
501 };
502
503 self.ingest_post_wal(event)?;
506
507 Ok(new_version)
508 }
509
510 #[cfg_attr(feature = "hotpath", hotpath::measure)]
513 fn ingest_post_wal(&self, event: &Event) -> Result<()> {
514 #[cfg(feature = "server")]
515 let timer = self.metrics.ingestion_duration_seconds.start_timer();
516
517 let mut events = self.events.write();
518 let offset = events.len();
519
520 self.index.index_event(
522 event.id,
523 event.entity_id_str(),
524 event.event_type_str(),
525 event.timestamp,
526 offset,
527 )?;
528
529 let projections = self.projections.read();
531 projections.process_event(event)?;
532 drop(projections);
533
534 let pipeline_results = self.pipeline_manager.process_event(event);
536 if !pipeline_results.is_empty() {
537 tracing::debug!(
538 "Event {} processed by {} pipeline(s)",
539 event.id,
540 pipeline_results.len()
541 );
542 for (pipeline_id, result) in pipeline_results {
543 tracing::trace!("Pipeline {} result: {:?}", pipeline_id, result);
544 }
545 }
546
547 if let Some(ref storage) = self.storage {
549 let storage = storage.read();
550 storage.append_event(event.clone())?;
551 }
552
553 events.push(event.clone());
555 let total_events = events.len();
556 drop(events);
557
558 let event_arc = Arc::new(event.clone());
560 let _ = self.event_broadcast_tx.send(Arc::clone(&event_arc));
561 #[cfg(feature = "server")]
562 self.websocket_manager.broadcast_event(event_arc);
563
564 #[cfg(feature = "server")]
566 self.dispatch_webhooks(event);
567
568 self.geo_index.index_event(event);
570
571 self.schema_evolution
573 .analyze_event(event.event_type_str(), &event.payload);
574
575 self.check_auto_snapshot(event.entity_id_str(), event);
577
578 #[cfg(feature = "server")]
580 {
581 self.metrics.events_ingested_total.inc();
582 self.metrics
583 .events_ingested_by_type
584 .with_label_values(&[event.event_type_str()])
585 .inc();
586 self.metrics.storage_events_total.set(total_events as i64);
587 }
588
589 let mut total = self.total_ingested.write();
591 *total += 1;
592
593 #[cfg(feature = "server")]
594 timer.observe_duration();
595
596 tracing::debug!("Event ingested: {} (offset: {})", event.id, offset);
597
598 Ok(())
599 }
600
601 #[cfg_attr(feature = "hotpath", hotpath::measure)]
603 pub fn ingest(&self, event: &Event) -> Result<()> {
604 #[cfg(feature = "server")]
606 let timer = self.metrics.ingestion_duration_seconds.start_timer();
607
608 if let Err(e) = self.ensure_writable() {
610 #[cfg(feature = "server")]
611 {
612 self.metrics.ingestion_errors_total.inc();
613 timer.observe_duration();
614 }
615 return Err(e);
616 }
617
618 let validation_result = self.validate_event(event);
620 if let Err(e) = validation_result {
621 #[cfg(feature = "server")]
622 {
623 self.metrics.ingestion_errors_total.inc();
624 timer.observe_duration();
625 }
626 return Err(e);
627 }
628
629 if let Some(ref wal) = self.wal
632 && let Err(e) = wal.append(event.clone())
633 {
634 #[cfg(feature = "server")]
635 {
636 self.metrics.ingestion_errors_total.inc();
637 timer.observe_duration();
638 }
639 return Err(e);
640 }
641
642 *self
644 .entity_versions
645 .entry(event.entity_id_str().to_string())
646 .or_insert(0) += 1;
647
648 let mut events = self.events.write();
649 let offset = events.len();
650
651 self.index.index_event(
653 event.id,
654 event.entity_id_str(),
655 event.event_type_str(),
656 event.timestamp,
657 offset,
658 )?;
659
660 let projections = self.projections.read();
662 projections.process_event(event)?;
663 drop(projections); let pipeline_results = self.pipeline_manager.process_event(event);
668 if !pipeline_results.is_empty() {
669 tracing::debug!(
670 "Event {} processed by {} pipeline(s)",
671 event.id,
672 pipeline_results.len()
673 );
674 for (pipeline_id, result) in pipeline_results {
677 tracing::trace!("Pipeline {} result: {:?}", pipeline_id, result);
678 }
679 }
680
681 if let Some(ref storage) = self.storage {
683 let storage = storage.read();
684 storage.append_event(event.clone())?;
685 }
686
687 events.push(event.clone());
689 let total_events = events.len();
690 drop(events); let event_arc = Arc::new(event.clone());
694 let _ = self.event_broadcast_tx.send(Arc::clone(&event_arc));
695 #[cfg(feature = "server")]
696 self.websocket_manager.broadcast_event(event_arc);
697
698 #[cfg(feature = "server")]
700 self.dispatch_webhooks(event);
701
702 self.geo_index.index_event(event);
704
705 self.schema_evolution
707 .analyze_event(event.event_type_str(), &event.payload);
708
709 self.check_auto_snapshot(event.entity_id_str(), event);
711
712 #[cfg(feature = "server")]
714 {
715 self.metrics.events_ingested_total.inc();
716 self.metrics
717 .events_ingested_by_type
718 .with_label_values(&[event.event_type_str()])
719 .inc();
720 self.metrics.storage_events_total.set(total_events as i64);
721 }
722
723 let mut total = self.total_ingested.write();
725 *total += 1;
726
727 #[cfg(feature = "server")]
728 timer.observe_duration();
729
730 tracing::debug!("Event ingested: {} (offset: {})", event.id, offset);
731
732 Ok(())
733 }
734
735 #[cfg_attr(feature = "hotpath", hotpath::measure)]
742 pub fn ingest_batch(&self, batch: Vec<Event>) -> Result<()> {
743 if batch.is_empty() {
744 return Ok(());
745 }
746
747 self.ensure_writable()?;
749
750 for event in &batch {
752 self.validate_event(event)?;
753 }
754
755 if let Some(ref wal) = self.wal {
757 for event in &batch {
758 wal.append(event.clone())?;
759 }
760 }
761
762 let mut events = self.events.write();
764 let projections = self.projections.read();
765
766 for event in batch {
767 let offset = events.len();
768
769 self.index.index_event(
770 event.id,
771 event.entity_id_str(),
772 event.event_type_str(),
773 event.timestamp,
774 offset,
775 )?;
776
777 projections.process_event(&event)?;
778 self.pipeline_manager.process_event(&event);
779
780 if let Some(ref storage) = self.storage {
781 let storage = storage.read();
782 storage.append_event(event.clone())?;
783 }
784
785 self.geo_index.index_event(&event);
786 self.schema_evolution
787 .analyze_event(event.event_type_str(), &event.payload);
788
789 *self
791 .entity_versions
792 .entry(event.entity_id_str().to_string())
793 .or_insert(0) += 1;
794
795 let _ = self.event_broadcast_tx.send(Arc::new(event.clone()));
797
798 events.push(event);
799 }
800
801 let total_events = events.len();
802 drop(projections);
803 drop(events);
804
805 let mut total = self.total_ingested.write();
806 *total += total_events as u64;
807
808 Ok(())
809 }
810
811 #[cfg_attr(feature = "hotpath", hotpath::measure)]
818 pub fn ingest_replicated(&self, event: &Event) -> Result<()> {
819 #[cfg(feature = "server")]
820 let timer = self.metrics.ingestion_duration_seconds.start_timer();
821
822 let mut events = self.events.write();
823 let offset = events.len();
824
825 self.index.index_event(
827 event.id,
828 event.entity_id_str(),
829 event.event_type_str(),
830 event.timestamp,
831 offset,
832 )?;
833
834 let projections = self.projections.read();
836 projections.process_event(event)?;
837 drop(projections);
838
839 let pipeline_results = self.pipeline_manager.process_event(event);
841 if !pipeline_results.is_empty() {
842 tracing::debug!(
843 "Replicated event {} processed by {} pipeline(s)",
844 event.id,
845 pipeline_results.len()
846 );
847 }
848
849 *self
851 .entity_versions
852 .entry(event.entity_id_str().to_string())
853 .or_insert(0) += 1;
854
855 events.push(event.clone());
857 let total_events = events.len();
858 drop(events);
859
860 let event_arc = Arc::new(event.clone());
862 let _ = self.event_broadcast_tx.send(Arc::clone(&event_arc));
863 #[cfg(feature = "server")]
864 self.websocket_manager.broadcast_event(event_arc);
865
866 #[cfg(feature = "server")]
868 {
869 self.metrics.events_ingested_total.inc();
870 self.metrics
871 .events_ingested_by_type
872 .with_label_values(&[event.event_type_str()])
873 .inc();
874 self.metrics.storage_events_total.set(total_events as i64);
875 }
876
877 let mut total = self.total_ingested.write();
878 *total += 1;
879
880 #[cfg(feature = "server")]
881 timer.observe_duration();
882
883 tracing::debug!(
884 "Replicated event ingested: {} (offset: {})",
885 event.id,
886 offset
887 );
888
889 Ok(())
890 }
891
892 #[cfg_attr(feature = "hotpath", hotpath::measure)]
895 pub fn get_entity_version(&self, entity_id: &str) -> u64 {
896 self.entity_versions.get(entity_id).map_or(0, |v| *v)
897 }
898
899 pub fn consumer_registry(&self) -> &ConsumerRegistry {
901 &self.consumer_registry
902 }
903
904 pub fn subscribe_events(&self) -> tokio::sync::broadcast::Receiver<Arc<Event>> {
910 self.event_broadcast_tx.subscribe()
911 }
912
913 pub fn set_consumer_registry(&mut self, registry: Arc<ConsumerRegistry>) {
918 self.consumer_registry = registry;
919 }
920
921 pub fn total_events(&self) -> usize {
923 self.events.read().len()
924 }
925
926 pub fn events_after_offset(
929 &self,
930 offset: u64,
931 filters: &[String],
932 limit: usize,
933 ) -> Vec<(u64, Event)> {
934 let events = self.events.read();
935 let start = offset as usize;
936 if start >= events.len() {
937 return vec![];
938 }
939
940 events[start..]
941 .iter()
942 .enumerate()
943 .filter(|(_, event)| ConsumerRegistry::matches_filters(event.event_type_str(), filters))
944 .take(limit)
945 .map(|(i, event)| ((start + i + 1) as u64, event.clone()))
946 .collect()
947 }
948
949 #[cfg(feature = "server")]
951 pub fn websocket_manager(&self) -> Arc<WebSocketManager> {
952 Arc::clone(&self.websocket_manager)
953 }
954
955 pub fn snapshot_manager(&self) -> Arc<SnapshotManager> {
957 Arc::clone(&self.snapshot_manager)
958 }
959
960 pub fn compaction_manager(&self) -> Option<Arc<CompactionManager>> {
962 self.compaction_manager.as_ref().map(Arc::clone)
963 }
964
965 pub fn schema_registry(&self) -> Arc<SchemaRegistry> {
967 Arc::clone(&self.schema_registry)
968 }
969
970 pub fn replay_manager(&self) -> Arc<ReplayManager> {
972 Arc::clone(&self.replay_manager)
973 }
974
975 pub fn pipeline_manager(&self) -> Arc<PipelineManager> {
977 Arc::clone(&self.pipeline_manager)
978 }
979
980 #[cfg(feature = "server")]
982 pub fn metrics(&self) -> Arc<MetricsRegistry> {
983 Arc::clone(&self.metrics)
984 }
985
986 pub fn projection_manager(&self) -> parking_lot::RwLockReadGuard<'_, ProjectionManager> {
988 self.projections.read()
989 }
990
991 pub fn register_projection(
1000 &self,
1001 projection: Arc<dyn crate::application::services::projection::Projection>,
1002 ) {
1003 let mut pm = self.projections.write();
1004 pm.register(projection);
1005 }
1006
1007 pub fn register_projection_with_backfill(
1019 &self,
1020 projection: &Arc<dyn crate::application::services::projection::Projection>,
1021 ) -> Result<()> {
1022 {
1024 let mut pm = self.projections.write();
1025 pm.register(Arc::clone(projection));
1026 }
1027
1028 let events = self.events.read();
1030 let mut ordered: Vec<&Event> = events.iter().collect();
1031 ordered.sort_by(|a, b| {
1032 a.timestamp
1033 .cmp(&b.timestamp)
1034 .then_with(|| a.version.cmp(&b.version))
1035 });
1036 for event in ordered {
1037 projection.process(event)?;
1038 }
1039
1040 Ok(())
1041 }
1042
1043 pub fn hydrate_all_from_storage(&self) -> Result<usize> {
1061 let Some(storage) = self.storage.as_ref().map(Arc::clone) else {
1062 return Ok(0);
1063 };
1064
1065 let events = storage.read().load_all_events()?;
1066 let read_count = events.len();
1067 let before = self.events.read().len();
1068 for event in events {
1069 self.append_loaded_event(event);
1070 }
1071 let applied = self.events.read().len() - before;
1072
1073 *self.total_ingested.write() = self.events.read().len() as u64;
1075
1076 tracing::info!(
1077 read = read_count,
1078 applied = applied,
1079 "🔄 hydrate_all_from_storage: in-memory pile reconstructed from Parquet"
1080 );
1081 Ok(applied)
1082 }
1083
1084 pub fn projection_state_cache(&self) -> Arc<DashMap<String, serde_json::Value>> {
1087 Arc::clone(&self.projection_state_cache)
1088 }
1089
1090 pub fn projection_status(&self) -> Arc<DashMap<String, String>> {
1092 Arc::clone(&self.projection_status)
1093 }
1094
1095 pub fn geo_index(&self) -> Arc<GeoIndex> {
1098 self.geo_index.clone()
1099 }
1100
1101 pub fn exactly_once(&self) -> Arc<ExactlyOnceRegistry> {
1103 self.exactly_once.clone()
1104 }
1105
1106 pub fn schema_evolution(&self) -> Arc<SchemaEvolutionManager> {
1108 self.schema_evolution.clone()
1109 }
1110
1111 pub fn snapshot_events(&self) -> Vec<Event> {
1117 self.events.read().clone()
1118 }
1119
1120 pub fn compact_entity_tokens(
1142 &self,
1143 entity_id: &str,
1144 token_event_type: &str,
1145 merged_event: Event,
1146 ) -> Result<bool> {
1147 self.ensure_writable()?;
1149
1150 {
1152 let events = self.events.read();
1153 let has_tokens = events
1154 .iter()
1155 .any(|e| e.entity_id_str() == entity_id && e.event_type_str() == token_event_type);
1156 if !has_tokens {
1157 return Ok(false);
1158 }
1159 }
1160
1161 let projections = self.projections.read();
1163 projections.process_event(&merged_event)?;
1164 drop(projections);
1165
1166 let mut events = self.events.write();
1168
1169 events.retain(|e| {
1170 !(e.entity_id_str() == entity_id && e.event_type_str() == token_event_type)
1171 });
1172
1173 events.push(merged_event.clone());
1174
1175 if let Some(ref wal) = self.wal {
1179 wal.append(merged_event)?;
1180 }
1181
1182 self.index.clear();
1187 for (offset, event) in events.iter().enumerate() {
1188 if let Err(e) = self.index.index_event(
1189 event.id,
1190 event.entity_id_str(),
1191 event.event_type_str(),
1192 event.timestamp,
1193 offset,
1194 ) {
1195 tracing::warn!(
1196 event_id = %event.id,
1197 offset,
1198 "Failed to re-index event during compaction: {e}"
1199 );
1200 }
1201 }
1202
1203 Ok(true)
1204 }
1205
1206 #[cfg(feature = "server")]
1207 pub fn webhook_registry(&self) -> Arc<WebhookRegistry> {
1208 Arc::clone(&self.webhook_registry)
1209 }
1210
1211 #[cfg(feature = "server")]
1214 pub fn set_webhook_tx(&self, tx: mpsc::UnboundedSender<WebhookDeliveryTask>) {
1215 *self.webhook_tx.write() = Some(tx);
1216 tracing::info!("Webhook delivery channel connected");
1217 }
1218
1219 #[cfg(feature = "server")]
1221 fn dispatch_webhooks(&self, event: &Event) {
1222 let matching = self.webhook_registry.find_matching(event);
1223 if matching.is_empty() {
1224 return;
1225 }
1226
1227 let tx_guard = self.webhook_tx.read();
1228 if let Some(ref tx) = *tx_guard {
1229 for webhook in matching {
1230 let task = WebhookDeliveryTask {
1231 webhook,
1232 event: event.clone(),
1233 };
1234 if let Err(e) = tx.send(task) {
1235 tracing::warn!("Failed to queue webhook delivery: {}", e);
1236 }
1237 }
1238 }
1239 }
1240
1241 pub fn flush_storage(&self) -> Result<()> {
1243 if let Some(ref storage) = self.storage {
1244 let storage = storage.read();
1245 storage.flush()?;
1246 tracing::info!("✅ Flushed events to persistent storage");
1247 }
1248 Ok(())
1249 }
1250
1251 pub fn checkpoint(&self) -> Result<()> {
1272 let Some(ref wal) = self.wal else {
1273 return Ok(());
1274 };
1275
1276 self.flush_storage()?;
1281 wal.truncate()?;
1282 tracing::debug!("✅ Checkpoint complete: Parquet flushed, WAL truncated");
1283 Ok(())
1284 }
1285
1286 pub fn checkpoint_interval(&self) -> Option<std::time::Duration> {
1288 self.checkpoint_interval_secs
1289 .map(std::time::Duration::from_secs)
1290 }
1291
1292 pub fn ensure_tenant_loaded(&self, tenant_id: &str) -> Result<()> {
1316 if self.tenant_loader.is_loaded(tenant_id) {
1318 return Ok(());
1319 }
1320
1321 let Some(storage) = self.storage.as_ref().map(Arc::clone) else {
1322 self.tenant_loader.mark_loaded(tenant_id);
1325 return Ok(());
1326 };
1327
1328 let lock = self.tenant_loader.lock_for(tenant_id);
1331 let timeout = self.tenant_loader.load_timeout();
1332 let _guard = lock.try_lock_for(timeout).ok_or_else(|| {
1333 AllSourceError::StorageError(format!(
1334 "ensure_tenant_loaded timed out after {timeout:?} waiting for in-flight load of \
1335 tenant {tenant_id:?}"
1336 ))
1337 })?;
1338
1339 if self.tenant_loader.is_loaded(tenant_id) {
1342 return Ok(());
1343 }
1344
1345 let started = std::time::Instant::now();
1346 let events = storage.read().load_events_for_tenant(tenant_id)?;
1347 let read_count = events.len();
1348
1349 let before = self.events.read().len();
1350 for event in events {
1351 self.append_loaded_event(event);
1352 }
1353 let applied = self.events.read().len() - before;
1354
1355 *self.total_ingested.write() += applied as u64;
1359 self.tenant_loader.mark_loaded(tenant_id);
1360
1361 tracing::info!(
1362 tenant_id = tenant_id,
1363 read = read_count,
1364 applied = applied,
1365 elapsed_ms = started.elapsed().as_millis() as u64,
1366 "ensure_tenant_loaded: tenant hydrated"
1367 );
1368
1369 self.enforce_cache_budget(tenant_id);
1377
1378 #[cfg(feature = "server")]
1381 self.metrics
1382 .cache_bytes
1383 .set(self.tenant_loader.total_bytes() as i64);
1384
1385 Ok(())
1386 }
1387
1388 fn enforce_cache_budget(&self, recently_touched: &str) {
1399 if !self.tenant_loader.over_budget() {
1400 return;
1401 }
1402 loop {
1403 let Some(victim) = self.tenant_loader.pick_lru_excluding(recently_touched) else {
1404 tracing::warn!(
1405 cache_bytes = self.tenant_loader.total_bytes(),
1406 budget = self.tenant_loader.byte_budget(),
1407 recently_touched = recently_touched,
1408 "cache over budget but no other tenant available to evict — \
1409 a single tenant exceeds the budget; consider raising it"
1410 );
1411 return;
1412 };
1413 self.evict_tenant(&victim);
1414 if !self.tenant_loader.over_budget() {
1415 return;
1416 }
1417 }
1418 }
1419
1420 pub fn is_tenant_loaded(&self, tenant_id: &str) -> bool {
1423 self.tenant_loader.is_loaded(tenant_id)
1424 }
1425
1426 pub fn evict_tenant(&self, tenant_id: &str) {
1454 let mut events = self.events.write();
1455 let before = events.len();
1456 let evicted_bytes = self.tenant_loader.bytes_for(tenant_id);
1457
1458 events.retain(|e| e.tenant_id_str() != tenant_id);
1459 let after = events.len();
1460 let dropped = before - after;
1461
1462 if dropped == 0 {
1463 drop(events);
1467 self.tenant_loader.mark_unloaded(tenant_id);
1468 return;
1469 }
1470
1471 self.index.clear();
1475 self.entity_versions.clear();
1476 for (offset, event) in events.iter().enumerate() {
1477 if let Err(e) = self.index.index_event(
1478 event.id,
1479 event.entity_id_str(),
1480 event.event_type_str(),
1481 event.timestamp,
1482 offset,
1483 ) {
1484 tracing::error!(
1485 "Failed to re-index event during eviction of {}: {}",
1486 tenant_id,
1487 e
1488 );
1489 }
1490 *self
1491 .entity_versions
1492 .entry(event.entity_id_str().to_string())
1493 .or_insert(0) += 1;
1494 }
1495 drop(events);
1496
1497 self.tenant_loader.mark_unloaded(tenant_id);
1498
1499 let mut t = self.total_ingested.write();
1502 *t = t.saturating_sub(dropped as u64);
1503 drop(t);
1504
1505 #[cfg(feature = "server")]
1508 {
1509 self.metrics.cache_evictions_total.inc();
1510 self.metrics
1511 .cache_bytes
1512 .set(self.tenant_loader.total_bytes() as i64);
1513 }
1514
1515 tracing::info!(
1516 tenant_id = tenant_id,
1517 events_dropped = dropped,
1518 bytes_freed = evicted_bytes,
1519 "evicted tenant from memory cache"
1520 );
1521 }
1522
1523 pub fn tenant_resident_bytes(&self, tenant_id: &str) -> u64 {
1527 self.tenant_loader.bytes_for(tenant_id)
1528 }
1529
1530 pub fn cache_resident_bytes(&self) -> u64 {
1533 self.tenant_loader.total_bytes()
1534 }
1535
1536 fn append_loaded_event(&self, event: Event) {
1558 if self.index.get_by_id(&event.id).is_some() {
1559 return;
1560 }
1561
1562 let event_bytes = event.estimated_size_bytes();
1563 let tenant = event.tenant_id_str().to_string();
1564
1565 let mut events = self.events.write();
1566 let offset = events.len();
1567
1568 if let Err(e) = self.index.index_event(
1569 event.id,
1570 event.entity_id_str(),
1571 event.event_type_str(),
1572 event.timestamp,
1573 offset,
1574 ) {
1575 tracing::error!("Failed to index loaded event {}: {}", event.id, e);
1576 }
1577
1578 if let Err(e) = self.projections.read().process_event(&event) {
1579 tracing::error!("Failed to project loaded event {}: {}", event.id, e);
1580 }
1581
1582 *self
1583 .entity_versions
1584 .entry(event.entity_id_str().to_string())
1585 .or_insert(0) += 1;
1586
1587 events.push(event);
1588 self.tenant_loader.add_bytes(&tenant, event_bytes);
1592 }
1593
1594 pub fn create_snapshot(&self, entity_id: &str) -> Result<()> {
1596 let events = self.query(&QueryEventsRequest {
1598 entity_id: Some(entity_id.to_string()),
1599 event_type: None,
1600 tenant_id: None,
1601 as_of: None,
1602 since: None,
1603 until: None,
1604 limit: None,
1605 event_type_prefix: None,
1606 payload_filter: None,
1607 })?;
1608
1609 if events.is_empty() {
1610 return Err(AllSourceError::EntityNotFound(entity_id.to_string()));
1611 }
1612
1613 let mut state = serde_json::json!({});
1615 for event in &events {
1616 if let serde_json::Value::Object(ref mut state_map) = state
1617 && let serde_json::Value::Object(ref payload_map) = event.payload
1618 {
1619 for (key, value) in payload_map {
1620 state_map.insert(key.clone(), value.clone());
1621 }
1622 }
1623 }
1624
1625 let last_event = events.last().unwrap();
1626 self.snapshot_manager.create_snapshot(
1627 entity_id,
1628 state,
1629 last_event.timestamp,
1630 events.len(),
1631 SnapshotType::Manual,
1632 )?;
1633
1634 Ok(())
1635 }
1636
1637 fn check_auto_snapshot(&self, entity_id: &str, event: &Event) {
1639 let entity_event_count = self
1641 .index
1642 .get_by_entity(entity_id)
1643 .map_or(0, |entries| entries.len());
1644
1645 if self.snapshot_manager.should_create_snapshot(
1646 entity_id,
1647 entity_event_count,
1648 event.timestamp,
1649 ) {
1650 if let Err(e) = self.create_snapshot(entity_id) {
1652 tracing::warn!(
1653 "Failed to create automatic snapshot for {}: {}",
1654 entity_id,
1655 e
1656 );
1657 }
1658 }
1659 }
1660
1661 fn validate_event(&self, event: &Event) -> Result<()> {
1663 if event.entity_id_str().is_empty() {
1666 return Err(AllSourceError::ValidationError(
1667 "entity_id cannot be empty".to_string(),
1668 ));
1669 }
1670
1671 if event.event_type_str().is_empty() {
1672 return Err(AllSourceError::ValidationError(
1673 "event_type cannot be empty".to_string(),
1674 ));
1675 }
1676
1677 if event.event_type().is_system() {
1680 return Err(AllSourceError::ValidationError(
1681 "Event types starting with '_system.' are reserved for internal use".to_string(),
1682 ));
1683 }
1684
1685 Ok(())
1686 }
1687
1688 pub fn reset_projection(&self, name: &str) -> Result<usize> {
1690 let projection_manager = self.projections.read();
1691 let projection = projection_manager.get_projection(name).ok_or_else(|| {
1692 AllSourceError::EntityNotFound(format!("Projection '{name}' not found"))
1693 })?;
1694
1695 projection.clear();
1697
1698 let prefix = format!("{name}:");
1700 let keys_to_remove: Vec<String> = self
1701 .projection_state_cache
1702 .iter()
1703 .filter(|entry| entry.key().starts_with(&prefix))
1704 .map(|entry| entry.key().clone())
1705 .collect();
1706 for key in keys_to_remove {
1707 self.projection_state_cache.remove(&key);
1708 }
1709
1710 let events = self.events.read();
1712 let mut reprocessed = 0usize;
1713 for event in events.iter() {
1714 if projection.process(event).is_ok() {
1715 reprocessed += 1;
1716 }
1717 }
1718
1719 Ok(reprocessed)
1720 }
1721
1722 pub fn get_event_by_id(&self, event_id: &uuid::Uuid) -> Result<Option<Event>> {
1724 if let Some(offset) = self.index.get_by_id(event_id) {
1725 let events = self.events.read();
1726 Ok(events.get(offset).cloned())
1727 } else {
1728 Ok(None)
1729 }
1730 }
1731
1732 #[cfg_attr(feature = "hotpath", hotpath::measure)]
1734 pub fn query(&self, request: &QueryEventsRequest) -> Result<Vec<Event>> {
1735 if let Some(ref tenant_id) = request.tenant_id {
1754 self.ensure_tenant_loaded(tenant_id)?;
1755 self.tenant_loader.touch(tenant_id);
1759 }
1760
1761 let query_type = if request.entity_id.is_some() {
1763 "entity"
1764 } else if request.event_type.is_some() {
1765 "type"
1766 } else if request.event_type_prefix.is_some() {
1767 "type_prefix"
1768 } else {
1769 "full_scan"
1770 };
1771
1772 #[cfg(feature = "server")]
1774 let timer = self
1775 .metrics
1776 .query_duration_seconds
1777 .with_label_values(&[query_type])
1778 .start_timer();
1779
1780 #[cfg(feature = "server")]
1782 self.metrics
1783 .queries_total
1784 .with_label_values(&[query_type])
1785 .inc();
1786
1787 let events = self.events.read();
1788
1789 let offsets: Vec<usize> = if let Some(entity_id) = &request.entity_id {
1791 self.index
1793 .get_by_entity(entity_id)
1794 .map(|entries| self.filter_entries(entries, request))
1795 .unwrap_or_default()
1796 } else if let Some(event_type) = &request.event_type {
1797 self.index
1799 .get_by_type(event_type)
1800 .map(|entries| self.filter_entries(entries, request))
1801 .unwrap_or_default()
1802 } else if let Some(prefix) = &request.event_type_prefix {
1803 let entries = self.index.get_by_type_prefix(prefix);
1805 self.filter_entries(entries, request)
1806 } else {
1807 (0..events.len()).collect()
1809 };
1810
1811 let mut results: Vec<Event> = offsets
1813 .iter()
1814 .filter_map(|&offset| events.get(offset).cloned())
1815 .filter(|event| self.apply_filters(event, request))
1816 .collect();
1817
1818 results.sort_by(|a, b| {
1823 a.timestamp
1824 .cmp(&b.timestamp)
1825 .then_with(|| a.version.cmp(&b.version))
1826 });
1827
1828 if let Some(limit) = request.limit {
1830 results.truncate(limit);
1831 }
1832
1833 #[cfg(feature = "server")]
1835 {
1836 self.metrics
1837 .query_results_total
1838 .with_label_values(&[query_type])
1839 .inc_by(results.len() as u64);
1840 timer.observe_duration();
1841 }
1842
1843 Ok(results)
1844 }
1845
1846 #[cfg_attr(feature = "hotpath", hotpath::measure)]
1848 fn filter_entries(&self, entries: Vec<IndexEntry>, request: &QueryEventsRequest) -> Vec<usize> {
1849 entries
1850 .into_iter()
1851 .filter(|entry| {
1852 if let Some(as_of) = request.as_of
1854 && entry.timestamp > as_of
1855 {
1856 return false;
1857 }
1858 if let Some(since) = request.since
1859 && entry.timestamp < since
1860 {
1861 return false;
1862 }
1863 if let Some(until) = request.until
1864 && entry.timestamp > until
1865 {
1866 return false;
1867 }
1868 true
1869 })
1870 .map(|entry| entry.offset)
1871 .collect()
1872 }
1873
1874 #[cfg_attr(feature = "hotpath", hotpath::measure)]
1876 fn apply_filters(&self, event: &Event, request: &QueryEventsRequest) -> bool {
1877 if let Some(ref tid) = request.tenant_id
1879 && event.tenant_id_str() != tid
1880 {
1881 return false;
1882 }
1883
1884 if request.entity_id.is_some()
1886 && let Some(ref event_type) = request.event_type
1887 && event.event_type_str() != event_type
1888 {
1889 return false;
1890 }
1891
1892 if request.entity_id.is_some()
1894 && let Some(ref prefix) = request.event_type_prefix
1895 && !event.event_type_str().starts_with(prefix)
1896 {
1897 return false;
1898 }
1899
1900 if let Some(ref filter_str) = request.payload_filter
1902 && let Ok(filter_obj) =
1903 serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(filter_str)
1904 {
1905 let payload = event.payload();
1906 for (key, expected_value) in &filter_obj {
1907 match payload.get(key) {
1908 Some(actual_value) if actual_value == expected_value => {}
1909 _ => return false,
1910 }
1911 }
1912 }
1913
1914 true
1915 }
1916
1917 #[cfg_attr(feature = "hotpath", hotpath::measure)]
1920 pub fn reconstruct_state(
1921 &self,
1922 entity_id: &str,
1923 as_of: Option<DateTime<Utc>>,
1924 ) -> Result<serde_json::Value> {
1925 let (merged_state, since_timestamp) = if let Some(as_of_time) = as_of {
1927 if let Some(snapshot) = self
1929 .snapshot_manager
1930 .get_snapshot_as_of(entity_id, as_of_time)
1931 {
1932 tracing::debug!(
1933 "Using snapshot from {} for entity {} (saved {} events)",
1934 snapshot.as_of,
1935 entity_id,
1936 snapshot.event_count
1937 );
1938 (snapshot.state.clone(), Some(snapshot.as_of))
1939 } else {
1940 (serde_json::json!({}), None)
1941 }
1942 } else {
1943 if let Some(snapshot) = self.snapshot_manager.get_latest_snapshot(entity_id) {
1945 tracing::debug!(
1946 "Using latest snapshot from {} for entity {}",
1947 snapshot.as_of,
1948 entity_id
1949 );
1950 (snapshot.state.clone(), Some(snapshot.as_of))
1951 } else {
1952 (serde_json::json!({}), None)
1953 }
1954 };
1955
1956 let events = self.query(&QueryEventsRequest {
1958 entity_id: Some(entity_id.to_string()),
1959 event_type: None,
1960 tenant_id: None,
1961 as_of,
1962 since: since_timestamp,
1963 until: None,
1964 limit: None,
1965 event_type_prefix: None,
1966 payload_filter: None,
1967 })?;
1968
1969 if events.is_empty() && since_timestamp.is_none() {
1971 return Err(AllSourceError::EntityNotFound(entity_id.to_string()));
1972 }
1973
1974 let mut merged_state = merged_state;
1976 for event in &events {
1977 if let serde_json::Value::Object(ref mut state_map) = merged_state
1978 && let serde_json::Value::Object(ref payload_map) = event.payload
1979 {
1980 for (key, value) in payload_map {
1981 state_map.insert(key.clone(), value.clone());
1982 }
1983 }
1984 }
1985
1986 let state = serde_json::json!({
1988 "entity_id": entity_id,
1989 "last_updated": events.last().map(|e| e.timestamp),
1990 "event_count": events.len(),
1991 "as_of": as_of,
1992 "current_state": merged_state,
1993 "history": events.iter().map(|e| {
1994 serde_json::json!({
1995 "event_id": e.id,
1996 "type": e.event_type,
1997 "timestamp": e.timestamp,
1998 "payload": e.payload
1999 })
2000 }).collect::<Vec<_>>()
2001 });
2002
2003 Ok(state)
2004 }
2005
2006 pub fn get_snapshot(&self, entity_id: &str) -> Result<serde_json::Value> {
2008 let projections = self.projections.read();
2009
2010 if let Some(snapshot_projection) = projections.get_projection("entity_snapshots")
2011 && let Some(state) = snapshot_projection.get_state(entity_id)
2012 {
2013 return Ok(serde_json::json!({
2014 "entity_id": entity_id,
2015 "snapshot": state,
2016 "from_projection": "entity_snapshots"
2017 }));
2018 }
2019
2020 Err(AllSourceError::EntityNotFound(entity_id.to_string()))
2021 }
2022
2023 pub fn stats(&self) -> StoreStats {
2025 let events = self.events.read();
2026 let index_stats = self.index.stats();
2027
2028 StoreStats {
2029 total_events: events.len(),
2030 total_entities: index_stats.total_entities,
2031 total_event_types: index_stats.total_event_types,
2032 total_ingested: *self.total_ingested.read(),
2033 }
2034 }
2035
2036 pub fn list_streams(&self) -> Vec<StreamInfo> {
2038 self.index
2039 .get_all_entities()
2040 .into_iter()
2041 .map(|entity_id| {
2042 let event_count = self
2043 .index
2044 .get_by_entity(&entity_id)
2045 .map_or(0, |entries| entries.len());
2046 let last_event_at = self
2047 .index
2048 .get_by_entity(&entity_id)
2049 .and_then(|entries| entries.last().map(|e| e.timestamp));
2050 StreamInfo {
2051 stream_id: entity_id,
2052 event_count,
2053 last_event_at,
2054 }
2055 })
2056 .collect()
2057 }
2058
2059 pub fn list_event_types(&self) -> Vec<EventTypeInfo> {
2061 self.index
2062 .get_all_types()
2063 .into_iter()
2064 .map(|event_type| {
2065 let event_count = self
2066 .index
2067 .get_by_type(&event_type)
2068 .map_or(0, |entries| entries.len());
2069 let last_event_at = self
2070 .index
2071 .get_by_type(&event_type)
2072 .and_then(|entries| entries.last().map(|e| e.timestamp));
2073 EventTypeInfo {
2074 event_type,
2075 event_count,
2076 last_event_at,
2077 }
2078 })
2079 .collect()
2080 }
2081
2082 pub fn enable_wal_replication(
2089 &self,
2090 tx: tokio::sync::broadcast::Sender<crate::infrastructure::persistence::wal::WALEntry>,
2091 ) {
2092 if let Some(ref wal_arc) = self.wal {
2093 wal_arc.set_replication_tx(tx);
2094 tracing::info!("WAL replication broadcast enabled");
2095 } else {
2096 tracing::warn!("Cannot enable WAL replication: WAL is not configured");
2097 }
2098 }
2099
2100 pub fn wal(&self) -> Option<&Arc<WriteAheadLog>> {
2103 self.wal.as_ref()
2104 }
2105
2106 pub fn parquet_storage(&self) -> Option<&Arc<RwLock<ParquetStorage>>> {
2109 self.storage.as_ref()
2110 }
2111}
2112
2113#[derive(Debug, Clone, Default)]
2115pub struct EventStoreConfig {
2116 pub storage_dir: Option<PathBuf>,
2118
2119 pub snapshot_config: SnapshotConfig,
2121
2122 pub wal_dir: Option<PathBuf>,
2124
2125 pub wal_config: WALConfig,
2127
2128 pub compaction_config: CompactionConfig,
2130
2131 pub schema_registry_config: SchemaRegistryConfig,
2133
2134 pub system_data_dir: Option<PathBuf>,
2139
2140 pub bootstrap_tenant: Option<String>,
2142
2143 pub cache_byte_budget: Option<u64>,
2150
2151 pub checkpoint_interval_secs: Option<u64>,
2163
2164 pub read_only: bool,
2168}
2169
2170impl EventStoreConfig {
2171 pub fn with_persistence(storage_dir: impl Into<PathBuf>) -> Self {
2173 Self {
2174 storage_dir: Some(storage_dir.into()),
2175 ..Self::default()
2176 }
2177 }
2178
2179 pub fn with_snapshots(snapshot_config: SnapshotConfig) -> Self {
2181 Self {
2182 snapshot_config,
2183 ..Self::default()
2184 }
2185 }
2186
2187 pub fn with_wal(wal_dir: impl Into<PathBuf>, wal_config: WALConfig) -> Self {
2189 Self {
2190 wal_dir: Some(wal_dir.into()),
2191 wal_config,
2192 ..Self::default()
2193 }
2194 }
2195
2196 pub fn with_all(storage_dir: impl Into<PathBuf>, snapshot_config: SnapshotConfig) -> Self {
2198 Self {
2199 storage_dir: Some(storage_dir.into()),
2200 snapshot_config,
2201 ..Self::default()
2202 }
2203 }
2204
2205 pub fn production(
2207 storage_dir: impl Into<PathBuf>,
2208 wal_dir: impl Into<PathBuf>,
2209 snapshot_config: SnapshotConfig,
2210 wal_config: WALConfig,
2211 compaction_config: CompactionConfig,
2212 ) -> Self {
2213 let storage_dir = storage_dir.into();
2214 let system_data_dir = storage_dir.join("__system");
2215 Self {
2216 storage_dir: Some(storage_dir),
2217 snapshot_config,
2218 wal_dir: Some(wal_dir.into()),
2219 wal_config,
2220 compaction_config,
2221 system_data_dir: Some(system_data_dir),
2222 ..Self::default()
2223 }
2224 }
2225
2226 pub fn effective_system_data_dir(&self) -> Option<PathBuf> {
2231 self.system_data_dir
2232 .clone()
2233 .or_else(|| self.storage_dir.as_ref().map(|d| d.join("__system")))
2234 }
2235
2236 pub fn from_env() -> (Self, &'static str) {
2244 Self::from_env_vars(
2245 std::env::var("ALLSOURCE_DATA_DIR")
2246 .ok()
2247 .filter(|s| !s.is_empty()),
2248 std::env::var("ALLSOURCE_STORAGE_DIR")
2249 .ok()
2250 .filter(|s| !s.is_empty()),
2251 std::env::var("ALLSOURCE_WAL_DIR")
2252 .ok()
2253 .filter(|s| !s.is_empty()),
2254 std::env::var("ALLSOURCE_WAL_ENABLED").ok(),
2255 std::env::var("ALLSOURCE_CACHE_BYTES").ok(),
2256 std::env::var("ALLSOURCE_SNAPSHOT_INTERVAL_SECONDS").ok(),
2257 std::env::var("ALLSOURCE_RETENTION_SYSTEM_DAYS").ok(),
2258 std::env::var("ALLSOURCE_CHECKPOINT_INTERVAL_SECONDS").ok(),
2259 )
2260 }
2261
2262 pub fn from_env_vars(
2264 data_dir: Option<String>,
2265 explicit_storage_dir: Option<String>,
2266 explicit_wal_dir: Option<String>,
2267 wal_enabled_var: Option<String>,
2268 cache_bytes_var: Option<String>,
2269 snapshot_interval_var: Option<String>,
2270 retention_system_days_var: Option<String>,
2271 checkpoint_interval_var: Option<String>,
2272 ) -> (Self, &'static str) {
2273 let data_dir = data_dir.filter(|s| !s.is_empty());
2274 let storage_dir = explicit_storage_dir
2275 .filter(|s| !s.is_empty())
2276 .or_else(|| data_dir.as_ref().map(|d| format!("{d}/storage")));
2277 let wal_dir = explicit_wal_dir
2278 .filter(|s| !s.is_empty())
2279 .or_else(|| data_dir.as_ref().map(|d| format!("{d}/wal")));
2280 let wal_enabled = wal_enabled_var.is_none_or(|v| v == "true");
2281 let cache_byte_budget =
2286 cache_bytes_var
2287 .filter(|s| !s.is_empty())
2288 .and_then(|s| match s.parse::<u64>() {
2289 Ok(v) => Some(v),
2290 Err(e) => {
2291 tracing::warn!(
2292 "ALLSOURCE_CACHE_BYTES={s:?} could not be parsed as u64: {e}; \
2293 cache budget disabled"
2294 );
2295 None
2296 }
2297 });
2298 let compaction_config =
2299 CompactionConfig::from_env_vars(snapshot_interval_var, retention_system_days_var);
2300
2301 let checkpoint_interval_secs = if wal_enabled {
2306 checkpoint_interval_var
2307 .filter(|s| !s.is_empty())
2308 .map(|s| match s.parse::<u64>() {
2309 Ok(v) => v,
2310 Err(e) => {
2311 tracing::warn!(
2312 "ALLSOURCE_CHECKPOINT_INTERVAL_SECONDS={s:?} could not be parsed as \
2313 u64: {e}; falling back to default 60s"
2314 );
2315 60
2316 }
2317 })
2318 .or(Some(60))
2319 } else {
2320 None
2321 };
2322
2323 let mut config = match (&storage_dir, &wal_dir) {
2324 (Some(sd), Some(wd)) if wal_enabled => Self::production(
2325 sd,
2326 wd,
2327 SnapshotConfig::default(),
2328 WALConfig::default(),
2329 compaction_config,
2330 ),
2331 (Some(sd), _) => Self::with_persistence(sd),
2332 (_, Some(wd)) if wal_enabled => Self::with_wal(wd, WALConfig::default()),
2333 _ => Self::default(),
2334 };
2335 config.cache_byte_budget = cache_byte_budget;
2336 config.checkpoint_interval_secs = checkpoint_interval_secs;
2337
2338 let mode = match (&storage_dir, &wal_dir) {
2339 (Some(_), Some(_)) if wal_enabled => "wal+parquet",
2340 (Some(_), _) => "parquet-only",
2341 (_, Some(_)) if wal_enabled => "wal-only",
2342 _ => "in-memory",
2343 };
2344 (config, mode)
2345 }
2346}
2347
2348#[derive(Debug, serde::Serialize)]
2349pub struct StoreStats {
2350 pub total_events: usize,
2351 pub total_entities: usize,
2352 pub total_event_types: usize,
2353 pub total_ingested: u64,
2354}
2355
2356#[derive(Debug, Clone, serde::Serialize)]
2358pub struct StreamInfo {
2359 pub stream_id: String,
2361 pub event_count: usize,
2363 pub last_event_at: Option<chrono::DateTime<chrono::Utc>>,
2365}
2366
2367#[derive(Debug, Clone, serde::Serialize)]
2369pub struct EventTypeInfo {
2370 pub event_type: String,
2372 pub event_count: usize,
2374 pub last_event_at: Option<chrono::DateTime<chrono::Utc>>,
2376}
2377
2378impl Default for EventStore {
2379 fn default() -> Self {
2380 Self::new()
2381 }
2382}
2383
2384#[cfg(test)]
2385mod tests {
2386 use super::*;
2387 use crate::domain::entities::Event;
2388 use tempfile::TempDir;
2389
2390 fn find_parquet_files(dir: &std::path::Path) -> Vec<std::path::PathBuf> {
2395 let mut out = Vec::new();
2396 let mut stack = vec![dir.to_path_buf()];
2397 while let Some(d) = stack.pop() {
2398 let Ok(entries) = std::fs::read_dir(&d) else {
2399 continue;
2400 };
2401 for e in entries.flatten() {
2402 let p = e.path();
2403 if p.is_dir() {
2404 stack.push(p);
2405 } else if p.extension().and_then(|s| s.to_str()) == Some("parquet") {
2406 out.push(p);
2407 }
2408 }
2409 }
2410 out
2411 }
2412
2413 fn create_test_event(entity_id: &str, event_type: &str) -> Event {
2414 Event::from_strings(
2415 event_type.to_string(),
2416 entity_id.to_string(),
2417 "default".to_string(),
2418 serde_json::json!({"name": "Test", "value": 42}),
2419 None,
2420 )
2421 .unwrap()
2422 }
2423
2424 fn create_test_event_with_payload(
2425 entity_id: &str,
2426 event_type: &str,
2427 payload: serde_json::Value,
2428 ) -> Event {
2429 Event::from_strings(
2430 event_type.to_string(),
2431 entity_id.to_string(),
2432 "default".to_string(),
2433 payload,
2434 None,
2435 )
2436 .unwrap()
2437 }
2438
2439 #[test]
2440 fn test_event_store_new() {
2441 let store = EventStore::new();
2442 assert_eq!(store.stats().total_events, 0);
2443 assert_eq!(store.stats().total_entities, 0);
2444 }
2445
2446 #[test]
2453 fn test_ensure_tenant_loaded_no_storage_is_a_noop() {
2454 let store = EventStore::new();
2458 assert!(!store.is_tenant_loaded("alice"));
2459 store.ensure_tenant_loaded("alice").unwrap();
2460 assert!(store.is_tenant_loaded("alice"));
2461 assert!(!store.is_tenant_loaded("bob"));
2463 }
2464
2465 #[test]
2466 fn test_ensure_tenant_loaded_warm_path_is_idempotent() {
2467 let store = EventStore::new();
2468 store.ensure_tenant_loaded("alice").unwrap();
2469 store.ensure_tenant_loaded("alice").unwrap();
2471 }
2472
2473 #[test]
2474 fn test_ensure_tenant_loaded_rejects_unsafe_tenant_id() {
2475 let temp_dir = TempDir::new().unwrap();
2481 let store = EventStore::with_config(EventStoreConfig::with_persistence(temp_dir.path()));
2482 for unsafe_tid in ["..", "a/b", "a\\b", ""] {
2483 let result = store.ensure_tenant_loaded(unsafe_tid);
2484 assert!(
2485 result.is_err(),
2486 "tenant_id {unsafe_tid:?} should have been rejected"
2487 );
2488 assert!(
2489 !store.is_tenant_loaded(unsafe_tid),
2490 "rejected tenant {unsafe_tid:?} must not be marked loaded"
2491 );
2492 }
2493 }
2494
2495 #[test]
2496 fn test_ensure_tenant_loaded_no_subtree_marks_loaded_with_zero_events() {
2497 let temp_dir = TempDir::new().unwrap();
2502 let store = EventStore::with_config(EventStoreConfig::with_persistence(temp_dir.path()));
2503 assert!(!store.is_tenant_loaded("never-existed"));
2504 store.ensure_tenant_loaded("never-existed").unwrap();
2505 assert!(store.is_tenant_loaded("never-existed"));
2506 }
2507
2508 #[test]
2509 fn test_evict_tenant_drops_events_and_resets_bytes() {
2510 let temp_dir = TempDir::new().unwrap();
2514 let storage_dir = temp_dir.path().to_path_buf();
2515
2516 {
2517 let store = EventStore::with_config(EventStoreConfig::with_persistence(&storage_dir));
2518 for i in 0..3 {
2519 store
2520 .ingest(
2521 &Event::from_strings(
2522 "test.event".to_string(),
2523 format!("a-{i}"),
2524 "alice".to_string(),
2525 serde_json::json!({"i": i}),
2526 None,
2527 )
2528 .unwrap(),
2529 )
2530 .unwrap();
2531 }
2532 for i in 0..2 {
2533 store
2534 .ingest(
2535 &Event::from_strings(
2536 "test.event".to_string(),
2537 format!("b-{i}"),
2538 "bob".to_string(),
2539 serde_json::json!({"i": i}),
2540 None,
2541 )
2542 .unwrap(),
2543 )
2544 .unwrap();
2545 }
2546 store.flush_storage().unwrap();
2547 }
2548
2549 let store = EventStore::with_config(EventStoreConfig::with_persistence(&storage_dir));
2550 store.ensure_tenant_loaded("alice").unwrap();
2551 store.ensure_tenant_loaded("bob").unwrap();
2552 assert_eq!(store.stats().total_events, 5);
2553 let alice_bytes = store.tenant_resident_bytes("alice");
2554 let bob_bytes = store.tenant_resident_bytes("bob");
2555 assert!(alice_bytes > 0 && bob_bytes > 0);
2556
2557 store.evict_tenant("alice");
2558
2559 assert!(!store.is_tenant_loaded("alice"));
2560 assert!(store.is_tenant_loaded("bob"));
2561 assert_eq!(store.tenant_resident_bytes("alice"), 0);
2562 assert_eq!(store.tenant_resident_bytes("bob"), bob_bytes);
2563 assert_eq!(store.stats().total_events, 2, "only bob's 2 events remain");
2564 }
2565
2566 #[test]
2567 fn test_evict_tenant_then_query_re_loads_from_disk() {
2568 let temp_dir = TempDir::new().unwrap();
2572 let storage_dir = temp_dir.path().to_path_buf();
2573
2574 {
2575 let store = EventStore::with_config(EventStoreConfig::with_persistence(&storage_dir));
2576 for i in 0..4 {
2577 store
2578 .ingest(
2579 &Event::from_strings(
2580 "test.event".to_string(),
2581 format!("a-{i}"),
2582 "alice".to_string(),
2583 serde_json::json!({"i": i}),
2584 None,
2585 )
2586 .unwrap(),
2587 )
2588 .unwrap();
2589 }
2590 store.flush_storage().unwrap();
2591 }
2592
2593 let store = EventStore::with_config(EventStoreConfig::with_persistence(&storage_dir));
2594 store.ensure_tenant_loaded("alice").unwrap();
2595 store.evict_tenant("alice");
2596 assert_eq!(store.stats().total_events, 0);
2597
2598 let results = store
2600 .query(&QueryEventsRequest {
2601 entity_id: None,
2602 event_type: None,
2603 tenant_id: Some("alice".to_string()),
2604 as_of: None,
2605 since: None,
2606 until: None,
2607 limit: None,
2608 event_type_prefix: None,
2609 payload_filter: None,
2610 })
2611 .unwrap();
2612 assert_eq!(results.len(), 4);
2613 assert!(store.is_tenant_loaded("alice"));
2614 }
2615
2616 #[test]
2617 fn test_evict_tenant_rebuilds_index_with_new_offsets() {
2618 let temp_dir = TempDir::new().unwrap();
2625 let store = EventStore::with_config(EventStoreConfig::with_persistence(temp_dir.path()));
2626
2627 for i in 0..3 {
2631 store
2632 .ingest(
2633 &Event::from_strings(
2634 "test.event".to_string(),
2635 format!("a-{i}"),
2636 "alice".to_string(),
2637 serde_json::json!({"i": i}),
2638 None,
2639 )
2640 .unwrap(),
2641 )
2642 .unwrap();
2643 if i < 2 {
2644 store
2645 .ingest(
2646 &Event::from_strings(
2647 "test.event".to_string(),
2648 format!("b-{i}"),
2649 "bob".to_string(),
2650 serde_json::json!({"i": i}),
2651 None,
2652 )
2653 .unwrap(),
2654 )
2655 .unwrap();
2656 }
2657 }
2658 store.tenant_loader.mark_loaded("alice");
2660 store.tenant_loader.mark_loaded("bob");
2661
2662 store.evict_tenant("alice");
2663
2664 let bob_results = store
2665 .query(&QueryEventsRequest {
2666 entity_id: None,
2667 event_type: None,
2668 tenant_id: Some("bob".to_string()),
2669 as_of: None,
2670 since: None,
2671 until: None,
2672 limit: None,
2673 event_type_prefix: None,
2674 payload_filter: None,
2675 })
2676 .unwrap();
2677 assert_eq!(bob_results.len(), 2);
2678 for e in &bob_results {
2679 assert_eq!(e.tenant_id_str(), "bob");
2680 }
2681 }
2682
2683 #[test]
2684 fn test_budget_eviction_keeps_resident_set_bounded() {
2685 let temp_dir = TempDir::new().unwrap();
2689 let storage_dir = temp_dir.path().to_path_buf();
2690
2691 let big_payload = serde_json::json!({"data": "x".repeat(1000)});
2694 {
2695 let store = EventStore::with_config(EventStoreConfig::with_persistence(&storage_dir));
2696 for tenant in ["alice", "bob", "carol"] {
2697 for i in 0..5 {
2698 store
2699 .ingest(
2700 &Event::from_strings(
2701 "test.event".to_string(),
2702 format!("{tenant}-{i}"),
2703 tenant.to_string(),
2704 big_payload.clone(),
2705 None,
2706 )
2707 .unwrap(),
2708 )
2709 .unwrap();
2710 }
2711 }
2712 store.flush_storage().unwrap();
2713 }
2714
2715 let mut config = EventStoreConfig::with_persistence(&storage_dir);
2718 config.cache_byte_budget = Some(12_000);
2719 let store = EventStore::with_config(config);
2720
2721 store.ensure_tenant_loaded("alice").unwrap();
2723 assert!(store.is_tenant_loaded("alice"));
2724
2725 store.tenant_loader.touch("alice");
2730 std::thread::sleep(std::time::Duration::from_millis(10));
2731 store.ensure_tenant_loaded("bob").unwrap();
2732 assert!(store.is_tenant_loaded("bob"));
2733
2734 store.tenant_loader.touch("bob");
2738 std::thread::sleep(std::time::Duration::from_millis(10));
2739 store.ensure_tenant_loaded("carol").unwrap();
2740 assert!(store.is_tenant_loaded("carol"));
2741
2742 let resident = store.cache_resident_bytes();
2747 let budget = 12_000u64;
2748
2749 if resident > budget {
2752 let loaded_count = ["alice", "bob", "carol"]
2753 .iter()
2754 .filter(|t| store.is_tenant_loaded(t))
2755 .count();
2756 assert_eq!(
2757 loaded_count, 1,
2758 "over budget but more than one tenant loaded — eviction policy didn't fire"
2759 );
2760 }
2761
2762 assert!(store.is_tenant_loaded("carol"));
2765 }
2766
2767 #[test]
2768 fn test_query_after_eviction_re_loads_transparently() {
2769 let temp_dir = TempDir::new().unwrap();
2772 let storage_dir = temp_dir.path().to_path_buf();
2773
2774 let big_payload = serde_json::json!({"data": "x".repeat(2000)});
2775 {
2776 let store = EventStore::with_config(EventStoreConfig::with_persistence(&storage_dir));
2777 for tenant in ["alice", "bob"] {
2778 for i in 0..3 {
2779 store
2780 .ingest(
2781 &Event::from_strings(
2782 "test.event".to_string(),
2783 format!("{tenant}-{i}"),
2784 tenant.to_string(),
2785 big_payload.clone(),
2786 None,
2787 )
2788 .unwrap(),
2789 )
2790 .unwrap();
2791 }
2792 }
2793 store.flush_storage().unwrap();
2794 }
2795
2796 let mut config = EventStoreConfig::with_persistence(&storage_dir);
2798 config.cache_byte_budget = Some(5_000);
2799 let store = EventStore::with_config(config);
2800
2801 let alice_first = store
2805 .query(&QueryEventsRequest {
2806 entity_id: None,
2807 event_type: None,
2808 tenant_id: Some("alice".to_string()),
2809 as_of: None,
2810 since: None,
2811 until: None,
2812 limit: None,
2813 event_type_prefix: None,
2814 payload_filter: None,
2815 })
2816 .unwrap();
2817 assert_eq!(alice_first.len(), 3);
2818
2819 std::thread::sleep(std::time::Duration::from_millis(15));
2821 let _bob = store
2823 .query(&QueryEventsRequest {
2824 entity_id: None,
2825 event_type: None,
2826 tenant_id: Some("bob".to_string()),
2827 as_of: None,
2828 since: None,
2829 until: None,
2830 limit: None,
2831 event_type_prefix: None,
2832 payload_filter: None,
2833 })
2834 .unwrap();
2835 assert!(
2836 !store.is_tenant_loaded("alice"),
2837 "alice should have been evicted"
2838 );
2839
2840 let alice_second = store
2842 .query(&QueryEventsRequest {
2843 entity_id: None,
2844 event_type: None,
2845 tenant_id: Some("alice".to_string()),
2846 as_of: None,
2847 since: None,
2848 until: None,
2849 limit: None,
2850 event_type_prefix: None,
2851 payload_filter: None,
2852 })
2853 .unwrap();
2854 assert_eq!(
2855 alice_second.len(),
2856 3,
2857 "alice's events come back via re-load"
2858 );
2859 assert!(store.is_tenant_loaded("alice"));
2860 }
2861
2862 #[test]
2863 #[cfg(feature = "server")]
2864 fn test_cache_metrics_track_evictions_and_bytes() {
2865 let temp_dir = TempDir::new().unwrap();
2869 let storage_dir = temp_dir.path().to_path_buf();
2870
2871 let big_payload = serde_json::json!({"data": "x".repeat(2000)});
2872 {
2873 let store = EventStore::with_config(EventStoreConfig::with_persistence(&storage_dir));
2874 for tenant in ["alice", "bob"] {
2875 for i in 0..3 {
2876 store
2877 .ingest(
2878 &Event::from_strings(
2879 "test.event".to_string(),
2880 format!("{tenant}-{i}"),
2881 tenant.to_string(),
2882 big_payload.clone(),
2883 None,
2884 )
2885 .unwrap(),
2886 )
2887 .unwrap();
2888 }
2889 }
2890 store.flush_storage().unwrap();
2891 }
2892
2893 let mut config = EventStoreConfig::with_persistence(&storage_dir);
2894 config.cache_byte_budget = Some(5_000); let store = EventStore::with_config(config);
2896
2897 assert_eq!(store.metrics.cache_evictions_total.get(), 0);
2898 assert_eq!(store.metrics.cache_bytes.get(), 0);
2899
2900 store.ensure_tenant_loaded("alice").unwrap();
2901 let after_alice = store.metrics.cache_bytes.get();
2903 assert!(after_alice > 0, "gauge should reflect alice's bytes");
2904 assert_eq!(store.metrics.cache_evictions_total.get(), 0);
2906
2907 std::thread::sleep(std::time::Duration::from_millis(10));
2908 store.ensure_tenant_loaded("bob").unwrap();
2909
2910 assert_eq!(
2913 store.metrics.cache_evictions_total.get(),
2914 1,
2915 "exactly one tenant evicted after bob's load"
2916 );
2917 let after_bob = store.metrics.cache_bytes.get();
2919 assert!(after_bob > 0);
2920 assert!(after_bob <= after_alice, "gauge dropped after eviction");
2921 }
2922
2923 #[test]
2924 fn test_stress_resident_set_stays_near_budget_under_rolling_queries() {
2925 let temp_dir = TempDir::new().unwrap();
2932 let storage_dir = temp_dir.path().to_path_buf();
2933
2934 const TENANT_COUNT: usize = 10;
2935 const EVENTS_PER_TENANT: usize = 50;
2936 let big_payload = serde_json::json!({"data": "x".repeat(10_000)});
2938
2939 {
2942 let store = EventStore::with_config(EventStoreConfig::with_persistence(&storage_dir));
2943 for t in 0..TENANT_COUNT {
2944 let tenant = format!("tenant-{t}");
2945 for i in 0..EVENTS_PER_TENANT {
2946 store
2947 .ingest(
2948 &Event::from_strings(
2949 "test.event".to_string(),
2950 format!("{tenant}-{i}"),
2951 tenant.clone(),
2952 big_payload.clone(),
2953 None,
2954 )
2955 .unwrap(),
2956 )
2957 .unwrap();
2958 }
2959 }
2960 store.flush_storage().unwrap();
2961 }
2962
2963 const BUDGET: u64 = 1_048_576;
2967 let mut config = EventStoreConfig::with_persistence(&storage_dir);
2968 config.cache_byte_budget = Some(BUDGET);
2969 let store = EventStore::with_config(config);
2970
2971 let mut peak_resident: u64 = 0;
2975 for t in 0..TENANT_COUNT {
2976 let tenant = format!("tenant-{t}");
2977 let results = store
2978 .query(&QueryEventsRequest {
2979 entity_id: None,
2980 event_type: None,
2981 tenant_id: Some(tenant.clone()),
2982 as_of: None,
2983 since: None,
2984 until: None,
2985 limit: None,
2986 event_type_prefix: None,
2987 payload_filter: None,
2988 })
2989 .unwrap();
2990 assert_eq!(
2991 results.len(),
2992 EVENTS_PER_TENANT,
2993 "every per-tenant query must return all of that tenant's events"
2994 );
2995 let resident = store.cache_resident_bytes();
2997 if resident > peak_resident {
2998 peak_resident = resident;
2999 }
3000 }
3001
3002 let final_resident = store.cache_resident_bytes();
3003
3004 let tolerance = BUDGET; assert!(
3010 peak_resident <= BUDGET + tolerance,
3011 "peak resident {peak_resident} exceeds budget {BUDGET} by more than {tolerance} \
3012 — eviction policy not keeping up with the working-set churn"
3013 );
3014 assert!(
3015 final_resident <= BUDGET + tolerance,
3016 "final resident {final_resident} exceeds budget {BUDGET} by more than {tolerance}"
3017 );
3018
3019 let last_tenant = format!("tenant-{}", TENANT_COUNT - 1);
3022 assert!(
3023 store.is_tenant_loaded(&last_tenant),
3024 "the most-recent tenant must remain loaded after the sweep"
3025 );
3026
3027 let still_loaded = (0..TENANT_COUNT)
3030 .filter(|t| store.is_tenant_loaded(&format!("tenant-{t}")))
3031 .count();
3032 assert!(
3033 still_loaded < TENANT_COUNT,
3034 "no tenants evicted ({still_loaded}/{TENANT_COUNT} still loaded) — \
3035 budget enforcement didn't engage"
3036 );
3037 }
3038
3039 #[test]
3040 fn test_evict_tenant_when_not_loaded_is_a_noop() {
3041 let store = EventStore::new();
3044 store.evict_tenant("nobody"); assert!(!store.is_tenant_loaded("nobody"));
3046 }
3047
3048 #[test]
3049 fn test_lazy_load_accounts_bytes_per_tenant() {
3050 let temp_dir = TempDir::new().unwrap();
3054 let storage_dir = temp_dir.path().to_path_buf();
3055
3056 {
3058 let store = EventStore::with_config(EventStoreConfig::with_persistence(&storage_dir));
3059 for i in 0..5 {
3060 store
3061 .ingest(
3062 &Event::from_strings(
3063 "test.event".to_string(),
3064 format!("a-{i}"),
3065 "alice".to_string(),
3066 serde_json::json!({"data": "x".repeat(1000)}),
3067 None,
3068 )
3069 .unwrap(),
3070 )
3071 .unwrap();
3072 }
3073 store.flush_storage().unwrap();
3074 }
3075
3076 let store = EventStore::with_config(EventStoreConfig::with_persistence(&storage_dir));
3077 assert_eq!(store.tenant_resident_bytes("alice"), 0);
3079 assert_eq!(store.cache_resident_bytes(), 0);
3080
3081 store.ensure_tenant_loaded("alice").unwrap();
3082
3083 let alice_bytes = store.tenant_resident_bytes("alice");
3086 assert!(
3087 alice_bytes >= 5 * 1000,
3088 "alice should have at least 5 KiB resident; got {alice_bytes}"
3089 );
3090 assert_eq!(store.tenant_resident_bytes("bob"), 0);
3092 assert_eq!(store.cache_resident_bytes(), alice_bytes);
3094 }
3095
3096 #[test]
3097 fn test_query_lazy_loads_tenant_on_first_call() {
3098 let temp_dir = TempDir::new().unwrap();
3102 let storage_dir = temp_dir.path().to_path_buf();
3103
3104 {
3106 let store = EventStore::with_config(EventStoreConfig::with_persistence(&storage_dir));
3107 for i in 0..3 {
3108 let event = Event::from_strings(
3109 "test.event".to_string(),
3110 format!("e-{i}"),
3111 "alice".to_string(),
3112 serde_json::json!({"i": i}),
3113 None,
3114 )
3115 .unwrap();
3116 store.ingest(&event).unwrap();
3117 }
3118 store.flush_storage().unwrap();
3119 }
3120
3121 let store = EventStore::with_config(EventStoreConfig::with_persistence(&storage_dir));
3123 assert_eq!(
3124 store.stats().total_events,
3125 0,
3126 "boot must be O(1) — no Parquet pre-load"
3127 );
3128 assert!(!store.is_tenant_loaded("alice"));
3129 assert!(!store.is_tenant_loaded("bob"));
3130
3131 let results = store
3133 .query(&QueryEventsRequest {
3134 entity_id: None,
3135 event_type: None,
3136 tenant_id: Some("alice".to_string()),
3137 as_of: None,
3138 since: None,
3139 until: None,
3140 limit: None,
3141 event_type_prefix: None,
3142 payload_filter: None,
3143 })
3144 .unwrap();
3145 assert_eq!(results.len(), 3, "alice's 3 events are returned");
3146 assert!(store.is_tenant_loaded("alice"), "alice now warm");
3147 assert!(!store.is_tenant_loaded("bob"), "bob still cold");
3150 }
3151
3152 #[test]
3153 fn test_query_invalid_tenant_id_returns_error_no_hang() {
3154 let temp_dir = TempDir::new().unwrap();
3158 let store = EventStore::with_config(EventStoreConfig::with_persistence(temp_dir.path()));
3159
3160 let result = store.query(&QueryEventsRequest {
3161 entity_id: None,
3162 event_type: None,
3163 tenant_id: Some("../etc".to_string()),
3164 as_of: None,
3165 since: None,
3166 until: None,
3167 limit: None,
3168 event_type_prefix: None,
3169 payload_filter: None,
3170 });
3171 assert!(result.is_err(), "unsafe tenant_id must surface as error");
3172 }
3173
3174 #[test]
3175 fn test_query_concurrent_first_queries_for_same_tenant_all_succeed() {
3176 let temp_dir = TempDir::new().unwrap();
3184 let storage_dir = temp_dir.path().to_path_buf();
3185
3186 {
3188 let store = EventStore::with_config(EventStoreConfig::with_persistence(&storage_dir));
3189 for i in 0..25 {
3190 let event = Event::from_strings(
3191 "test.event".to_string(),
3192 format!("e-{i}"),
3193 "alice".to_string(),
3194 serde_json::json!({"i": i}),
3195 None,
3196 )
3197 .unwrap();
3198 store.ingest(&event).unwrap();
3199 }
3200 store.flush_storage().unwrap();
3201 }
3202
3203 let store = Arc::new(EventStore::with_config(EventStoreConfig::with_persistence(
3205 &storage_dir,
3206 )));
3207 assert!(!store.is_tenant_loaded("alice"));
3208
3209 let mut handles = Vec::new();
3210 for _ in 0..8 {
3211 let s = store.clone();
3212 handles.push(std::thread::spawn(move || {
3213 s.query(&QueryEventsRequest {
3214 entity_id: None,
3215 event_type: None,
3216 tenant_id: Some("alice".to_string()),
3217 as_of: None,
3218 since: None,
3219 until: None,
3220 limit: None,
3221 event_type_prefix: None,
3222 payload_filter: None,
3223 })
3224 }));
3225 }
3226
3227 for h in handles {
3228 let result = h.join().unwrap().unwrap();
3229 assert_eq!(
3230 result.len(),
3231 25,
3232 "every concurrent caller must see all 25 events"
3233 );
3234 }
3235 assert!(store.is_tenant_loaded("alice"));
3236 assert_eq!(store.stats().total_events, 25);
3238 }
3239
3240 #[test]
3241 fn test_query_two_cold_tenants_load_independently() {
3242 let temp_dir = TempDir::new().unwrap();
3246 let storage_dir = temp_dir.path().to_path_buf();
3247
3248 {
3249 let store = EventStore::with_config(EventStoreConfig::with_persistence(&storage_dir));
3250 for i in 0..3 {
3251 store
3252 .ingest(
3253 &Event::from_strings(
3254 "test.event".to_string(),
3255 format!("a-{i}"),
3256 "alice".to_string(),
3257 serde_json::json!({"i": i}),
3258 None,
3259 )
3260 .unwrap(),
3261 )
3262 .unwrap();
3263 }
3264 for i in 0..5 {
3265 store
3266 .ingest(
3267 &Event::from_strings(
3268 "test.event".to_string(),
3269 format!("b-{i}"),
3270 "bob".to_string(),
3271 serde_json::json!({"i": i}),
3272 None,
3273 )
3274 .unwrap(),
3275 )
3276 .unwrap();
3277 }
3278 store.flush_storage().unwrap();
3279 }
3280
3281 let store = EventStore::with_config(EventStoreConfig::with_persistence(&storage_dir));
3282 assert_eq!(store.stats().total_events, 0);
3283
3284 let alice = store
3286 .query(&QueryEventsRequest {
3287 entity_id: None,
3288 event_type: None,
3289 tenant_id: Some("alice".to_string()),
3290 as_of: None,
3291 since: None,
3292 until: None,
3293 limit: None,
3294 event_type_prefix: None,
3295 payload_filter: None,
3296 })
3297 .unwrap();
3298 assert_eq!(alice.len(), 3);
3299 assert!(store.is_tenant_loaded("alice"));
3300 assert!(!store.is_tenant_loaded("bob"));
3301 assert_eq!(store.stats().total_events, 3);
3302
3303 let bob = store
3305 .query(&QueryEventsRequest {
3306 entity_id: None,
3307 event_type: None,
3308 tenant_id: Some("bob".to_string()),
3309 as_of: None,
3310 since: None,
3311 until: None,
3312 limit: None,
3313 event_type_prefix: None,
3314 payload_filter: None,
3315 })
3316 .unwrap();
3317 assert_eq!(bob.len(), 5);
3318 assert!(store.is_tenant_loaded("bob"));
3319 assert_eq!(store.stats().total_events, 8);
3320 }
3321
3322 #[test]
3323 fn test_boot_with_persisted_data_is_o1() {
3324 let temp_dir = TempDir::new().unwrap();
3337 let storage_dir = temp_dir.path().to_path_buf();
3338
3339 {
3340 let store = EventStore::with_config(EventStoreConfig::with_persistence(&storage_dir));
3341 for tenant in ["alice", "bob", "carol"] {
3342 for i in 0..50 / 3 {
3343 store
3344 .ingest(
3345 &Event::from_strings(
3346 "test.event".to_string(),
3347 format!("{tenant}-{i}"),
3348 tenant.to_string(),
3349 serde_json::json!({"i": i}),
3350 None,
3351 )
3352 .unwrap(),
3353 )
3354 .unwrap();
3355 }
3356 }
3357 store.flush_storage().unwrap();
3358 }
3359
3360 let on_disk = find_parquet_files(&storage_dir);
3362 assert!(
3363 !on_disk.is_empty(),
3364 "session 1 should have produced parquet files; pre-condition for the test"
3365 );
3366
3367 let started = std::time::Instant::now();
3368 let store = EventStore::with_config(EventStoreConfig::with_persistence(&storage_dir));
3369 let boot_elapsed = started.elapsed();
3370
3371 assert_eq!(
3372 store.stats().total_events,
3373 0,
3374 "boot must not pre-load any Parquet events"
3375 );
3376
3377 assert!(
3381 boot_elapsed < std::time::Duration::from_secs(2),
3382 "boot took {boot_elapsed:?} — Step 2 boot should be O(1)"
3383 );
3384 }
3385
3386 #[test]
3387 fn test_query_warm_tenant_does_not_re_read_disk() {
3388 let temp_dir = TempDir::new().unwrap();
3394 let storage_dir = temp_dir.path().to_path_buf();
3395
3396 let store = EventStore::with_config(EventStoreConfig::with_persistence(&storage_dir));
3397 for i in 0..3 {
3398 let event = Event::from_strings(
3399 "test.event".to_string(),
3400 format!("e-{i}"),
3401 "alice".to_string(),
3402 serde_json::json!({"i": i}),
3403 None,
3404 )
3405 .unwrap();
3406 store.ingest(&event).unwrap();
3407 }
3408 store.flush_storage().unwrap();
3409
3410 let _ = store
3412 .query(&QueryEventsRequest {
3413 entity_id: None,
3414 event_type: None,
3415 tenant_id: Some("alice".to_string()),
3416 as_of: None,
3417 since: None,
3418 until: None,
3419 limit: None,
3420 event_type_prefix: None,
3421 payload_filter: None,
3422 })
3423 .unwrap();
3424 assert!(store.is_tenant_loaded("alice"));
3425
3426 let parquet_files = find_parquet_files(&storage_dir);
3429 for f in parquet_files {
3430 std::fs::remove_file(&f).unwrap();
3431 }
3432
3433 let results = store
3434 .query(&QueryEventsRequest {
3435 entity_id: None,
3436 event_type: None,
3437 tenant_id: Some("alice".to_string()),
3438 as_of: None,
3439 since: None,
3440 until: None,
3441 limit: None,
3442 event_type_prefix: None,
3443 payload_filter: None,
3444 })
3445 .unwrap();
3446 assert_eq!(
3447 results.len(),
3448 3,
3449 "warm tenant query must not need disk; got {} events from a deleted parquet",
3450 results.len()
3451 );
3452 }
3453
3454 #[test]
3455 fn test_event_store_default() {
3456 let store = EventStore::default();
3457 assert_eq!(store.stats().total_events, 0);
3458 }
3459
3460 #[test]
3461 fn test_ingest_single_event() {
3462 let store = EventStore::new();
3463 let event = create_test_event("entity-1", "user.created");
3464
3465 store.ingest(&event).unwrap();
3466
3467 assert_eq!(store.stats().total_events, 1);
3468 assert_eq!(store.stats().total_ingested, 1);
3469 }
3470
3471 #[test]
3472 fn test_ingest_multiple_events() {
3473 let store = EventStore::new();
3474
3475 for i in 0..10 {
3476 let event = create_test_event(&format!("entity-{i}"), "user.created");
3477 store.ingest(&event).unwrap();
3478 }
3479
3480 assert_eq!(store.stats().total_events, 10);
3481 assert_eq!(store.stats().total_ingested, 10);
3482 }
3483
3484 #[test]
3485 fn test_query_by_entity_id() {
3486 let store = EventStore::new();
3487
3488 store
3489 .ingest(&create_test_event("entity-1", "user.created"))
3490 .unwrap();
3491 store
3492 .ingest(&create_test_event("entity-2", "user.created"))
3493 .unwrap();
3494 store
3495 .ingest(&create_test_event("entity-1", "user.updated"))
3496 .unwrap();
3497
3498 let results = store
3499 .query(&QueryEventsRequest {
3500 entity_id: Some("entity-1".to_string()),
3501 event_type: None,
3502 tenant_id: None,
3503 as_of: None,
3504 since: None,
3505 until: None,
3506 limit: None,
3507 event_type_prefix: None,
3508 payload_filter: None,
3509 })
3510 .unwrap();
3511
3512 assert_eq!(results.len(), 2);
3513 }
3514
3515 #[test]
3516 fn test_query_by_event_type() {
3517 let store = EventStore::new();
3518
3519 store
3520 .ingest(&create_test_event("entity-1", "user.created"))
3521 .unwrap();
3522 store
3523 .ingest(&create_test_event("entity-2", "user.updated"))
3524 .unwrap();
3525 store
3526 .ingest(&create_test_event("entity-3", "user.created"))
3527 .unwrap();
3528
3529 let results = store
3530 .query(&QueryEventsRequest {
3531 entity_id: None,
3532 event_type: Some("user.created".to_string()),
3533 tenant_id: None,
3534 as_of: None,
3535 since: None,
3536 until: None,
3537 limit: None,
3538 event_type_prefix: None,
3539 payload_filter: None,
3540 })
3541 .unwrap();
3542
3543 assert_eq!(results.len(), 2);
3544 }
3545
3546 #[test]
3547 fn test_query_with_limit() {
3548 let store = EventStore::new();
3549
3550 for i in 0..10 {
3551 let event = create_test_event(&format!("entity-{i}"), "user.created");
3552 store.ingest(&event).unwrap();
3553 }
3554
3555 let results = store
3556 .query(&QueryEventsRequest {
3557 entity_id: None,
3558 event_type: None,
3559 tenant_id: None,
3560 as_of: None,
3561 since: None,
3562 until: None,
3563 limit: Some(5),
3564 event_type_prefix: None,
3565 payload_filter: None,
3566 })
3567 .unwrap();
3568
3569 assert_eq!(results.len(), 5);
3570 }
3571
3572 #[test]
3573 fn test_query_empty_store() {
3574 let store = EventStore::new();
3575
3576 let results = store
3577 .query(&QueryEventsRequest {
3578 entity_id: Some("non-existent".to_string()),
3579 event_type: None,
3580 tenant_id: None,
3581 as_of: None,
3582 since: None,
3583 until: None,
3584 limit: None,
3585 event_type_prefix: None,
3586 payload_filter: None,
3587 })
3588 .unwrap();
3589
3590 assert!(results.is_empty());
3591 }
3592
3593 #[test]
3594 fn test_reconstruct_state() {
3595 let store = EventStore::new();
3596
3597 store
3598 .ingest(&create_test_event("entity-1", "user.created"))
3599 .unwrap();
3600
3601 let state = store.reconstruct_state("entity-1", None).unwrap();
3602 assert_eq!(state["current_state"]["name"], "Test");
3604 assert_eq!(state["current_state"]["value"], 42);
3605 }
3606
3607 #[test]
3608 fn test_reconstruct_state_not_found() {
3609 let store = EventStore::new();
3610
3611 let result = store.reconstruct_state("non-existent", None);
3612 assert!(result.is_err());
3613 }
3614
3615 #[test]
3616 fn test_get_snapshot_empty() {
3617 let store = EventStore::new();
3618
3619 let result = store.get_snapshot("non-existent");
3620 assert!(result.is_err());
3622 }
3623
3624 #[test]
3625 fn test_create_snapshot() {
3626 let store = EventStore::new();
3627
3628 store
3629 .ingest(&create_test_event("entity-1", "user.created"))
3630 .unwrap();
3631
3632 store.create_snapshot("entity-1").unwrap();
3633
3634 let snapshot = store.get_snapshot("entity-1").unwrap();
3636 assert!(snapshot != serde_json::json!(null));
3637 }
3638
3639 #[test]
3640 fn test_create_snapshot_entity_not_found() {
3641 let store = EventStore::new();
3642
3643 let result = store.create_snapshot("non-existent");
3644 assert!(result.is_err());
3645 }
3646
3647 #[test]
3648 fn test_websocket_manager() {
3649 let store = EventStore::new();
3650 let manager = store.websocket_manager();
3651 assert!(Arc::strong_count(&manager) >= 1);
3653 }
3654
3655 #[test]
3656 fn test_snapshot_manager() {
3657 let store = EventStore::new();
3658 let manager = store.snapshot_manager();
3659 assert!(Arc::strong_count(&manager) >= 1);
3660 }
3661
3662 #[test]
3663 fn test_compaction_manager_none() {
3664 let store = EventStore::new();
3665 assert!(store.compaction_manager().is_none());
3667 }
3668
3669 #[test]
3670 fn test_schema_registry() {
3671 let store = EventStore::new();
3672 let registry = store.schema_registry();
3673 assert!(Arc::strong_count(®istry) >= 1);
3674 }
3675
3676 #[test]
3677 fn test_replay_manager() {
3678 let store = EventStore::new();
3679 let manager = store.replay_manager();
3680 assert!(Arc::strong_count(&manager) >= 1);
3681 }
3682
3683 #[test]
3684 fn test_pipeline_manager() {
3685 let store = EventStore::new();
3686 let manager = store.pipeline_manager();
3687 assert!(Arc::strong_count(&manager) >= 1);
3688 }
3689
3690 #[test]
3691 fn test_projection_manager() {
3692 let store = EventStore::new();
3693 let manager = store.projection_manager();
3694 let projections = manager.list_projections();
3696 assert!(projections.len() >= 2); }
3698
3699 #[test]
3700 fn test_projection_state_cache() {
3701 let store = EventStore::new();
3702 let cache = store.projection_state_cache();
3703
3704 cache.insert("test:key".to_string(), serde_json::json!({"value": 123}));
3705 assert_eq!(cache.len(), 1);
3706
3707 let value = cache.get("test:key").unwrap();
3708 assert_eq!(value["value"], 123);
3709 }
3710
3711 #[test]
3712 fn test_metrics() {
3713 let store = EventStore::new();
3714 let metrics = store.metrics();
3715 assert!(Arc::strong_count(&metrics) >= 1);
3716 }
3717
3718 #[test]
3719 fn test_store_stats() {
3720 let store = EventStore::new();
3721
3722 store
3723 .ingest(&create_test_event("entity-1", "user.created"))
3724 .unwrap();
3725 store
3726 .ingest(&create_test_event("entity-2", "order.placed"))
3727 .unwrap();
3728
3729 let stats = store.stats();
3730 assert_eq!(stats.total_events, 2);
3731 assert_eq!(stats.total_entities, 2);
3732 assert_eq!(stats.total_event_types, 2);
3733 assert_eq!(stats.total_ingested, 2);
3734 }
3735
3736 #[test]
3737 fn test_event_store_config_default() {
3738 let config = EventStoreConfig::default();
3739 assert!(config.storage_dir.is_none());
3740 assert!(config.wal_dir.is_none());
3741 }
3742
3743 #[test]
3744 fn test_event_store_config_with_persistence() {
3745 let temp_dir = TempDir::new().unwrap();
3746 let config = EventStoreConfig::with_persistence(temp_dir.path());
3747
3748 assert!(config.storage_dir.is_some());
3749 assert!(config.wal_dir.is_none());
3750 }
3751
3752 #[test]
3753 fn test_event_store_config_with_wal() {
3754 let temp_dir = TempDir::new().unwrap();
3755 let config = EventStoreConfig::with_wal(temp_dir.path(), WALConfig::default());
3756
3757 assert!(config.storage_dir.is_none());
3758 assert!(config.wal_dir.is_some());
3759 }
3760
3761 #[test]
3762 fn test_event_store_config_with_all() {
3763 let temp_dir = TempDir::new().unwrap();
3764 let config = EventStoreConfig::with_all(temp_dir.path(), SnapshotConfig::default());
3765
3766 assert!(config.storage_dir.is_some());
3767 }
3768
3769 #[test]
3770 fn test_event_store_config_production() {
3771 let storage_dir = TempDir::new().unwrap();
3772 let wal_dir = TempDir::new().unwrap();
3773 let config = EventStoreConfig::production(
3774 storage_dir.path(),
3775 wal_dir.path(),
3776 SnapshotConfig::default(),
3777 WALConfig::default(),
3778 CompactionConfig::default(),
3779 );
3780
3781 assert!(config.storage_dir.is_some());
3782 assert!(config.wal_dir.is_some());
3783 }
3784
3785 #[test]
3791 fn test_from_env_vars_data_dir_enables_full_persistence() {
3792 let (config, mode) = EventStoreConfig::from_env_vars(
3793 Some("/app/data".to_string()),
3794 None,
3795 None,
3796 None,
3797 None,
3798 None,
3799 None,
3800 None,
3801 );
3802 assert_eq!(mode, "wal+parquet");
3803 assert_eq!(
3804 config.storage_dir.unwrap().to_str().unwrap(),
3805 "/app/data/storage"
3806 );
3807 assert_eq!(config.wal_dir.unwrap().to_str().unwrap(), "/app/data/wal");
3808 }
3809
3810 #[test]
3811 fn test_from_env_vars_explicit_dirs() {
3812 let (config, mode) = EventStoreConfig::from_env_vars(
3813 None,
3814 Some("/custom/storage".to_string()),
3815 Some("/custom/wal".to_string()),
3816 None,
3817 None,
3818 None,
3819 None,
3820 None,
3821 );
3822 assert_eq!(mode, "wal+parquet");
3823 assert_eq!(
3824 config.storage_dir.unwrap().to_str().unwrap(),
3825 "/custom/storage"
3826 );
3827 assert_eq!(config.wal_dir.unwrap().to_str().unwrap(), "/custom/wal");
3828 }
3829
3830 #[test]
3831 fn test_from_env_vars_wal_disabled() {
3832 let (config, mode) = EventStoreConfig::from_env_vars(
3833 Some("/app/data".to_string()),
3834 None,
3835 None,
3836 Some("false".to_string()),
3837 None,
3838 None,
3839 None,
3840 None,
3841 );
3842 assert_eq!(mode, "parquet-only");
3843 assert!(config.storage_dir.is_some());
3844 assert!(config.wal_dir.is_none());
3845 }
3846
3847 #[test]
3848 fn test_from_env_vars_no_dirs_is_in_memory() {
3849 let (config, mode) =
3850 EventStoreConfig::from_env_vars(None, None, None, None, None, None, None, None);
3851 assert_eq!(mode, "in-memory");
3852 assert!(config.storage_dir.is_none());
3853 assert!(config.wal_dir.is_none());
3854 }
3855
3856 #[test]
3857 fn test_from_env_vars_empty_strings_treated_as_none() {
3858 let (_, mode) = EventStoreConfig::from_env_vars(
3859 Some(String::new()),
3860 Some(String::new()),
3861 Some(String::new()),
3862 None,
3863 None,
3864 None,
3865 None,
3866 None,
3867 );
3868 assert_eq!(mode, "in-memory");
3869 }
3870
3871 #[test]
3872 fn test_from_env_vars_explicit_overrides_data_dir() {
3873 let (config, mode) = EventStoreConfig::from_env_vars(
3874 Some("/app/data".to_string()),
3875 Some("/override/storage".to_string()),
3876 Some("/override/wal".to_string()),
3877 None,
3878 None,
3879 None,
3880 None,
3881 None,
3882 );
3883 assert_eq!(mode, "wal+parquet");
3884 assert_eq!(
3885 config.storage_dir.unwrap().to_str().unwrap(),
3886 "/override/storage"
3887 );
3888 assert_eq!(config.wal_dir.unwrap().to_str().unwrap(), "/override/wal");
3889 }
3890
3891 #[test]
3892 fn test_from_env_vars_wal_only() {
3893 let (config, mode) = EventStoreConfig::from_env_vars(
3894 None,
3895 None,
3896 Some("/wal/only".to_string()),
3897 None,
3898 None,
3899 None,
3900 None,
3901 None,
3902 );
3903 assert_eq!(mode, "wal-only");
3904 assert!(config.storage_dir.is_none());
3905 assert_eq!(config.wal_dir.unwrap().to_str().unwrap(), "/wal/only");
3906 }
3907
3908 #[test]
3909 fn test_from_env_vars_cache_bytes_parses_decimal() {
3910 let (config, _) = EventStoreConfig::from_env_vars(
3911 Some("/app/data".to_string()),
3912 None,
3913 None,
3914 None,
3915 Some("536870912".to_string()),
3916 None,
3918 None,
3919 None,
3920 );
3921 assert_eq!(config.cache_byte_budget, Some(536_870_912));
3922 }
3923
3924 #[test]
3925 fn test_from_env_vars_cache_bytes_unparseable_disables_budget() {
3926 let (config, _) = EventStoreConfig::from_env_vars(
3930 Some("/app/data".to_string()),
3931 None,
3932 None,
3933 None,
3934 Some("not-a-number".to_string()),
3935 None,
3936 None,
3937 None,
3938 );
3939 assert_eq!(config.cache_byte_budget, None);
3940 }
3941
3942 #[test]
3943 fn test_from_env_vars_cache_bytes_empty_disables_budget() {
3944 let (config, _) = EventStoreConfig::from_env_vars(
3945 Some("/app/data".to_string()),
3946 None,
3947 None,
3948 None,
3949 Some(String::new()),
3950 None,
3951 None,
3952 None,
3953 );
3954 assert_eq!(config.cache_byte_budget, None);
3955 }
3956
3957 #[test]
3958 fn test_from_env_vars_snapshot_interval_overrides_default() {
3959 let (config, _) = EventStoreConfig::from_env_vars(
3963 Some("/app/data".to_string()),
3964 None,
3965 None,
3966 None,
3967 None,
3968 Some("60".to_string()),
3969 None,
3970 None,
3971 );
3972 assert_eq!(config.compaction_config.compaction_interval_seconds, 60);
3973 }
3974
3975 #[test]
3976 fn test_from_env_vars_snapshot_interval_default_is_hourly() {
3977 let (config, _) = EventStoreConfig::from_env_vars(
3978 Some("/app/data".to_string()),
3979 None,
3980 None,
3981 None,
3982 None,
3983 None,
3984 None,
3985 None,
3986 );
3987 assert_eq!(config.compaction_config.compaction_interval_seconds, 3600);
3988 }
3989
3990 #[test]
3991 fn test_from_env_vars_snapshot_interval_unparseable_falls_back() {
3992 let (config, _) = EventStoreConfig::from_env_vars(
3993 Some("/app/data".to_string()),
3994 None,
3995 None,
3996 None,
3997 None,
3998 Some("not-a-number".to_string()),
3999 None,
4000 None,
4001 );
4002 assert_eq!(config.compaction_config.compaction_interval_seconds, 3600);
4003 }
4004
4005 #[test]
4006 fn test_from_env_vars_retention_system_days_overrides_default() {
4007 let (config, _) = EventStoreConfig::from_env_vars(
4010 Some("/app/data".to_string()),
4011 None,
4012 None,
4013 None,
4014 None,
4015 None,
4016 Some("7".to_string()),
4017 None,
4018 );
4019 let ttl = config
4020 .compaction_config
4021 .retention
4022 .ttl_for("system")
4023 .unwrap();
4024 assert_eq!(ttl.as_secs(), 7 * 24 * 3600);
4025 }
4026
4027 #[test]
4028 fn test_from_env_vars_retention_default_is_30_days_for_system() {
4029 let (config, _) = EventStoreConfig::from_env_vars(
4030 Some("/app/data".to_string()),
4031 None,
4032 None,
4033 None,
4034 None,
4035 None,
4036 None,
4037 None,
4038 );
4039 let ttl = config
4040 .compaction_config
4041 .retention
4042 .ttl_for("system")
4043 .unwrap();
4044 assert_eq!(ttl.as_secs(), 30 * 24 * 3600);
4045 assert!(config.compaction_config.retention.ttl_for("acme").is_none());
4047 }
4048
4049 #[test]
4050 fn test_store_stats_serde() {
4051 let stats = StoreStats {
4052 total_events: 100,
4053 total_entities: 50,
4054 total_event_types: 10,
4055 total_ingested: 100,
4056 };
4057
4058 let json = serde_json::to_string(&stats).unwrap();
4059 assert!(json.contains("\"total_events\":100"));
4060 assert!(json.contains("\"total_entities\":50"));
4061 }
4062
4063 #[test]
4064 fn test_query_with_entity_and_type() {
4065 let store = EventStore::new();
4066
4067 store
4068 .ingest(&create_test_event("entity-1", "user.created"))
4069 .unwrap();
4070 store
4071 .ingest(&create_test_event("entity-1", "user.updated"))
4072 .unwrap();
4073 store
4074 .ingest(&create_test_event("entity-2", "user.created"))
4075 .unwrap();
4076
4077 let results = store
4078 .query(&QueryEventsRequest {
4079 entity_id: Some("entity-1".to_string()),
4080 event_type: Some("user.created".to_string()),
4081 tenant_id: None,
4082 as_of: None,
4083 since: None,
4084 until: None,
4085 limit: None,
4086 event_type_prefix: None,
4087 payload_filter: None,
4088 })
4089 .unwrap();
4090
4091 assert_eq!(results.len(), 1);
4092 assert_eq!(results[0].event_type_str(), "user.created");
4093 }
4094
4095 #[test]
4096 fn test_query_by_event_type_prefix() {
4097 let store = EventStore::new();
4098
4099 store
4101 .ingest(&create_test_event("entity-1", "index.created"))
4102 .unwrap();
4103 store
4104 .ingest(&create_test_event("entity-2", "index.updated"))
4105 .unwrap();
4106 store
4107 .ingest(&create_test_event("entity-3", "trade.created"))
4108 .unwrap();
4109 store
4110 .ingest(&create_test_event("entity-4", "trade.completed"))
4111 .unwrap();
4112 store
4113 .ingest(&create_test_event("entity-5", "balance.updated"))
4114 .unwrap();
4115
4116 let results = store
4118 .query(&QueryEventsRequest {
4119 entity_id: None,
4120 event_type: None,
4121 tenant_id: None,
4122 as_of: None,
4123 since: None,
4124 until: None,
4125 limit: None,
4126 event_type_prefix: Some("index.".to_string()),
4127 payload_filter: None,
4128 })
4129 .unwrap();
4130
4131 assert_eq!(results.len(), 2);
4132 assert!(
4133 results
4134 .iter()
4135 .all(|e| e.event_type_str().starts_with("index."))
4136 );
4137 }
4138
4139 #[test]
4140 fn test_query_by_event_type_prefix_empty_returns_all() {
4141 let store = EventStore::new();
4142
4143 store
4144 .ingest(&create_test_event("entity-1", "index.created"))
4145 .unwrap();
4146 store
4147 .ingest(&create_test_event("entity-2", "trade.created"))
4148 .unwrap();
4149
4150 let results = store
4152 .query(&QueryEventsRequest {
4153 entity_id: None,
4154 event_type: None,
4155 tenant_id: None,
4156 as_of: None,
4157 since: None,
4158 until: None,
4159 limit: None,
4160 event_type_prefix: Some(String::new()),
4161 payload_filter: None,
4162 })
4163 .unwrap();
4164
4165 assert_eq!(results.len(), 2);
4166 }
4167
4168 #[test]
4169 fn test_query_by_event_type_prefix_no_match() {
4170 let store = EventStore::new();
4171
4172 store
4173 .ingest(&create_test_event("entity-1", "index.created"))
4174 .unwrap();
4175
4176 let results = store
4177 .query(&QueryEventsRequest {
4178 entity_id: None,
4179 event_type: None,
4180 tenant_id: None,
4181 as_of: None,
4182 since: None,
4183 until: None,
4184 limit: None,
4185 event_type_prefix: Some("nonexistent.".to_string()),
4186 payload_filter: None,
4187 })
4188 .unwrap();
4189
4190 assert!(results.is_empty());
4191 }
4192
4193 #[test]
4194 fn test_query_by_entity_with_type_prefix() {
4195 let store = EventStore::new();
4196
4197 store
4198 .ingest(&create_test_event("entity-1", "index.created"))
4199 .unwrap();
4200 store
4201 .ingest(&create_test_event("entity-1", "trade.created"))
4202 .unwrap();
4203 store
4204 .ingest(&create_test_event("entity-2", "index.updated"))
4205 .unwrap();
4206
4207 let results = store
4209 .query(&QueryEventsRequest {
4210 entity_id: Some("entity-1".to_string()),
4211 event_type: None,
4212 tenant_id: None,
4213 as_of: None,
4214 since: None,
4215 until: None,
4216 limit: None,
4217 event_type_prefix: Some("index.".to_string()),
4218 payload_filter: None,
4219 })
4220 .unwrap();
4221
4222 assert_eq!(results.len(), 1);
4223 assert_eq!(results[0].event_type_str(), "index.created");
4224 }
4225
4226 #[test]
4227 fn test_query_prefix_with_limit() {
4228 let store = EventStore::new();
4229
4230 for i in 0..5 {
4231 store
4232 .ingest(&create_test_event(&format!("entity-{i}"), "index.created"))
4233 .unwrap();
4234 }
4235
4236 let results = store
4237 .query(&QueryEventsRequest {
4238 entity_id: None,
4239 event_type: None,
4240 tenant_id: None,
4241 as_of: None,
4242 since: None,
4243 until: None,
4244 limit: Some(3),
4245 event_type_prefix: Some("index.".to_string()),
4246 payload_filter: None,
4247 })
4248 .unwrap();
4249
4250 assert_eq!(results.len(), 3);
4251 }
4252
4253 #[test]
4254 fn test_query_prefix_alongside_existing_filters() {
4255 let store = EventStore::new();
4256
4257 store
4258 .ingest(&create_test_event("entity-1", "index.created"))
4259 .unwrap();
4260 std::thread::sleep(std::time::Duration::from_millis(10));
4262 store
4263 .ingest(&create_test_event("entity-2", "index.strategy.updated"))
4264 .unwrap();
4265 std::thread::sleep(std::time::Duration::from_millis(10));
4266 store
4267 .ingest(&create_test_event("entity-3", "index.deleted"))
4268 .unwrap();
4269
4270 let results = store
4272 .query(&QueryEventsRequest {
4273 entity_id: None,
4274 event_type: None,
4275 tenant_id: None,
4276 as_of: None,
4277 since: None,
4278 until: None,
4279 limit: Some(2),
4280 event_type_prefix: Some("index.".to_string()),
4281 payload_filter: None,
4282 })
4283 .unwrap();
4284
4285 assert_eq!(results.len(), 2);
4286 }
4287
4288 #[test]
4289 fn test_query_with_payload_filter() {
4290 let store = EventStore::new();
4291
4292 for i in 0..5 {
4294 store
4295 .ingest(&create_test_event_with_payload(
4296 &format!("entity-{i}"),
4297 "user.action",
4298 serde_json::json!({"user_id": "alice", "action": "click"}),
4299 ))
4300 .unwrap();
4301 }
4302 for i in 5..10 {
4304 store
4305 .ingest(&create_test_event_with_payload(
4306 &format!("entity-{i}"),
4307 "user.action",
4308 serde_json::json!({"user_id": "bob", "action": "view"}),
4309 ))
4310 .unwrap();
4311 }
4312
4313 let results = store
4315 .query(&QueryEventsRequest {
4316 entity_id: None,
4317 event_type: Some("user.action".to_string()),
4318 tenant_id: None,
4319 as_of: None,
4320 since: None,
4321 until: None,
4322 limit: None,
4323 event_type_prefix: None,
4324 payload_filter: Some(r#"{"user_id":"alice"}"#.to_string()),
4325 })
4326 .unwrap();
4327
4328 assert_eq!(results.len(), 5);
4329 }
4330
4331 #[test]
4332 fn test_query_payload_filter_non_existent_field() {
4333 let store = EventStore::new();
4334
4335 store
4336 .ingest(&create_test_event_with_payload(
4337 "entity-1",
4338 "user.action",
4339 serde_json::json!({"user_id": "alice"}),
4340 ))
4341 .unwrap();
4342
4343 let results = store
4345 .query(&QueryEventsRequest {
4346 entity_id: None,
4347 event_type: None,
4348 tenant_id: None,
4349 as_of: None,
4350 since: None,
4351 until: None,
4352 limit: None,
4353 event_type_prefix: None,
4354 payload_filter: Some(r#"{"nonexistent":"value"}"#.to_string()),
4355 })
4356 .unwrap();
4357
4358 assert!(results.is_empty());
4359 }
4360
4361 #[test]
4362 fn test_query_payload_filter_with_prefix() {
4363 let store = EventStore::new();
4364
4365 store
4366 .ingest(&create_test_event_with_payload(
4367 "entity-1",
4368 "index.created",
4369 serde_json::json!({"status": "active"}),
4370 ))
4371 .unwrap();
4372 store
4373 .ingest(&create_test_event_with_payload(
4374 "entity-2",
4375 "index.created",
4376 serde_json::json!({"status": "inactive"}),
4377 ))
4378 .unwrap();
4379 store
4380 .ingest(&create_test_event_with_payload(
4381 "entity-3",
4382 "trade.created",
4383 serde_json::json!({"status": "active"}),
4384 ))
4385 .unwrap();
4386
4387 let results = store
4389 .query(&QueryEventsRequest {
4390 entity_id: None,
4391 event_type: None,
4392 tenant_id: None,
4393 as_of: None,
4394 since: None,
4395 until: None,
4396 limit: None,
4397 event_type_prefix: Some("index.".to_string()),
4398 payload_filter: Some(r#"{"status":"active"}"#.to_string()),
4399 })
4400 .unwrap();
4401
4402 assert_eq!(results.len(), 1);
4403 assert_eq!(results[0].entity_id().to_string(), "entity-1");
4404 }
4405
4406 #[test]
4407 fn test_flush_storage_no_storage() {
4408 let store = EventStore::new();
4409 let result = store.flush_storage();
4411 assert!(result.is_ok());
4412 }
4413
4414 #[test]
4415 fn test_state_evolution() {
4416 let store = EventStore::new();
4417
4418 store
4420 .ingest(
4421 &Event::from_strings(
4422 "user.created".to_string(),
4423 "user-1".to_string(),
4424 "default".to_string(),
4425 serde_json::json!({"name": "Alice", "age": 25}),
4426 None,
4427 )
4428 .unwrap(),
4429 )
4430 .unwrap();
4431
4432 store
4434 .ingest(
4435 &Event::from_strings(
4436 "user.updated".to_string(),
4437 "user-1".to_string(),
4438 "default".to_string(),
4439 serde_json::json!({"age": 26}),
4440 None,
4441 )
4442 .unwrap(),
4443 )
4444 .unwrap();
4445
4446 let state = store.reconstruct_state("user-1", None).unwrap();
4447 assert_eq!(state["current_state"]["name"], "Alice");
4449 assert_eq!(state["current_state"]["age"], 26);
4450 }
4451
4452 #[test]
4453 fn test_reject_system_event_types() {
4454 let store = EventStore::new();
4455
4456 let event = Event::reconstruct_from_strings(
4458 uuid::Uuid::new_v4(),
4459 "_system.tenant.created".to_string(),
4460 "_system:tenant:acme".to_string(),
4461 "_system".to_string(),
4462 serde_json::json!({"name": "ACME"}),
4463 chrono::Utc::now(),
4464 None,
4465 1,
4466 );
4467
4468 let result = store.ingest(&event);
4469 assert!(result.is_err());
4470 let err = result.unwrap_err();
4471 assert!(
4472 err.to_string().contains("reserved for internal use"),
4473 "Expected system namespace rejection, got: {err}"
4474 );
4475 }
4476
4477 #[test]
4485 fn test_wal_recovery_checkpoints_to_parquet() {
4486 let data_dir = TempDir::new().unwrap();
4487 let storage_dir = data_dir.path().join("storage");
4488 let wal_dir = data_dir.path().join("wal");
4489
4490 {
4492 let config = EventStoreConfig::production(
4493 &storage_dir,
4494 &wal_dir,
4495 SnapshotConfig::default(),
4496 WALConfig {
4497 sync_on_write: true,
4498 ..WALConfig::default()
4499 },
4500 CompactionConfig::default(),
4501 );
4502 let store = EventStore::with_config(config);
4503
4504 for i in 0..5 {
4505 let event = Event::from_strings(
4506 "test.created".to_string(),
4507 format!("entity-{i}"),
4508 "default".to_string(),
4509 serde_json::json!({"index": i}),
4510 None,
4511 )
4512 .unwrap();
4513 store.ingest(&event).unwrap();
4514 }
4515
4516 assert_eq!(store.stats().total_events, 5);
4517
4518 }
4521
4522 let wal_files: Vec<_> = std::fs::read_dir(&wal_dir)
4524 .unwrap()
4525 .filter_map(std::result::Result::ok)
4526 .filter(|e| e.path().extension().is_some_and(|ext| ext == "log"))
4527 .collect();
4528 assert!(!wal_files.is_empty(), "WAL file should exist");
4529 let wal_size = wal_files[0].metadata().unwrap().len();
4530 assert!(wal_size > 0, "WAL file should have data (got 0 bytes)");
4531
4532 {
4534 let config = EventStoreConfig::production(
4535 &storage_dir,
4536 &wal_dir,
4537 SnapshotConfig::default(),
4538 WALConfig {
4539 sync_on_write: true,
4540 ..WALConfig::default()
4541 },
4542 CompactionConfig::default(),
4543 );
4544 let store = EventStore::with_config(config);
4545
4546 assert_eq!(
4548 store.stats().total_events,
4549 5,
4550 "Session 2 should have all 5 events after WAL recovery"
4551 );
4552
4553 let parquet_files = find_parquet_files(&storage_dir);
4557 assert!(
4558 !parquet_files.is_empty(),
4559 "Parquet file should exist after WAL checkpoint"
4560 );
4561 }
4562
4563 {
4566 let config = EventStoreConfig::production(
4567 &storage_dir,
4568 &wal_dir,
4569 SnapshotConfig::default(),
4570 WALConfig {
4571 sync_on_write: true,
4572 ..WALConfig::default()
4573 },
4574 CompactionConfig::default(),
4575 );
4576 let store = EventStore::with_config(config);
4577
4578 assert_eq!(
4582 store.stats().total_events,
4583 0,
4584 "Session 3 boot should not pre-load Parquet (lazy-load mode)"
4585 );
4586
4587 store.ensure_tenant_loaded("default").unwrap();
4590 assert_eq!(
4591 store.stats().total_events,
4592 5,
4593 "Session 3 should have all 5 events after ensure_tenant_loaded"
4594 );
4595 }
4596 }
4597
4598 #[test]
4599 fn test_parquet_restore_surfaces_errors_not_silent() {
4600 let data_dir = TempDir::new().unwrap();
4604 let storage_dir = data_dir.path().join("storage");
4605 let wal_dir = data_dir.path().join("wal");
4606
4607 {
4609 let config = EventStoreConfig::production(
4610 &storage_dir,
4611 &wal_dir,
4612 SnapshotConfig::default(),
4613 WALConfig {
4614 sync_on_write: true,
4615 ..WALConfig::default()
4616 },
4617 CompactionConfig::default(),
4618 );
4619 let store = EventStore::with_config(config);
4620
4621 for i in 0..3 {
4622 let event = Event::from_strings(
4623 "test.created".to_string(),
4624 format!("entity-{i}"),
4625 "default".to_string(),
4626 serde_json::json!({"i": i}),
4627 None,
4628 )
4629 .unwrap();
4630 store.ingest(&event).unwrap();
4631 }
4632
4633 store.flush_storage().unwrap();
4634 assert_eq!(store.stats().total_events, 3);
4635 }
4636
4637 let parquet_files = find_parquet_files(&storage_dir);
4640 assert!(!parquet_files.is_empty(), "Parquet file must exist");
4641
4642 std::fs::write(&parquet_files[0], b"corrupted data").unwrap();
4644
4645 for entry in std::fs::read_dir(&wal_dir).unwrap().flatten() {
4647 std::fs::write(entry.path(), b"").unwrap();
4648 }
4649
4650 {
4657 let config = EventStoreConfig::production(
4658 &storage_dir,
4659 &wal_dir,
4660 SnapshotConfig::default(),
4661 WALConfig::default(),
4662 CompactionConfig::default(),
4663 );
4664 let store = EventStore::with_config(config);
4665
4666 assert_eq!(store.stats().total_events, 0);
4669 }
4670 }
4671
4672 fn count_wal_entries(wal_dir: &std::path::Path) -> usize {
4682 use std::io::{BufRead, BufReader};
4683 let mut total = 0usize;
4684 let Ok(entries) = std::fs::read_dir(wal_dir) else {
4685 return 0;
4686 };
4687 for entry in entries.flatten() {
4688 let path = entry.path();
4689 if path.extension().is_none_or(|e| e != "log") {
4690 continue;
4691 }
4692 let Ok(file) = std::fs::File::open(&path) else {
4693 continue;
4694 };
4695 for line in BufReader::new(file)
4696 .lines()
4697 .map_while(std::result::Result::ok)
4698 {
4699 if !line.trim().is_empty() {
4700 total += 1;
4701 }
4702 }
4703 }
4704 total
4705 }
4706
4707 #[test]
4708 fn test_checkpoint_truncates_wal_after_flush() {
4709 let data_dir = TempDir::new().unwrap();
4714 let storage_dir = data_dir.path().join("storage");
4715 let wal_dir = data_dir.path().join("wal");
4716
4717 let config = EventStoreConfig::production(
4718 &storage_dir,
4719 &wal_dir,
4720 SnapshotConfig::default(),
4721 WALConfig {
4722 sync_on_write: true,
4723 ..WALConfig::default()
4724 },
4725 CompactionConfig::default(),
4726 );
4727 let store = EventStore::with_config(config);
4728
4729 for i in 0..10 {
4730 let event = Event::from_strings(
4731 "test.created".to_string(),
4732 format!("entity-{i}"),
4733 "default".to_string(),
4734 serde_json::json!({"i": i}),
4735 None,
4736 )
4737 .unwrap();
4738 store.ingest(&event).unwrap();
4739 }
4740
4741 assert_eq!(
4743 count_wal_entries(&wal_dir),
4744 10,
4745 "WAL should have 10 events before checkpoint"
4746 );
4747
4748 store.checkpoint().unwrap();
4749
4750 assert_eq!(
4751 count_wal_entries(&wal_dir),
4752 0,
4753 "WAL should be empty after successful checkpoint"
4754 );
4755 let parquet_files = find_parquet_files(&storage_dir);
4756 assert!(!parquet_files.is_empty(), "Parquet should hold the events");
4757 }
4758
4759 #[test]
4760 fn test_replay_only_post_checkpoint_events_after_crash() {
4761 let data_dir = TempDir::new().unwrap();
4768 let storage_dir = data_dir.path().join("storage");
4769 let wal_dir = data_dir.path().join("wal");
4770
4771 let config_factory = || {
4772 EventStoreConfig::production(
4773 &storage_dir,
4774 &wal_dir,
4775 SnapshotConfig::default(),
4776 WALConfig {
4777 sync_on_write: true,
4778 ..WALConfig::default()
4779 },
4780 CompactionConfig::default(),
4781 )
4782 };
4783
4784 const N: usize = 50;
4787 const K: usize = 5;
4788 {
4789 let store = EventStore::with_config(config_factory());
4790 for i in 0..N {
4791 store
4792 .ingest(
4793 &Event::from_strings(
4794 "pre.checkpoint".to_string(),
4795 format!("e-{i}"),
4796 "default".to_string(),
4797 serde_json::json!({"i": i}),
4798 None,
4799 )
4800 .unwrap(),
4801 )
4802 .unwrap();
4803 }
4804 store.checkpoint().unwrap();
4805 assert_eq!(
4806 count_wal_entries(&wal_dir),
4807 0,
4808 "WAL should be empty immediately after checkpoint"
4809 );
4810
4811 for i in 0..K {
4812 store
4813 .ingest(
4814 &Event::from_strings(
4815 "post.checkpoint".to_string(),
4816 format!("p-{i}"),
4817 "default".to_string(),
4818 serde_json::json!({"i": i}),
4819 None,
4820 )
4821 .unwrap(),
4822 )
4823 .unwrap();
4824 }
4825 assert_eq!(
4826 count_wal_entries(&wal_dir),
4827 K,
4828 "WAL should hold only post-checkpoint events"
4829 );
4830 }
4832
4833 {
4837 let store = EventStore::with_config(config_factory());
4838 assert_eq!(
4842 store.stats().total_events,
4843 K,
4844 "Boot should replay exactly K events from WAL (the post-checkpoint window), not N+K"
4845 );
4846
4847 store.ensure_tenant_loaded("default").unwrap();
4849 assert_eq!(
4850 store.stats().total_events,
4851 N + K,
4852 "After lazy-load, both pre- and post-checkpoint events should be reachable"
4853 );
4854 }
4855 }
4856
4857 #[test]
4858 fn test_checkpoint_is_idempotent() {
4859 let data_dir = TempDir::new().unwrap();
4862 let storage_dir = data_dir.path().join("storage");
4863 let wal_dir = data_dir.path().join("wal");
4864
4865 let store = EventStore::with_config(EventStoreConfig::production(
4866 &storage_dir,
4867 &wal_dir,
4868 SnapshotConfig::default(),
4869 WALConfig::default(),
4870 CompactionConfig::default(),
4871 ));
4872
4873 for i in 0..5 {
4874 store
4875 .ingest(
4876 &Event::from_strings(
4877 "x".to_string(),
4878 format!("e-{i}"),
4879 "default".to_string(),
4880 serde_json::json!({}),
4881 None,
4882 )
4883 .unwrap(),
4884 )
4885 .unwrap();
4886 }
4887
4888 store.checkpoint().unwrap();
4889 store.checkpoint().unwrap();
4891 assert_eq!(count_wal_entries(&wal_dir), 0);
4892 }
4893
4894 #[test]
4895 fn test_checkpoint_noop_in_memory_only_mode() {
4896 let store = EventStore::new();
4898 store.checkpoint().unwrap();
4899 }
4900
4901 #[test]
4902 fn test_checkpoint_interval_from_env_defaults_to_60s_when_wal_enabled() {
4903 let (config, _) = EventStoreConfig::from_env_vars(
4904 Some("/app/data".to_string()),
4905 None,
4906 None,
4907 None,
4908 None,
4909 None,
4910 None,
4911 None,
4912 );
4913 assert_eq!(config.checkpoint_interval_secs, Some(60));
4914 }
4915
4916 #[test]
4917 fn test_checkpoint_interval_from_env_overrides_default() {
4918 let (config, _) = EventStoreConfig::from_env_vars(
4919 Some("/app/data".to_string()),
4920 None,
4921 None,
4922 None,
4923 None,
4924 None,
4925 None,
4926 Some("15".to_string()),
4927 );
4928 assert_eq!(config.checkpoint_interval_secs, Some(15));
4929 }
4930
4931 #[test]
4932 fn test_checkpoint_interval_disabled_when_wal_disabled() {
4933 let (config, _) = EventStoreConfig::from_env_vars(
4935 Some("/app/data".to_string()),
4936 None,
4937 None,
4938 Some("false".to_string()),
4939 None,
4940 None,
4941 None,
4942 Some("15".to_string()),
4943 );
4944 assert_eq!(config.checkpoint_interval_secs, None);
4945 }
4946
4947 #[test]
4948 fn test_checkpoint_interval_unparseable_falls_back_to_default() {
4949 let (config, _) = EventStoreConfig::from_env_vars(
4950 Some("/app/data".to_string()),
4951 None,
4952 None,
4953 None,
4954 None,
4955 None,
4956 None,
4957 Some("not-a-number".to_string()),
4958 );
4959 assert_eq!(config.checkpoint_interval_secs, Some(60));
4960 }
4961}