Skip to main content

lattice_embed/migration/
controller.rs

1//! Migration controller: state machine executor.
2
3use std::time::Instant;
4
5use super::types::{MigrationError, MigrationPlan, MigrationProgress, MigrationState, SkipReason};
6
7/// Manages the state machine for a single migration.
8///
9/// # Example
10///
11/// ```rust
12/// use lattice_embed::migration::{MigrationController, MigrationPlan};
13/// use lattice_embed::EmbeddingModel;
14///
15/// let plan = MigrationPlan {
16///     id: "mig-001".to_string(),
17///     source_model: EmbeddingModel::BgeSmallEnV15,
18///     target_model: EmbeddingModel::BgeBaseEnV15,
19///     total_embeddings: 100,
20///     batch_size: 50,
21///     created_at: "2026-01-27T00:00:00Z".to_string(),
22/// };
23///
24/// let mut ctrl = MigrationController::new(plan);
25/// ctrl.start().unwrap();
26/// ctrl.record_progress(50).unwrap();
27///
28/// let report = ctrl.progress();
29/// assert!(report.state.is_active());
30/// assert_eq!(report.state.processed(), 50);
31/// ```
32#[derive(Debug)]
33pub struct MigrationController {
34    pub(super) plan: MigrationPlan,
35    pub(super) state: MigrationState,
36    started_at: Option<Instant>,
37    error_count: usize,
38    skip_reasons: Vec<SkipReason>,
39}
40
41impl MigrationController {
42    /// Create a new migration controller from a plan.
43    pub fn new(plan: MigrationPlan) -> Self {
44        Self {
45            plan,
46            state: MigrationState::Planned,
47            started_at: None,
48            error_count: 0,
49            skip_reasons: Vec::new(),
50        }
51    }
52
53    /// Start the migration (`Planned` -> `InProgress`).
54    pub fn start(&mut self) -> Result<(), MigrationError> {
55        match &self.state {
56            MigrationState::Planned => {
57                self.state = MigrationState::InProgress {
58                    processed: 0,
59                    total: self.plan.total_embeddings,
60                    skipped: 0,
61                };
62                self.started_at = Some(Instant::now());
63                Ok(())
64            }
65            other => Err(MigrationError::InvalidTransition {
66                from: format!("{other:?}"),
67                to: "InProgress".to_string(),
68            }),
69        }
70    }
71
72    /// Record that `newly_processed` embeddings were completed.
73    pub fn record_progress(&mut self, newly_processed: usize) -> Result<(), MigrationError> {
74        match &self.state {
75            MigrationState::InProgress {
76                processed,
77                total,
78                skipped,
79            } => {
80                let new_processed = processed + newly_processed;
81                let effective_total = total.saturating_sub(*skipped);
82                if new_processed >= effective_total {
83                    let duration = self
84                        .started_at
85                        .map(|s| s.elapsed().as_secs_f64())
86                        .unwrap_or(0.0);
87                    self.state = MigrationState::Completed {
88                        processed: new_processed,
89                        skipped: *skipped,
90                        duration_secs: duration,
91                    };
92                } else {
93                    self.state = MigrationState::InProgress {
94                        processed: new_processed,
95                        total: *total,
96                        skipped: *skipped,
97                    };
98                }
99                Ok(())
100            }
101            other => Err(MigrationError::InvalidTransition {
102                from: format!("{other:?}"),
103                to: "InProgress (progress)".to_string(),
104            }),
105        }
106    }
107
108    /// Record a non-fatal error during processing.
109    pub fn record_error(&mut self) {
110        self.error_count += 1;
111    }
112
113    /// Record an item that will be permanently skipped.
114    ///
115    /// # Example
116    ///
117    /// ```rust
118    /// use lattice_embed::migration::{MigrationController, MigrationPlan, SkipReason};
119    /// use lattice_embed::EmbeddingModel;
120    ///
121    /// let plan = MigrationPlan {
122    ///     id: "mig-001".to_string(),
123    ///     source_model: EmbeddingModel::BgeSmallEnV15,
124    ///     target_model: EmbeddingModel::BgeBaseEnV15,
125    ///     total_embeddings: 100,
126    ///     batch_size: 50,
127    ///     created_at: "2026-01-27T00:00:00Z".to_string(),
128    /// };
129    ///
130    /// let mut ctrl = MigrationController::new(plan);
131    /// ctrl.start().unwrap();
132    /// ctrl.record_skip(SkipReason::ContentTooLarge { size: 50000, max: 8192 }).unwrap();
133    /// assert_eq!(ctrl.state().skipped(), 1);
134    /// ```
135    pub fn record_skip(&mut self, reason: SkipReason) -> Result<(), MigrationError> {
136        match &self.state {
137            MigrationState::InProgress {
138                processed,
139                total,
140                skipped,
141            } => {
142                // Guard: processed + skipped must not reach total before this skip is
143                // counted. Equality means the budget is already exhausted (duplicate or
144                // retried skip); allow only while strictly less than total.
145                if *processed + *skipped >= *total {
146                    return Err(MigrationError::InvalidTransition {
147                        from: format!("{:?}", self.state),
148                        to: format!(
149                            "InProgress (skip rejected: processed + skipped would exceed total ({total}))",
150                        ),
151                    });
152                }
153                self.skip_reasons.push(reason);
154                self.state = MigrationState::InProgress {
155                    processed: *processed,
156                    total: *total,
157                    skipped: skipped + 1,
158                };
159                Ok(())
160            }
161            other => Err(MigrationError::InvalidTransition {
162                from: format!("{other:?}"),
163                to: "InProgress (skip)".to_string(),
164            }),
165        }
166    }
167
168    /// Returns the list of reasons why entries were skipped during migration.
169    #[inline]
170    pub fn skip_reasons(&self) -> &[SkipReason] {
171        &self.skip_reasons
172    }
173
174    /// Returns the effective coverage fraction (0.0–1.0) of the migration.
175    pub fn effective_coverage(&self) -> f64 {
176        self.state.effective_coverage()
177    }
178
179    /// Pause the migration (`InProgress` -> `Paused`).
180    pub fn pause(&mut self, reason: impl Into<String>) -> Result<(), MigrationError> {
181        match &self.state {
182            MigrationState::InProgress {
183                processed,
184                total,
185                skipped,
186            } => {
187                self.state = MigrationState::Paused {
188                    processed: *processed,
189                    total: *total,
190                    skipped: *skipped,
191                    reason: reason.into(),
192                };
193                Ok(())
194            }
195            other => Err(MigrationError::InvalidTransition {
196                from: format!("{other:?}"),
197                to: "Paused".to_string(),
198            }),
199        }
200    }
201
202    /// Resume the migration (`Paused`/`Failed` -> `InProgress`).
203    pub fn resume(&mut self) -> Result<(), MigrationError> {
204        match &self.state {
205            MigrationState::Paused {
206                processed,
207                total,
208                skipped,
209                ..
210            }
211            | MigrationState::Failed {
212                processed,
213                total,
214                skipped,
215                ..
216            } => {
217                self.state = MigrationState::InProgress {
218                    processed: *processed,
219                    total: *total,
220                    skipped: *skipped,
221                };
222                if self.started_at.is_none() {
223                    self.started_at = Some(Instant::now());
224                }
225                Ok(())
226            }
227            other => Err(MigrationError::InvalidTransition {
228                from: format!("{other:?}"),
229                to: "InProgress (resume)".to_string(),
230            }),
231        }
232    }
233
234    /// Fail the migration (`InProgress` -> `Failed`).
235    pub fn fail(&mut self, error: impl Into<String>) -> Result<(), MigrationError> {
236        match &self.state {
237            MigrationState::InProgress {
238                processed,
239                total,
240                skipped,
241            } => {
242                self.state = MigrationState::Failed {
243                    processed: *processed,
244                    total: *total,
245                    skipped: *skipped,
246                    error: error.into(),
247                };
248                Ok(())
249            }
250            other => Err(MigrationError::InvalidTransition {
251                from: format!("{other:?}"),
252                to: "Failed".to_string(),
253            }),
254        }
255    }
256
257    /// Cancel the migration (any non-terminal state -> `Cancelled`).
258    pub fn cancel(&mut self) -> Result<(), MigrationError> {
259        if self.state.is_terminal() {
260            return Err(MigrationError::InvalidTransition {
261                from: format!("{:?}", self.state),
262                to: "Cancelled".to_string(),
263            });
264        }
265        let (processed, total, skipped) = match &self.state {
266            MigrationState::Planned => (0, self.plan.total_embeddings, 0),
267            MigrationState::InProgress {
268                processed,
269                total,
270                skipped,
271            } => (*processed, *total, *skipped),
272            MigrationState::Paused {
273                processed,
274                total,
275                skipped,
276                ..
277            } => (*processed, *total, *skipped),
278            MigrationState::Failed {
279                processed,
280                total,
281                skipped,
282                ..
283            } => (*processed, *total, *skipped),
284            _ => unreachable!(),
285        };
286        self.state = MigrationState::Cancelled {
287            processed,
288            total,
289            skipped,
290        };
291        Ok(())
292    }
293
294    /// Get a snapshot of current progress.
295    pub fn progress(&self) -> MigrationProgress {
296        let throughput = match (&self.state, self.started_at) {
297            (MigrationState::InProgress { processed, .. }, Some(start)) => {
298                let elapsed = start.elapsed().as_secs_f64();
299                if elapsed > 0.0 {
300                    *processed as f64 / elapsed
301                } else {
302                    0.0
303                }
304            }
305            _ => 0.0,
306        };
307
308        let eta_secs = match &self.state {
309            MigrationState::InProgress {
310                processed,
311                total,
312                skipped,
313            } if throughput > 0.0 => {
314                let effective_total = total.saturating_sub(*skipped);
315                let remaining = effective_total.saturating_sub(*processed);
316                Some(remaining as f64 / throughput)
317            }
318            _ => None,
319        };
320
321        MigrationProgress {
322            migration_id: self.plan.id.clone(),
323            state: self.state.clone(),
324            skipped: self.state.skipped(),
325            effective_total: self.state.effective_total(),
326            effective_coverage: self.state.effective_coverage(),
327            throughput,
328            eta_secs,
329            error_count: self.error_count,
330        }
331    }
332
333    /// Returns the current migration state.
334    #[inline]
335    pub fn state(&self) -> &MigrationState {
336        &self.state
337    }
338
339    /// Returns the migration plan.
340    #[inline]
341    pub fn plan(&self) -> &MigrationPlan {
342        &self.plan
343    }
344}