Skip to main content

lattice_embed/migration/
types.rs

1//! Migration types: states, plans, progress snapshots, and errors.
2
3use serde::{Deserialize, Serialize};
4
5use crate::model::EmbeddingModel;
6
7/// Reason why an embedding was skipped during migration.
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
9#[serde(rename_all = "snake_case")]
10#[non_exhaustive]
11pub enum SkipReason {
12    /// Content exceeds maximum size for embedding.
13    ContentTooLarge {
14        /// Actual content size in bytes.
15        size: usize,
16        /// Maximum allowed size in bytes.
17        max: usize,
18    },
19    /// Content encoding is invalid or unsupported.
20    InvalidEncoding(String),
21    /// Content was deleted during migration.
22    ContentDeleted,
23    /// Embedding API returned a permanent (non-retryable) error.
24    PermanentApiError(String),
25    /// Manually skipped by operator.
26    ManualSkip(String),
27}
28
29impl std::fmt::Display for SkipReason {
30    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31        match self {
32            SkipReason::ContentTooLarge { size, max } => {
33                write!(f, "content too large: {size} bytes (max {max})")
34            }
35            SkipReason::InvalidEncoding(enc) => write!(f, "invalid encoding: {enc}"),
36            SkipReason::ContentDeleted => write!(f, "content deleted"),
37            SkipReason::PermanentApiError(msg) => write!(f, "permanent API error: {msg}"),
38            SkipReason::ManualSkip(reason) => write!(f, "manually skipped: {reason}"),
39        }
40    }
41}
42
43/// Migration state machine states.
44#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
45#[serde(rename_all = "snake_case")]
46#[non_exhaustive]
47pub enum MigrationState {
48    /// Migration is planned but has not started.
49    // FP-036: alias allows deserializing data stored before rename_all = "snake_case" was applied.
50    #[serde(alias = "Planned")]
51    Planned,
52    /// Migration is actively processing embeddings.
53    #[serde(alias = "InProgress")]
54    InProgress {
55        /// Number of embeddings processed so far.
56        processed: usize,
57        /// Total number of embeddings to process.
58        total: usize,
59        /// Number of embeddings skipped.
60        #[serde(default)]
61        skipped: usize,
62    },
63    /// Migration is paused (can be resumed).
64    #[serde(alias = "Paused")]
65    Paused {
66        /// Number of embeddings processed before pause.
67        processed: usize,
68        /// Total number of embeddings to process.
69        total: usize,
70        /// Number of embeddings skipped.
71        #[serde(default)]
72        skipped: usize,
73        /// Reason the migration was paused.
74        reason: String,
75    },
76    /// Migration completed successfully.
77    #[serde(alias = "Completed")]
78    Completed {
79        /// Total number of embeddings processed.
80        processed: usize,
81        /// Number of embeddings skipped.
82        #[serde(default)]
83        skipped: usize,
84        /// Wall-clock duration in seconds.
85        duration_secs: f64,
86    },
87    /// Migration failed with an error.
88    #[serde(alias = "Failed")]
89    Failed {
90        /// Number of embeddings processed before failure.
91        processed: usize,
92        /// Total number of embeddings to process.
93        total: usize,
94        /// Number of embeddings skipped.
95        #[serde(default)]
96        skipped: usize,
97        /// Error message describing the failure.
98        error: String,
99    },
100    /// Migration was cancelled by the operator.
101    #[serde(alias = "Cancelled")]
102    Cancelled {
103        /// Number of embeddings processed before cancellation.
104        processed: usize,
105        /// Total number of embeddings to process.
106        total: usize,
107        /// Number of embeddings skipped.
108        #[serde(default)]
109        skipped: usize,
110    },
111}
112
113impl MigrationState {
114    /// Returns `true` if the migration can be resumed (paused or failed).
115    #[inline]
116    pub fn is_resumable(&self) -> bool {
117        matches!(
118            self,
119            MigrationState::Paused { .. } | MigrationState::Failed { .. }
120        )
121    }
122
123    /// Returns `true` if the migration has reached a terminal state (completed or cancelled).
124    #[inline]
125    pub fn is_terminal(&self) -> bool {
126        matches!(
127            self,
128            MigrationState::Completed { .. } | MigrationState::Cancelled { .. }
129        )
130    }
131
132    /// Returns `true` if the migration is currently in progress.
133    #[inline]
134    pub fn is_active(&self) -> bool {
135        matches!(self, MigrationState::InProgress { .. })
136    }
137
138    /// Returns the progress as a fraction in [0.0, 1.0], or `None` if not applicable.
139    pub fn progress(&self) -> Option<f64> {
140        match self {
141            MigrationState::Planned => Some(0.0),
142            MigrationState::InProgress {
143                processed, total, ..
144            }
145            | MigrationState::Paused {
146                processed, total, ..
147            }
148            | MigrationState::Failed {
149                processed, total, ..
150            }
151            | MigrationState::Cancelled {
152                processed, total, ..
153            } => {
154                if *total == 0 {
155                    Some(1.0)
156                } else {
157                    Some(*processed as f64 / *total as f64)
158                }
159            }
160            MigrationState::Completed { .. } => Some(1.0),
161        }
162    }
163
164    /// Returns the number of embeddings processed so far.
165    pub fn processed(&self) -> usize {
166        match self {
167            MigrationState::Planned => 0,
168            MigrationState::InProgress { processed, .. }
169            | MigrationState::Paused { processed, .. }
170            | MigrationState::Failed { processed, .. }
171            | MigrationState::Cancelled { processed, .. }
172            | MigrationState::Completed { processed, .. } => *processed,
173        }
174    }
175
176    /// Returns the number of embeddings skipped.
177    pub fn skipped(&self) -> usize {
178        match self {
179            MigrationState::Planned => 0,
180            MigrationState::InProgress { skipped, .. }
181            | MigrationState::Paused { skipped, .. }
182            | MigrationState::Failed { skipped, .. }
183            | MigrationState::Cancelled { skipped, .. }
184            | MigrationState::Completed { skipped, .. } => *skipped,
185        }
186    }
187
188    /// Returns the total number of embeddings to process.
189    pub fn total(&self) -> usize {
190        match self {
191            MigrationState::Planned | MigrationState::Completed { .. } => 0,
192            MigrationState::InProgress { total, .. }
193            | MigrationState::Paused { total, .. }
194            | MigrationState::Failed { total, .. }
195            | MigrationState::Cancelled { total, .. } => *total,
196        }
197    }
198
199    /// Returns total minus skipped -- the number of embeddings that actually need processing.
200    pub fn effective_total(&self) -> usize {
201        self.total().saturating_sub(self.skipped())
202    }
203
204    /// Returns the fraction of effective_total that has been processed (0.0 to 1.0).
205    pub fn effective_coverage(&self) -> f64 {
206        let eff = self.effective_total();
207        if eff == 0 {
208            1.0
209        } else {
210            self.processed() as f64 / eff as f64
211        }
212    }
213}
214
215/// Describes an embedding migration operation.
216///
217/// # Example
218///
219/// ```rust
220/// use lattice_embed::migration::MigrationPlan;
221/// use lattice_embed::EmbeddingModel;
222///
223/// let plan = MigrationPlan {
224///     id: "mig-001".to_string(),
225///     source_model: EmbeddingModel::BgeSmallEnV15,
226///     target_model: EmbeddingModel::BgeBaseEnV15,
227///     total_embeddings: 10_000,
228///     batch_size: 256,
229///     created_at: "2026-01-27T00:00:00Z".to_string(),
230/// };
231///
232/// assert_eq!(plan.total_embeddings, 10_000);
233/// ```
234#[derive(Debug, Clone, Serialize, Deserialize)]
235pub struct MigrationPlan {
236    /// Unique migration identifier.
237    pub id: String,
238    /// Model to migrate from.
239    pub source_model: EmbeddingModel,
240    /// Model to migrate to.
241    pub target_model: EmbeddingModel,
242    /// Total number of embeddings to migrate.
243    pub total_embeddings: usize,
244    /// Number of embeddings processed per batch.
245    pub batch_size: usize,
246    /// ISO 8601 timestamp when the plan was created.
247    pub created_at: String,
248}
249
250/// Progress report for an active migration.
251#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct MigrationProgress {
253    /// Identifier of the migration this progress belongs to.
254    pub migration_id: String,
255    /// Current state of the migration.
256    pub state: MigrationState,
257    /// Number of embeddings skipped so far.
258    #[serde(default)]
259    pub skipped: usize,
260    /// Total minus skipped -- the number of embeddings that actually need processing.
261    #[serde(default)]
262    pub effective_total: usize,
263    /// Fraction of effective_total that has been processed (0.0 to 1.0).
264    #[serde(default)]
265    pub effective_coverage: f64,
266    /// Embeddings processed per second.
267    pub throughput: f64,
268    /// Estimated seconds remaining, if calculable.
269    pub eta_secs: Option<f64>,
270    /// Number of errors encountered during processing.
271    pub error_count: usize,
272}
273
274/// Errors from migration operations.
275#[derive(Debug, Clone)]
276#[non_exhaustive]
277pub enum MigrationError {
278    /// Attempted an invalid state transition.
279    InvalidTransition {
280        /// State being transitioned from.
281        from: String,
282        /// State being transitioned to.
283        to: String,
284    },
285}
286
287impl std::fmt::Display for MigrationError {
288    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
289        match self {
290            MigrationError::InvalidTransition { from, to } => {
291                write!(f, "invalid migration transition from {from} to {to}")
292            }
293        }
294    }
295}
296
297impl std::error::Error for MigrationError {}