1use std::time::Instant;
9
10use super::types::{MigrationError, MigrationPlan, MigrationProgress, MigrationState, SkipReason};
11
12#[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 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 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 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 pub fn record_error(&mut self) {
94 self.error_count += 1;
95 }
96
97 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 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 #[inline]
134 pub fn skip_reasons(&self) -> &[SkipReason] {
135 &self.skip_reasons
136 }
137
138 pub fn effective_coverage(&self) -> f64 {
140 self.state.effective_coverage()
141 }
142
143 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 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 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 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 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 #[inline]
299 pub fn state(&self) -> &MigrationState {
300 &self.state
301 }
302
303 #[inline]
305 pub fn plan(&self) -> &MigrationPlan {
306 &self.plan
307 }
308}