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