Skip to main content

allsource_core/application/services/
replay.rs

1use crate::{
2    application::{dto::QueryEventsRequest, services::projection::Projection},
3    domain::entities::Event,
4    error::{AllSourceError, Result},
5    store::EventStore,
6};
7use chrono::{DateTime, Utc};
8use parking_lot::RwLock;
9use serde::{Deserialize, Serialize};
10use std::sync::{
11    Arc,
12    atomic::{AtomicBool, AtomicU64, Ordering},
13};
14use uuid::Uuid;
15
16/// Status of a replay operation
17#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
18#[serde(rename_all = "lowercase")]
19pub enum ReplayStatus {
20    /// Replay is pending and hasn't started yet
21    Pending,
22    /// Replay is currently running
23    Running,
24    /// Replay completed successfully
25    Completed,
26    /// Replay failed with an error
27    Failed,
28    /// Replay was cancelled by user
29    Cancelled,
30}
31
32/// Configuration for replay operations
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct ReplayConfig {
35    /// Batch size for processing events
36    pub batch_size: usize,
37
38    /// Whether to run replay in parallel
39    pub parallel: bool,
40
41    /// Number of parallel workers (if parallel is true)
42    pub workers: usize,
43
44    /// Whether to emit progress events
45    pub emit_progress: bool,
46
47    /// Progress reporting interval (every N events)
48    pub progress_interval: usize,
49}
50
51impl Default for ReplayConfig {
52    fn default() -> Self {
53        Self {
54            batch_size: 1000,
55            parallel: false,
56            workers: 4,
57            emit_progress: true,
58            progress_interval: 1000,
59        }
60    }
61}
62
63/// Request to start a replay operation
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct StartReplayRequest {
66    /// Optional projection name to rebuild (if None, replays all projections)
67    pub projection_name: Option<String>,
68
69    /// Start from this timestamp (if None, starts from beginning)
70    pub from_timestamp: Option<DateTime<Utc>>,
71
72    /// End at this timestamp (if None, goes to end)
73    pub to_timestamp: Option<DateTime<Utc>>,
74
75    /// Filter by entity_id (optional)
76    pub entity_id: Option<String>,
77
78    /// Filter by event_type (optional)
79    pub event_type: Option<String>,
80
81    /// Replay configuration
82    pub config: Option<ReplayConfig>,
83}
84
85/// Response from starting a replay
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct StartReplayResponse {
88    pub replay_id: Uuid,
89    pub status: ReplayStatus,
90    pub started_at: DateTime<Utc>,
91    pub total_events: usize,
92}
93
94/// Progress information for a replay
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct ReplayProgress {
97    pub replay_id: Uuid,
98    pub status: ReplayStatus,
99    pub started_at: DateTime<Utc>,
100    pub updated_at: DateTime<Utc>,
101    pub completed_at: Option<DateTime<Utc>>,
102    pub total_events: usize,
103    pub processed_events: usize,
104    pub failed_events: usize,
105    pub progress_percentage: f64,
106    pub events_per_second: f64,
107    pub error_message: Option<String>,
108}
109
110/// Manages event replay and projection rebuilding
111pub struct ReplayManager {
112    /// Active replay operations
113    replays: Arc<RwLock<Vec<ReplayState>>>,
114}
115
116/// Internal state for a replay operation
117struct ReplayState {
118    id: Uuid,
119    projection_name: Option<String>,
120    status: Arc<RwLock<ReplayStatus>>,
121    started_at: DateTime<Utc>,
122    completed_at: Arc<RwLock<Option<DateTime<Utc>>>>,
123    total_events: usize,
124    processed_events: Arc<AtomicU64>,
125    failed_events: Arc<AtomicU64>,
126    error_message: Arc<RwLock<Option<String>>>,
127    cancelled: Arc<AtomicBool>,
128}
129
130impl ReplayManager {
131    pub fn new() -> Self {
132        Self {
133            replays: Arc::new(RwLock::new(Vec::new())),
134        }
135    }
136
137    /// Start a replay operation
138    pub fn start_replay(
139        &self,
140        store: Arc<EventStore>,
141        request: StartReplayRequest,
142    ) -> Result<StartReplayResponse> {
143        let replay_id = Uuid::new_v4();
144        let started_at = Utc::now();
145        let config = request.config.unwrap_or_default();
146
147        // Query events to replay
148        let query = QueryEventsRequest {
149            entity_id: request.entity_id.clone(),
150            event_type: request.event_type.clone(),
151            tenant_id: None,
152            as_of: request.to_timestamp,
153            since: request.from_timestamp,
154            until: request.to_timestamp,
155            limit: None,
156            event_type_prefix: None,
157            exclude_event_type_prefix: None,
158            payload_filter: None,
159        };
160
161        let events = store.query(&query)?;
162        let total_events = events.len();
163
164        tracing::info!(
165            "🔄 Starting replay {} for {} events{}",
166            replay_id,
167            total_events,
168            request
169                .projection_name
170                .as_ref()
171                .map(|n| format!(" (projection: {n})"))
172                .unwrap_or_default()
173        );
174
175        // Create replay state
176        let state = ReplayState {
177            id: replay_id,
178            projection_name: request.projection_name.clone(),
179            status: Arc::new(RwLock::new(ReplayStatus::Running)),
180            started_at,
181            completed_at: Arc::new(RwLock::new(None)),
182            total_events,
183            processed_events: Arc::new(AtomicU64::new(0)),
184            failed_events: Arc::new(AtomicU64::new(0)),
185            error_message: Arc::new(RwLock::new(None)),
186            cancelled: Arc::new(AtomicBool::new(false)),
187        };
188
189        // Store replay state
190        self.replays.write().push(state);
191
192        // Get the state references we need
193        let replays = Arc::clone(&self.replays);
194        let replay_idx = replays.read().len() - 1;
195
196        // Spawn replay task
197        tokio::spawn(async move {
198            let result = Self::run_replay(
199                store,
200                events,
201                request.projection_name,
202                config,
203                replays.clone(),
204                replay_idx,
205            )
206            .await;
207
208            // Update final status
209            let mut replays_lock = replays.write();
210            if let Some(state) = replays_lock.get_mut(replay_idx) {
211                *state.completed_at.write() = Some(Utc::now());
212
213                match result {
214                    Ok(()) => {
215                        if state.cancelled.load(Ordering::Relaxed) {
216                            *state.status.write() = ReplayStatus::Cancelled;
217                            tracing::info!("🛑 Replay {} cancelled", state.id);
218                        } else {
219                            *state.status.write() = ReplayStatus::Completed;
220                            tracing::info!("✅ Replay {} completed successfully", state.id);
221                        }
222                    }
223                    Err(e) => {
224                        *state.status.write() = ReplayStatus::Failed;
225                        *state.error_message.write() = Some(e.to_string());
226                        tracing::error!("❌ Replay {} failed: {}", state.id, e);
227                    }
228                }
229            }
230        });
231
232        Ok(StartReplayResponse {
233            replay_id,
234            status: ReplayStatus::Running,
235            started_at,
236            total_events,
237        })
238    }
239
240    /// Internal replay execution
241    // Kept `async`: this is the body of the `tokio::spawn`ed replay task and is
242    // awaited as such. Rewriting it to return `impl Future` would mean threading
243    // `std::future::ready(..)` through every early return in the replay loop —
244    // a real edit to replay control flow to satisfy a style lint. It has no
245    // `.await` only because the current batch loop is CPU-bound.
246    #[allow(unknown_lints, clippy::unused_async_trait_impl)]
247    async fn run_replay(
248        store: Arc<EventStore>,
249        events: Vec<Event>,
250        projection_name: Option<String>,
251        config: ReplayConfig,
252        replays: Arc<RwLock<Vec<ReplayState>>>,
253        replay_idx: usize,
254    ) -> Result<()> {
255        let total = events.len();
256        let projections = store.projections.read();
257
258        // Get target projection(s)
259        let target_projections: Vec<(String, Arc<dyn Projection>)> =
260            if let Some(name) = projection_name {
261                if let Some(proj) = projections.get_projection(&name) {
262                    vec![(name, proj)]
263                } else {
264                    return Err(AllSourceError::ValidationError(format!(
265                        "Projection not found: {name}"
266                    )));
267                }
268            } else {
269                projections.list_projections()
270            };
271
272        drop(projections); // Release lock
273
274        // Process events in batches
275        for (batch_idx, chunk) in events.chunks(config.batch_size).enumerate() {
276            // Check if cancelled
277            {
278                let replays_lock = replays.read();
279                if let Some(state) = replays_lock.get(replay_idx)
280                    && state.cancelled.load(Ordering::Relaxed)
281                {
282                    return Ok(());
283                }
284            }
285
286            // Process batch
287            for event in chunk {
288                // Apply event to each target projection
289                for (proj_name, projection) in &target_projections {
290                    if let Err(e) = projection.process(event) {
291                        tracing::warn!(
292                            "Failed to process event {} in projection {}: {}",
293                            event.id,
294                            proj_name,
295                            e
296                        );
297
298                        // Increment failed counter
299                        let replays_lock = replays.read();
300                        if let Some(state) = replays_lock.get(replay_idx) {
301                            state.failed_events.fetch_add(1, Ordering::Relaxed);
302                        }
303                    }
304                }
305
306                // Increment processed counter
307                let replays_lock = replays.read();
308                if let Some(state) = replays_lock.get(replay_idx) {
309                    let processed = state.processed_events.fetch_add(1, Ordering::Relaxed) + 1;
310
311                    // Emit progress
312                    if config.emit_progress && processed % config.progress_interval as u64 == 0 {
313                        let progress = (processed as f64 / total as f64) * 100.0;
314                        tracing::debug!(
315                            "Replay progress: {}/{} ({:.1}%)",
316                            processed,
317                            total,
318                            progress
319                        );
320                    }
321                }
322            }
323        }
324
325        Ok(())
326    }
327
328    /// Get progress for a replay operation
329    pub fn get_progress(&self, replay_id: Uuid) -> Result<ReplayProgress> {
330        let replays = self.replays.read();
331
332        let state = replays.iter().find(|r| r.id == replay_id).ok_or_else(|| {
333            AllSourceError::ValidationError(format!("Replay not found: {replay_id}"))
334        })?;
335
336        let processed = state.processed_events.load(Ordering::Relaxed);
337        let failed = state.failed_events.load(Ordering::Relaxed);
338        let total_events = state.total_events;
339        let started_at = state.started_at;
340        let status = *state.status.read();
341        let completed_at = *state.completed_at.read();
342        let error_message = state.error_message.read().clone();
343
344        drop(replays); // Release lock before calculations
345
346        let progress_percentage = if total_events > 0 {
347            (processed as f64 / total_events as f64) * 100.0
348        } else {
349            0.0
350        };
351
352        let updated_at = Utc::now();
353        let elapsed_seconds = (updated_at - started_at).num_seconds().max(1) as f64;
354        let events_per_second = processed as f64 / elapsed_seconds;
355
356        Ok(ReplayProgress {
357            replay_id,
358            status,
359            started_at,
360            updated_at,
361            completed_at,
362            total_events,
363            processed_events: processed as usize,
364            failed_events: failed as usize,
365            progress_percentage,
366            events_per_second,
367            error_message,
368        })
369    }
370
371    /// Cancel a running replay
372    pub fn cancel_replay(&self, replay_id: Uuid) -> Result<()> {
373        let replays = self.replays.read();
374
375        let state = replays.iter().find(|r| r.id == replay_id).ok_or_else(|| {
376            AllSourceError::ValidationError(format!("Replay not found: {replay_id}"))
377        })?;
378
379        let status = *state.status.read();
380        if status != ReplayStatus::Running {
381            return Err(AllSourceError::ValidationError(format!(
382                "Cannot cancel replay in status: {status:?}"
383            )));
384        }
385
386        state.cancelled.store(true, Ordering::Relaxed);
387        tracing::info!("🛑 Cancelling replay {}", replay_id);
388
389        Ok(())
390    }
391
392    /// List all replay operations
393    pub fn list_replays(&self) -> Vec<ReplayProgress> {
394        let replays = self.replays.read();
395
396        replays
397            .iter()
398            .map(|state| {
399                let processed = state.processed_events.load(Ordering::Relaxed);
400                let failed = state.failed_events.load(Ordering::Relaxed);
401                let progress_percentage = if state.total_events > 0 {
402                    (processed as f64 / state.total_events as f64) * 100.0
403                } else {
404                    0.0
405                };
406
407                let updated_at = Utc::now();
408                let elapsed_seconds = (updated_at - state.started_at).num_seconds().max(1) as f64;
409                let events_per_second = processed as f64 / elapsed_seconds;
410
411                ReplayProgress {
412                    replay_id: state.id,
413                    status: *state.status.read(),
414                    started_at: state.started_at,
415                    updated_at,
416                    completed_at: *state.completed_at.read(),
417                    total_events: state.total_events,
418                    processed_events: processed as usize,
419                    failed_events: failed as usize,
420                    progress_percentage,
421                    events_per_second,
422                    error_message: state.error_message.read().clone(),
423                }
424            })
425            .collect()
426    }
427
428    /// Delete a completed or failed replay from history
429    pub fn delete_replay(&self, replay_id: Uuid) -> Result<bool> {
430        let mut replays = self.replays.write();
431
432        let idx = replays
433            .iter()
434            .position(|r| r.id == replay_id)
435            .ok_or_else(|| {
436                AllSourceError::ValidationError(format!("Replay not found: {replay_id}"))
437            })?;
438
439        let status = *replays[idx].status.read();
440        if status == ReplayStatus::Running {
441            return Err(AllSourceError::ValidationError(
442                "Cannot delete a running replay. Cancel it first.".to_string(),
443            ));
444        }
445
446        replays.remove(idx);
447        tracing::info!("🗑️  Deleted replay {}", replay_id);
448
449        Ok(true)
450    }
451}
452
453impl Default for ReplayManager {
454    fn default() -> Self {
455        Self::new()
456    }
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462    use crate::domain::entities::Event;
463    use serde_json::json;
464
465    #[tokio::test]
466    async fn test_replay_manager_creation() {
467        let manager = ReplayManager::new();
468        let replays = manager.list_replays();
469        assert_eq!(replays.len(), 0);
470    }
471
472    #[tokio::test]
473    async fn test_replay_progress_tracking() {
474        let manager = ReplayManager::new();
475        let store = Arc::new(EventStore::new());
476
477        // Ingest some test events
478        for i in 0..10 {
479            let event = Event::from_strings(
480                "test.event".to_string(),
481                "test-entity".to_string(),
482                "default".to_string(),
483                json!({"value": i}),
484                None,
485            )
486            .unwrap();
487            store.ingest(&event).unwrap();
488        }
489
490        // Start replay
491        let request = StartReplayRequest {
492            projection_name: None,
493            from_timestamp: None,
494            to_timestamp: None,
495            entity_id: None,
496            event_type: None,
497            config: Some(ReplayConfig {
498                batch_size: 5,
499                parallel: false,
500                workers: 1,
501                emit_progress: true,
502                progress_interval: 5,
503            }),
504        };
505
506        let response = manager.start_replay(store, request).unwrap();
507        assert_eq!(response.status, ReplayStatus::Running);
508        assert_eq!(response.total_events, 10);
509
510        // Wait a bit for replay to process
511        tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
512
513        // Check progress
514        let progress = manager.get_progress(response.replay_id).unwrap();
515        assert!(progress.processed_events <= 10);
516    }
517}