1use std::time::Instant;
4
5use super::types::{MigrationError, MigrationPlan, MigrationProgress, MigrationState, SkipReason};
6
7#[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 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 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 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 pub fn record_error(&mut self) {
110 self.error_count += 1;
111 }
112
113 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 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 #[inline]
170 pub fn skip_reasons(&self) -> &[SkipReason] {
171 &self.skip_reasons
172 }
173
174 pub fn effective_coverage(&self) -> f64 {
176 self.state.effective_coverage()
177 }
178
179 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 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 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 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 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 #[inline]
335 pub fn state(&self) -> &MigrationState {
336 &self.state
337 }
338
339 #[inline]
341 pub fn plan(&self) -> &MigrationPlan {
342 &self.plan
343 }
344}