Skip to main content

allsource_core/
store.rs

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
41/// High-performance event store with columnar storage
42pub struct EventStore {
43    /// In-memory event storage
44    events: Arc<RwLock<Vec<Event>>>,
45
46    /// High-performance concurrent index
47    index: Arc<EventIndex>,
48
49    /// Projection manager for real-time aggregations
50    pub(crate) projections: Arc<RwLock<ProjectionManager>>,
51
52    /// Optional persistent storage (v0.2 feature)
53    storage: Option<Arc<RwLock<ParquetStorage>>>,
54
55    /// WebSocket manager for real-time event streaming (v0.2 feature)
56    #[cfg(feature = "server")]
57    websocket_manager: Arc<WebSocketManager>,
58
59    /// Snapshot manager for fast state recovery (v0.2 feature)
60    snapshot_manager: Arc<SnapshotManager>,
61
62    /// Write-Ahead Log for durability (v0.2 feature)
63    wal: Option<Arc<WriteAheadLog>>,
64
65    /// Compaction manager for Parquet optimization (v0.2 feature)
66    compaction_manager: Option<Arc<CompactionManager>>,
67
68    /// Schema registry for event validation (v0.5 feature)
69    schema_registry: Arc<SchemaRegistry>,
70
71    /// Replay manager for event replay and projection rebuilding (v0.5 feature)
72    replay_manager: Arc<ReplayManager>,
73
74    /// Pipeline manager for stream processing (v0.5 feature)
75    pipeline_manager: Arc<PipelineManager>,
76
77    /// Prometheus metrics registry (v0.6 feature)
78    #[cfg(feature = "server")]
79    metrics: Arc<MetricsRegistry>,
80
81    /// Total events ingested (for metrics)
82    total_ingested: Arc<RwLock<u64>>,
83
84    /// Projection state cache for Query Service integration (v0.7 feature)
85    /// Key format: "{projection_name}:{entity_id}"
86    /// This DashMap provides O(1) access with ~11.9 μs latency
87    projection_state_cache: Arc<DashMap<String, serde_json::Value>>,
88
89    /// Projection status overrides (v0.13 feature)
90    /// Tracks pause/start state per projection name: "running" or "paused"
91    projection_status: Arc<DashMap<String, String>>,
92
93    /// Webhook registry for outbound event delivery (v0.11 feature)
94    #[cfg(feature = "server")]
95    webhook_registry: Arc<WebhookRegistry>,
96
97    /// Channel sender for async webhook delivery tasks
98    #[cfg(feature = "server")]
99    webhook_tx: Arc<RwLock<Option<mpsc::UnboundedSender<WebhookDeliveryTask>>>>,
100
101    /// Geospatial index for coordinate-based queries (v2.0 feature)
102    geo_index: Arc<GeoIndex>,
103
104    /// Exactly-once processing registry (v2.0 feature)
105    exactly_once: Arc<ExactlyOnceRegistry>,
106
107    /// Autonomous schema evolution manager (v2.0 feature)
108    schema_evolution: Arc<SchemaEvolutionManager>,
109
110    /// Per-entity version counters for optimistic concurrency control (v0.14 feature)
111    /// Key: entity_id string, Value: monotonic version (number of events for that entity)
112    entity_versions: Arc<DashMap<String, u64>>,
113
114    /// Durable consumer registry for subscription cursor tracking (v0.14 feature)
115    consumer_registry: Arc<ConsumerRegistry>,
116
117    /// In-process broadcast of every successfully-ingested event. Always enabled
118    /// so embedded consumers (TUI, web) can tail changes without the `server`
119    /// feature / HTTP stack. Lagging receivers see `RecvError::Lagged`.
120    event_broadcast_tx: tokio::sync::broadcast::Sender<Arc<Event>>,
121
122    /// Per-tenant lazy-load bookkeeping. Tracks which tenants have
123    /// been hydrated from Parquet into the in-memory pile and
124    /// serializes concurrent first-loads of the same tenant. See
125    /// `ensure_tenant_loaded` and Step 2 of the sustainable data
126    /// strategy.
127    tenant_loader: Arc<TenantLoader>,
128
129    /// Cadence of the runtime checkpoint loop (Step 6). `None` means
130    /// the loop is disabled — WAL grows until boot. Production reads
131    /// this from `ALLSOURCE_CHECKPOINT_INTERVAL_SECONDS`. Stored on
132    /// the store so background tasks can read it without re-parsing
133    /// the env.
134    checkpoint_interval_secs: Option<u64>,
135}
136
137/// A task queued for async webhook delivery
138#[cfg(feature = "server")]
139#[derive(Debug, Clone)]
140pub struct WebhookDeliveryTask {
141    pub webhook: crate::application::services::webhook::WebhookSubscription,
142    pub event: Event,
143}
144
145impl EventStore {
146    /// Create a new in-memory event store
147    pub fn new() -> Self {
148        Self::with_config(EventStoreConfig::default())
149    }
150
151    /// Create event store with custom configuration
152    pub fn with_config(config: EventStoreConfig) -> Self {
153        let mut projections = ProjectionManager::new();
154
155        // Register built-in projections
156        projections.register(Arc::new(EntitySnapshotProjection::new("entity_snapshots")));
157        projections.register(Arc::new(EventCounterProjection::new("event_counters")));
158
159        // Initialize persistent storage if configured
160        let storage = config
161            .storage_dir
162            .as_ref()
163            .and_then(|dir| match ParquetStorage::new(dir) {
164                Ok(storage) => {
165                    tracing::info!("✅ Parquet persistence enabled at: {}", dir.display());
166                    Some(Arc::new(RwLock::new(storage)))
167                }
168                Err(e) => {
169                    tracing::error!("❌ Failed to initialize Parquet storage: {}", e);
170                    None
171                }
172            });
173
174        // Initialize WAL if configured (v0.2 feature)
175        let wal = config.wal_dir.as_ref().and_then(|dir| {
176            match WriteAheadLog::new(dir, config.wal_config.clone()) {
177                Ok(wal) => {
178                    tracing::info!("✅ WAL enabled at: {}", dir.display());
179                    Some(Arc::new(wal))
180                }
181                Err(e) => {
182                    tracing::error!("❌ Failed to initialize WAL: {}", e);
183                    None
184                }
185            }
186        });
187
188        // Initialize compaction manager if Parquet storage is enabled (v0.2 feature)
189        let compaction_manager = config.storage_dir.as_ref().map(|dir| {
190            let manager = CompactionManager::new(dir, config.compaction_config.clone());
191            Arc::new(manager)
192        });
193
194        // Initialize schema registry (v0.5 feature)
195        let schema_registry = Arc::new(SchemaRegistry::new(config.schema_registry_config.clone()));
196        tracing::info!("✅ Schema registry enabled");
197
198        // Initialize replay manager (v0.5 feature)
199        let replay_manager = Arc::new(ReplayManager::new());
200        tracing::info!("✅ Replay manager enabled");
201
202        // Initialize pipeline manager (v0.5 feature)
203        let pipeline_manager = Arc::new(PipelineManager::new());
204        tracing::info!("✅ Pipeline manager enabled");
205
206        // Initialize metrics registry (v0.6 feature)
207        #[cfg(feature = "server")]
208        let metrics = {
209            let m = MetricsRegistry::new();
210            tracing::info!("✅ Prometheus metrics registry initialized");
211            m
212        };
213
214        // Initialize projection state cache (v0.7 feature)
215        let projection_state_cache = Arc::new(DashMap::new());
216        tracing::info!("✅ Projection state cache initialized");
217
218        // Initialize webhook registry (v0.11 feature)
219        #[cfg(feature = "server")]
220        let webhook_registry = {
221            let w = Arc::new(WebhookRegistry::new());
222            tracing::info!("✅ Webhook registry initialized");
223            w
224        };
225
226        // Unconditional in-process event broadcaster so embedded consumers
227        // (TUI, web) can live-reload without the `server` feature.
228        let (event_broadcast_tx, _) = tokio::sync::broadcast::channel(1024);
229
230        let store = Self {
231            events: Arc::new(RwLock::new(Vec::new())),
232            index: Arc::new(EventIndex::new()),
233            projections: Arc::new(RwLock::new(projections)),
234            storage,
235            #[cfg(feature = "server")]
236            websocket_manager: Arc::new(WebSocketManager::new()),
237            snapshot_manager: Arc::new(SnapshotManager::new(config.snapshot_config)),
238            wal,
239            compaction_manager,
240            schema_registry,
241            replay_manager,
242            pipeline_manager,
243            #[cfg(feature = "server")]
244            metrics,
245            total_ingested: Arc::new(RwLock::new(0)),
246            projection_state_cache,
247            projection_status: Arc::new(DashMap::new()),
248            #[cfg(feature = "server")]
249            webhook_registry,
250            #[cfg(feature = "server")]
251            webhook_tx: Arc::new(RwLock::new(None)),
252            geo_index: Arc::new(GeoIndex::new()),
253            exactly_once: Arc::new(ExactlyOnceRegistry::new(ExactlyOnceConfig::default())),
254            schema_evolution: Arc::new(SchemaEvolutionManager::new()),
255            entity_versions: Arc::new(DashMap::new()),
256            consumer_registry: Arc::new(ConsumerRegistry::new()),
257            event_broadcast_tx,
258            tenant_loader: {
259                let loader = TenantLoader::new();
260                if let Some(budget) = config.cache_byte_budget {
261                    loader.set_byte_budget(budget);
262                    tracing::info!(
263                        "✅ Cache byte budget set to {} bytes ({:.2} GiB) — LRU eviction enabled",
264                        budget,
265                        budget as f64 / (1024.0 * 1024.0 * 1024.0)
266                    );
267                } else {
268                    tracing::info!(
269                        "✅ Cache budget unset — every loaded tenant stays resident \
270                         (set ALLSOURCE_CACHE_BYTES to enable eviction)"
271                    );
272                }
273                Arc::new(loader)
274            },
275            checkpoint_interval_secs: config.checkpoint_interval_secs,
276        };
277
278        // Boot is now O(1) regardless of dataset size (Step 2 of the
279        // sustainable data strategy). Pre-Step-2 we scanned every
280        // Parquet file at startup; on the production volume that
281        // grew past available memory and Core OOM'd during recovery
282        // (issue #160). Now:
283        //
284        //   - Parquet data stays on disk. Tenants are hydrated on
285        //     demand by `ensure_tenant_loaded`, called from the
286        //     query path on first access.
287        //   - WAL is still recovered eagerly. WAL is bounded
288        //     (rotates / truncates after each Parquet flush), so
289        //     replaying it is O(recent un-flushed writes) — small
290        //     by construction and required for correctness, since
291        //     those events aren't durably in Parquet yet.
292        //
293        // After WAL recovery we still checkpoint recovered events
294        // to Parquet and truncate the WAL. The lazy-load path's
295        // dedupe in `append_loaded_event` (index.get_by_id check)
296        // makes it safe for the same event to be reachable through
297        // both WAL recovery and a subsequent ensure_tenant_loaded
298        // pass on the same tenant.
299        if let Some(ref wal) = store.wal {
300            match wal.recover() {
301                Ok(recovered_events) if !recovered_events.is_empty() => {
302                    let mut wal_new = 0usize;
303                    for event in recovered_events {
304                        let offset = store.events.read().len();
305                        if let Err(e) = store.index.index_event(
306                            event.id,
307                            event.entity_id_str(),
308                            event.event_type_str(),
309                            event.timestamp,
310                            offset,
311                        ) {
312                            tracing::error!("Failed to re-index WAL event {}: {}", event.id, e);
313                        }
314
315                        if let Err(e) = store.projections.read().process_event(&event) {
316                            tracing::error!("Failed to re-process WAL event {}: {}", event.id, e);
317                        }
318
319                        *store
320                            .entity_versions
321                            .entry(event.entity_id_str().to_string())
322                            .or_insert(0) += 1;
323
324                        store.events.write().push(event);
325                        wal_new += 1;
326                    }
327
328                    // Step 6: expose replay size as a gauge so ops
329                    // can graph "how big was the last replay?" and
330                    // catch regressions where the checkpoint loop
331                    // stops draining the WAL.
332                    #[cfg(feature = "server")]
333                    store.metrics.wal_replay_events_total.set(wal_new as i64);
334
335                    if wal_new > 0 {
336                        let total = store.events.read().len();
337                        // total_ingested now reflects "events the
338                        // process knows about" rather than "events
339                        // ever written" — Parquet data isn't loaded
340                        // on boot, so the count grows as tenants
341                        // get hydrated. Consumers that need the
342                        // historical total should look at Parquet
343                        // file stats, not this counter.
344                        *store.total_ingested.write() = total as u64;
345                        tracing::info!(
346                            "✅ Recovered {} events from WAL (Parquet data stays cold until \
347                             first per-tenant query)",
348                            wal_new
349                        );
350
351                        // Checkpoint WAL events to Parquet — buffer
352                        // them into the per-tenant Parquet batches
353                        // first, then flush. Without this,
354                        // flush_storage() finds an empty current
355                        // batch and silently no-ops, the WAL gets
356                        // truncated, and the events exist only in
357                        // memory (lost on next restart).
358                        if let Some(ref storage) = store.storage {
359                            tracing::info!(
360                                "📸 Checkpointing {} WAL events to Parquet storage...",
361                                wal_new
362                            );
363                            let parquet = storage.read();
364                            let events = store.events.read();
365                            let mut buffered = 0usize;
366                            for event in events.iter().skip(events.len() - wal_new) {
367                                if let Err(e) = parquet.append_event(event.clone()) {
368                                    tracing::error!(
369                                        "Failed to buffer WAL event for Parquet: {}",
370                                        e
371                                    );
372                                } else {
373                                    buffered += 1;
374                                }
375                            }
376                            drop(events);
377                            drop(parquet);
378
379                            if buffered > 0 {
380                                if let Err(e) = store.flush_storage() {
381                                    tracing::error!("Failed to checkpoint to Parquet: {}", e);
382                                } else if let Err(e) = wal.truncate() {
383                                    tracing::error!(
384                                        "Failed to truncate WAL after checkpoint: {}",
385                                        e
386                                    );
387                                } else {
388                                    tracing::info!(
389                                        "✅ WAL checkpointed and truncated ({} events)",
390                                        buffered
391                                    );
392                                }
393                            }
394                        }
395                    }
396                }
397                Ok(_) => {
398                    tracing::debug!("No events to recover from WAL");
399                    #[cfg(feature = "server")]
400                    store.metrics.wal_replay_events_total.set(0);
401                }
402                Err(e) => {
403                    tracing::error!("❌ WAL recovery failed: {}", e);
404                }
405            }
406        } else if store.storage.is_some() {
407            tracing::info!(
408                "📂 Boot complete (lazy-load mode): Parquet data stays on disk until first \
409                 per-tenant query"
410            );
411        }
412
413        store
414    }
415
416    /// Ingest a new event with optional optimistic concurrency check.
417    ///
418    /// If `expected_version` is `Some(v)`, the write is rejected with
419    /// `VersionConflict` unless the entity's current version equals `v`.
420    /// The version check and WAL append are atomic (locked together).
421    ///
422    /// Returns the new entity version after the append.
423    #[cfg_attr(feature = "hotpath", hotpath::measure)]
424    pub fn ingest_with_expected_version(
425        &self,
426        event: &Event,
427        expected_version: Option<u64>,
428    ) -> Result<u64> {
429        // Validate event first (before any locking)
430        self.validate_event(event)?;
431
432        let entity_id = event.entity_id_str().to_string();
433
434        // Atomic version check + append: hold the DashMap entry lock
435        // to prevent TOCTOU races between check and write.
436        let new_version = {
437            let mut version_entry = self.entity_versions.entry(entity_id.clone()).or_insert(0);
438            let current = *version_entry;
439
440            if let Some(expected) = expected_version
441                && current != expected
442            {
443                return Err(crate::error::AllSourceError::VersionConflict { expected, current });
444            }
445
446            // Write to WAL FIRST for durability (under version lock to keep atomicity)
447            if let Some(ref wal) = self.wal {
448                wal.append(event.clone())?;
449            }
450
451            *version_entry += 1;
452            *version_entry
453        };
454
455        // From here on, the event is durable (WAL) and version is bumped.
456        // Continue with indexing, projections, storage, and broadcast.
457        self.ingest_post_wal(event)?;
458
459        Ok(new_version)
460    }
461
462    /// Post-WAL ingestion: index, projections, storage, broadcast.
463    /// Called after WAL append and version bump are complete.
464    #[cfg_attr(feature = "hotpath", hotpath::measure)]
465    fn ingest_post_wal(&self, event: &Event) -> Result<()> {
466        #[cfg(feature = "server")]
467        let timer = self.metrics.ingestion_duration_seconds.start_timer();
468
469        let mut events = self.events.write();
470        let offset = events.len();
471
472        // Index the event
473        self.index.index_event(
474            event.id,
475            event.entity_id_str(),
476            event.event_type_str(),
477            event.timestamp,
478            offset,
479        )?;
480
481        // Process through projections
482        let projections = self.projections.read();
483        projections.process_event(event)?;
484        drop(projections);
485
486        // Process through pipelines
487        let pipeline_results = self.pipeline_manager.process_event(event);
488        if !pipeline_results.is_empty() {
489            tracing::debug!(
490                "Event {} processed by {} pipeline(s)",
491                event.id,
492                pipeline_results.len()
493            );
494            for (pipeline_id, result) in pipeline_results {
495                tracing::trace!("Pipeline {} result: {:?}", pipeline_id, result);
496            }
497        }
498
499        // Persist to Parquet storage if enabled
500        if let Some(ref storage) = self.storage {
501            let storage = storage.read();
502            storage.append_event(event.clone())?;
503        }
504
505        // Store the event in memory
506        events.push(event.clone());
507        let total_events = events.len();
508        drop(events);
509
510        // Broadcast to in-process subscribers (always on) + optional WS.
511        let event_arc = Arc::new(event.clone());
512        let _ = self.event_broadcast_tx.send(Arc::clone(&event_arc));
513        #[cfg(feature = "server")]
514        self.websocket_manager.broadcast_event(event_arc);
515
516        // Dispatch to matching webhook subscriptions
517        #[cfg(feature = "server")]
518        self.dispatch_webhooks(event);
519
520        // Update geospatial index
521        self.geo_index.index_event(event);
522
523        // Autonomous schema evolution
524        self.schema_evolution
525            .analyze_event(event.event_type_str(), &event.payload);
526
527        // Check if automatic snapshot should be created
528        self.check_auto_snapshot(event.entity_id_str(), event);
529
530        // Update metrics
531        #[cfg(feature = "server")]
532        {
533            self.metrics.events_ingested_total.inc();
534            self.metrics
535                .events_ingested_by_type
536                .with_label_values(&[event.event_type_str()])
537                .inc();
538            self.metrics.storage_events_total.set(total_events as i64);
539        }
540
541        // Update legacy total counter
542        let mut total = self.total_ingested.write();
543        *total += 1;
544
545        #[cfg(feature = "server")]
546        timer.observe_duration();
547
548        tracing::debug!("Event ingested: {} (offset: {})", event.id, offset);
549
550        Ok(())
551    }
552
553    /// Ingest a new event into the store
554    #[cfg_attr(feature = "hotpath", hotpath::measure)]
555    pub fn ingest(&self, event: &Event) -> Result<()> {
556        // Start metrics timer (v0.6 feature)
557        #[cfg(feature = "server")]
558        let timer = self.metrics.ingestion_duration_seconds.start_timer();
559
560        // Validate event
561        let validation_result = self.validate_event(event);
562        if let Err(e) = validation_result {
563            #[cfg(feature = "server")]
564            {
565                self.metrics.ingestion_errors_total.inc();
566                timer.observe_duration();
567            }
568            return Err(e);
569        }
570
571        // Write to WAL FIRST for durability (v0.2 feature)
572        // This ensures event is persisted before processing
573        if let Some(ref wal) = self.wal
574            && let Err(e) = wal.append(event.clone())
575        {
576            #[cfg(feature = "server")]
577            {
578                self.metrics.ingestion_errors_total.inc();
579                timer.observe_duration();
580            }
581            return Err(e);
582        }
583
584        // Track per-entity version (unconditional increment, no version check)
585        *self
586            .entity_versions
587            .entry(event.entity_id_str().to_string())
588            .or_insert(0) += 1;
589
590        let mut events = self.events.write();
591        let offset = events.len();
592
593        // Index the event
594        self.index.index_event(
595            event.id,
596            event.entity_id_str(),
597            event.event_type_str(),
598            event.timestamp,
599            offset,
600        )?;
601
602        // Process through projections
603        let projections = self.projections.read();
604        projections.process_event(event)?;
605        drop(projections); // Release lock
606
607        // Process through pipelines (v0.5 feature)
608        // Pipelines can transform, filter, and aggregate events in real-time
609        let pipeline_results = self.pipeline_manager.process_event(event);
610        if !pipeline_results.is_empty() {
611            tracing::debug!(
612                "Event {} processed by {} pipeline(s)",
613                event.id,
614                pipeline_results.len()
615            );
616            // Pipeline results could be stored, emitted, or forwarded elsewhere
617            // For now, we just log them for observability
618            for (pipeline_id, result) in pipeline_results {
619                tracing::trace!("Pipeline {} result: {:?}", pipeline_id, result);
620            }
621        }
622
623        // Persist to Parquet storage if enabled (v0.2)
624        if let Some(ref storage) = self.storage {
625            let storage = storage.read();
626            storage.append_event(event.clone())?;
627        }
628
629        // Store the event in memory
630        events.push(event.clone());
631        let total_events = events.len();
632        drop(events); // Release lock early
633
634        // Broadcast to in-process subscribers + optional WS.
635        let event_arc = Arc::new(event.clone());
636        let _ = self.event_broadcast_tx.send(Arc::clone(&event_arc));
637        #[cfg(feature = "server")]
638        self.websocket_manager.broadcast_event(event_arc);
639
640        // Dispatch to matching webhook subscriptions (v0.11 feature)
641        #[cfg(feature = "server")]
642        self.dispatch_webhooks(event);
643
644        // Update geospatial index (v2.0 feature)
645        self.geo_index.index_event(event);
646
647        // Autonomous schema evolution (v2.0 feature)
648        self.schema_evolution
649            .analyze_event(event.event_type_str(), &event.payload);
650
651        // Check if automatic snapshot should be created (v0.2 feature)
652        self.check_auto_snapshot(event.entity_id_str(), event);
653
654        // Update metrics (v0.6 feature)
655        #[cfg(feature = "server")]
656        {
657            self.metrics.events_ingested_total.inc();
658            self.metrics
659                .events_ingested_by_type
660                .with_label_values(&[event.event_type_str()])
661                .inc();
662            self.metrics.storage_events_total.set(total_events as i64);
663        }
664
665        // Update legacy total counter
666        let mut total = self.total_ingested.write();
667        *total += 1;
668
669        #[cfg(feature = "server")]
670        timer.observe_duration();
671
672        tracing::debug!("Event ingested: {} (offset: {})", event.id, offset);
673
674        Ok(())
675    }
676
677    /// Ingest a batch of events with a single write lock acquisition.
678    ///
679    /// All events are validated first. If any event fails validation, no
680    /// events are stored (all-or-nothing validation). Events are then written
681    /// to WAL, indexed, processed through projections, and pushed to the
682    /// events vector under a single write lock.
683    #[cfg_attr(feature = "hotpath", hotpath::measure)]
684    pub fn ingest_batch(&self, batch: Vec<Event>) -> Result<()> {
685        if batch.is_empty() {
686            return Ok(());
687        }
688
689        // Phase 1: Validate all events before acquiring any locks
690        for event in &batch {
691            self.validate_event(event)?;
692        }
693
694        // Phase 2: Write all events to WAL (before write lock, for durability)
695        if let Some(ref wal) = self.wal {
696            for event in &batch {
697                wal.append(event.clone())?;
698            }
699        }
700
701        // Phase 3: Single write lock for index + projections + push
702        let mut events = self.events.write();
703        let projections = self.projections.read();
704
705        for event in batch {
706            let offset = events.len();
707
708            self.index.index_event(
709                event.id,
710                event.entity_id_str(),
711                event.event_type_str(),
712                event.timestamp,
713                offset,
714            )?;
715
716            projections.process_event(&event)?;
717            self.pipeline_manager.process_event(&event);
718
719            if let Some(ref storage) = self.storage {
720                let storage = storage.read();
721                storage.append_event(event.clone())?;
722            }
723
724            self.geo_index.index_event(&event);
725            self.schema_evolution
726                .analyze_event(event.event_type_str(), &event.payload);
727
728            // Track per-entity version
729            *self
730                .entity_versions
731                .entry(event.entity_id_str().to_string())
732                .or_insert(0) += 1;
733
734            // Broadcast to in-process subscribers
735            let _ = self.event_broadcast_tx.send(Arc::new(event.clone()));
736
737            events.push(event);
738        }
739
740        let total_events = events.len();
741        drop(projections);
742        drop(events);
743
744        let mut total = self.total_ingested.write();
745        *total += total_events as u64;
746
747        Ok(())
748    }
749
750    /// Ingest a replicated event from the leader (follower mode).
751    ///
752    /// Unlike `ingest()`, this method:
753    /// - Skips WAL writing (the follower's WalReceiver manages its own local WAL)
754    /// - Skips schema validation (the leader already validated)
755    /// - Still indexes, processes projections/pipelines, and broadcasts to WebSocket clients
756    #[cfg_attr(feature = "hotpath", hotpath::measure)]
757    pub fn ingest_replicated(&self, event: &Event) -> Result<()> {
758        #[cfg(feature = "server")]
759        let timer = self.metrics.ingestion_duration_seconds.start_timer();
760
761        let mut events = self.events.write();
762        let offset = events.len();
763
764        // Index the event
765        self.index.index_event(
766            event.id,
767            event.entity_id_str(),
768            event.event_type_str(),
769            event.timestamp,
770            offset,
771        )?;
772
773        // Process through projections
774        let projections = self.projections.read();
775        projections.process_event(event)?;
776        drop(projections);
777
778        // Process through pipelines
779        let pipeline_results = self.pipeline_manager.process_event(event);
780        if !pipeline_results.is_empty() {
781            tracing::debug!(
782                "Replicated event {} processed by {} pipeline(s)",
783                event.id,
784                pipeline_results.len()
785            );
786        }
787
788        // Track per-entity version
789        *self
790            .entity_versions
791            .entry(event.entity_id_str().to_string())
792            .or_insert(0) += 1;
793
794        // Store the event in memory
795        events.push(event.clone());
796        let total_events = events.len();
797        drop(events);
798
799        // Broadcast to in-process subscribers + optional WS.
800        let event_arc = Arc::new(event.clone());
801        let _ = self.event_broadcast_tx.send(Arc::clone(&event_arc));
802        #[cfg(feature = "server")]
803        self.websocket_manager.broadcast_event(event_arc);
804
805        // Update metrics
806        #[cfg(feature = "server")]
807        {
808            self.metrics.events_ingested_total.inc();
809            self.metrics
810                .events_ingested_by_type
811                .with_label_values(&[event.event_type_str()])
812                .inc();
813            self.metrics.storage_events_total.set(total_events as i64);
814        }
815
816        let mut total = self.total_ingested.write();
817        *total += 1;
818
819        #[cfg(feature = "server")]
820        timer.observe_duration();
821
822        tracing::debug!(
823            "Replicated event ingested: {} (offset: {})",
824            event.id,
825            offset
826        );
827
828        Ok(())
829    }
830
831    /// Get the current version for an entity (number of events appended for it).
832    /// Returns 0 if the entity has no events.
833    #[cfg_attr(feature = "hotpath", hotpath::measure)]
834    pub fn get_entity_version(&self, entity_id: &str) -> u64 {
835        self.entity_versions.get(entity_id).map_or(0, |v| *v)
836    }
837
838    /// Get the consumer registry for durable subscriptions.
839    pub fn consumer_registry(&self) -> &ConsumerRegistry {
840        &self.consumer_registry
841    }
842
843    /// Subscribe to every successfully-ingested event in this store.
844    ///
845    /// Returns a `tokio::sync::broadcast::Receiver` that yields an `Arc<Event>`
846    /// for each ingest. Always available — does not require the `server`
847    /// feature. Lagging receivers surface `RecvError::Lagged`.
848    pub fn subscribe_events(&self) -> tokio::sync::broadcast::Receiver<Arc<Event>> {
849        self.event_broadcast_tx.subscribe()
850    }
851
852    /// Replace the default in-memory consumer registry with a durable one.
853    ///
854    /// Called during startup when system repositories are available, so that
855    /// consumer cursors survive Core restarts via WAL persistence.
856    pub fn set_consumer_registry(&mut self, registry: Arc<ConsumerRegistry>) {
857        self.consumer_registry = registry;
858    }
859
860    /// Get the total number of events in the store (used as max offset for consumer ack).
861    pub fn total_events(&self) -> usize {
862        self.events.read().len()
863    }
864
865    /// Get events after a given offset, optionally filtered by event type prefixes.
866    /// Used by consumer polling to fetch unprocessed events.
867    pub fn events_after_offset(
868        &self,
869        offset: u64,
870        filters: &[String],
871        limit: usize,
872    ) -> Vec<(u64, Event)> {
873        let events = self.events.read();
874        let start = offset as usize;
875        if start >= events.len() {
876            return vec![];
877        }
878
879        events[start..]
880            .iter()
881            .enumerate()
882            .filter(|(_, event)| ConsumerRegistry::matches_filters(event.event_type_str(), filters))
883            .take(limit)
884            .map(|(i, event)| ((start + i + 1) as u64, event.clone()))
885            .collect()
886    }
887
888    /// Get the WebSocket manager for this store
889    #[cfg(feature = "server")]
890    pub fn websocket_manager(&self) -> Arc<WebSocketManager> {
891        Arc::clone(&self.websocket_manager)
892    }
893
894    /// Get the snapshot manager for this store
895    pub fn snapshot_manager(&self) -> Arc<SnapshotManager> {
896        Arc::clone(&self.snapshot_manager)
897    }
898
899    /// Get the compaction manager for this store
900    pub fn compaction_manager(&self) -> Option<Arc<CompactionManager>> {
901        self.compaction_manager.as_ref().map(Arc::clone)
902    }
903
904    /// Get the schema registry for this store (v0.5 feature)
905    pub fn schema_registry(&self) -> Arc<SchemaRegistry> {
906        Arc::clone(&self.schema_registry)
907    }
908
909    /// Get the replay manager for this store (v0.5 feature)
910    pub fn replay_manager(&self) -> Arc<ReplayManager> {
911        Arc::clone(&self.replay_manager)
912    }
913
914    /// Get the pipeline manager for this store (v0.5 feature)
915    pub fn pipeline_manager(&self) -> Arc<PipelineManager> {
916        Arc::clone(&self.pipeline_manager)
917    }
918
919    /// Get the metrics registry for this store (v0.6 feature)
920    #[cfg(feature = "server")]
921    pub fn metrics(&self) -> Arc<MetricsRegistry> {
922        Arc::clone(&self.metrics)
923    }
924
925    /// Get the projection manager for this store (v0.7 feature)
926    pub fn projection_manager(&self) -> parking_lot::RwLockReadGuard<'_, ProjectionManager> {
927        self.projections.read()
928    }
929
930    /// Register a custom projection at runtime.
931    ///
932    /// The projection will receive all future events via `process()`.
933    /// Historical events are **not** replayed — only events ingested after
934    /// registration will be processed by this projection.
935    ///
936    /// See [`register_projection_with_backfill`](Self::register_projection_with_backfill)
937    /// to also process historical events.
938    pub fn register_projection(
939        &self,
940        projection: Arc<dyn crate::application::services::projection::Projection>,
941    ) {
942        let mut pm = self.projections.write();
943        pm.register(projection);
944    }
945
946    /// Register a custom projection and replay all existing events through it.
947    ///
948    /// After registration, the projection will also receive all future events.
949    /// Historical events are replayed under a read lock — the projection's
950    /// internal state (typically DashMap) handles concurrent access.
951    ///
952    /// Replay is ordered by `(timestamp, version)`. The in-memory pile can be
953    /// physically out of order when Parquet is hydrated after WAL recovery —
954    /// the WAL tail holds the newest events while Parquet holds the older
955    /// history — so the backfill must sort before replaying. Projections with
956    /// last-write-wins merge semantics produce wrong state otherwise.
957    pub fn register_projection_with_backfill(
958        &self,
959        projection: &Arc<dyn crate::application::services::projection::Projection>,
960    ) -> Result<()> {
961        // First register so future events are processed
962        {
963            let mut pm = self.projections.write();
964            pm.register(Arc::clone(projection));
965        }
966
967        // Then replay existing events in chronological order under read lock
968        let events = self.events.read();
969        let mut ordered: Vec<&Event> = events.iter().collect();
970        ordered.sort_by(|a, b| {
971            a.timestamp
972                .cmp(&b.timestamp)
973                .then_with(|| a.version.cmp(&b.version))
974        });
975        for event in ordered {
976            projection.process(event)?;
977        }
978
979        Ok(())
980    }
981
982    /// Eagerly reconstruct the in-memory event pile from the full Parquet
983    /// archive.
984    ///
985    /// The default boot path (Step 2, issue #160) keeps Parquet cold and
986    /// hydrates tenants lazily on first query — the multi-tenant server
987    /// cannot fit every tenant in memory. Embedded single-store consumers
988    /// like Prime are the opposite case: their projections *are* the
989    /// queryable surface and never trigger the lazy query path, so the
990    /// projections must be backfilled from the complete history.
991    ///
992    /// Call this *before* registering projections. The dedupe in
993    /// `append_loaded_event` makes it safe to run after WAL recovery —
994    /// events already replayed from the WAL are not double-counted.
995    /// No-op (and `Ok(0)`) when no Parquet storage is configured, e.g.
996    /// in-memory test mode.
997    ///
998    /// Returns the number of events newly loaded from Parquet.
999    pub fn hydrate_all_from_storage(&self) -> Result<usize> {
1000        let Some(storage) = self.storage.as_ref().map(Arc::clone) else {
1001            return Ok(0);
1002        };
1003
1004        let events = storage.read().load_all_events()?;
1005        let read_count = events.len();
1006        let before = self.events.read().len();
1007        for event in events {
1008            self.append_loaded_event(event);
1009        }
1010        let applied = self.events.read().len() - before;
1011
1012        // The pile is now the authoritative full history.
1013        *self.total_ingested.write() = self.events.read().len() as u64;
1014
1015        tracing::info!(
1016            read = read_count,
1017            applied = applied,
1018            "🔄 hydrate_all_from_storage: in-memory pile reconstructed from Parquet"
1019        );
1020        Ok(applied)
1021    }
1022
1023    /// Get the projection state cache for this store (v0.7 feature)
1024    /// Used by Elixir Query Service for state synchronization
1025    pub fn projection_state_cache(&self) -> Arc<DashMap<String, serde_json::Value>> {
1026        Arc::clone(&self.projection_state_cache)
1027    }
1028
1029    /// Get the projection status map (v0.13 feature)
1030    pub fn projection_status(&self) -> Arc<DashMap<String, String>> {
1031        Arc::clone(&self.projection_status)
1032    }
1033
1034    /// Get the webhook registry for this store (v0.11 feature)
1035    /// Geospatial index for coordinate-based queries (v2.0 feature)
1036    pub fn geo_index(&self) -> Arc<GeoIndex> {
1037        self.geo_index.clone()
1038    }
1039
1040    /// Exactly-once processing registry (v2.0 feature)
1041    pub fn exactly_once(&self) -> Arc<ExactlyOnceRegistry> {
1042        self.exactly_once.clone()
1043    }
1044
1045    /// Schema evolution manager (v2.0 feature)
1046    pub fn schema_evolution(&self) -> Arc<SchemaEvolutionManager> {
1047        self.schema_evolution.clone()
1048    }
1049
1050    /// Get a read-locked snapshot of all events (for EventQL/GraphQL queries).
1051    ///
1052    /// Returns an `Arc` reference to the internal events vec, avoiding a full
1053    /// clone. The caller holds a read lock for the duration of the `Arc`
1054    /// lifetime — prefer short-lived usage.
1055    pub fn snapshot_events(&self) -> Vec<Event> {
1056        self.events.read().clone()
1057    }
1058
1059    /// Compact token events for an entity by replacing matching events with a
1060    /// single merged event. Used by the embedded streaming feature.
1061    ///
1062    /// Returns `Ok(true)` if compaction was performed, `Ok(false)` if no
1063    /// matching events were found.
1064    ///
1065    /// **Note:** The merged event is processed through projections *without*
1066    /// clearing the removed events' projection state first. Projections that
1067    /// accumulate state (e.g., counters) should be designed to handle this
1068    /// (the merged event replaces individual tokens, not adds to them).
1069    ///
1070    /// **Crash safety:** The WAL append happens *after* the in-memory swap
1071    /// under the write lock. If the process crashes before the WAL write,
1072    /// no change is persisted — WAL replay restores the pre-compaction state.
1073    /// If the process crashes after the WAL write, replay sees the merged
1074    /// event (and the original tokens, which are idempotent to replay since
1075    /// the merged event supersedes them).
1076    ///
1077    /// The write lock is held for the swap + WAL write + index rebuild.
1078    /// The index rebuild is O(N) over all events, which is acceptable for
1079    /// embedded workloads but should not be called in hot paths for large stores.
1080    pub fn compact_entity_tokens(
1081        &self,
1082        entity_id: &str,
1083        token_event_type: &str,
1084        merged_event: Event,
1085    ) -> Result<bool> {
1086        // Phase 1: Read-only check — do we have anything to compact?
1087        {
1088            let events = self.events.read();
1089            let has_tokens = events
1090                .iter()
1091                .any(|e| e.entity_id_str() == entity_id && e.event_type_str() == token_event_type);
1092            if !has_tokens {
1093                return Ok(false);
1094            }
1095        }
1096
1097        // Phase 2: Process merged event through projections (no write lock held)
1098        let projections = self.projections.read();
1099        projections.process_event(&merged_event)?;
1100        drop(projections);
1101
1102        // Phase 3: Acquire write lock for the swap + WAL + index rebuild
1103        let mut events = self.events.write();
1104
1105        events.retain(|e| {
1106            !(e.entity_id_str() == entity_id && e.event_type_str() == token_event_type)
1107        });
1108
1109        events.push(merged_event.clone());
1110
1111        // WAL append inside write lock: crash before this line = no change persisted.
1112        // Crash after = merged event in WAL, original tokens also in WAL but
1113        // superseded by the merged event's entity_id + event_type.
1114        if let Some(ref wal) = self.wal {
1115            wal.append(merged_event)?;
1116        }
1117
1118        // Rebuild entire index since retain() shifted event positions.
1119        // Errors here indicate a corrupt event (missing entity_id/event_type)
1120        // which should not happen for well-formed events. Log and continue
1121        // rather than failing the entire compaction.
1122        self.index.clear();
1123        for (offset, event) in events.iter().enumerate() {
1124            if let Err(e) = self.index.index_event(
1125                event.id,
1126                event.entity_id_str(),
1127                event.event_type_str(),
1128                event.timestamp,
1129                offset,
1130            ) {
1131                tracing::warn!(
1132                    event_id = %event.id,
1133                    offset,
1134                    "Failed to re-index event during compaction: {e}"
1135                );
1136            }
1137        }
1138
1139        Ok(true)
1140    }
1141
1142    #[cfg(feature = "server")]
1143    pub fn webhook_registry(&self) -> Arc<WebhookRegistry> {
1144        Arc::clone(&self.webhook_registry)
1145    }
1146
1147    /// Set the channel for async webhook delivery.
1148    /// Called during server startup to wire the delivery worker.
1149    #[cfg(feature = "server")]
1150    pub fn set_webhook_tx(&self, tx: mpsc::UnboundedSender<WebhookDeliveryTask>) {
1151        *self.webhook_tx.write() = Some(tx);
1152        tracing::info!("Webhook delivery channel connected");
1153    }
1154
1155    /// Dispatch matching webhooks for a given event (non-blocking).
1156    #[cfg(feature = "server")]
1157    fn dispatch_webhooks(&self, event: &Event) {
1158        let matching = self.webhook_registry.find_matching(event);
1159        if matching.is_empty() {
1160            return;
1161        }
1162
1163        let tx_guard = self.webhook_tx.read();
1164        if let Some(ref tx) = *tx_guard {
1165            for webhook in matching {
1166                let task = WebhookDeliveryTask {
1167                    webhook,
1168                    event: event.clone(),
1169                };
1170                if let Err(e) = tx.send(task) {
1171                    tracing::warn!("Failed to queue webhook delivery: {}", e);
1172                }
1173            }
1174        }
1175    }
1176
1177    /// Manually flush any pending events to persistent storage
1178    pub fn flush_storage(&self) -> Result<()> {
1179        if let Some(ref storage) = self.storage {
1180            let storage = storage.read();
1181            storage.flush()?;
1182            tracing::info!("✅ Flushed events to persistent storage");
1183        }
1184        Ok(())
1185    }
1186
1187    /// Run a checkpoint: flush pending Parquet batches, then truncate the
1188    /// WAL through the checkpoint point (Step 6 of the sustainable data
1189    /// strategy).
1190    ///
1191    /// Order matters. We flush Parquet first; only on success do we
1192    /// truncate the WAL. If the process crashes between the flush and the
1193    /// truncate, the WAL still contains the events that were just durably
1194    /// written, and recovery will replay them. The dedupe in
1195    /// `append_loaded_event` (index probe) makes that idempotent — the
1196    /// event is already in Parquet, so the lazy-load splice no-ops once
1197    /// the tenant is hydrated.
1198    ///
1199    /// The reverse order would be unsafe: a crash between truncate and
1200    /// flush would lose committed events.
1201    ///
1202    /// This bounds dirty-restart replay time to one checkpoint interval
1203    /// regardless of total dataset size — that's the load-bearing
1204    /// property for cold-start time as ingest rate grows.
1205    ///
1206    /// No-op when no WAL is configured (in-memory-only mode).
1207    pub fn checkpoint(&self) -> Result<()> {
1208        let Some(ref wal) = self.wal else {
1209            return Ok(());
1210        };
1211
1212        // Flush before recording the truncation target — both
1213        // because flush() may rotate the WAL underneath us and
1214        // because we want the truncate point to reflect what's
1215        // actually durable on disk.
1216        self.flush_storage()?;
1217        wal.truncate()?;
1218        tracing::debug!("✅ Checkpoint complete: Parquet flushed, WAL truncated");
1219        Ok(())
1220    }
1221
1222    /// Get the configured checkpoint cadence (used by background tasks).
1223    pub fn checkpoint_interval(&self) -> Option<std::time::Duration> {
1224        self.checkpoint_interval_secs
1225            .map(std::time::Duration::from_secs)
1226    }
1227
1228    /// Hydrate `tenant_id`'s persisted Parquet data into the in-memory
1229    /// pile if it isn't already loaded. Cheap on the warm path
1230    /// (DashMap probe); on the cold path it walks just that tenant's
1231    /// subtree (`load_events_for_tenant`) and splices the events into
1232    /// `events`/`index`/`projections`/`entity_versions`.
1233    ///
1234    /// Concurrent first-callers for the same tenant serialize on a
1235    /// per-tenant Mutex (singleflight) so the disk read happens once.
1236    /// Other tenants are unaffected — distinct lock per tenant.
1237    ///
1238    /// Returns `Err` if the tenant_id fails the path-safety
1239    /// whitelist, the Parquet read fails, or another in-flight load
1240    /// holds the lock past the configured timeout. The caller (a
1241    /// query handler) is expected to surface that as a 5xx — see
1242    /// Step 2's "no infinite hangs" acceptance criterion.
1243    ///
1244    /// On failure, `loaded` is NOT marked, so a transient error is
1245    /// retried on the next request rather than poisoning the tenant
1246    /// permanently. A future commit may add a circuit breaker if
1247    /// thrash becomes an issue.
1248    ///
1249    /// No-op (and Ok) when no Parquet storage is configured — the
1250    /// in-memory-only mode used by tests has nothing to hydrate.
1251    pub fn ensure_tenant_loaded(&self, tenant_id: &str) -> Result<()> {
1252        // Fast path: warm tenant. Avoids the Mutex altogether.
1253        if self.tenant_loader.is_loaded(tenant_id) {
1254            return Ok(());
1255        }
1256
1257        let Some(storage) = self.storage.as_ref().map(Arc::clone) else {
1258            // No persistent storage to load from. Mark loaded so we
1259            // don't keep re-entering the slow path.
1260            self.tenant_loader.mark_loaded(tenant_id);
1261            return Ok(());
1262        };
1263
1264        // Singleflight: get-or-insert the per-tenant lock and try to
1265        // acquire it within the timeout budget.
1266        let lock = self.tenant_loader.lock_for(tenant_id);
1267        let timeout = self.tenant_loader.load_timeout();
1268        let _guard = lock.try_lock_for(timeout).ok_or_else(|| {
1269            AllSourceError::StorageError(format!(
1270                "ensure_tenant_loaded timed out after {timeout:?} waiting for in-flight load of \
1271                 tenant {tenant_id:?}"
1272            ))
1273        })?;
1274
1275        // Re-check inside the lock — another thread may have completed
1276        // the load while we were waiting.
1277        if self.tenant_loader.is_loaded(tenant_id) {
1278            return Ok(());
1279        }
1280
1281        let started = std::time::Instant::now();
1282        let events = storage.read().load_events_for_tenant(tenant_id)?;
1283        let read_count = events.len();
1284
1285        let before = self.events.read().len();
1286        for event in events {
1287            self.append_loaded_event(event);
1288        }
1289        let applied = self.events.read().len() - before;
1290
1291        // total_ingested only counts events newly added to memory.
1292        // Dedupe (e.g. WAL events re-checkpointed to Parquet) makes
1293        // applied < read_count possible.
1294        *self.total_ingested.write() += applied as u64;
1295        self.tenant_loader.mark_loaded(tenant_id);
1296
1297        tracing::info!(
1298            tenant_id = tenant_id,
1299            read = read_count,
1300            applied = applied,
1301            elapsed_ms = started.elapsed().as_millis() as u64,
1302            "ensure_tenant_loaded: tenant hydrated"
1303        );
1304
1305        // Budget check (Step 3 #3). After splicing the new
1306        // tenant in, evict LRU tenants until we're back under
1307        // budget. Excludes the just-loaded tenant from the
1308        // candidate set — otherwise a single oversized tenant
1309        // would evict itself in a tight loop. If no other tenant
1310        // is loaded, accept the over-budget state and log a
1311        // warning so ops can see it.
1312        self.enforce_cache_budget(tenant_id);
1313
1314        // Refresh the resident-bytes gauge — covers both the
1315        // load-with-no-eviction case and the post-eviction state.
1316        #[cfg(feature = "server")]
1317        self.metrics
1318            .cache_bytes
1319            .set(self.tenant_loader.total_bytes() as i64);
1320
1321        Ok(())
1322    }
1323
1324    /// Walks the LRU until total resident bytes are within the
1325    /// configured budget, calling `evict_tenant` on each victim.
1326    /// Excludes `recently_touched` from the candidate set so we
1327    /// don't evict the tenant that just triggered the call. Step 3
1328    /// #3 entry point.
1329    ///
1330    /// No-op when no budget is set or when already under budget —
1331    /// most queries take the early-return fast path. Eviction is
1332    /// the cold path; with a well-sized budget this only fires
1333    /// during warm-up of new tenants past the working-set size.
1334    fn enforce_cache_budget(&self, recently_touched: &str) {
1335        if !self.tenant_loader.over_budget() {
1336            return;
1337        }
1338        loop {
1339            let Some(victim) = self.tenant_loader.pick_lru_excluding(recently_touched) else {
1340                tracing::warn!(
1341                    cache_bytes = self.tenant_loader.total_bytes(),
1342                    budget = self.tenant_loader.byte_budget(),
1343                    recently_touched = recently_touched,
1344                    "cache over budget but no other tenant available to evict — \
1345                     a single tenant exceeds the budget; consider raising it"
1346                );
1347                return;
1348            };
1349            self.evict_tenant(&victim);
1350            if !self.tenant_loader.over_budget() {
1351                return;
1352            }
1353        }
1354    }
1355
1356    /// True iff `ensure_tenant_loaded` has previously succeeded for
1357    /// this tenant. Diagnostic / testing API.
1358    pub fn is_tenant_loaded(&self, tenant_id: &str) -> bool {
1359        self.tenant_loader.is_loaded(tenant_id)
1360    }
1361
1362    /// Drop `tenant_id` from the in-memory cache. Step 3 #2 of the
1363    /// sustainable data strategy.
1364    ///
1365    /// Removes every event for this tenant from the events Vec,
1366    /// rebuilds the index/entity_versions for the retained events
1367    /// (Vec offsets shift on remove, so the index has to be
1368    /// rebuilt), and resets the tenant_loader bookkeeping so a
1369    /// subsequent query triggers a fresh `ensure_tenant_loaded`.
1370    ///
1371    /// **Parquet is canonical, in-memory is just cache.** This
1372    /// only affects the in-memory side. Disk data is untouched —
1373    /// that's why eviction is safe even for tenants with
1374    /// recently-ingested data: a query after eviction transparently
1375    /// re-reads from Parquet.
1376    ///
1377    /// Projection state is NOT rolled back. Projections accumulate
1378    /// across boots and tenants (their durability story is
1379    /// separate); subtracting them would need replay support that
1380    /// doesn't exist in this commit. After eviction + re-load,
1381    /// projections may double-count the re-loaded events. Step 3
1382    /// #4's stress test only asserts the cache budget is held; a
1383    /// future commit will tackle projection-aware eviction.
1384    ///
1385    /// Locking: takes the events write lock for the full duration
1386    /// of the filter + re-index. Concurrent ingest blocks until
1387    /// done. Eviction is the cold path; the working set should
1388    /// stay in budget so this rarely fires.
1389    pub fn evict_tenant(&self, tenant_id: &str) {
1390        let mut events = self.events.write();
1391        let before = events.len();
1392        let evicted_bytes = self.tenant_loader.bytes_for(tenant_id);
1393
1394        events.retain(|e| e.tenant_id_str() != tenant_id);
1395        let after = events.len();
1396        let dropped = before - after;
1397
1398        if dropped == 0 {
1399            // Tenant had no events in memory. Still clear loader
1400            // state (e.g. a "loaded with zero events" marker) so
1401            // is_tenant_loaded reports the right thing.
1402            drop(events);
1403            self.tenant_loader.mark_unloaded(tenant_id);
1404            return;
1405        }
1406
1407        // Rebuild the index — Vec offsets shifted under retain().
1408        // Rebuild entity_versions from scratch too, since the
1409        // counter reflects "how many events of this entity remain".
1410        self.index.clear();
1411        self.entity_versions.clear();
1412        for (offset, event) in events.iter().enumerate() {
1413            if let Err(e) = self.index.index_event(
1414                event.id,
1415                event.entity_id_str(),
1416                event.event_type_str(),
1417                event.timestamp,
1418                offset,
1419            ) {
1420                tracing::error!(
1421                    "Failed to re-index event during eviction of {}: {}",
1422                    tenant_id,
1423                    e
1424                );
1425            }
1426            *self
1427                .entity_versions
1428                .entry(event.entity_id_str().to_string())
1429                .or_insert(0) += 1;
1430        }
1431        drop(events);
1432
1433        self.tenant_loader.mark_unloaded(tenant_id);
1434
1435        // total_ingested under Steps 2-3 means "events currently
1436        // resident in memory". Subtract what we just dropped.
1437        let mut t = self.total_ingested.write();
1438        *t = t.saturating_sub(dropped as u64);
1439        drop(t);
1440
1441        // Step 3 #4: cache observability. Increment the eviction
1442        // counter, refresh the resident-bytes gauge.
1443        #[cfg(feature = "server")]
1444        {
1445            self.metrics.cache_evictions_total.inc();
1446            self.metrics
1447                .cache_bytes
1448                .set(self.tenant_loader.total_bytes() as i64);
1449        }
1450
1451        tracing::info!(
1452            tenant_id = tenant_id,
1453            events_dropped = dropped,
1454            bytes_freed = evicted_bytes,
1455            "evicted tenant from memory cache"
1456        );
1457    }
1458
1459    /// Approximate resident bytes a single tenant occupies in the
1460    /// in-memory cache. Step 3 budget-tracking input. 0 for cold
1461    /// or evicted tenants.
1462    pub fn tenant_resident_bytes(&self, tenant_id: &str) -> u64 {
1463        self.tenant_loader.bytes_for(tenant_id)
1464    }
1465
1466    /// Sum of resident-byte estimates across every loaded tenant.
1467    /// What the budget check compares against.
1468    pub fn cache_resident_bytes(&self) -> u64 {
1469        self.tenant_loader.total_bytes()
1470    }
1471
1472    /// Splice a single loaded event into the in-memory structures
1473    /// (events vec, index, projections, entity_versions) atomically
1474    /// w.r.t. concurrent ingest. Used by `ensure_tenant_loaded`.
1475    ///
1476    /// The WAL recovery path on boot has its own (single-threaded)
1477    /// variant inline because boot can't race with ingest. This
1478    /// helper is the variant safe to call while traffic is flowing
1479    /// — it holds the events write lock across the index/offset
1480    /// assignment so (offset, push) stays atomic.
1481    ///
1482    /// Dedupes against events already in memory by event ID. Two
1483    /// paths can surface the same event:
1484    /// 1. WAL recovery on boot pushed it into memory.
1485    /// 2. The event was then checkpointed to Parquet and the
1486    ///    WAL truncated. A later ensure_tenant_loaded re-reads
1487    ///    the Parquet file, including this event.
1488    ///
1489    /// Without the dedupe, step 2 would double-count the event.
1490    /// The check is O(1) — DashMap probe by UUID — and the
1491    /// alternative (loading every tenant before truncating WAL)
1492    /// would defeat the lazy-load.
1493    fn append_loaded_event(&self, event: Event) {
1494        if self.index.get_by_id(&event.id).is_some() {
1495            return;
1496        }
1497
1498        let event_bytes = event.estimated_size_bytes();
1499        let tenant = event.tenant_id_str().to_string();
1500
1501        let mut events = self.events.write();
1502        let offset = events.len();
1503
1504        if let Err(e) = self.index.index_event(
1505            event.id,
1506            event.entity_id_str(),
1507            event.event_type_str(),
1508            event.timestamp,
1509            offset,
1510        ) {
1511            tracing::error!("Failed to index loaded event {}: {}", event.id, e);
1512        }
1513
1514        if let Err(e) = self.projections.read().process_event(&event) {
1515            tracing::error!("Failed to project loaded event {}: {}", event.id, e);
1516        }
1517
1518        *self
1519            .entity_versions
1520            .entry(event.entity_id_str().to_string())
1521            .or_insert(0) += 1;
1522
1523        events.push(event);
1524        // Account for the bytes AFTER the push so a panic in the
1525        // index/projection path doesn't leave the counter inflated.
1526        // The DashMap update is itself the last fallible step.
1527        self.tenant_loader.add_bytes(&tenant, event_bytes);
1528    }
1529
1530    /// Manually create a snapshot for an entity
1531    pub fn create_snapshot(&self, entity_id: &str) -> Result<()> {
1532        // Get all events for this entity
1533        let events = self.query(&QueryEventsRequest {
1534            entity_id: Some(entity_id.to_string()),
1535            event_type: None,
1536            tenant_id: None,
1537            as_of: None,
1538            since: None,
1539            until: None,
1540            limit: None,
1541            event_type_prefix: None,
1542            payload_filter: None,
1543        })?;
1544
1545        if events.is_empty() {
1546            return Err(AllSourceError::EntityNotFound(entity_id.to_string()));
1547        }
1548
1549        // Build current state
1550        let mut state = serde_json::json!({});
1551        for event in &events {
1552            if let serde_json::Value::Object(ref mut state_map) = state
1553                && let serde_json::Value::Object(ref payload_map) = event.payload
1554            {
1555                for (key, value) in payload_map {
1556                    state_map.insert(key.clone(), value.clone());
1557                }
1558            }
1559        }
1560
1561        let last_event = events.last().unwrap();
1562        self.snapshot_manager.create_snapshot(
1563            entity_id,
1564            state,
1565            last_event.timestamp,
1566            events.len(),
1567            SnapshotType::Manual,
1568        )?;
1569
1570        Ok(())
1571    }
1572
1573    /// Check and create automatic snapshots if needed
1574    fn check_auto_snapshot(&self, entity_id: &str, event: &Event) {
1575        // Count events for this entity
1576        let entity_event_count = self
1577            .index
1578            .get_by_entity(entity_id)
1579            .map_or(0, |entries| entries.len());
1580
1581        if self.snapshot_manager.should_create_snapshot(
1582            entity_id,
1583            entity_event_count,
1584            event.timestamp,
1585        ) {
1586            // Create snapshot in background (don't block ingestion)
1587            if let Err(e) = self.create_snapshot(entity_id) {
1588                tracing::warn!(
1589                    "Failed to create automatic snapshot for {}: {}",
1590                    entity_id,
1591                    e
1592                );
1593            }
1594        }
1595    }
1596
1597    /// Validate an event before ingestion
1598    fn validate_event(&self, event: &Event) -> Result<()> {
1599        // EntityId and EventType value objects already validate non-empty in their constructors
1600        // So these checks are now redundant, but we keep them for explicit validation
1601        if event.entity_id_str().is_empty() {
1602            return Err(AllSourceError::ValidationError(
1603                "entity_id cannot be empty".to_string(),
1604            ));
1605        }
1606
1607        if event.event_type_str().is_empty() {
1608            return Err(AllSourceError::ValidationError(
1609                "event_type cannot be empty".to_string(),
1610            ));
1611        }
1612
1613        // Reject system namespace events from user-facing ingestion.
1614        // System events are written exclusively via SystemMetadataStore.
1615        if event.event_type().is_system() {
1616            return Err(AllSourceError::ValidationError(
1617                "Event types starting with '_system.' are reserved for internal use".to_string(),
1618            ));
1619        }
1620
1621        Ok(())
1622    }
1623
1624    /// Reset a projection by clearing its state and reprocessing all events
1625    pub fn reset_projection(&self, name: &str) -> Result<usize> {
1626        let projection_manager = self.projections.read();
1627        let projection = projection_manager.get_projection(name).ok_or_else(|| {
1628            AllSourceError::EntityNotFound(format!("Projection '{name}' not found"))
1629        })?;
1630
1631        // Clear existing state
1632        projection.clear();
1633
1634        // Clear cached state for this projection
1635        let prefix = format!("{name}:");
1636        let keys_to_remove: Vec<String> = self
1637            .projection_state_cache
1638            .iter()
1639            .filter(|entry| entry.key().starts_with(&prefix))
1640            .map(|entry| entry.key().clone())
1641            .collect();
1642        for key in keys_to_remove {
1643            self.projection_state_cache.remove(&key);
1644        }
1645
1646        // Reprocess all events through this projection
1647        let events = self.events.read();
1648        let mut reprocessed = 0usize;
1649        for event in events.iter() {
1650            if projection.process(event).is_ok() {
1651                reprocessed += 1;
1652            }
1653        }
1654
1655        Ok(reprocessed)
1656    }
1657
1658    /// Get a single event by its UUID
1659    pub fn get_event_by_id(&self, event_id: &uuid::Uuid) -> Result<Option<Event>> {
1660        if let Some(offset) = self.index.get_by_id(event_id) {
1661            let events = self.events.read();
1662            Ok(events.get(offset).cloned())
1663        } else {
1664            Ok(None)
1665        }
1666    }
1667
1668    /// Query events based on filters (optimized with indices)
1669    #[cfg_attr(feature = "hotpath", hotpath::measure)]
1670    pub fn query(&self, request: &QueryEventsRequest) -> Result<Vec<Event>> {
1671        // Lazy-load gate (Step 2): if the request scopes to a tenant,
1672        // make sure that tenant's persisted data is in memory before
1673        // running the in-memory index lookup. First call for a cold
1674        // tenant blocks here for the disk read (single-digit seconds
1675        // on ~100k events); warm tenants take the DashMap fast path
1676        // and add no measurable latency.
1677        //
1678        // Errors propagate as `Err`; the HTTP layer turns that into
1679        // a 5xx, which is the explicit "no infinite hangs" contract
1680        // from the Step 2 acceptance criteria.
1681        //
1682        // Unfiltered (cross-tenant) queries — `tenant_id = None` —
1683        // run against whatever is currently in memory. They cannot
1684        // pre-load every tenant without defeating the whole point
1685        // of the lazy-load model. In practice the gateway always
1686        // injects an auth-derived `tenant_id`; an unfiltered query
1687        // is admin-only and gets degraded results until a future
1688        // commit adds an explicit "load all tenants" admin path.
1689        if let Some(ref tenant_id) = request.tenant_id {
1690            self.ensure_tenant_loaded(tenant_id)?;
1691            // LRU touch — the most-recently-queried tenant moves
1692            // to the back of the eviction queue. Cheap (single
1693            // DashMap insert), called on every per-tenant query.
1694            self.tenant_loader.touch(tenant_id);
1695        }
1696
1697        // Determine query type for metrics (v0.6 feature)
1698        let query_type = if request.entity_id.is_some() {
1699            "entity"
1700        } else if request.event_type.is_some() {
1701            "type"
1702        } else if request.event_type_prefix.is_some() {
1703            "type_prefix"
1704        } else {
1705            "full_scan"
1706        };
1707
1708        // Start metrics timer (v0.6 feature)
1709        #[cfg(feature = "server")]
1710        let timer = self
1711            .metrics
1712            .query_duration_seconds
1713            .with_label_values(&[query_type])
1714            .start_timer();
1715
1716        // Increment query counter (v0.6 feature)
1717        #[cfg(feature = "server")]
1718        self.metrics
1719            .queries_total
1720            .with_label_values(&[query_type])
1721            .inc();
1722
1723        let events = self.events.read();
1724
1725        // Use index for fast lookups
1726        let offsets: Vec<usize> = if let Some(entity_id) = &request.entity_id {
1727            // Use entity index
1728            self.index
1729                .get_by_entity(entity_id)
1730                .map(|entries| self.filter_entries(entries, request))
1731                .unwrap_or_default()
1732        } else if let Some(event_type) = &request.event_type {
1733            // Use type index (exact match)
1734            self.index
1735                .get_by_type(event_type)
1736                .map(|entries| self.filter_entries(entries, request))
1737                .unwrap_or_default()
1738        } else if let Some(prefix) = &request.event_type_prefix {
1739            // Use type index (prefix match)
1740            let entries = self.index.get_by_type_prefix(prefix);
1741            self.filter_entries(entries, request)
1742        } else {
1743            // Full scan (less efficient but necessary for complex queries)
1744            (0..events.len()).collect()
1745        };
1746
1747        // Fetch events and apply remaining filters
1748        let mut results: Vec<Event> = offsets
1749            .iter()
1750            .filter_map(|&offset| events.get(offset).cloned())
1751            .filter(|event| self.apply_filters(event, request))
1752            .collect();
1753
1754        // Sort by timestamp ascending, with version as a deterministic
1755        // tie-breaker so events that share a timestamp keep a stable,
1756        // well-defined order — "the latest event" must be unambiguous
1757        // (issue #177).
1758        results.sort_by(|a, b| {
1759            a.timestamp
1760                .cmp(&b.timestamp)
1761                .then_with(|| a.version.cmp(&b.version))
1762        });
1763
1764        // Apply limit
1765        if let Some(limit) = request.limit {
1766            results.truncate(limit);
1767        }
1768
1769        // Record query results count (v0.6 feature)
1770        #[cfg(feature = "server")]
1771        {
1772            self.metrics
1773                .query_results_total
1774                .with_label_values(&[query_type])
1775                .inc_by(results.len() as u64);
1776            timer.observe_duration();
1777        }
1778
1779        Ok(results)
1780    }
1781
1782    /// Filter index entries based on query parameters
1783    #[cfg_attr(feature = "hotpath", hotpath::measure)]
1784    fn filter_entries(&self, entries: Vec<IndexEntry>, request: &QueryEventsRequest) -> Vec<usize> {
1785        entries
1786            .into_iter()
1787            .filter(|entry| {
1788                // Time filters
1789                if let Some(as_of) = request.as_of
1790                    && entry.timestamp > as_of
1791                {
1792                    return false;
1793                }
1794                if let Some(since) = request.since
1795                    && entry.timestamp < since
1796                {
1797                    return false;
1798                }
1799                if let Some(until) = request.until
1800                    && entry.timestamp > until
1801                {
1802                    return false;
1803                }
1804                true
1805            })
1806            .map(|entry| entry.offset)
1807            .collect()
1808    }
1809
1810    /// Apply filters to an event
1811    #[cfg_attr(feature = "hotpath", hotpath::measure)]
1812    fn apply_filters(&self, event: &Event, request: &QueryEventsRequest) -> bool {
1813        // Tenant isolation: if a tenant_id is specified, only return events from that tenant
1814        if let Some(ref tid) = request.tenant_id
1815            && event.tenant_id_str() != tid
1816        {
1817            return false;
1818        }
1819
1820        // Additional type filter if entity was primary
1821        if request.entity_id.is_some()
1822            && let Some(ref event_type) = request.event_type
1823            && event.event_type_str() != event_type
1824        {
1825            return false;
1826        }
1827
1828        // Additional prefix filter if entity was primary
1829        if request.entity_id.is_some()
1830            && let Some(ref prefix) = request.event_type_prefix
1831            && !event.event_type_str().starts_with(prefix)
1832        {
1833            return false;
1834        }
1835
1836        // Payload field filtering
1837        if let Some(ref filter_str) = request.payload_filter
1838            && let Ok(filter_obj) =
1839                serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(filter_str)
1840        {
1841            let payload = event.payload();
1842            for (key, expected_value) in &filter_obj {
1843                match payload.get(key) {
1844                    Some(actual_value) if actual_value == expected_value => {}
1845                    _ => return false,
1846                }
1847            }
1848        }
1849
1850        true
1851    }
1852
1853    /// Reconstruct entity state as of a specific timestamp
1854    /// v0.2: Now uses snapshots for fast reconstruction
1855    #[cfg_attr(feature = "hotpath", hotpath::measure)]
1856    pub fn reconstruct_state(
1857        &self,
1858        entity_id: &str,
1859        as_of: Option<DateTime<Utc>>,
1860    ) -> Result<serde_json::Value> {
1861        // Try to find a snapshot to use as a base (v0.2 optimization)
1862        let (merged_state, since_timestamp) = if let Some(as_of_time) = as_of {
1863            // Get snapshot closest to requested time
1864            if let Some(snapshot) = self
1865                .snapshot_manager
1866                .get_snapshot_as_of(entity_id, as_of_time)
1867            {
1868                tracing::debug!(
1869                    "Using snapshot from {} for entity {} (saved {} events)",
1870                    snapshot.as_of,
1871                    entity_id,
1872                    snapshot.event_count
1873                );
1874                (snapshot.state.clone(), Some(snapshot.as_of))
1875            } else {
1876                (serde_json::json!({}), None)
1877            }
1878        } else {
1879            // Get latest snapshot for current state
1880            if let Some(snapshot) = self.snapshot_manager.get_latest_snapshot(entity_id) {
1881                tracing::debug!(
1882                    "Using latest snapshot from {} for entity {}",
1883                    snapshot.as_of,
1884                    entity_id
1885                );
1886                (snapshot.state.clone(), Some(snapshot.as_of))
1887            } else {
1888                (serde_json::json!({}), None)
1889            }
1890        };
1891
1892        // Query events after the snapshot (or all if no snapshot)
1893        let events = self.query(&QueryEventsRequest {
1894            entity_id: Some(entity_id.to_string()),
1895            event_type: None,
1896            tenant_id: None,
1897            as_of,
1898            since: since_timestamp,
1899            until: None,
1900            limit: None,
1901            event_type_prefix: None,
1902            payload_filter: None,
1903        })?;
1904
1905        // If no events and no snapshot, entity not found
1906        if events.is_empty() && since_timestamp.is_none() {
1907            return Err(AllSourceError::EntityNotFound(entity_id.to_string()));
1908        }
1909
1910        // Merge events on top of snapshot (or from scratch if no snapshot)
1911        let mut merged_state = merged_state;
1912        for event in &events {
1913            if let serde_json::Value::Object(ref mut state_map) = merged_state
1914                && let serde_json::Value::Object(ref payload_map) = event.payload
1915            {
1916                for (key, value) in payload_map {
1917                    state_map.insert(key.clone(), value.clone());
1918                }
1919            }
1920        }
1921
1922        // Wrap with metadata
1923        let state = serde_json::json!({
1924            "entity_id": entity_id,
1925            "last_updated": events.last().map(|e| e.timestamp),
1926            "event_count": events.len(),
1927            "as_of": as_of,
1928            "current_state": merged_state,
1929            "history": events.iter().map(|e| {
1930                serde_json::json!({
1931                    "event_id": e.id,
1932                    "type": e.event_type,
1933                    "timestamp": e.timestamp,
1934                    "payload": e.payload
1935                })
1936            }).collect::<Vec<_>>()
1937        });
1938
1939        Ok(state)
1940    }
1941
1942    /// Get snapshot from projection (faster than reconstructing)
1943    pub fn get_snapshot(&self, entity_id: &str) -> Result<serde_json::Value> {
1944        let projections = self.projections.read();
1945
1946        if let Some(snapshot_projection) = projections.get_projection("entity_snapshots")
1947            && let Some(state) = snapshot_projection.get_state(entity_id)
1948        {
1949            return Ok(serde_json::json!({
1950                "entity_id": entity_id,
1951                "snapshot": state,
1952                "from_projection": "entity_snapshots"
1953            }));
1954        }
1955
1956        Err(AllSourceError::EntityNotFound(entity_id.to_string()))
1957    }
1958
1959    /// Get statistics about the event store
1960    pub fn stats(&self) -> StoreStats {
1961        let events = self.events.read();
1962        let index_stats = self.index.stats();
1963
1964        StoreStats {
1965            total_events: events.len(),
1966            total_entities: index_stats.total_entities,
1967            total_event_types: index_stats.total_event_types,
1968            total_ingested: *self.total_ingested.read(),
1969        }
1970    }
1971
1972    /// Get all unique streams (entity_ids) in the store
1973    pub fn list_streams(&self) -> Vec<StreamInfo> {
1974        self.index
1975            .get_all_entities()
1976            .into_iter()
1977            .map(|entity_id| {
1978                let event_count = self
1979                    .index
1980                    .get_by_entity(&entity_id)
1981                    .map_or(0, |entries| entries.len());
1982                let last_event_at = self
1983                    .index
1984                    .get_by_entity(&entity_id)
1985                    .and_then(|entries| entries.last().map(|e| e.timestamp));
1986                StreamInfo {
1987                    stream_id: entity_id,
1988                    event_count,
1989                    last_event_at,
1990                }
1991            })
1992            .collect()
1993    }
1994
1995    /// Get all unique event types in the store
1996    pub fn list_event_types(&self) -> Vec<EventTypeInfo> {
1997        self.index
1998            .get_all_types()
1999            .into_iter()
2000            .map(|event_type| {
2001                let event_count = self
2002                    .index
2003                    .get_by_type(&event_type)
2004                    .map_or(0, |entries| entries.len());
2005                let last_event_at = self
2006                    .index
2007                    .get_by_type(&event_type)
2008                    .and_then(|entries| entries.last().map(|e| e.timestamp));
2009                EventTypeInfo {
2010                    event_type,
2011                    event_count,
2012                    last_event_at,
2013                }
2014            })
2015            .collect()
2016    }
2017
2018    /// Attach a broadcast sender to the WAL for replication.
2019    ///
2020    /// Thread-safe: can be called through `Arc<EventStore>` at runtime.
2021    /// Used during initial setup and during follower → leader promotion.
2022    /// When set, every WAL append publishes the entry to the broadcast
2023    /// channel so the WAL shipper can stream it to followers.
2024    pub fn enable_wal_replication(
2025        &self,
2026        tx: tokio::sync::broadcast::Sender<crate::infrastructure::persistence::wal::WALEntry>,
2027    ) {
2028        if let Some(ref wal_arc) = self.wal {
2029            wal_arc.set_replication_tx(tx);
2030            tracing::info!("WAL replication broadcast enabled");
2031        } else {
2032            tracing::warn!("Cannot enable WAL replication: WAL is not configured");
2033        }
2034    }
2035
2036    /// Get a reference to the WAL (if configured).
2037    /// Used by the replication catch-up protocol to determine oldest available offset.
2038    pub fn wal(&self) -> Option<&Arc<WriteAheadLog>> {
2039        self.wal.as_ref()
2040    }
2041
2042    /// Get a reference to the Parquet storage (if configured).
2043    /// Used by the replication catch-up protocol to stream snapshot files to followers.
2044    pub fn parquet_storage(&self) -> Option<&Arc<RwLock<ParquetStorage>>> {
2045        self.storage.as_ref()
2046    }
2047}
2048
2049/// Configuration for EventStore
2050#[derive(Debug, Clone, Default)]
2051pub struct EventStoreConfig {
2052    /// Optional directory for persistent Parquet storage (v0.2 feature)
2053    pub storage_dir: Option<PathBuf>,
2054
2055    /// Snapshot configuration (v0.2 feature)
2056    pub snapshot_config: SnapshotConfig,
2057
2058    /// Optional directory for WAL (Write-Ahead Log) (v0.2 feature)
2059    pub wal_dir: Option<PathBuf>,
2060
2061    /// WAL configuration (v0.2 feature)
2062    pub wal_config: WALConfig,
2063
2064    /// Compaction configuration (v0.2 feature)
2065    pub compaction_config: CompactionConfig,
2066
2067    /// Schema registry configuration (v0.5 feature)
2068    pub schema_registry_config: SchemaRegistryConfig,
2069
2070    /// Optional directory for system metadata storage (dogfood feature).
2071    /// When set, operational metadata (tenants, config, audit) is stored
2072    /// using AllSource's own event store rather than an external database.
2073    /// Defaults to `{storage_dir}/__system/` when storage_dir is set.
2074    pub system_data_dir: Option<PathBuf>,
2075
2076    /// Name of the default tenant to auto-create on first boot.
2077    pub bootstrap_tenant: Option<String>,
2078
2079    /// In-memory cache budget in bytes (Step 3). When the resident
2080    /// total exceeds this after a load, the LRU tenant is evicted
2081    /// until the cache fits. `None` (the default in tests) disables
2082    /// the budget — every loaded tenant stays resident. Production
2083    /// reads this from the `ALLSOURCE_CACHE_BYTES` env var; see
2084    /// `from_env`.
2085    pub cache_byte_budget: Option<u64>,
2086
2087    /// Cadence of the runtime checkpoint loop, in seconds (Step 6).
2088    /// Each tick flushes pending Parquet batches and, on success,
2089    /// truncates the WAL up through the checkpoint. This bounds
2090    /// dirty-restart replay time to one interval of writes
2091    /// regardless of total dataset size.
2092    ///
2093    /// `None` disables the loop — the WAL still grows but is only
2094    /// truncated at boot, which is the pre-Step-6 behavior. Tests
2095    /// default to `None`; production reads
2096    /// `ALLSOURCE_CHECKPOINT_INTERVAL_SECONDS` (default 60s) via
2097    /// `from_env_vars`.
2098    pub checkpoint_interval_secs: Option<u64>,
2099}
2100
2101impl EventStoreConfig {
2102    /// Create config with persistent storage enabled
2103    pub fn with_persistence(storage_dir: impl Into<PathBuf>) -> Self {
2104        Self {
2105            storage_dir: Some(storage_dir.into()),
2106            ..Self::default()
2107        }
2108    }
2109
2110    /// Create config with custom snapshot settings
2111    pub fn with_snapshots(snapshot_config: SnapshotConfig) -> Self {
2112        Self {
2113            snapshot_config,
2114            ..Self::default()
2115        }
2116    }
2117
2118    /// Create config with WAL enabled
2119    pub fn with_wal(wal_dir: impl Into<PathBuf>, wal_config: WALConfig) -> Self {
2120        Self {
2121            wal_dir: Some(wal_dir.into()),
2122            wal_config,
2123            ..Self::default()
2124        }
2125    }
2126
2127    /// Create config with both persistence and snapshots
2128    pub fn with_all(storage_dir: impl Into<PathBuf>, snapshot_config: SnapshotConfig) -> Self {
2129        Self {
2130            storage_dir: Some(storage_dir.into()),
2131            snapshot_config,
2132            ..Self::default()
2133        }
2134    }
2135
2136    /// Create production config with all features enabled
2137    pub fn production(
2138        storage_dir: impl Into<PathBuf>,
2139        wal_dir: impl Into<PathBuf>,
2140        snapshot_config: SnapshotConfig,
2141        wal_config: WALConfig,
2142        compaction_config: CompactionConfig,
2143    ) -> Self {
2144        let storage_dir = storage_dir.into();
2145        let system_data_dir = storage_dir.join("__system");
2146        Self {
2147            storage_dir: Some(storage_dir),
2148            snapshot_config,
2149            wal_dir: Some(wal_dir.into()),
2150            wal_config,
2151            compaction_config,
2152            system_data_dir: Some(system_data_dir),
2153            ..Self::default()
2154        }
2155    }
2156
2157    /// Resolve the effective system data directory.
2158    ///
2159    /// If explicitly set, returns that. Otherwise, derives from storage_dir.
2160    /// Returns None if neither is configured (in-memory mode).
2161    pub fn effective_system_data_dir(&self) -> Option<PathBuf> {
2162        self.system_data_dir
2163            .clone()
2164            .or_else(|| self.storage_dir.as_ref().map(|d| d.join("__system")))
2165    }
2166
2167    /// Build config from environment variables.
2168    ///
2169    /// Reads `ALLSOURCE_DATA_DIR`, `ALLSOURCE_STORAGE_DIR`, `ALLSOURCE_WAL_DIR`,
2170    /// and `ALLSOURCE_WAL_ENABLED` to determine persistence mode.
2171    ///
2172    /// Returns `(config, description)` where description is a human-readable
2173    /// summary of the persistence mode for logging.
2174    pub fn from_env() -> (Self, &'static str) {
2175        Self::from_env_vars(
2176            std::env::var("ALLSOURCE_DATA_DIR")
2177                .ok()
2178                .filter(|s| !s.is_empty()),
2179            std::env::var("ALLSOURCE_STORAGE_DIR")
2180                .ok()
2181                .filter(|s| !s.is_empty()),
2182            std::env::var("ALLSOURCE_WAL_DIR")
2183                .ok()
2184                .filter(|s| !s.is_empty()),
2185            std::env::var("ALLSOURCE_WAL_ENABLED").ok(),
2186            std::env::var("ALLSOURCE_CACHE_BYTES").ok(),
2187            std::env::var("ALLSOURCE_SNAPSHOT_INTERVAL_SECONDS").ok(),
2188            std::env::var("ALLSOURCE_RETENTION_SYSTEM_DAYS").ok(),
2189            std::env::var("ALLSOURCE_CHECKPOINT_INTERVAL_SECONDS").ok(),
2190        )
2191    }
2192
2193    /// Build config from explicit env-var values (testable without mutating process env).
2194    pub fn from_env_vars(
2195        data_dir: Option<String>,
2196        explicit_storage_dir: Option<String>,
2197        explicit_wal_dir: Option<String>,
2198        wal_enabled_var: Option<String>,
2199        cache_bytes_var: Option<String>,
2200        snapshot_interval_var: Option<String>,
2201        retention_system_days_var: Option<String>,
2202        checkpoint_interval_var: Option<String>,
2203    ) -> (Self, &'static str) {
2204        let data_dir = data_dir.filter(|s| !s.is_empty());
2205        let storage_dir = explicit_storage_dir
2206            .filter(|s| !s.is_empty())
2207            .or_else(|| data_dir.as_ref().map(|d| format!("{d}/storage")));
2208        let wal_dir = explicit_wal_dir
2209            .filter(|s| !s.is_empty())
2210            .or_else(|| data_dir.as_ref().map(|d| format!("{d}/wal")));
2211        let wal_enabled = wal_enabled_var.is_none_or(|v| v == "true");
2212        // ALLSOURCE_CACHE_BYTES: parse decimal bytes. Unparseable
2213        // input is logged and ignored rather than failing boot —
2214        // the unbounded fallback is safe (worst case is the
2215        // original pre-Step-3 behavior).
2216        let cache_byte_budget =
2217            cache_bytes_var
2218                .filter(|s| !s.is_empty())
2219                .and_then(|s| match s.parse::<u64>() {
2220                    Ok(v) => Some(v),
2221                    Err(e) => {
2222                        tracing::warn!(
2223                            "ALLSOURCE_CACHE_BYTES={s:?} could not be parsed as u64: {e}; \
2224                         cache budget disabled"
2225                        );
2226                        None
2227                    }
2228                });
2229        let compaction_config =
2230            CompactionConfig::from_env_vars(snapshot_interval_var, retention_system_days_var);
2231
2232        // ALLSOURCE_CHECKPOINT_INTERVAL_SECONDS: parse decimal seconds. The
2233        // default (60s) only applies when WAL is enabled — there's no
2234        // checkpoint loop to run otherwise. Unparseable input is logged
2235        // and falls back to the default rather than failing boot.
2236        let checkpoint_interval_secs = if wal_enabled {
2237            checkpoint_interval_var
2238                .filter(|s| !s.is_empty())
2239                .map(|s| match s.parse::<u64>() {
2240                    Ok(v) => v,
2241                    Err(e) => {
2242                        tracing::warn!(
2243                            "ALLSOURCE_CHECKPOINT_INTERVAL_SECONDS={s:?} could not be parsed as \
2244                             u64: {e}; falling back to default 60s"
2245                        );
2246                        60
2247                    }
2248                })
2249                .or(Some(60))
2250        } else {
2251            None
2252        };
2253
2254        let mut config = match (&storage_dir, &wal_dir) {
2255            (Some(sd), Some(wd)) if wal_enabled => Self::production(
2256                sd,
2257                wd,
2258                SnapshotConfig::default(),
2259                WALConfig::default(),
2260                compaction_config,
2261            ),
2262            (Some(sd), _) => Self::with_persistence(sd),
2263            (_, Some(wd)) if wal_enabled => Self::with_wal(wd, WALConfig::default()),
2264            _ => Self::default(),
2265        };
2266        config.cache_byte_budget = cache_byte_budget;
2267        config.checkpoint_interval_secs = checkpoint_interval_secs;
2268
2269        let mode = match (&storage_dir, &wal_dir) {
2270            (Some(_), Some(_)) if wal_enabled => "wal+parquet",
2271            (Some(_), _) => "parquet-only",
2272            (_, Some(_)) if wal_enabled => "wal-only",
2273            _ => "in-memory",
2274        };
2275        (config, mode)
2276    }
2277}
2278
2279#[derive(Debug, serde::Serialize)]
2280pub struct StoreStats {
2281    pub total_events: usize,
2282    pub total_entities: usize,
2283    pub total_event_types: usize,
2284    pub total_ingested: u64,
2285}
2286
2287/// Information about a stream (entity_id)
2288#[derive(Debug, Clone, serde::Serialize)]
2289pub struct StreamInfo {
2290    /// The stream identifier (entity_id)
2291    pub stream_id: String,
2292    /// Total number of events in this stream
2293    pub event_count: usize,
2294    /// Timestamp of the last event in this stream
2295    pub last_event_at: Option<chrono::DateTime<chrono::Utc>>,
2296}
2297
2298/// Information about an event type
2299#[derive(Debug, Clone, serde::Serialize)]
2300pub struct EventTypeInfo {
2301    /// The event type name
2302    pub event_type: String,
2303    /// Total number of events of this type
2304    pub event_count: usize,
2305    /// Timestamp of the last event of this type
2306    pub last_event_at: Option<chrono::DateTime<chrono::Utc>>,
2307}
2308
2309impl Default for EventStore {
2310    fn default() -> Self {
2311        Self::new()
2312    }
2313}
2314
2315#[cfg(test)]
2316mod tests {
2317    use super::*;
2318    use crate::domain::entities::Event;
2319    use tempfile::TempDir;
2320
2321    /// Recursively walk `dir` looking for `*.parquet` files.
2322    /// Tests that pre-date Step 1's tenant-partitioned layout used a
2323    /// flat `read_dir` here; after the move to <root>/<tenant>/<yyyy-mm>/
2324    /// they need to walk subdirectories.
2325    fn find_parquet_files(dir: &std::path::Path) -> Vec<std::path::PathBuf> {
2326        let mut out = Vec::new();
2327        let mut stack = vec![dir.to_path_buf()];
2328        while let Some(d) = stack.pop() {
2329            let Ok(entries) = std::fs::read_dir(&d) else {
2330                continue;
2331            };
2332            for e in entries.flatten() {
2333                let p = e.path();
2334                if p.is_dir() {
2335                    stack.push(p);
2336                } else if p.extension().and_then(|s| s.to_str()) == Some("parquet") {
2337                    out.push(p);
2338                }
2339            }
2340        }
2341        out
2342    }
2343
2344    fn create_test_event(entity_id: &str, event_type: &str) -> Event {
2345        Event::from_strings(
2346            event_type.to_string(),
2347            entity_id.to_string(),
2348            "default".to_string(),
2349            serde_json::json!({"name": "Test", "value": 42}),
2350            None,
2351        )
2352        .unwrap()
2353    }
2354
2355    fn create_test_event_with_payload(
2356        entity_id: &str,
2357        event_type: &str,
2358        payload: serde_json::Value,
2359    ) -> Event {
2360        Event::from_strings(
2361            event_type.to_string(),
2362            entity_id.to_string(),
2363            "default".to_string(),
2364            payload,
2365            None,
2366        )
2367        .unwrap()
2368    }
2369
2370    #[test]
2371    fn test_event_store_new() {
2372        let store = EventStore::new();
2373        assert_eq!(store.stats().total_events, 0);
2374        assert_eq!(store.stats().total_entities, 0);
2375    }
2376
2377    // -----------------------------------------------------------------
2378    // Step 2: ensure_tenant_loaded smoke tests. The full
2379    // cold-boot/lazy-hydrate paths land in commit #2 (skip boot
2380    // load) and commit #4 (integration test).
2381    // -----------------------------------------------------------------
2382
2383    #[test]
2384    fn test_ensure_tenant_loaded_no_storage_is_a_noop() {
2385        // An in-memory-only store (no ParquetStorage configured) has
2386        // nothing to hydrate. The method must succeed and mark the
2387        // tenant loaded so subsequent calls hit the fast path.
2388        let store = EventStore::new();
2389        assert!(!store.is_tenant_loaded("alice"));
2390        store.ensure_tenant_loaded("alice").unwrap();
2391        assert!(store.is_tenant_loaded("alice"));
2392        // Other tenants stay cold — the call is per-tenant.
2393        assert!(!store.is_tenant_loaded("bob"));
2394    }
2395
2396    #[test]
2397    fn test_ensure_tenant_loaded_warm_path_is_idempotent() {
2398        let store = EventStore::new();
2399        store.ensure_tenant_loaded("alice").unwrap();
2400        // Second call hits the DashMap fast path and returns Ok.
2401        store.ensure_tenant_loaded("alice").unwrap();
2402    }
2403
2404    #[test]
2405    fn test_ensure_tenant_loaded_rejects_unsafe_tenant_id() {
2406        // With persistence configured, the call has to walk a
2407        // tenant subtree, so the path-safety whitelist applies.
2408        // The error must propagate; the tenant must NOT be marked
2409        // loaded (otherwise an attacker probing path-traversal
2410        // strings could spam the loaded-set with junk).
2411        let temp_dir = TempDir::new().unwrap();
2412        let store = EventStore::with_config(EventStoreConfig::with_persistence(temp_dir.path()));
2413        for unsafe_tid in ["..", "a/b", "a\\b", ""] {
2414            let result = store.ensure_tenant_loaded(unsafe_tid);
2415            assert!(
2416                result.is_err(),
2417                "tenant_id {unsafe_tid:?} should have been rejected"
2418            );
2419            assert!(
2420                !store.is_tenant_loaded(unsafe_tid),
2421                "rejected tenant {unsafe_tid:?} must not be marked loaded"
2422            );
2423        }
2424    }
2425
2426    #[test]
2427    fn test_ensure_tenant_loaded_no_subtree_marks_loaded_with_zero_events() {
2428        // A tenant that has no on-disk data (fresh tenant, never
2429        // persisted) must still succeed — load_events_for_tenant
2430        // returns empty, ensure_tenant_loaded marks it loaded so we
2431        // don't re-walk the empty subtree on every query.
2432        let temp_dir = TempDir::new().unwrap();
2433        let store = EventStore::with_config(EventStoreConfig::with_persistence(temp_dir.path()));
2434        assert!(!store.is_tenant_loaded("never-existed"));
2435        store.ensure_tenant_loaded("never-existed").unwrap();
2436        assert!(store.is_tenant_loaded("never-existed"));
2437    }
2438
2439    #[test]
2440    fn test_evict_tenant_drops_events_and_resets_bytes() {
2441        // After eviction, the tenant's events are gone from memory,
2442        // its byte counter is reset, and is_tenant_loaded returns
2443        // false. Other tenants are untouched.
2444        let temp_dir = TempDir::new().unwrap();
2445        let storage_dir = temp_dir.path().to_path_buf();
2446
2447        {
2448            let store = EventStore::with_config(EventStoreConfig::with_persistence(&storage_dir));
2449            for i in 0..3 {
2450                store
2451                    .ingest(
2452                        &Event::from_strings(
2453                            "test.event".to_string(),
2454                            format!("a-{i}"),
2455                            "alice".to_string(),
2456                            serde_json::json!({"i": i}),
2457                            None,
2458                        )
2459                        .unwrap(),
2460                    )
2461                    .unwrap();
2462            }
2463            for i in 0..2 {
2464                store
2465                    .ingest(
2466                        &Event::from_strings(
2467                            "test.event".to_string(),
2468                            format!("b-{i}"),
2469                            "bob".to_string(),
2470                            serde_json::json!({"i": i}),
2471                            None,
2472                        )
2473                        .unwrap(),
2474                    )
2475                    .unwrap();
2476            }
2477            store.flush_storage().unwrap();
2478        }
2479
2480        let store = EventStore::with_config(EventStoreConfig::with_persistence(&storage_dir));
2481        store.ensure_tenant_loaded("alice").unwrap();
2482        store.ensure_tenant_loaded("bob").unwrap();
2483        assert_eq!(store.stats().total_events, 5);
2484        let alice_bytes = store.tenant_resident_bytes("alice");
2485        let bob_bytes = store.tenant_resident_bytes("bob");
2486        assert!(alice_bytes > 0 && bob_bytes > 0);
2487
2488        store.evict_tenant("alice");
2489
2490        assert!(!store.is_tenant_loaded("alice"));
2491        assert!(store.is_tenant_loaded("bob"));
2492        assert_eq!(store.tenant_resident_bytes("alice"), 0);
2493        assert_eq!(store.tenant_resident_bytes("bob"), bob_bytes);
2494        assert_eq!(store.stats().total_events, 2, "only bob's 2 events remain");
2495    }
2496
2497    #[test]
2498    fn test_evict_tenant_then_query_re_loads_from_disk() {
2499        // The transparent re-load behavior the bead's AC #5 calls
2500        // out: evict, then query the same tenant — its data comes
2501        // back via ensure_tenant_loaded, sourced from Parquet.
2502        let temp_dir = TempDir::new().unwrap();
2503        let storage_dir = temp_dir.path().to_path_buf();
2504
2505        {
2506            let store = EventStore::with_config(EventStoreConfig::with_persistence(&storage_dir));
2507            for i in 0..4 {
2508                store
2509                    .ingest(
2510                        &Event::from_strings(
2511                            "test.event".to_string(),
2512                            format!("a-{i}"),
2513                            "alice".to_string(),
2514                            serde_json::json!({"i": i}),
2515                            None,
2516                        )
2517                        .unwrap(),
2518                    )
2519                    .unwrap();
2520            }
2521            store.flush_storage().unwrap();
2522        }
2523
2524        let store = EventStore::with_config(EventStoreConfig::with_persistence(&storage_dir));
2525        store.ensure_tenant_loaded("alice").unwrap();
2526        store.evict_tenant("alice");
2527        assert_eq!(store.stats().total_events, 0);
2528
2529        // Query — re-load happens transparently.
2530        let results = store
2531            .query(&QueryEventsRequest {
2532                entity_id: None,
2533                event_type: None,
2534                tenant_id: Some("alice".to_string()),
2535                as_of: None,
2536                since: None,
2537                until: None,
2538                limit: None,
2539                event_type_prefix: None,
2540                payload_filter: None,
2541            })
2542            .unwrap();
2543        assert_eq!(results.len(), 4);
2544        assert!(store.is_tenant_loaded("alice"));
2545    }
2546
2547    #[test]
2548    fn test_evict_tenant_rebuilds_index_with_new_offsets() {
2549        // After eviction, the events Vec is compacted. The index
2550        // must be rebuilt against the new offsets — otherwise
2551        // queries return stale or wrong events. This test checks
2552        // index correctness end-to-end via a query for the
2553        // surviving tenant after the evicted tenant's events are
2554        // gone.
2555        let temp_dir = TempDir::new().unwrap();
2556        let store = EventStore::with_config(EventStoreConfig::with_persistence(temp_dir.path()));
2557
2558        // Interleave: alice, bob, alice, bob, alice. After
2559        // evicting alice, the events Vec compacts to [bob, bob]
2560        // and the index must reflect the new layout.
2561        for i in 0..3 {
2562            store
2563                .ingest(
2564                    &Event::from_strings(
2565                        "test.event".to_string(),
2566                        format!("a-{i}"),
2567                        "alice".to_string(),
2568                        serde_json::json!({"i": i}),
2569                        None,
2570                    )
2571                    .unwrap(),
2572                )
2573                .unwrap();
2574            if i < 2 {
2575                store
2576                    .ingest(
2577                        &Event::from_strings(
2578                            "test.event".to_string(),
2579                            format!("b-{i}"),
2580                            "bob".to_string(),
2581                            serde_json::json!({"i": i}),
2582                            None,
2583                        )
2584                        .unwrap(),
2585                    )
2586                    .unwrap();
2587            }
2588        }
2589        // Mark both as loaded for accurate eviction bookkeeping.
2590        store.tenant_loader.mark_loaded("alice");
2591        store.tenant_loader.mark_loaded("bob");
2592
2593        store.evict_tenant("alice");
2594
2595        let bob_results = store
2596            .query(&QueryEventsRequest {
2597                entity_id: None,
2598                event_type: None,
2599                tenant_id: Some("bob".to_string()),
2600                as_of: None,
2601                since: None,
2602                until: None,
2603                limit: None,
2604                event_type_prefix: None,
2605                payload_filter: None,
2606            })
2607            .unwrap();
2608        assert_eq!(bob_results.len(), 2);
2609        for e in &bob_results {
2610            assert_eq!(e.tenant_id_str(), "bob");
2611        }
2612    }
2613
2614    #[test]
2615    fn test_budget_eviction_keeps_resident_set_bounded() {
2616        // Configure a tiny budget. Load three tenants in sequence;
2617        // the third load must evict the LRU tenant, keeping the
2618        // resident set under (or near) the budget.
2619        let temp_dir = TempDir::new().unwrap();
2620        let storage_dir = temp_dir.path().to_path_buf();
2621
2622        // Persist 5 events per tenant with ~1 KiB payloads. Each
2623        // tenant ends up at ~5 KiB + overhead.
2624        let big_payload = serde_json::json!({"data": "x".repeat(1000)});
2625        {
2626            let store = EventStore::with_config(EventStoreConfig::with_persistence(&storage_dir));
2627            for tenant in ["alice", "bob", "carol"] {
2628                for i in 0..5 {
2629                    store
2630                        .ingest(
2631                            &Event::from_strings(
2632                                "test.event".to_string(),
2633                                format!("{tenant}-{i}"),
2634                                tenant.to_string(),
2635                                big_payload.clone(),
2636                                None,
2637                            )
2638                            .unwrap(),
2639                        )
2640                        .unwrap();
2641                }
2642            }
2643            store.flush_storage().unwrap();
2644        }
2645
2646        // Budget = 12 KiB. Two tenants (~6 KiB each = ~12 KiB) is
2647        // tight; loading a third must evict.
2648        let mut config = EventStoreConfig::with_persistence(&storage_dir);
2649        config.cache_byte_budget = Some(12_000);
2650        let store = EventStore::with_config(config);
2651
2652        // Load alice — under budget, no eviction.
2653        store.ensure_tenant_loaded("alice").unwrap();
2654        assert!(store.is_tenant_loaded("alice"));
2655
2656        // Touch alice and immediately load bob. Bob is the
2657        // freshly-loaded one, so bob is excluded from eviction.
2658        // Alice is the next-oldest. After the load, total may
2659        // exceed budget — if so, evict alice.
2660        store.tenant_loader.touch("alice");
2661        std::thread::sleep(std::time::Duration::from_millis(10));
2662        store.ensure_tenant_loaded("bob").unwrap();
2663        assert!(store.is_tenant_loaded("bob"));
2664
2665        // Touch bob, load carol. Carol is freshly-loaded; the LRU
2666        // candidate is the older of {alice, bob} — alice (since
2667        // bob was just touched).
2668        store.tenant_loader.touch("bob");
2669        std::thread::sleep(std::time::Duration::from_millis(10));
2670        store.ensure_tenant_loaded("carol").unwrap();
2671        assert!(store.is_tenant_loaded("carol"));
2672
2673        // After all loads, the cache must respect the budget OR
2674        // (if a single tenant alone exceeds it) we should at most
2675        // hold the just-loaded tenant. The test budget is small
2676        // enough that we expect at least one eviction.
2677        let resident = store.cache_resident_bytes();
2678        let budget = 12_000u64;
2679
2680        // Either we're within the budget, or only the freshly-loaded
2681        // tenant is left (the "single oversized tenant" fallback).
2682        if resident > budget {
2683            let loaded_count = ["alice", "bob", "carol"]
2684                .iter()
2685                .filter(|t| store.is_tenant_loaded(t))
2686                .count();
2687            assert_eq!(
2688                loaded_count, 1,
2689                "over budget but more than one tenant loaded — eviction policy didn't fire"
2690            );
2691        }
2692
2693        // Carol must still be loaded — it's the most recent and
2694        // never picked as a victim.
2695        assert!(store.is_tenant_loaded("carol"));
2696    }
2697
2698    #[test]
2699    fn test_query_after_eviction_re_loads_transparently() {
2700        // The end-to-end shape of AC #5: query → evict → query
2701        // again returns the right data.
2702        let temp_dir = TempDir::new().unwrap();
2703        let storage_dir = temp_dir.path().to_path_buf();
2704
2705        let big_payload = serde_json::json!({"data": "x".repeat(2000)});
2706        {
2707            let store = EventStore::with_config(EventStoreConfig::with_persistence(&storage_dir));
2708            for tenant in ["alice", "bob"] {
2709                for i in 0..3 {
2710                    store
2711                        .ingest(
2712                            &Event::from_strings(
2713                                "test.event".to_string(),
2714                                format!("{tenant}-{i}"),
2715                                tenant.to_string(),
2716                                big_payload.clone(),
2717                                None,
2718                            )
2719                            .unwrap(),
2720                        )
2721                        .unwrap();
2722                }
2723            }
2724            store.flush_storage().unwrap();
2725        }
2726
2727        // Budget = 5 KiB — one tenant fits, two don't.
2728        let mut config = EventStoreConfig::with_persistence(&storage_dir);
2729        config.cache_byte_budget = Some(5_000);
2730        let store = EventStore::with_config(config);
2731
2732        // Query alice — sized at ~6 KiB, so over budget but no
2733        // peer to evict; alice stays as the single-oversized-tenant
2734        // case.
2735        let alice_first = store
2736            .query(&QueryEventsRequest {
2737                entity_id: None,
2738                event_type: None,
2739                tenant_id: Some("alice".to_string()),
2740                as_of: None,
2741                since: None,
2742                until: None,
2743                limit: None,
2744                event_type_prefix: None,
2745                payload_filter: None,
2746            })
2747            .unwrap();
2748        assert_eq!(alice_first.len(), 3);
2749
2750        // Sleep to make alice older than bob in the LRU ordering.
2751        std::thread::sleep(std::time::Duration::from_millis(15));
2752        // Query bob — alice will get evicted.
2753        let _bob = store
2754            .query(&QueryEventsRequest {
2755                entity_id: None,
2756                event_type: None,
2757                tenant_id: Some("bob".to_string()),
2758                as_of: None,
2759                since: None,
2760                until: None,
2761                limit: None,
2762                event_type_prefix: None,
2763                payload_filter: None,
2764            })
2765            .unwrap();
2766        assert!(
2767            !store.is_tenant_loaded("alice"),
2768            "alice should have been evicted"
2769        );
2770
2771        // Re-query alice — must transparently re-load.
2772        let alice_second = store
2773            .query(&QueryEventsRequest {
2774                entity_id: None,
2775                event_type: None,
2776                tenant_id: Some("alice".to_string()),
2777                as_of: None,
2778                since: None,
2779                until: None,
2780                limit: None,
2781                event_type_prefix: None,
2782                payload_filter: None,
2783            })
2784            .unwrap();
2785        assert_eq!(
2786            alice_second.len(),
2787            3,
2788            "alice's events come back via re-load"
2789        );
2790        assert!(store.is_tenant_loaded("alice"));
2791    }
2792
2793    #[test]
2794    #[cfg(feature = "server")]
2795    fn test_cache_metrics_track_evictions_and_bytes() {
2796        // Smoke test for the Step 3 #4 Prometheus metrics —
2797        // confirms the counter increments on eviction and the
2798        // gauge tracks the resident bytes.
2799        let temp_dir = TempDir::new().unwrap();
2800        let storage_dir = temp_dir.path().to_path_buf();
2801
2802        let big_payload = serde_json::json!({"data": "x".repeat(2000)});
2803        {
2804            let store = EventStore::with_config(EventStoreConfig::with_persistence(&storage_dir));
2805            for tenant in ["alice", "bob"] {
2806                for i in 0..3 {
2807                    store
2808                        .ingest(
2809                            &Event::from_strings(
2810                                "test.event".to_string(),
2811                                format!("{tenant}-{i}"),
2812                                tenant.to_string(),
2813                                big_payload.clone(),
2814                                None,
2815                            )
2816                            .unwrap(),
2817                        )
2818                        .unwrap();
2819                }
2820            }
2821            store.flush_storage().unwrap();
2822        }
2823
2824        let mut config = EventStoreConfig::with_persistence(&storage_dir);
2825        config.cache_byte_budget = Some(5_000); // forces eviction
2826        let store = EventStore::with_config(config);
2827
2828        assert_eq!(store.metrics.cache_evictions_total.get(), 0);
2829        assert_eq!(store.metrics.cache_bytes.get(), 0);
2830
2831        store.ensure_tenant_loaded("alice").unwrap();
2832        // After loading alice, gauge reflects her bytes.
2833        let after_alice = store.metrics.cache_bytes.get();
2834        assert!(after_alice > 0, "gauge should reflect alice's bytes");
2835        // Single oversized tenant — no eviction yet.
2836        assert_eq!(store.metrics.cache_evictions_total.get(), 0);
2837
2838        std::thread::sleep(std::time::Duration::from_millis(10));
2839        store.ensure_tenant_loaded("bob").unwrap();
2840
2841        // Bob's load pushed total over budget; alice (older) was
2842        // evicted. Counter increments.
2843        assert_eq!(
2844            store.metrics.cache_evictions_total.get(),
2845            1,
2846            "exactly one tenant evicted after bob's load"
2847        );
2848        // Gauge now reflects only bob's bytes.
2849        let after_bob = store.metrics.cache_bytes.get();
2850        assert!(after_bob > 0);
2851        assert!(after_bob <= after_alice, "gauge dropped after eviction");
2852    }
2853
2854    #[test]
2855    fn test_stress_resident_set_stays_near_budget_under_rolling_queries() {
2856        // Scaled-down version of the bead's stress test: the
2857        // bead's 10 × 50 MB / 100 MB ratio (10× tenants vs
2858        // budget-headroom) preserved at 500 KB / 1 MB to stay
2859        // unit-test-fast. The same correctness property: after
2860        // many rolling queries across more tenants than fit, the
2861        // resident set must stay at-or-near the budget.
2862        let temp_dir = TempDir::new().unwrap();
2863        let storage_dir = temp_dir.path().to_path_buf();
2864
2865        const TENANT_COUNT: usize = 10;
2866        const EVENTS_PER_TENANT: usize = 50;
2867        // Per-event payload ~10 KiB → tenant ~ 500 KiB.
2868        let big_payload = serde_json::json!({"data": "x".repeat(10_000)});
2869
2870        // Persist all tenants. Each ends up at ~500 KiB on disk
2871        // (and roughly the same in memory once loaded).
2872        {
2873            let store = EventStore::with_config(EventStoreConfig::with_persistence(&storage_dir));
2874            for t in 0..TENANT_COUNT {
2875                let tenant = format!("tenant-{t}");
2876                for i in 0..EVENTS_PER_TENANT {
2877                    store
2878                        .ingest(
2879                            &Event::from_strings(
2880                                "test.event".to_string(),
2881                                format!("{tenant}-{i}"),
2882                                tenant.clone(),
2883                                big_payload.clone(),
2884                                None,
2885                            )
2886                            .unwrap(),
2887                        )
2888                        .unwrap();
2889                }
2890            }
2891            store.flush_storage().unwrap();
2892        }
2893
2894        // Budget = 1 MiB → fits ~2 tenants. We're going to query
2895        // all 10, so the LRU policy must hold the resident set
2896        // near 1 MiB across the rolling sequence.
2897        const BUDGET: u64 = 1_048_576;
2898        let mut config = EventStoreConfig::with_persistence(&storage_dir);
2899        config.cache_byte_budget = Some(BUDGET);
2900        let store = EventStore::with_config(config);
2901
2902        // Sweep through tenants in order. Each query loads its
2903        // tenant; if budget is exceeded after the load, an LRU
2904        // eviction fires.
2905        let mut peak_resident: u64 = 0;
2906        for t in 0..TENANT_COUNT {
2907            let tenant = format!("tenant-{t}");
2908            let results = store
2909                .query(&QueryEventsRequest {
2910                    entity_id: None,
2911                    event_type: None,
2912                    tenant_id: Some(tenant.clone()),
2913                    as_of: None,
2914                    since: None,
2915                    until: None,
2916                    limit: None,
2917                    event_type_prefix: None,
2918                    payload_filter: None,
2919                })
2920                .unwrap();
2921            assert_eq!(
2922                results.len(),
2923                EVENTS_PER_TENANT,
2924                "every per-tenant query must return all of that tenant's events"
2925            );
2926            // Track peak resident bytes seen during the sweep.
2927            let resident = store.cache_resident_bytes();
2928            if resident > peak_resident {
2929                peak_resident = resident;
2930            }
2931        }
2932
2933        let final_resident = store.cache_resident_bytes();
2934
2935        // Tolerance: a tenant's bytes get added before eviction
2936        // fires, so peak transiently exceeds the budget by at
2937        // most one tenant's worth (~500 KiB). The final state
2938        // after the sweep should be well-bounded.
2939        let tolerance = BUDGET; // generous: 2× budget upper bound
2940        assert!(
2941            peak_resident <= BUDGET + tolerance,
2942            "peak resident {peak_resident} exceeds budget {BUDGET} by more than {tolerance} \
2943             — eviction policy not keeping up with the working-set churn"
2944        );
2945        assert!(
2946            final_resident <= BUDGET + tolerance,
2947            "final resident {final_resident} exceeds budget {BUDGET} by more than {tolerance}"
2948        );
2949
2950        // The most-recently-queried tenant must still be loaded
2951        // (it was just touched).
2952        let last_tenant = format!("tenant-{}", TENANT_COUNT - 1);
2953        assert!(
2954            store.is_tenant_loaded(&last_tenant),
2955            "the most-recent tenant must remain loaded after the sweep"
2956        );
2957
2958        // At least some tenants must have been evicted — otherwise
2959        // the budget didn't fire.
2960        let still_loaded = (0..TENANT_COUNT)
2961            .filter(|t| store.is_tenant_loaded(&format!("tenant-{t}")))
2962            .count();
2963        assert!(
2964            still_loaded < TENANT_COUNT,
2965            "no tenants evicted ({still_loaded}/{TENANT_COUNT} still loaded) — \
2966             budget enforcement didn't engage"
2967        );
2968    }
2969
2970    #[test]
2971    fn test_evict_tenant_when_not_loaded_is_a_noop() {
2972        // Eviction of a never-loaded tenant must not panic and
2973        // must not affect other tenants.
2974        let store = EventStore::new();
2975        store.evict_tenant("nobody"); // should not panic
2976        assert!(!store.is_tenant_loaded("nobody"));
2977    }
2978
2979    #[test]
2980    fn test_lazy_load_accounts_bytes_per_tenant() {
2981        // Step 3 #1: per-tenant byte tracking. Loading a tenant
2982        // should accumulate bytes proportional to its event
2983        // payload sizes; another tenant's counter must stay 0.
2984        let temp_dir = TempDir::new().unwrap();
2985        let storage_dir = temp_dir.path().to_path_buf();
2986
2987        // Persist 5 events for alice with measurable-size payloads.
2988        {
2989            let store = EventStore::with_config(EventStoreConfig::with_persistence(&storage_dir));
2990            for i in 0..5 {
2991                store
2992                    .ingest(
2993                        &Event::from_strings(
2994                            "test.event".to_string(),
2995                            format!("a-{i}"),
2996                            "alice".to_string(),
2997                            serde_json::json!({"data": "x".repeat(1000)}),
2998                            None,
2999                        )
3000                        .unwrap(),
3001                    )
3002                    .unwrap();
3003            }
3004            store.flush_storage().unwrap();
3005        }
3006
3007        let store = EventStore::with_config(EventStoreConfig::with_persistence(&storage_dir));
3008        // Cold: zero bytes accounted.
3009        assert_eq!(store.tenant_resident_bytes("alice"), 0);
3010        assert_eq!(store.cache_resident_bytes(), 0);
3011
3012        store.ensure_tenant_loaded("alice").unwrap();
3013
3014        // After load: alice's counter is non-trivial (5 events
3015        // each carrying ~1000 bytes of payload + overhead).
3016        let alice_bytes = store.tenant_resident_bytes("alice");
3017        assert!(
3018            alice_bytes >= 5 * 1000,
3019            "alice should have at least 5 KiB resident; got {alice_bytes}"
3020        );
3021        // Bob never loaded → 0.
3022        assert_eq!(store.tenant_resident_bytes("bob"), 0);
3023        // Total equals alice's portion (only loaded tenant).
3024        assert_eq!(store.cache_resident_bytes(), alice_bytes);
3025    }
3026
3027    #[test]
3028    fn test_query_lazy_loads_tenant_on_first_call() {
3029        // The end-to-end shape of Step 2: persist events for a
3030        // tenant in session 1, restart, and confirm session 2 boots
3031        // empty but a query for that tenant pulls them in.
3032        let temp_dir = TempDir::new().unwrap();
3033        let storage_dir = temp_dir.path().to_path_buf();
3034
3035        // Session 1: ingest 3 events for tenant "alice", flush, drop.
3036        {
3037            let store = EventStore::with_config(EventStoreConfig::with_persistence(&storage_dir));
3038            for i in 0..3 {
3039                let event = Event::from_strings(
3040                    "test.event".to_string(),
3041                    format!("e-{i}"),
3042                    "alice".to_string(),
3043                    serde_json::json!({"i": i}),
3044                    None,
3045                )
3046                .unwrap();
3047                store.ingest(&event).unwrap();
3048            }
3049            store.flush_storage().unwrap();
3050        }
3051
3052        // Session 2: fresh boot. Events on disk, nothing in memory.
3053        let store = EventStore::with_config(EventStoreConfig::with_persistence(&storage_dir));
3054        assert_eq!(
3055            store.stats().total_events,
3056            0,
3057            "boot must be O(1) — no Parquet pre-load"
3058        );
3059        assert!(!store.is_tenant_loaded("alice"));
3060        assert!(!store.is_tenant_loaded("bob"));
3061
3062        // First query for alice: triggers ensure_tenant_loaded.
3063        let results = store
3064            .query(&QueryEventsRequest {
3065                entity_id: None,
3066                event_type: None,
3067                tenant_id: Some("alice".to_string()),
3068                as_of: None,
3069                since: None,
3070                until: None,
3071                limit: None,
3072                event_type_prefix: None,
3073                payload_filter: None,
3074            })
3075            .unwrap();
3076        assert_eq!(results.len(), 3, "alice's 3 events are returned");
3077        assert!(store.is_tenant_loaded("alice"), "alice now warm");
3078        // bob untouched — load is per-tenant, so a query for alice
3079        // must not have hydrated bob.
3080        assert!(!store.is_tenant_loaded("bob"), "bob still cold");
3081    }
3082
3083    #[test]
3084    fn test_query_invalid_tenant_id_returns_error_no_hang() {
3085        // Step 2 acceptance criterion: in-flight load failures
3086        // surface as errors, not infinite hangs. Path-traversal
3087        // input fails fast at sanitization and propagates.
3088        let temp_dir = TempDir::new().unwrap();
3089        let store = EventStore::with_config(EventStoreConfig::with_persistence(temp_dir.path()));
3090
3091        let result = store.query(&QueryEventsRequest {
3092            entity_id: None,
3093            event_type: None,
3094            tenant_id: Some("../etc".to_string()),
3095            as_of: None,
3096            since: None,
3097            until: None,
3098            limit: None,
3099            event_type_prefix: None,
3100            payload_filter: None,
3101        });
3102        assert!(result.is_err(), "unsafe tenant_id must surface as error");
3103    }
3104
3105    #[test]
3106    fn test_query_concurrent_first_queries_for_same_tenant_all_succeed() {
3107        // Singleflight: N threads racing to query the same cold
3108        // tenant must all return the same correct result. The
3109        // tenant-load must happen exactly once (verified
3110        // structurally by the per-tenant Mutex in tenant_loader,
3111        // tested directly in test_singleflight_blocks_second_caller).
3112        // This integration test confirms the wiring at the query
3113        // level — no thread observes a half-loaded state.
3114        let temp_dir = TempDir::new().unwrap();
3115        let storage_dir = temp_dir.path().to_path_buf();
3116
3117        // Persist 25 events for tenant "alice".
3118        {
3119            let store = EventStore::with_config(EventStoreConfig::with_persistence(&storage_dir));
3120            for i in 0..25 {
3121                let event = Event::from_strings(
3122                    "test.event".to_string(),
3123                    format!("e-{i}"),
3124                    "alice".to_string(),
3125                    serde_json::json!({"i": i}),
3126                    None,
3127                )
3128                .unwrap();
3129                store.ingest(&event).unwrap();
3130            }
3131            store.flush_storage().unwrap();
3132        }
3133
3134        // Fresh boot, then 8 threads simultaneously query alice.
3135        let store = Arc::new(EventStore::with_config(EventStoreConfig::with_persistence(
3136            &storage_dir,
3137        )));
3138        assert!(!store.is_tenant_loaded("alice"));
3139
3140        let mut handles = Vec::new();
3141        for _ in 0..8 {
3142            let s = store.clone();
3143            handles.push(std::thread::spawn(move || {
3144                s.query(&QueryEventsRequest {
3145                    entity_id: None,
3146                    event_type: None,
3147                    tenant_id: Some("alice".to_string()),
3148                    as_of: None,
3149                    since: None,
3150                    until: None,
3151                    limit: None,
3152                    event_type_prefix: None,
3153                    payload_filter: None,
3154                })
3155            }));
3156        }
3157
3158        for h in handles {
3159            let result = h.join().unwrap().unwrap();
3160            assert_eq!(
3161                result.len(),
3162                25,
3163                "every concurrent caller must see all 25 events"
3164            );
3165        }
3166        assert!(store.is_tenant_loaded("alice"));
3167        // Memory has exactly 25 events — no double-load.
3168        assert_eq!(store.stats().total_events, 25);
3169    }
3170
3171    #[test]
3172    fn test_query_two_cold_tenants_load_independently() {
3173        // Querying tenant A loads only A; querying B then loads
3174        // only B. State after both queries: both tenants warm,
3175        // memory has exactly the expected event counts.
3176        let temp_dir = TempDir::new().unwrap();
3177        let storage_dir = temp_dir.path().to_path_buf();
3178
3179        {
3180            let store = EventStore::with_config(EventStoreConfig::with_persistence(&storage_dir));
3181            for i in 0..3 {
3182                store
3183                    .ingest(
3184                        &Event::from_strings(
3185                            "test.event".to_string(),
3186                            format!("a-{i}"),
3187                            "alice".to_string(),
3188                            serde_json::json!({"i": i}),
3189                            None,
3190                        )
3191                        .unwrap(),
3192                    )
3193                    .unwrap();
3194            }
3195            for i in 0..5 {
3196                store
3197                    .ingest(
3198                        &Event::from_strings(
3199                            "test.event".to_string(),
3200                            format!("b-{i}"),
3201                            "bob".to_string(),
3202                            serde_json::json!({"i": i}),
3203                            None,
3204                        )
3205                        .unwrap(),
3206                    )
3207                    .unwrap();
3208            }
3209            store.flush_storage().unwrap();
3210        }
3211
3212        let store = EventStore::with_config(EventStoreConfig::with_persistence(&storage_dir));
3213        assert_eq!(store.stats().total_events, 0);
3214
3215        // Query alice — bob stays cold.
3216        let alice = store
3217            .query(&QueryEventsRequest {
3218                entity_id: None,
3219                event_type: None,
3220                tenant_id: Some("alice".to_string()),
3221                as_of: None,
3222                since: None,
3223                until: None,
3224                limit: None,
3225                event_type_prefix: None,
3226                payload_filter: None,
3227            })
3228            .unwrap();
3229        assert_eq!(alice.len(), 3);
3230        assert!(store.is_tenant_loaded("alice"));
3231        assert!(!store.is_tenant_loaded("bob"));
3232        assert_eq!(store.stats().total_events, 3);
3233
3234        // Query bob — both warm now.
3235        let bob = store
3236            .query(&QueryEventsRequest {
3237                entity_id: None,
3238                event_type: None,
3239                tenant_id: Some("bob".to_string()),
3240                as_of: None,
3241                since: None,
3242                until: None,
3243                limit: None,
3244                event_type_prefix: None,
3245                payload_filter: None,
3246            })
3247            .unwrap();
3248        assert_eq!(bob.len(), 5);
3249        assert!(store.is_tenant_loaded("bob"));
3250        assert_eq!(store.stats().total_events, 8);
3251    }
3252
3253    #[test]
3254    fn test_boot_with_persisted_data_is_o1() {
3255        // Step 2's headline acceptance criterion: boot time does
3256        // not scale with persisted-data size. The 5M-events / <2s
3257        // target is too large for a unit test, so this asserts the
3258        // weaker but structural property: boot reads zero events
3259        // into memory regardless of how many are on disk.
3260        //
3261        // We persist 50 events across 3 tenants in session 1,
3262        // restart in session 2, and verify session 2's
3263        // total_events is 0. The actual boot wall-clock isn't
3264        // asserted here — it's machine-dependent — but the absence
3265        // of any in-memory data is the structural proxy that the
3266        // boot path no longer iterates Parquet.
3267        let temp_dir = TempDir::new().unwrap();
3268        let storage_dir = temp_dir.path().to_path_buf();
3269
3270        {
3271            let store = EventStore::with_config(EventStoreConfig::with_persistence(&storage_dir));
3272            for tenant in ["alice", "bob", "carol"] {
3273                for i in 0..50 / 3 {
3274                    store
3275                        .ingest(
3276                            &Event::from_strings(
3277                                "test.event".to_string(),
3278                                format!("{tenant}-{i}"),
3279                                tenant.to_string(),
3280                                serde_json::json!({"i": i}),
3281                                None,
3282                            )
3283                            .unwrap(),
3284                        )
3285                        .unwrap();
3286                }
3287            }
3288            store.flush_storage().unwrap();
3289        }
3290
3291        // Confirm there is in fact data on disk to load.
3292        let on_disk = find_parquet_files(&storage_dir);
3293        assert!(
3294            !on_disk.is_empty(),
3295            "session 1 should have produced parquet files; pre-condition for the test"
3296        );
3297
3298        let started = std::time::Instant::now();
3299        let store = EventStore::with_config(EventStoreConfig::with_persistence(&storage_dir));
3300        let boot_elapsed = started.elapsed();
3301
3302        assert_eq!(
3303            store.stats().total_events,
3304            0,
3305            "boot must not pre-load any Parquet events"
3306        );
3307
3308        // Sanity: even on a slow CI box, an O(1) boot finishes in
3309        // well under a second. If this trips it's a strong signal
3310        // the boot path regressed to scanning the Parquet tree.
3311        assert!(
3312            boot_elapsed < std::time::Duration::from_secs(2),
3313            "boot took {boot_elapsed:?} — Step 2 boot should be O(1)"
3314        );
3315    }
3316
3317    #[test]
3318    fn test_query_warm_tenant_does_not_re_read_disk() {
3319        // Performance contract: a warm tenant query goes through the
3320        // DashMap fast path. We can't easily assert "no disk read"
3321        // directly in a unit test, but we CAN assert the call
3322        // succeeds in O(in-memory-events) time even after the
3323        // on-disk file is removed — proving we didn't re-walk it.
3324        let temp_dir = TempDir::new().unwrap();
3325        let storage_dir = temp_dir.path().to_path_buf();
3326
3327        let store = EventStore::with_config(EventStoreConfig::with_persistence(&storage_dir));
3328        for i in 0..3 {
3329            let event = Event::from_strings(
3330                "test.event".to_string(),
3331                format!("e-{i}"),
3332                "alice".to_string(),
3333                serde_json::json!({"i": i}),
3334                None,
3335            )
3336            .unwrap();
3337            store.ingest(&event).unwrap();
3338        }
3339        store.flush_storage().unwrap();
3340
3341        // First query: cold, hits disk.
3342        let _ = store
3343            .query(&QueryEventsRequest {
3344                entity_id: None,
3345                event_type: None,
3346                tenant_id: Some("alice".to_string()),
3347                as_of: None,
3348                since: None,
3349                until: None,
3350                limit: None,
3351                event_type_prefix: None,
3352                payload_filter: None,
3353            })
3354            .unwrap();
3355        assert!(store.is_tenant_loaded("alice"));
3356
3357        // Now wipe the on-disk file. A warm-path query must still
3358        // succeed because it doesn't need disk.
3359        let parquet_files = find_parquet_files(&storage_dir);
3360        for f in parquet_files {
3361            std::fs::remove_file(&f).unwrap();
3362        }
3363
3364        let results = store
3365            .query(&QueryEventsRequest {
3366                entity_id: None,
3367                event_type: None,
3368                tenant_id: Some("alice".to_string()),
3369                as_of: None,
3370                since: None,
3371                until: None,
3372                limit: None,
3373                event_type_prefix: None,
3374                payload_filter: None,
3375            })
3376            .unwrap();
3377        assert_eq!(
3378            results.len(),
3379            3,
3380            "warm tenant query must not need disk; got {} events from a deleted parquet",
3381            results.len()
3382        );
3383    }
3384
3385    #[test]
3386    fn test_event_store_default() {
3387        let store = EventStore::default();
3388        assert_eq!(store.stats().total_events, 0);
3389    }
3390
3391    #[test]
3392    fn test_ingest_single_event() {
3393        let store = EventStore::new();
3394        let event = create_test_event("entity-1", "user.created");
3395
3396        store.ingest(&event).unwrap();
3397
3398        assert_eq!(store.stats().total_events, 1);
3399        assert_eq!(store.stats().total_ingested, 1);
3400    }
3401
3402    #[test]
3403    fn test_ingest_multiple_events() {
3404        let store = EventStore::new();
3405
3406        for i in 0..10 {
3407            let event = create_test_event(&format!("entity-{i}"), "user.created");
3408            store.ingest(&event).unwrap();
3409        }
3410
3411        assert_eq!(store.stats().total_events, 10);
3412        assert_eq!(store.stats().total_ingested, 10);
3413    }
3414
3415    #[test]
3416    fn test_query_by_entity_id() {
3417        let store = EventStore::new();
3418
3419        store
3420            .ingest(&create_test_event("entity-1", "user.created"))
3421            .unwrap();
3422        store
3423            .ingest(&create_test_event("entity-2", "user.created"))
3424            .unwrap();
3425        store
3426            .ingest(&create_test_event("entity-1", "user.updated"))
3427            .unwrap();
3428
3429        let results = store
3430            .query(&QueryEventsRequest {
3431                entity_id: Some("entity-1".to_string()),
3432                event_type: None,
3433                tenant_id: None,
3434                as_of: None,
3435                since: None,
3436                until: None,
3437                limit: None,
3438                event_type_prefix: None,
3439                payload_filter: None,
3440            })
3441            .unwrap();
3442
3443        assert_eq!(results.len(), 2);
3444    }
3445
3446    #[test]
3447    fn test_query_by_event_type() {
3448        let store = EventStore::new();
3449
3450        store
3451            .ingest(&create_test_event("entity-1", "user.created"))
3452            .unwrap();
3453        store
3454            .ingest(&create_test_event("entity-2", "user.updated"))
3455            .unwrap();
3456        store
3457            .ingest(&create_test_event("entity-3", "user.created"))
3458            .unwrap();
3459
3460        let results = store
3461            .query(&QueryEventsRequest {
3462                entity_id: None,
3463                event_type: Some("user.created".to_string()),
3464                tenant_id: None,
3465                as_of: None,
3466                since: None,
3467                until: None,
3468                limit: None,
3469                event_type_prefix: None,
3470                payload_filter: None,
3471            })
3472            .unwrap();
3473
3474        assert_eq!(results.len(), 2);
3475    }
3476
3477    #[test]
3478    fn test_query_with_limit() {
3479        let store = EventStore::new();
3480
3481        for i in 0..10 {
3482            let event = create_test_event(&format!("entity-{i}"), "user.created");
3483            store.ingest(&event).unwrap();
3484        }
3485
3486        let results = store
3487            .query(&QueryEventsRequest {
3488                entity_id: None,
3489                event_type: None,
3490                tenant_id: None,
3491                as_of: None,
3492                since: None,
3493                until: None,
3494                limit: Some(5),
3495                event_type_prefix: None,
3496                payload_filter: None,
3497            })
3498            .unwrap();
3499
3500        assert_eq!(results.len(), 5);
3501    }
3502
3503    #[test]
3504    fn test_query_empty_store() {
3505        let store = EventStore::new();
3506
3507        let results = store
3508            .query(&QueryEventsRequest {
3509                entity_id: Some("non-existent".to_string()),
3510                event_type: None,
3511                tenant_id: None,
3512                as_of: None,
3513                since: None,
3514                until: None,
3515                limit: None,
3516                event_type_prefix: None,
3517                payload_filter: None,
3518            })
3519            .unwrap();
3520
3521        assert!(results.is_empty());
3522    }
3523
3524    #[test]
3525    fn test_reconstruct_state() {
3526        let store = EventStore::new();
3527
3528        store
3529            .ingest(&create_test_event("entity-1", "user.created"))
3530            .unwrap();
3531
3532        let state = store.reconstruct_state("entity-1", None).unwrap();
3533        // The state is wrapped with metadata
3534        assert_eq!(state["current_state"]["name"], "Test");
3535        assert_eq!(state["current_state"]["value"], 42);
3536    }
3537
3538    #[test]
3539    fn test_reconstruct_state_not_found() {
3540        let store = EventStore::new();
3541
3542        let result = store.reconstruct_state("non-existent", None);
3543        assert!(result.is_err());
3544    }
3545
3546    #[test]
3547    fn test_get_snapshot_empty() {
3548        let store = EventStore::new();
3549
3550        let result = store.get_snapshot("non-existent");
3551        // Entity not found error is expected
3552        assert!(result.is_err());
3553    }
3554
3555    #[test]
3556    fn test_create_snapshot() {
3557        let store = EventStore::new();
3558
3559        store
3560            .ingest(&create_test_event("entity-1", "user.created"))
3561            .unwrap();
3562
3563        store.create_snapshot("entity-1").unwrap();
3564
3565        // Verify snapshot was created
3566        let snapshot = store.get_snapshot("entity-1").unwrap();
3567        assert!(snapshot != serde_json::json!(null));
3568    }
3569
3570    #[test]
3571    fn test_create_snapshot_entity_not_found() {
3572        let store = EventStore::new();
3573
3574        let result = store.create_snapshot("non-existent");
3575        assert!(result.is_err());
3576    }
3577
3578    #[test]
3579    fn test_websocket_manager() {
3580        let store = EventStore::new();
3581        let manager = store.websocket_manager();
3582        // Manager should be accessible
3583        assert!(Arc::strong_count(&manager) >= 1);
3584    }
3585
3586    #[test]
3587    fn test_snapshot_manager() {
3588        let store = EventStore::new();
3589        let manager = store.snapshot_manager();
3590        assert!(Arc::strong_count(&manager) >= 1);
3591    }
3592
3593    #[test]
3594    fn test_compaction_manager_none() {
3595        let store = EventStore::new();
3596        // Without storage_dir, compaction manager should be None
3597        assert!(store.compaction_manager().is_none());
3598    }
3599
3600    #[test]
3601    fn test_schema_registry() {
3602        let store = EventStore::new();
3603        let registry = store.schema_registry();
3604        assert!(Arc::strong_count(&registry) >= 1);
3605    }
3606
3607    #[test]
3608    fn test_replay_manager() {
3609        let store = EventStore::new();
3610        let manager = store.replay_manager();
3611        assert!(Arc::strong_count(&manager) >= 1);
3612    }
3613
3614    #[test]
3615    fn test_pipeline_manager() {
3616        let store = EventStore::new();
3617        let manager = store.pipeline_manager();
3618        assert!(Arc::strong_count(&manager) >= 1);
3619    }
3620
3621    #[test]
3622    fn test_projection_manager() {
3623        let store = EventStore::new();
3624        let manager = store.projection_manager();
3625        // Built-in projections should be registered
3626        let projections = manager.list_projections();
3627        assert!(projections.len() >= 2); // entity_snapshots and event_counters
3628    }
3629
3630    #[test]
3631    fn test_projection_state_cache() {
3632        let store = EventStore::new();
3633        let cache = store.projection_state_cache();
3634
3635        cache.insert("test:key".to_string(), serde_json::json!({"value": 123}));
3636        assert_eq!(cache.len(), 1);
3637
3638        let value = cache.get("test:key").unwrap();
3639        assert_eq!(value["value"], 123);
3640    }
3641
3642    #[test]
3643    fn test_metrics() {
3644        let store = EventStore::new();
3645        let metrics = store.metrics();
3646        assert!(Arc::strong_count(&metrics) >= 1);
3647    }
3648
3649    #[test]
3650    fn test_store_stats() {
3651        let store = EventStore::new();
3652
3653        store
3654            .ingest(&create_test_event("entity-1", "user.created"))
3655            .unwrap();
3656        store
3657            .ingest(&create_test_event("entity-2", "order.placed"))
3658            .unwrap();
3659
3660        let stats = store.stats();
3661        assert_eq!(stats.total_events, 2);
3662        assert_eq!(stats.total_entities, 2);
3663        assert_eq!(stats.total_event_types, 2);
3664        assert_eq!(stats.total_ingested, 2);
3665    }
3666
3667    #[test]
3668    fn test_event_store_config_default() {
3669        let config = EventStoreConfig::default();
3670        assert!(config.storage_dir.is_none());
3671        assert!(config.wal_dir.is_none());
3672    }
3673
3674    #[test]
3675    fn test_event_store_config_with_persistence() {
3676        let temp_dir = TempDir::new().unwrap();
3677        let config = EventStoreConfig::with_persistence(temp_dir.path());
3678
3679        assert!(config.storage_dir.is_some());
3680        assert!(config.wal_dir.is_none());
3681    }
3682
3683    #[test]
3684    fn test_event_store_config_with_wal() {
3685        let temp_dir = TempDir::new().unwrap();
3686        let config = EventStoreConfig::with_wal(temp_dir.path(), WALConfig::default());
3687
3688        assert!(config.storage_dir.is_none());
3689        assert!(config.wal_dir.is_some());
3690    }
3691
3692    #[test]
3693    fn test_event_store_config_with_all() {
3694        let temp_dir = TempDir::new().unwrap();
3695        let config = EventStoreConfig::with_all(temp_dir.path(), SnapshotConfig::default());
3696
3697        assert!(config.storage_dir.is_some());
3698    }
3699
3700    #[test]
3701    fn test_event_store_config_production() {
3702        let storage_dir = TempDir::new().unwrap();
3703        let wal_dir = TempDir::new().unwrap();
3704        let config = EventStoreConfig::production(
3705            storage_dir.path(),
3706            wal_dir.path(),
3707            SnapshotConfig::default(),
3708            WALConfig::default(),
3709            CompactionConfig::default(),
3710        );
3711
3712        assert!(config.storage_dir.is_some());
3713        assert!(config.wal_dir.is_some());
3714    }
3715
3716    // -----------------------------------------------------------------------
3717    // from_env_vars tests — verifies the env-var-to-config wiring that
3718    // caused the durability bug (events lost on restart) in v0.10.3.
3719    // -----------------------------------------------------------------------
3720
3721    #[test]
3722    fn test_from_env_vars_data_dir_enables_full_persistence() {
3723        let (config, mode) = EventStoreConfig::from_env_vars(
3724            Some("/app/data".to_string()),
3725            None,
3726            None,
3727            None,
3728            None,
3729            None,
3730            None,
3731            None,
3732        );
3733        assert_eq!(mode, "wal+parquet");
3734        assert_eq!(
3735            config.storage_dir.unwrap().to_str().unwrap(),
3736            "/app/data/storage"
3737        );
3738        assert_eq!(config.wal_dir.unwrap().to_str().unwrap(), "/app/data/wal");
3739    }
3740
3741    #[test]
3742    fn test_from_env_vars_explicit_dirs() {
3743        let (config, mode) = EventStoreConfig::from_env_vars(
3744            None,
3745            Some("/custom/storage".to_string()),
3746            Some("/custom/wal".to_string()),
3747            None,
3748            None,
3749            None,
3750            None,
3751            None,
3752        );
3753        assert_eq!(mode, "wal+parquet");
3754        assert_eq!(
3755            config.storage_dir.unwrap().to_str().unwrap(),
3756            "/custom/storage"
3757        );
3758        assert_eq!(config.wal_dir.unwrap().to_str().unwrap(), "/custom/wal");
3759    }
3760
3761    #[test]
3762    fn test_from_env_vars_wal_disabled() {
3763        let (config, mode) = EventStoreConfig::from_env_vars(
3764            Some("/app/data".to_string()),
3765            None,
3766            None,
3767            Some("false".to_string()),
3768            None,
3769            None,
3770            None,
3771            None,
3772        );
3773        assert_eq!(mode, "parquet-only");
3774        assert!(config.storage_dir.is_some());
3775        assert!(config.wal_dir.is_none());
3776    }
3777
3778    #[test]
3779    fn test_from_env_vars_no_dirs_is_in_memory() {
3780        let (config, mode) =
3781            EventStoreConfig::from_env_vars(None, None, None, None, None, None, None, None);
3782        assert_eq!(mode, "in-memory");
3783        assert!(config.storage_dir.is_none());
3784        assert!(config.wal_dir.is_none());
3785    }
3786
3787    #[test]
3788    fn test_from_env_vars_empty_strings_treated_as_none() {
3789        let (_, mode) = EventStoreConfig::from_env_vars(
3790            Some(String::new()),
3791            Some(String::new()),
3792            Some(String::new()),
3793            None,
3794            None,
3795            None,
3796            None,
3797            None,
3798        );
3799        assert_eq!(mode, "in-memory");
3800    }
3801
3802    #[test]
3803    fn test_from_env_vars_explicit_overrides_data_dir() {
3804        let (config, mode) = EventStoreConfig::from_env_vars(
3805            Some("/app/data".to_string()),
3806            Some("/override/storage".to_string()),
3807            Some("/override/wal".to_string()),
3808            None,
3809            None,
3810            None,
3811            None,
3812            None,
3813        );
3814        assert_eq!(mode, "wal+parquet");
3815        assert_eq!(
3816            config.storage_dir.unwrap().to_str().unwrap(),
3817            "/override/storage"
3818        );
3819        assert_eq!(config.wal_dir.unwrap().to_str().unwrap(), "/override/wal");
3820    }
3821
3822    #[test]
3823    fn test_from_env_vars_wal_only() {
3824        let (config, mode) = EventStoreConfig::from_env_vars(
3825            None,
3826            None,
3827            Some("/wal/only".to_string()),
3828            None,
3829            None,
3830            None,
3831            None,
3832            None,
3833        );
3834        assert_eq!(mode, "wal-only");
3835        assert!(config.storage_dir.is_none());
3836        assert_eq!(config.wal_dir.unwrap().to_str().unwrap(), "/wal/only");
3837    }
3838
3839    #[test]
3840    fn test_from_env_vars_cache_bytes_parses_decimal() {
3841        let (config, _) = EventStoreConfig::from_env_vars(
3842            Some("/app/data".to_string()),
3843            None,
3844            None,
3845            None,
3846            Some("536870912".to_string()),
3847            // 512 MiB
3848            None,
3849            None,
3850            None,
3851        );
3852        assert_eq!(config.cache_byte_budget, Some(536_870_912));
3853    }
3854
3855    #[test]
3856    fn test_from_env_vars_cache_bytes_unparseable_disables_budget() {
3857        // Garbage in CACHE_BYTES doesn't fail boot — we log and
3858        // fall back to no-budget. The unbounded fallback is safe
3859        // (just the pre-Step-3 behavior).
3860        let (config, _) = EventStoreConfig::from_env_vars(
3861            Some("/app/data".to_string()),
3862            None,
3863            None,
3864            None,
3865            Some("not-a-number".to_string()),
3866            None,
3867            None,
3868            None,
3869        );
3870        assert_eq!(config.cache_byte_budget, None);
3871    }
3872
3873    #[test]
3874    fn test_from_env_vars_cache_bytes_empty_disables_budget() {
3875        let (config, _) = EventStoreConfig::from_env_vars(
3876            Some("/app/data".to_string()),
3877            None,
3878            None,
3879            None,
3880            Some(String::new()),
3881            None,
3882            None,
3883            None,
3884        );
3885        assert_eq!(config.cache_byte_budget, None);
3886    }
3887
3888    #[test]
3889    fn test_from_env_vars_snapshot_interval_overrides_default() {
3890        // ALLSOURCE_SNAPSHOT_INTERVAL_SECONDS plumbs through to
3891        // CompactionConfig.compaction_interval_seconds. Default is
3892        // 3600s (hourly) per the bead.
3893        let (config, _) = EventStoreConfig::from_env_vars(
3894            Some("/app/data".to_string()),
3895            None,
3896            None,
3897            None,
3898            None,
3899            Some("60".to_string()),
3900            None,
3901            None,
3902        );
3903        assert_eq!(config.compaction_config.compaction_interval_seconds, 60);
3904    }
3905
3906    #[test]
3907    fn test_from_env_vars_snapshot_interval_default_is_hourly() {
3908        let (config, _) = EventStoreConfig::from_env_vars(
3909            Some("/app/data".to_string()),
3910            None,
3911            None,
3912            None,
3913            None,
3914            None,
3915            None,
3916            None,
3917        );
3918        assert_eq!(config.compaction_config.compaction_interval_seconds, 3600);
3919    }
3920
3921    #[test]
3922    fn test_from_env_vars_snapshot_interval_unparseable_falls_back() {
3923        let (config, _) = EventStoreConfig::from_env_vars(
3924            Some("/app/data".to_string()),
3925            None,
3926            None,
3927            None,
3928            None,
3929            Some("not-a-number".to_string()),
3930            None,
3931            None,
3932        );
3933        assert_eq!(config.compaction_config.compaction_interval_seconds, 3600);
3934    }
3935
3936    #[test]
3937    fn test_from_env_vars_retention_system_days_overrides_default() {
3938        // Step 5: ALLSOURCE_RETENTION_SYSTEM_DAYS overrides the
3939        // default 30-day TTL for the system tenant.
3940        let (config, _) = EventStoreConfig::from_env_vars(
3941            Some("/app/data".to_string()),
3942            None,
3943            None,
3944            None,
3945            None,
3946            None,
3947            Some("7".to_string()),
3948            None,
3949        );
3950        let ttl = config
3951            .compaction_config
3952            .retention
3953            .ttl_for("system")
3954            .unwrap();
3955        assert_eq!(ttl.as_secs(), 7 * 24 * 3600);
3956    }
3957
3958    #[test]
3959    fn test_from_env_vars_retention_default_is_30_days_for_system() {
3960        let (config, _) = EventStoreConfig::from_env_vars(
3961            Some("/app/data".to_string()),
3962            None,
3963            None,
3964            None,
3965            None,
3966            None,
3967            None,
3968            None,
3969        );
3970        let ttl = config
3971            .compaction_config
3972            .retention
3973            .ttl_for("system")
3974            .unwrap();
3975        assert_eq!(ttl.as_secs(), 30 * 24 * 3600);
3976        // Other tenants keep forever by default.
3977        assert!(config.compaction_config.retention.ttl_for("acme").is_none());
3978    }
3979
3980    #[test]
3981    fn test_store_stats_serde() {
3982        let stats = StoreStats {
3983            total_events: 100,
3984            total_entities: 50,
3985            total_event_types: 10,
3986            total_ingested: 100,
3987        };
3988
3989        let json = serde_json::to_string(&stats).unwrap();
3990        assert!(json.contains("\"total_events\":100"));
3991        assert!(json.contains("\"total_entities\":50"));
3992    }
3993
3994    #[test]
3995    fn test_query_with_entity_and_type() {
3996        let store = EventStore::new();
3997
3998        store
3999            .ingest(&create_test_event("entity-1", "user.created"))
4000            .unwrap();
4001        store
4002            .ingest(&create_test_event("entity-1", "user.updated"))
4003            .unwrap();
4004        store
4005            .ingest(&create_test_event("entity-2", "user.created"))
4006            .unwrap();
4007
4008        let results = store
4009            .query(&QueryEventsRequest {
4010                entity_id: Some("entity-1".to_string()),
4011                event_type: Some("user.created".to_string()),
4012                tenant_id: None,
4013                as_of: None,
4014                since: None,
4015                until: None,
4016                limit: None,
4017                event_type_prefix: None,
4018                payload_filter: None,
4019            })
4020            .unwrap();
4021
4022        assert_eq!(results.len(), 1);
4023        assert_eq!(results[0].event_type_str(), "user.created");
4024    }
4025
4026    #[test]
4027    fn test_query_by_event_type_prefix() {
4028        let store = EventStore::new();
4029
4030        // Ingest events with various types
4031        store
4032            .ingest(&create_test_event("entity-1", "index.created"))
4033            .unwrap();
4034        store
4035            .ingest(&create_test_event("entity-2", "index.updated"))
4036            .unwrap();
4037        store
4038            .ingest(&create_test_event("entity-3", "trade.created"))
4039            .unwrap();
4040        store
4041            .ingest(&create_test_event("entity-4", "trade.completed"))
4042            .unwrap();
4043        store
4044            .ingest(&create_test_event("entity-5", "balance.updated"))
4045            .unwrap();
4046
4047        // Query with prefix "index." should return exactly 2
4048        let results = store
4049            .query(&QueryEventsRequest {
4050                entity_id: None,
4051                event_type: None,
4052                tenant_id: None,
4053                as_of: None,
4054                since: None,
4055                until: None,
4056                limit: None,
4057                event_type_prefix: Some("index.".to_string()),
4058                payload_filter: None,
4059            })
4060            .unwrap();
4061
4062        assert_eq!(results.len(), 2);
4063        assert!(
4064            results
4065                .iter()
4066                .all(|e| e.event_type_str().starts_with("index."))
4067        );
4068    }
4069
4070    #[test]
4071    fn test_query_by_event_type_prefix_empty_returns_all() {
4072        let store = EventStore::new();
4073
4074        store
4075            .ingest(&create_test_event("entity-1", "index.created"))
4076            .unwrap();
4077        store
4078            .ingest(&create_test_event("entity-2", "trade.created"))
4079            .unwrap();
4080
4081        // Empty prefix matches all types
4082        let results = store
4083            .query(&QueryEventsRequest {
4084                entity_id: None,
4085                event_type: None,
4086                tenant_id: None,
4087                as_of: None,
4088                since: None,
4089                until: None,
4090                limit: None,
4091                event_type_prefix: Some(String::new()),
4092                payload_filter: None,
4093            })
4094            .unwrap();
4095
4096        assert_eq!(results.len(), 2);
4097    }
4098
4099    #[test]
4100    fn test_query_by_event_type_prefix_no_match() {
4101        let store = EventStore::new();
4102
4103        store
4104            .ingest(&create_test_event("entity-1", "index.created"))
4105            .unwrap();
4106
4107        let results = store
4108            .query(&QueryEventsRequest {
4109                entity_id: None,
4110                event_type: None,
4111                tenant_id: None,
4112                as_of: None,
4113                since: None,
4114                until: None,
4115                limit: None,
4116                event_type_prefix: Some("nonexistent.".to_string()),
4117                payload_filter: None,
4118            })
4119            .unwrap();
4120
4121        assert!(results.is_empty());
4122    }
4123
4124    #[test]
4125    fn test_query_by_entity_with_type_prefix() {
4126        let store = EventStore::new();
4127
4128        store
4129            .ingest(&create_test_event("entity-1", "index.created"))
4130            .unwrap();
4131        store
4132            .ingest(&create_test_event("entity-1", "trade.created"))
4133            .unwrap();
4134        store
4135            .ingest(&create_test_event("entity-2", "index.updated"))
4136            .unwrap();
4137
4138        // Query entity-1 with prefix "index." should return 1
4139        let results = store
4140            .query(&QueryEventsRequest {
4141                entity_id: Some("entity-1".to_string()),
4142                event_type: None,
4143                tenant_id: None,
4144                as_of: None,
4145                since: None,
4146                until: None,
4147                limit: None,
4148                event_type_prefix: Some("index.".to_string()),
4149                payload_filter: None,
4150            })
4151            .unwrap();
4152
4153        assert_eq!(results.len(), 1);
4154        assert_eq!(results[0].event_type_str(), "index.created");
4155    }
4156
4157    #[test]
4158    fn test_query_prefix_with_limit() {
4159        let store = EventStore::new();
4160
4161        for i in 0..5 {
4162            store
4163                .ingest(&create_test_event(&format!("entity-{i}"), "index.created"))
4164                .unwrap();
4165        }
4166
4167        let results = store
4168            .query(&QueryEventsRequest {
4169                entity_id: None,
4170                event_type: None,
4171                tenant_id: None,
4172                as_of: None,
4173                since: None,
4174                until: None,
4175                limit: Some(3),
4176                event_type_prefix: Some("index.".to_string()),
4177                payload_filter: None,
4178            })
4179            .unwrap();
4180
4181        assert_eq!(results.len(), 3);
4182    }
4183
4184    #[test]
4185    fn test_query_prefix_alongside_existing_filters() {
4186        let store = EventStore::new();
4187
4188        store
4189            .ingest(&create_test_event("entity-1", "index.created"))
4190            .unwrap();
4191        // Sleep briefly to ensure different timestamps
4192        std::thread::sleep(std::time::Duration::from_millis(10));
4193        store
4194            .ingest(&create_test_event("entity-2", "index.strategy.updated"))
4195            .unwrap();
4196        std::thread::sleep(std::time::Duration::from_millis(10));
4197        store
4198            .ingest(&create_test_event("entity-3", "index.deleted"))
4199            .unwrap();
4200
4201        // Prefix with limit
4202        let results = store
4203            .query(&QueryEventsRequest {
4204                entity_id: None,
4205                event_type: None,
4206                tenant_id: None,
4207                as_of: None,
4208                since: None,
4209                until: None,
4210                limit: Some(2),
4211                event_type_prefix: Some("index.".to_string()),
4212                payload_filter: None,
4213            })
4214            .unwrap();
4215
4216        assert_eq!(results.len(), 2);
4217    }
4218
4219    #[test]
4220    fn test_query_with_payload_filter() {
4221        let store = EventStore::new();
4222
4223        // Ingest 5 events with user_id=alice
4224        for i in 0..5 {
4225            store
4226                .ingest(&create_test_event_with_payload(
4227                    &format!("entity-{i}"),
4228                    "user.action",
4229                    serde_json::json!({"user_id": "alice", "action": "click"}),
4230                ))
4231                .unwrap();
4232        }
4233        // Ingest 5 events with user_id=bob
4234        for i in 5..10 {
4235            store
4236                .ingest(&create_test_event_with_payload(
4237                    &format!("entity-{i}"),
4238                    "user.action",
4239                    serde_json::json!({"user_id": "bob", "action": "view"}),
4240                ))
4241                .unwrap();
4242        }
4243
4244        // Filter for alice
4245        let results = store
4246            .query(&QueryEventsRequest {
4247                entity_id: None,
4248                event_type: Some("user.action".to_string()),
4249                tenant_id: None,
4250                as_of: None,
4251                since: None,
4252                until: None,
4253                limit: None,
4254                event_type_prefix: None,
4255                payload_filter: Some(r#"{"user_id":"alice"}"#.to_string()),
4256            })
4257            .unwrap();
4258
4259        assert_eq!(results.len(), 5);
4260    }
4261
4262    #[test]
4263    fn test_query_payload_filter_non_existent_field() {
4264        let store = EventStore::new();
4265
4266        store
4267            .ingest(&create_test_event_with_payload(
4268                "entity-1",
4269                "user.action",
4270                serde_json::json!({"user_id": "alice"}),
4271            ))
4272            .unwrap();
4273
4274        // Filter for a field that doesn't exist — returns 0, not error
4275        let results = store
4276            .query(&QueryEventsRequest {
4277                entity_id: None,
4278                event_type: None,
4279                tenant_id: None,
4280                as_of: None,
4281                since: None,
4282                until: None,
4283                limit: None,
4284                event_type_prefix: None,
4285                payload_filter: Some(r#"{"nonexistent":"value"}"#.to_string()),
4286            })
4287            .unwrap();
4288
4289        assert!(results.is_empty());
4290    }
4291
4292    #[test]
4293    fn test_query_payload_filter_with_prefix() {
4294        let store = EventStore::new();
4295
4296        store
4297            .ingest(&create_test_event_with_payload(
4298                "entity-1",
4299                "index.created",
4300                serde_json::json!({"status": "active"}),
4301            ))
4302            .unwrap();
4303        store
4304            .ingest(&create_test_event_with_payload(
4305                "entity-2",
4306                "index.created",
4307                serde_json::json!({"status": "inactive"}),
4308            ))
4309            .unwrap();
4310        store
4311            .ingest(&create_test_event_with_payload(
4312                "entity-3",
4313                "trade.created",
4314                serde_json::json!({"status": "active"}),
4315            ))
4316            .unwrap();
4317
4318        // Combine prefix + payload filter
4319        let results = store
4320            .query(&QueryEventsRequest {
4321                entity_id: None,
4322                event_type: None,
4323                tenant_id: None,
4324                as_of: None,
4325                since: None,
4326                until: None,
4327                limit: None,
4328                event_type_prefix: Some("index.".to_string()),
4329                payload_filter: Some(r#"{"status":"active"}"#.to_string()),
4330            })
4331            .unwrap();
4332
4333        assert_eq!(results.len(), 1);
4334        assert_eq!(results[0].entity_id().to_string(), "entity-1");
4335    }
4336
4337    #[test]
4338    fn test_flush_storage_no_storage() {
4339        let store = EventStore::new();
4340        // Without storage, flush should succeed (no-op)
4341        let result = store.flush_storage();
4342        assert!(result.is_ok());
4343    }
4344
4345    #[test]
4346    fn test_state_evolution() {
4347        let store = EventStore::new();
4348
4349        // Initial state
4350        store
4351            .ingest(
4352                &Event::from_strings(
4353                    "user.created".to_string(),
4354                    "user-1".to_string(),
4355                    "default".to_string(),
4356                    serde_json::json!({"name": "Alice", "age": 25}),
4357                    None,
4358                )
4359                .unwrap(),
4360            )
4361            .unwrap();
4362
4363        // Update state
4364        store
4365            .ingest(
4366                &Event::from_strings(
4367                    "user.updated".to_string(),
4368                    "user-1".to_string(),
4369                    "default".to_string(),
4370                    serde_json::json!({"age": 26}),
4371                    None,
4372                )
4373                .unwrap(),
4374            )
4375            .unwrap();
4376
4377        let state = store.reconstruct_state("user-1", None).unwrap();
4378        // The state is wrapped with metadata
4379        assert_eq!(state["current_state"]["name"], "Alice");
4380        assert_eq!(state["current_state"]["age"], 26);
4381    }
4382
4383    #[test]
4384    fn test_reject_system_event_types() {
4385        let store = EventStore::new();
4386
4387        // System event types should be rejected via user-facing ingestion
4388        let event = Event::reconstruct_from_strings(
4389            uuid::Uuid::new_v4(),
4390            "_system.tenant.created".to_string(),
4391            "_system:tenant:acme".to_string(),
4392            "_system".to_string(),
4393            serde_json::json!({"name": "ACME"}),
4394            chrono::Utc::now(),
4395            None,
4396            1,
4397        );
4398
4399        let result = store.ingest(&event);
4400        assert!(result.is_err());
4401        let err = result.unwrap_err();
4402        assert!(
4403            err.to_string().contains("reserved for internal use"),
4404            "Expected system namespace rejection, got: {err}"
4405        );
4406    }
4407
4408    // -----------------------------------------------------------------------
4409    // Crash recovery: WAL events survive restart via Parquet checkpoint.
4410    // Regression test for GitHub issue #84 — flush_storage() was a no-op
4411    // during recovery because events were never buffered into Parquet's
4412    // current_batch before flushing.
4413    // -----------------------------------------------------------------------
4414
4415    #[test]
4416    fn test_wal_recovery_checkpoints_to_parquet() {
4417        let data_dir = TempDir::new().unwrap();
4418        let storage_dir = data_dir.path().join("storage");
4419        let wal_dir = data_dir.path().join("wal");
4420
4421        // Session 1: ingest events with WAL + Parquet
4422        {
4423            let config = EventStoreConfig::production(
4424                &storage_dir,
4425                &wal_dir,
4426                SnapshotConfig::default(),
4427                WALConfig {
4428                    sync_on_write: true,
4429                    ..WALConfig::default()
4430                },
4431                CompactionConfig::default(),
4432            );
4433            let store = EventStore::with_config(config);
4434
4435            for i in 0..5 {
4436                let event = Event::from_strings(
4437                    "test.created".to_string(),
4438                    format!("entity-{i}"),
4439                    "default".to_string(),
4440                    serde_json::json!({"index": i}),
4441                    None,
4442                )
4443                .unwrap();
4444                store.ingest(&event).unwrap();
4445            }
4446
4447            assert_eq!(store.stats().total_events, 5);
4448
4449            // Do NOT call flush_storage or shutdown — simulate a crash.
4450            // Events are in WAL (sync_on_write: true) but NOT in Parquet.
4451        }
4452
4453        // Verify WAL file has data
4454        let wal_files: Vec<_> = std::fs::read_dir(&wal_dir)
4455            .unwrap()
4456            .filter_map(std::result::Result::ok)
4457            .filter(|e| e.path().extension().is_some_and(|ext| ext == "log"))
4458            .collect();
4459        assert!(!wal_files.is_empty(), "WAL file should exist");
4460        let wal_size = wal_files[0].metadata().unwrap().len();
4461        assert!(wal_size > 0, "WAL file should have data (got 0 bytes)");
4462
4463        // Session 2: reopen — recovery should checkpoint WAL to Parquet, then truncate
4464        {
4465            let config = EventStoreConfig::production(
4466                &storage_dir,
4467                &wal_dir,
4468                SnapshotConfig::default(),
4469                WALConfig {
4470                    sync_on_write: true,
4471                    ..WALConfig::default()
4472                },
4473                CompactionConfig::default(),
4474            );
4475            let store = EventStore::with_config(config);
4476
4477            // Events should be recovered
4478            assert_eq!(
4479                store.stats().total_events,
4480                5,
4481                "Session 2 should have all 5 events after WAL recovery"
4482            );
4483
4484            // Parquet should now have files (checkpoint happened).
4485            // After Step 1, files live under <root>/<tenant>/<yyyy-mm>/,
4486            // so walk recursively.
4487            let parquet_files = find_parquet_files(&storage_dir);
4488            assert!(
4489                !parquet_files.is_empty(),
4490                "Parquet file should exist after WAL checkpoint"
4491            );
4492        }
4493
4494        // Session 3: reopen again — events should be reachable via
4495        // lazy-load (Step 2: boot does not pre-load Parquet).
4496        {
4497            let config = EventStoreConfig::production(
4498                &storage_dir,
4499                &wal_dir,
4500                SnapshotConfig::default(),
4501                WALConfig {
4502                    sync_on_write: true,
4503                    ..WALConfig::default()
4504                },
4505                CompactionConfig::default(),
4506            );
4507            let store = EventStore::with_config(config);
4508
4509            // Boot is now O(1) — Parquet stays cold until first
4510            // per-tenant query. WAL was truncated in session 2,
4511            // so nothing is pre-loaded.
4512            assert_eq!(
4513                store.stats().total_events,
4514                0,
4515                "Session 3 boot should not pre-load Parquet (lazy-load mode)"
4516            );
4517
4518            // Trigger lazy load for the test tenant (events were
4519            // ingested with tenant_id=\"default\").
4520            store.ensure_tenant_loaded("default").unwrap();
4521            assert_eq!(
4522                store.stats().total_events,
4523                5,
4524                "Session 3 should have all 5 events after ensure_tenant_loaded"
4525            );
4526        }
4527    }
4528
4529    #[test]
4530    fn test_parquet_restore_surfaces_errors_not_silent() {
4531        // Write events with WAL+Parquet, flush to Parquet, then corrupt the
4532        // Parquet file. On reload, the error must be logged (not silently
4533        // swallowed as 0 events).
4534        let data_dir = TempDir::new().unwrap();
4535        let storage_dir = data_dir.path().join("storage");
4536        let wal_dir = data_dir.path().join("wal");
4537
4538        // Session 1: write events and flush to Parquet
4539        {
4540            let config = EventStoreConfig::production(
4541                &storage_dir,
4542                &wal_dir,
4543                SnapshotConfig::default(),
4544                WALConfig {
4545                    sync_on_write: true,
4546                    ..WALConfig::default()
4547                },
4548                CompactionConfig::default(),
4549            );
4550            let store = EventStore::with_config(config);
4551
4552            for i in 0..3 {
4553                let event = Event::from_strings(
4554                    "test.created".to_string(),
4555                    format!("entity-{i}"),
4556                    "default".to_string(),
4557                    serde_json::json!({"i": i}),
4558                    None,
4559                )
4560                .unwrap();
4561                store.ingest(&event).unwrap();
4562            }
4563
4564            store.flush_storage().unwrap();
4565            assert_eq!(store.stats().total_events, 3);
4566        }
4567
4568        // Verify parquet file exists. After Step 1 the file lives
4569        // under <root>/<tenant>/<yyyy-mm>/, so walk recursively.
4570        let parquet_files = find_parquet_files(&storage_dir);
4571        assert!(!parquet_files.is_empty(), "Parquet file must exist");
4572
4573        // Corrupt the parquet file
4574        std::fs::write(&parquet_files[0], b"corrupted data").unwrap();
4575
4576        // Truncate WAL so only Parquet matters
4577        for entry in std::fs::read_dir(&wal_dir).unwrap().flatten() {
4578            std::fs::write(entry.path(), b"").unwrap();
4579        }
4580
4581        // Session 2: reload — should NOT silently report 0 events.
4582        // The error is logged via tracing::error! which we can't capture in a
4583        // unit test, but we CAN verify the store has 0 events (previously this
4584        // looked identical to "no data on disk" — now there's an error log).
4585        // The key behavioral change is that with_config no longer uses a
4586        // let-chain that silently drops the Err variant.
4587        {
4588            let config = EventStoreConfig::production(
4589                &storage_dir,
4590                &wal_dir,
4591                SnapshotConfig::default(),
4592                WALConfig::default(),
4593                CompactionConfig::default(),
4594            );
4595            let store = EventStore::with_config(config);
4596
4597            // Store has 0 events because Parquet is corrupted — but the error
4598            // is now logged (not silently swallowed).
4599            assert_eq!(store.stats().total_events, 0);
4600        }
4601    }
4602
4603    // -----------------------------------------------------------------------
4604    // Step 6: Bounded WAL replay. Each successful checkpoint truncates the
4605    // WAL so cold-start replay is O(one checkpoint interval) regardless of
4606    // total dataset size.
4607    // -----------------------------------------------------------------------
4608
4609    /// Count entries in every WAL file under `wal_dir` (any line that
4610    /// parses as a valid JSON object — the line format is one
4611    /// JSON-serialized WALEntry per line, see WALFile::write_entry).
4612    fn count_wal_entries(wal_dir: &std::path::Path) -> usize {
4613        use std::io::{BufRead, BufReader};
4614        let mut total = 0usize;
4615        let Ok(entries) = std::fs::read_dir(wal_dir) else {
4616            return 0;
4617        };
4618        for entry in entries.flatten() {
4619            let path = entry.path();
4620            if path.extension().is_none_or(|e| e != "log") {
4621                continue;
4622            }
4623            let Ok(file) = std::fs::File::open(&path) else {
4624                continue;
4625            };
4626            for line in BufReader::new(file)
4627                .lines()
4628                .map_while(std::result::Result::ok)
4629            {
4630                if !line.trim().is_empty() {
4631                    total += 1;
4632                }
4633            }
4634        }
4635        total
4636    }
4637
4638    #[test]
4639    fn test_checkpoint_truncates_wal_after_flush() {
4640        // After a successful checkpoint, every previously-ingested event
4641        // should be in Parquet, and the WAL should be empty (truncated).
4642        // This is the load-bearing invariant for Step 6's bounded-replay
4643        // promise — without truncation, the WAL grows unboundedly.
4644        let data_dir = TempDir::new().unwrap();
4645        let storage_dir = data_dir.path().join("storage");
4646        let wal_dir = data_dir.path().join("wal");
4647
4648        let config = EventStoreConfig::production(
4649            &storage_dir,
4650            &wal_dir,
4651            SnapshotConfig::default(),
4652            WALConfig {
4653                sync_on_write: true,
4654                ..WALConfig::default()
4655            },
4656            CompactionConfig::default(),
4657        );
4658        let store = EventStore::with_config(config);
4659
4660        for i in 0..10 {
4661            let event = Event::from_strings(
4662                "test.created".to_string(),
4663                format!("entity-{i}"),
4664                "default".to_string(),
4665                serde_json::json!({"i": i}),
4666                None,
4667            )
4668            .unwrap();
4669            store.ingest(&event).unwrap();
4670        }
4671
4672        // Sanity: all 10 events are in the WAL pre-checkpoint.
4673        assert_eq!(
4674            count_wal_entries(&wal_dir),
4675            10,
4676            "WAL should have 10 events before checkpoint"
4677        );
4678
4679        store.checkpoint().unwrap();
4680
4681        assert_eq!(
4682            count_wal_entries(&wal_dir),
4683            0,
4684            "WAL should be empty after successful checkpoint"
4685        );
4686        let parquet_files = find_parquet_files(&storage_dir);
4687        assert!(!parquet_files.is_empty(), "Parquet should hold the events");
4688    }
4689
4690    #[test]
4691    fn test_replay_only_post_checkpoint_events_after_crash() {
4692        // Headline AC for the bead: write N events, checkpoint, write K
4693        // more, simulate a crash, restart, and verify only K events go
4694        // through replay (not N+K).
4695        //
4696        // Uses small N (50) and K (5) for test speed — the property
4697        // is the same as the spec's 1M+10k example, just scaled down.
4698        let data_dir = TempDir::new().unwrap();
4699        let storage_dir = data_dir.path().join("storage");
4700        let wal_dir = data_dir.path().join("wal");
4701
4702        let config_factory = || {
4703            EventStoreConfig::production(
4704                &storage_dir,
4705                &wal_dir,
4706                SnapshotConfig::default(),
4707                WALConfig {
4708                    sync_on_write: true,
4709                    ..WALConfig::default()
4710                },
4711                CompactionConfig::default(),
4712            )
4713        };
4714
4715        // Session 1: ingest N, checkpoint, ingest K, then drop without
4716        // a graceful shutdown — that's the crash.
4717        const N: usize = 50;
4718        const K: usize = 5;
4719        {
4720            let store = EventStore::with_config(config_factory());
4721            for i in 0..N {
4722                store
4723                    .ingest(
4724                        &Event::from_strings(
4725                            "pre.checkpoint".to_string(),
4726                            format!("e-{i}"),
4727                            "default".to_string(),
4728                            serde_json::json!({"i": i}),
4729                            None,
4730                        )
4731                        .unwrap(),
4732                    )
4733                    .unwrap();
4734            }
4735            store.checkpoint().unwrap();
4736            assert_eq!(
4737                count_wal_entries(&wal_dir),
4738                0,
4739                "WAL should be empty immediately after checkpoint"
4740            );
4741
4742            for i in 0..K {
4743                store
4744                    .ingest(
4745                        &Event::from_strings(
4746                            "post.checkpoint".to_string(),
4747                            format!("p-{i}"),
4748                            "default".to_string(),
4749                            serde_json::json!({"i": i}),
4750                            None,
4751                        )
4752                        .unwrap(),
4753                    )
4754                    .unwrap();
4755            }
4756            assert_eq!(
4757                count_wal_entries(&wal_dir),
4758                K,
4759                "WAL should hold only post-checkpoint events"
4760            );
4761            // Drop without flushing — simulates a crash mid-write.
4762        }
4763
4764        // Session 2: reopen. Recovery should replay only the K post-
4765        // checkpoint events from the WAL — the N pre-checkpoint events
4766        // are durable in Parquet and lazy-loaded on demand.
4767        {
4768            let store = EventStore::with_config(config_factory());
4769            // total_events reflects only WAL-recovered events at boot
4770            // (Step 2 — Parquet stays cold until first per-tenant
4771            // query). So the WAL replay size IS exactly K.
4772            assert_eq!(
4773                store.stats().total_events,
4774                K,
4775                "Boot should replay exactly K events from WAL (the post-checkpoint window), not N+K"
4776            );
4777
4778            // Lazy-load brings the rest in.
4779            store.ensure_tenant_loaded("default").unwrap();
4780            assert_eq!(
4781                store.stats().total_events,
4782                N + K,
4783                "After lazy-load, both pre- and post-checkpoint events should be reachable"
4784            );
4785        }
4786    }
4787
4788    #[test]
4789    fn test_checkpoint_is_idempotent() {
4790        // Calling checkpoint() twice in a row is safe: the second call
4791        // finds an empty WAL and an empty Parquet batch, and no-ops.
4792        let data_dir = TempDir::new().unwrap();
4793        let storage_dir = data_dir.path().join("storage");
4794        let wal_dir = data_dir.path().join("wal");
4795
4796        let store = EventStore::with_config(EventStoreConfig::production(
4797            &storage_dir,
4798            &wal_dir,
4799            SnapshotConfig::default(),
4800            WALConfig::default(),
4801            CompactionConfig::default(),
4802        ));
4803
4804        for i in 0..5 {
4805            store
4806                .ingest(
4807                    &Event::from_strings(
4808                        "x".to_string(),
4809                        format!("e-{i}"),
4810                        "default".to_string(),
4811                        serde_json::json!({}),
4812                        None,
4813                    )
4814                    .unwrap(),
4815                )
4816                .unwrap();
4817        }
4818
4819        store.checkpoint().unwrap();
4820        // Second call is a no-op and must not error.
4821        store.checkpoint().unwrap();
4822        assert_eq!(count_wal_entries(&wal_dir), 0);
4823    }
4824
4825    #[test]
4826    fn test_checkpoint_noop_in_memory_only_mode() {
4827        // Without WAL configured, checkpoint() is a no-op.
4828        let store = EventStore::new();
4829        store.checkpoint().unwrap();
4830    }
4831
4832    #[test]
4833    fn test_checkpoint_interval_from_env_defaults_to_60s_when_wal_enabled() {
4834        let (config, _) = EventStoreConfig::from_env_vars(
4835            Some("/app/data".to_string()),
4836            None,
4837            None,
4838            None,
4839            None,
4840            None,
4841            None,
4842            None,
4843        );
4844        assert_eq!(config.checkpoint_interval_secs, Some(60));
4845    }
4846
4847    #[test]
4848    fn test_checkpoint_interval_from_env_overrides_default() {
4849        let (config, _) = EventStoreConfig::from_env_vars(
4850            Some("/app/data".to_string()),
4851            None,
4852            None,
4853            None,
4854            None,
4855            None,
4856            None,
4857            Some("15".to_string()),
4858        );
4859        assert_eq!(config.checkpoint_interval_secs, Some(15));
4860    }
4861
4862    #[test]
4863    fn test_checkpoint_interval_disabled_when_wal_disabled() {
4864        // No WAL → no checkpoint loop, regardless of env var value.
4865        let (config, _) = EventStoreConfig::from_env_vars(
4866            Some("/app/data".to_string()),
4867            None,
4868            None,
4869            Some("false".to_string()),
4870            None,
4871            None,
4872            None,
4873            Some("15".to_string()),
4874        );
4875        assert_eq!(config.checkpoint_interval_secs, None);
4876    }
4877
4878    #[test]
4879    fn test_checkpoint_interval_unparseable_falls_back_to_default() {
4880        let (config, _) = EventStoreConfig::from_env_vars(
4881            Some("/app/data".to_string()),
4882            None,
4883            None,
4884            None,
4885            None,
4886            None,
4887            None,
4888            Some("not-a-number".to_string()),
4889        );
4890        assert_eq!(config.checkpoint_interval_secs, Some(60));
4891    }
4892}