Skip to main content

eventcore_memory/
lib.rs

1//! In-memory event store implementation for testing.
2//!
3//! This module provides the `InMemoryEventStore` - a lightweight, zero-dependency
4//! storage backend for EventCore integration tests and development. Command
5//! state snapshots are retained only for the lifetime of the store instance;
6//! use a durable adapter when snapshots must survive a process restart.
7
8use std::collections::HashMap;
9use std::sync::{Arc, RwLock};
10
11use eventcore_types::{
12    CheckpointStore, CommandStateSnapshot, CommandStateSnapshotId, Event, EventFilter, EventPage,
13    EventReader, EventStore, EventStoreError, EventStream, EventStreamSlice, Operation,
14    ProjectorCoordinator, StreamId, StreamPosition, StreamVersion, StreamWriteEntry, StreamWrites,
15};
16use uuid::Uuid;
17
18type StreamData = (Vec<Box<dyn std::any::Any + Send>>, StreamVersion);
19
20/// Entry in the global event log with indexed stream_id for efficient filtering.
21///
22/// This structure mirrors the Postgres schema where stream_id is a separate
23/// indexed column and event_id (UUID7) serves as the global position.
24/// By storing stream_id and event_id separately, we can filter by stream
25/// prefix and position without parsing JSON, matching the performance
26/// characteristics of the database implementation.
27#[derive(Debug, Clone)]
28struct GlobalLogEntry {
29    /// Event identifier (UUID7), used as global position
30    event_id: Uuid,
31    /// Stream identifier, extracted at write time for efficient filtering
32    stream_id: String,
33    /// Event type name, stored at write time for efficient type filtering
34    event_type: String,
35    /// Event data as serialized JSON (serialized once at append time)
36    event_data: String,
37}
38
39/// Internal storage combining per-stream data with global event ordering.
40struct StoreData {
41    streams: HashMap<StreamId, StreamData>,
42    /// Global log with indexed stream_id for efficient EventReader queries
43    global_log: Vec<GlobalLogEntry>,
44    /// Checkpoint storage for projection progress tracking
45    checkpoints: HashMap<String, StreamPosition>,
46    /// Durable-in-process command-state projections.
47    command_state_snapshots: HashMap<CommandStateSnapshotId, CommandStateSnapshot>,
48    /// Coordination locks for projector leadership
49    locks: Arc<RwLock<HashMap<String, ()>>>,
50}
51
52/// In-memory event store implementation for testing.
53///
54/// `InMemoryEventStore` provides a lightweight, zero-dependency storage backend
55/// for EventCore integration tests and development. It implements the `EventStore`,
56/// `EventReader`, `CheckpointStore`, and `ProjectorCoordinator` traits using
57/// standard library collections with optimistic concurrency control via version
58/// checking.
59///
60/// # Example
61///
62/// ```no_run
63/// use eventcore_memory::InMemoryEventStore;
64///
65/// let store = InMemoryEventStore::new();
66/// // Use store with execute() function
67/// ```
68///
69/// # Thread Safety
70///
71/// `InMemoryEventStore` uses interior mutability (`Mutex`) for concurrent access.
72pub struct InMemoryEventStore {
73    data: std::sync::Mutex<StoreData>,
74}
75
76impl InMemoryEventStore {
77    /// Create a new in-memory event store.
78    ///
79    /// Returns an empty event store ready for command execution.
80    /// All streams start at version 0 (no events).
81    pub fn new() -> Self {
82        Self {
83            data: std::sync::Mutex::new(StoreData {
84                streams: HashMap::new(),
85                global_log: Vec::new(),
86                checkpoints: HashMap::new(),
87                command_state_snapshots: HashMap::new(),
88                locks: Arc::new(RwLock::new(HashMap::new())),
89            }),
90        }
91    }
92}
93
94impl Default for InMemoryEventStore {
95    fn default() -> Self {
96        Self::new()
97    }
98}
99
100impl EventStore for InMemoryEventStore {
101    async fn read_stream<E: Event>(
102        &self,
103        stream_id: StreamId,
104    ) -> Result<EventStream<E>, EventStoreError> {
105        // The in-memory store keeps events behind a lock as type-erased
106        // `Box<dyn Any>`. Producing an owned `E` per item requires a downcast
107        // and clone (expected — see #363), so we materialize the per-event
108        // results while holding the lock, then release it and yield the items
109        // one at a time. The stream is still consumed incrementally by the
110        // executor; only this local, in-process backend buffers the owned
111        // clones (which already existed in memory).
112        let items: Vec<Result<E, EventStoreError>> = {
113            let data = self
114                .data
115                .lock()
116                .map_err(|_| EventStoreError::StoreFailure {
117                    operation: Operation::ReadStream,
118                })?;
119            match data.streams.get(&stream_id) {
120                None => Vec::new(),
121                Some((boxed_events, _version)) => boxed_events
122                    .iter()
123                    .map(|boxed| match boxed.downcast_ref::<E>() {
124                        Some(event) => Ok(event.clone()),
125                        None => Err(EventStoreError::DeserializationFailed {
126                            stream_id: stream_id.clone(),
127                            detail: format!(
128                                "event could not be downcast to {}",
129                                std::any::type_name::<E>()
130                            ),
131                        }),
132                    })
133                    .collect(),
134            }
135        };
136
137        Ok(EventStream::new(futures::stream::iter(items)))
138    }
139
140    async fn read_stream_after<E: Event>(
141        &self,
142        stream_id: StreamId,
143        exclusive_version: StreamVersion,
144    ) -> Result<EventStream<E>, EventStoreError> {
145        let count: usize = exclusive_version.into();
146        let items: Vec<Result<E, EventStoreError>> = {
147            let data = self
148                .data
149                .lock()
150                .map_err(|_| EventStoreError::StoreFailure {
151                    operation: Operation::ReadStream,
152                })?;
153            match data.streams.get(&stream_id) {
154                None => Vec::new(),
155                Some((boxed_events, _version)) => boxed_events
156                    .iter()
157                    .skip(count)
158                    .map(|boxed| match boxed.downcast_ref::<E>() {
159                        Some(event) => Ok(event.clone()),
160                        None => Err(EventStoreError::DeserializationFailed {
161                            stream_id: stream_id.clone(),
162                            detail: format!(
163                                "event could not be downcast to {}",
164                                std::any::type_name::<E>()
165                            ),
166                        }),
167                    })
168                    .collect(),
169            }
170        };
171
172        Ok(EventStream::new(futures::stream::iter(items)))
173    }
174
175    async fn append_events(
176        &self,
177        writes: StreamWrites,
178    ) -> Result<EventStreamSlice, EventStoreError> {
179        let mut data = self
180            .data
181            .lock()
182            .map_err(|_| EventStoreError::StoreFailure {
183                operation: Operation::AppendEvents,
184            })?;
185        let expected_versions = writes.expected_versions().clone();
186
187        // Check all version constraints before writing any events
188        for (stream_id, expected_version) in &expected_versions {
189            let current_version = data
190                .streams
191                .get(stream_id)
192                .map(|(_events, version)| *version)
193                .unwrap_or_else(|| StreamVersion::new(0));
194
195            if current_version != *expected_version {
196                return Err(EventStoreError::VersionConflict {
197                    stream_id: stream_id.clone(),
198                    expected: *expected_version,
199                    actual: current_version,
200                });
201            }
202        }
203
204        // All versions match - proceed with writes
205        for entry in writes.into_entries() {
206            let StreamWriteEntry {
207                stream_id,
208                event,
209                event_type,
210                event_data,
211            } = entry;
212
213            // Generate UUID7 for this event (monotonic, timestamp-ordered)
214            let event_id = Uuid::now_v7();
215
216            // Store in global log for EventReader with indexed stream_id, event_type, and event_id.
217            // event_data is already serialized JSON; keep the raw string.
218            data.global_log.push(GlobalLogEntry {
219                event_id,
220                stream_id: stream_id.as_ref().to_string(),
221                event_type: event_type.to_string(),
222                event_data: event_data.get().to_owned(),
223            });
224
225            let (events, version) = data
226                .streams
227                .entry(stream_id)
228                .or_insert_with(|| (Vec::new(), StreamVersion::new(0)));
229            events.push(event);
230            *version = version.increment();
231        }
232
233        Ok(EventStreamSlice)
234    }
235
236    async fn load_command_state_snapshot(
237        &self,
238        snapshot_id: CommandStateSnapshotId,
239    ) -> Result<Option<CommandStateSnapshot>, EventStoreError> {
240        let data = self
241            .data
242            .lock()
243            .map_err(|_| EventStoreError::StoreFailure {
244                operation: Operation::ReadStream,
245            })?;
246        Ok(data.command_state_snapshots.get(&snapshot_id).cloned())
247    }
248
249    async fn save_command_state_snapshot(
250        &self,
251        snapshot_id: CommandStateSnapshotId,
252        snapshot: CommandStateSnapshot,
253    ) -> Result<(), EventStoreError> {
254        let mut data = self
255            .data
256            .lock()
257            .map_err(|_| EventStoreError::StoreFailure {
258                operation: Operation::AppendEvents,
259            })?;
260        match data.command_state_snapshots.get(&snapshot_id) {
261            Some(current) if !snapshot.covers(current) => {}
262            _ => {
263                let _ = data.command_state_snapshots.insert(snapshot_id, snapshot);
264            }
265        }
266        Ok(())
267    }
268}
269
270impl EventReader for InMemoryEventStore {
271    type Error = EventStoreError;
272
273    async fn read_events<E: Event>(
274        &self,
275        filter: EventFilter,
276        page: EventPage,
277    ) -> Result<Vec<(E, StreamPosition)>, Self::Error> {
278        let data = self
279            .data
280            .lock()
281            .map_err(|_| EventStoreError::StoreFailure {
282                operation: Operation::ReadStream,
283            })?;
284
285        let after_event_id = page.after_position().map(|p| p.into_inner());
286
287        let events: Vec<(E, StreamPosition)> = data
288            .global_log
289            .iter()
290            .filter(|entry| {
291                // Filter by event_id (UUID7 comparison)
292                match after_event_id {
293                    None => true,
294                    Some(after_id) => entry.event_id > after_id,
295                }
296            })
297            .filter(|entry| {
298                // Filter by indexed stream_id WITHOUT parsing JSON (matches Postgres behavior)
299                match filter.stream_prefix() {
300                    None => true,
301                    Some(prefix) => entry.stream_id.starts_with(prefix.as_ref()),
302                }
303            })
304            .filter(|entry| {
305                // Filter by glob pattern (ADR-0047) at the query level, before
306                // take(), so non-matching streams don't consume batch slots.
307                match filter.stream_pattern() {
308                    None => true,
309                    Some(pattern) => pattern.matches(&entry.stream_id),
310                }
311            })
312            .filter(|entry| {
313                // Filter by event_type BEFORE take() so non-matching types
314                // don't consume batch slots (fixes issue #372).
315                // Use explicit filter if set, otherwise derive from E::event_type_name().
316                let type_filter = filter.event_type().unwrap_or_else(|| E::event_type_name());
317                entry.event_type == type_filter
318            })
319            .take(page.limit().into_inner())
320            .filter_map(|entry| {
321                serde_json::from_str::<E>(&entry.event_data)
322                    .ok()
323                    .map(|e| (e, StreamPosition::new(entry.event_id)))
324            })
325            .collect();
326
327        Ok(events)
328    }
329}
330
331impl CheckpointStore for InMemoryEventStore {
332    type Error = InMemoryCheckpointError;
333
334    async fn load(&self, name: &str) -> Result<Option<StreamPosition>, Self::Error> {
335        let data = self
336            .data
337            .lock()
338            .map_err(|e| InMemoryCheckpointError::LockFailed(e.to_string()))?;
339        Ok(data.checkpoints.get(name).copied())
340    }
341
342    async fn save(&self, name: &str, position: StreamPosition) -> Result<(), Self::Error> {
343        let mut data = self
344            .data
345            .lock()
346            .map_err(|e| InMemoryCheckpointError::LockFailed(e.to_string()))?;
347        let _ = data.checkpoints.insert(name.to_string(), position);
348        Ok(())
349    }
350}
351
352impl ProjectorCoordinator for InMemoryEventStore {
353    type Error = InMemoryCoordinationError;
354    type Guard = InMemoryCoordinationGuard;
355
356    async fn try_acquire(&self, subscription_name: &str) -> Result<Self::Guard, Self::Error> {
357        let data = self
358            .data
359            .lock()
360            .map_err(|e| InMemoryCoordinationError::LockPoisoned {
361                message: e.to_string(),
362            })?;
363
364        let mut guard =
365            data.locks
366                .write()
367                .map_err(|e| InMemoryCoordinationError::LockPoisoned {
368                    message: e.to_string(),
369                })?;
370
371        if guard.contains_key(subscription_name) {
372            return Err(InMemoryCoordinationError::LeadershipNotAcquired {
373                subscription_name: subscription_name.to_string(),
374            });
375        }
376
377        let _ = guard.insert(subscription_name.to_string(), ());
378
379        Ok(InMemoryCoordinationGuard {
380            subscription_name: subscription_name.to_string(),
381            locks: Arc::clone(&data.locks),
382        })
383    }
384}
385
386/// In-memory checkpoint store for tracking projection progress.
387///
388/// `InMemoryCheckpointStore` stores checkpoint positions in memory using a
389/// thread-safe `Arc<RwLock<HashMap>>`. It is primarily useful for testing
390/// and single-process deployments where persistence across restarts is not required.
391///
392/// For production deployments requiring durability, use a persistent
393/// checkpoint store implementation.
394///
395/// # Example
396///
397/// ```no_run
398/// use eventcore_memory::InMemoryCheckpointStore;
399///
400/// let checkpoint_store = InMemoryCheckpointStore::new();
401/// // Implements CheckpointStore for tracking projection progress.
402/// ```
403#[derive(Debug, Clone, Default)]
404pub struct InMemoryCheckpointStore {
405    checkpoints: Arc<RwLock<HashMap<String, StreamPosition>>>,
406}
407
408impl InMemoryCheckpointStore {
409    /// Create a new in-memory checkpoint store.
410    pub fn new() -> Self {
411        Self::default()
412    }
413}
414
415/// Error type for in-memory checkpoint store operations.
416///
417/// Since the in-memory store uses an `RwLock`, the only possible error
418/// is a poisoned lock from a panic in another thread.
419#[derive(Debug, Clone, thiserror::Error)]
420pub enum InMemoryCheckpointError {
421    #[error("failed to acquire lock: {0}")]
422    LockFailed(String),
423}
424
425/// Error type for in-memory coordinator operations.
426#[derive(Debug, Clone, thiserror::Error)]
427pub enum InMemoryCoordinationError {
428    /// Leadership is already held by another instance.
429    #[error(
430        "leadership not acquired for subscription '{subscription_name}': another instance holds the lock"
431    )]
432    LeadershipNotAcquired { subscription_name: String },
433    /// Lock was poisoned by a panic in another thread.
434    #[error("lock poisoned: {message}")]
435    LockPoisoned { message: String },
436}
437
438/// Guard that releases leadership when dropped.
439#[derive(Debug)]
440pub struct InMemoryCoordinationGuard {
441    subscription_name: String,
442    locks: Arc<RwLock<HashMap<String, ()>>>,
443}
444
445impl Drop for InMemoryCoordinationGuard {
446    fn drop(&mut self) {
447        if let Ok(mut guard) = self.locks.write() {
448            let _ = guard.remove(&self.subscription_name);
449        } else {
450            tracing::error!(
451                subscription_name = %self.subscription_name,
452                "failed to release coordination lock: RwLock poisoned"
453            );
454        }
455    }
456}
457
458/// In-memory projector coordinator for single-process deployments.
459///
460/// `InMemoryProjectorCoordinator` provides coordination for projectors within a single
461/// process using an in-memory lock table. This is suitable for testing and single-process
462/// deployments where distributed coordination is not required.
463///
464/// For distributed deployments with multiple process instances, use a database-backed
465/// coordinator implementation (e.g., PostgreSQL advisory locks).
466#[derive(Debug, Clone, Default)]
467pub struct InMemoryProjectorCoordinator {
468    locks: Arc<RwLock<HashMap<String, ()>>>,
469}
470
471impl InMemoryProjectorCoordinator {
472    /// Create a new in-memory projector coordinator.
473    pub fn new() -> Self {
474        Self::default()
475    }
476}
477
478impl ProjectorCoordinator for InMemoryProjectorCoordinator {
479    type Error = InMemoryCoordinationError;
480    type Guard = InMemoryCoordinationGuard;
481
482    async fn try_acquire(&self, subscription_name: &str) -> Result<Self::Guard, Self::Error> {
483        let mut guard =
484            self.locks
485                .write()
486                .map_err(|e| InMemoryCoordinationError::LockPoisoned {
487                    message: e.to_string(),
488                })?;
489
490        if guard.contains_key(subscription_name) {
491            return Err(InMemoryCoordinationError::LeadershipNotAcquired {
492                subscription_name: subscription_name.to_string(),
493            });
494        }
495
496        let _ = guard.insert(subscription_name.to_string(), ());
497
498        Ok(InMemoryCoordinationGuard {
499            subscription_name: subscription_name.to_string(),
500            locks: Arc::clone(&self.locks),
501        })
502    }
503}
504
505impl CheckpointStore for InMemoryCheckpointStore {
506    type Error = InMemoryCheckpointError;
507
508    async fn load(&self, name: &str) -> Result<Option<StreamPosition>, Self::Error> {
509        let guard = self
510            .checkpoints
511            .read()
512            .map_err(|e| InMemoryCheckpointError::LockFailed(e.to_string()))?;
513        Ok(guard.get(name).copied())
514    }
515
516    async fn save(&self, name: &str, position: StreamPosition) -> Result<(), Self::Error> {
517        let mut guard = self
518            .checkpoints
519            .write()
520            .map_err(|e| InMemoryCheckpointError::LockFailed(e.to_string()))?;
521        let _ = guard.insert(name.to_string(), position);
522        Ok(())
523    }
524}
525
526#[cfg(test)]
527#[path = "lib.test.rs"]
528mod tests;