Skip to main content

lattice_embed/migration/
controller.rs

1//! Executes lifecycle transitions and progress accounting for one migration.
2//!
3//! This layer tracks a plan and its work budget only; query/write routing belongs to the
4//! backfill coordinator.
5//!
6//! See [docs/migration.md](../../docs/migration.md) for the state machine and accounting rules.
7
8use std::time::Instant;
9
10use super::types::{MigrationError, MigrationPlan, MigrationProgress, MigrationState, SkipReason};
11
12/// Manages the state machine for a single migration.
13///
14/// It owns lifecycle transitions, progress, skips, and diagnostics but not model routing.
15/// See [docs/migration.md](../../docs/migration.md) for its transition and accounting rules.
16#[derive(Debug)]
17pub struct MigrationController {
18    pub(super) plan: MigrationPlan,
19    pub(super) state: MigrationState,
20    started_at: Option<Instant>,
21    error_count: usize,
22    skip_reasons: Vec<SkipReason>,
23}
24
25impl MigrationController {
26    /// Create a new migration controller from a plan.
27    pub fn new(plan: MigrationPlan) -> Self {
28        Self {
29            plan,
30            state: MigrationState::Planned,
31            started_at: None,
32            error_count: 0,
33            skip_reasons: Vec::new(),
34        }
35    }
36
37    /// Start the migration (`Planned` -> `InProgress`).
38    pub fn start(&mut self) -> Result<(), MigrationError> {
39        match &self.state {
40            MigrationState::Planned => {
41                self.state = MigrationState::InProgress {
42                    processed: 0,
43                    total: self.plan.total_embeddings,
44                    skipped: 0,
45                };
46                self.started_at = Some(Instant::now());
47                Ok(())
48            }
49            other => Err(MigrationError::InvalidTransition {
50                from: format!("{other:?}"),
51                to: "InProgress".to_string(),
52            }),
53        }
54    }
55
56    /// Record that `newly_processed` embeddings were completed.
57    pub fn record_progress(&mut self, newly_processed: usize) -> Result<(), MigrationError> {
58        match &self.state {
59            MigrationState::InProgress {
60                processed,
61                total,
62                skipped,
63            } => {
64                let new_processed = processed + newly_processed;
65                let effective_total = total.saturating_sub(*skipped);
66                if new_processed >= effective_total {
67                    let duration = self
68                        .started_at
69                        .map(|s| s.elapsed().as_secs_f64())
70                        .unwrap_or(0.0);
71                    self.state = MigrationState::Completed {
72                        processed: new_processed,
73                        skipped: *skipped,
74                        duration_secs: duration,
75                    };
76                } else {
77                    self.state = MigrationState::InProgress {
78                        processed: new_processed,
79                        total: *total,
80                        skipped: *skipped,
81                    };
82                }
83                Ok(())
84            }
85            other => Err(MigrationError::InvalidTransition {
86                from: format!("{other:?}"),
87                to: "InProgress (progress)".to_string(),
88            }),
89        }
90    }
91
92    /// Record a non-fatal error during processing.
93    pub fn record_error(&mut self) {
94        self.error_count += 1;
95    }
96
97    /// Record an item that will be permanently skipped.
98    ///
99    /// Skips reduce the effective completion total but require a later progress call to
100    /// transition to completed. See [docs/migration.md](../../docs/migration.md).
101    pub fn record_skip(&mut self, reason: SkipReason) -> Result<(), MigrationError> {
102        match &self.state {
103            MigrationState::InProgress {
104                processed,
105                total,
106                skipped,
107            } => {
108                // Accept skips only while their combined work count is strictly below total.
109                if *processed + *skipped >= *total {
110                    return Err(MigrationError::InvalidTransition {
111                        from: format!("{:?}", self.state),
112                        to: format!(
113                            "InProgress (skip rejected: processed + skipped would exceed total ({total}))",
114                        ),
115                    });
116                }
117                self.skip_reasons.push(reason);
118                self.state = MigrationState::InProgress {
119                    processed: *processed,
120                    total: *total,
121                    skipped: skipped + 1,
122                };
123                Ok(())
124            }
125            other => Err(MigrationError::InvalidTransition {
126                from: format!("{other:?}"),
127                to: "InProgress (skip)".to_string(),
128            }),
129        }
130    }
131
132    /// Returns the list of reasons why entries were skipped during migration.
133    #[inline]
134    pub fn skip_reasons(&self) -> &[SkipReason] {
135        &self.skip_reasons
136    }
137
138    /// Returns processed-to-effective-total coverage; a zero effective total returns 1.0.
139    pub fn effective_coverage(&self) -> f64 {
140        self.state.effective_coverage()
141    }
142
143    /// Pause the migration (`InProgress` -> `Paused`).
144    pub fn pause(&mut self, reason: impl Into<String>) -> Result<(), MigrationError> {
145        match &self.state {
146            MigrationState::InProgress {
147                processed,
148                total,
149                skipped,
150            } => {
151                self.state = MigrationState::Paused {
152                    processed: *processed,
153                    total: *total,
154                    skipped: *skipped,
155                    reason: reason.into(),
156                };
157                Ok(())
158            }
159            other => Err(MigrationError::InvalidTransition {
160                from: format!("{other:?}"),
161                to: "Paused".to_string(),
162            }),
163        }
164    }
165
166    /// Resume the migration (`Paused`/`Failed` -> `InProgress`).
167    pub fn resume(&mut self) -> Result<(), MigrationError> {
168        match &self.state {
169            MigrationState::Paused {
170                processed,
171                total,
172                skipped,
173                ..
174            }
175            | MigrationState::Failed {
176                processed,
177                total,
178                skipped,
179                ..
180            } => {
181                self.state = MigrationState::InProgress {
182                    processed: *processed,
183                    total: *total,
184                    skipped: *skipped,
185                };
186                if self.started_at.is_none() {
187                    self.started_at = Some(Instant::now());
188                }
189                Ok(())
190            }
191            other => Err(MigrationError::InvalidTransition {
192                from: format!("{other:?}"),
193                to: "InProgress (resume)".to_string(),
194            }),
195        }
196    }
197
198    /// Fail the migration (`InProgress` -> `Failed`).
199    pub fn fail(&mut self, error: impl Into<String>) -> Result<(), MigrationError> {
200        match &self.state {
201            MigrationState::InProgress {
202                processed,
203                total,
204                skipped,
205            } => {
206                self.state = MigrationState::Failed {
207                    processed: *processed,
208                    total: *total,
209                    skipped: *skipped,
210                    error: error.into(),
211                };
212                Ok(())
213            }
214            other => Err(MigrationError::InvalidTransition {
215                from: format!("{other:?}"),
216                to: "Failed".to_string(),
217            }),
218        }
219    }
220
221    /// Cancel the migration (any non-terminal state -> `Cancelled`).
222    pub fn cancel(&mut self) -> Result<(), MigrationError> {
223        if self.state.is_terminal() {
224            return Err(MigrationError::InvalidTransition {
225                from: format!("{:?}", self.state),
226                to: "Cancelled".to_string(),
227            });
228        }
229        let (processed, total, skipped) = match &self.state {
230            MigrationState::Planned => (0, self.plan.total_embeddings, 0),
231            MigrationState::InProgress {
232                processed,
233                total,
234                skipped,
235            } => (*processed, *total, *skipped),
236            MigrationState::Paused {
237                processed,
238                total,
239                skipped,
240                ..
241            } => (*processed, *total, *skipped),
242            MigrationState::Failed {
243                processed,
244                total,
245                skipped,
246                ..
247            } => (*processed, *total, *skipped),
248            _ => unreachable!(),
249        };
250        self.state = MigrationState::Cancelled {
251            processed,
252            total,
253            skipped,
254        };
255        Ok(())
256    }
257
258    /// Get a snapshot of current progress.
259    pub fn progress(&self) -> MigrationProgress {
260        let throughput = match (&self.state, self.started_at) {
261            (MigrationState::InProgress { processed, .. }, Some(start)) => {
262                let elapsed = start.elapsed().as_secs_f64();
263                if elapsed > 0.0 {
264                    *processed as f64 / elapsed
265                } else {
266                    0.0
267                }
268            }
269            _ => 0.0,
270        };
271
272        let eta_secs = match &self.state {
273            MigrationState::InProgress {
274                processed,
275                total,
276                skipped,
277            } if throughput > 0.0 => {
278                let effective_total = total.saturating_sub(*skipped);
279                let remaining = effective_total.saturating_sub(*processed);
280                Some(remaining as f64 / throughput)
281            }
282            _ => None,
283        };
284
285        MigrationProgress {
286            migration_id: self.plan.id.clone(),
287            state: self.state.clone(),
288            skipped: self.state.skipped(),
289            effective_total: self.state.effective_total(),
290            effective_coverage: self.state.effective_coverage(),
291            throughput,
292            eta_secs,
293            error_count: self.error_count,
294        }
295    }
296
297    /// Returns the current migration state.
298    #[inline]
299    pub fn state(&self) -> &MigrationState {
300        &self.state
301    }
302
303    /// Returns the migration plan.
304    #[inline]
305    pub fn plan(&self) -> &MigrationPlan {
306        &self.plan
307    }
308}