Skip to main content

ipfrs_transport/
schema_migration.rs

1//! Online Arrow IPC schema migration for TensorSwap protocol.
2//!
3//! This module provides versioned schema migration: field-level evolution
4//! operations can be applied to a live stream without reconnecting.
5//!
6//! # Overview
7//!
8//! - [`FieldMigration`] — atomic field-level change (add, drop, rename, nullability).
9//! - [`SchemaMigration`] — an ordered list of [`FieldMigration`] steps that
10//!   advance a schema from one version to the next.
11//! - [`SchemaEvolutionManager`] — concurrent registry that applies migrations
12//!   to the current version on demand.
13
14use std::collections::HashMap;
15use std::sync::atomic::{AtomicU64, Ordering};
16use std::sync::Arc;
17
18use parking_lot::RwLock;
19
20// ---------------------------------------------------------------------------
21// FieldDefault
22// ---------------------------------------------------------------------------
23
24/// Default value to fill in when a new nullable or typed field is added.
25#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
26pub enum FieldDefault {
27    /// Numeric zero (0 / 0.0 / false depending on target type).
28    Zero,
29    /// Numeric one (1 / 1.0 / true depending on target type).
30    One,
31    /// SQL-style NULL / Arrow null.
32    Null,
33    /// Arbitrary string literal (e.g. for dictionary/utf8 columns).
34    StringValue(String),
35}
36
37// ---------------------------------------------------------------------------
38// FieldMigration
39// ---------------------------------------------------------------------------
40
41/// A single field-level evolution operation.
42#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
43pub enum FieldMigration {
44    /// Append a new field to the schema.
45    ///
46    /// Existing records will have `default_value` substituted for the missing
47    /// column.
48    AddField {
49        name: String,
50        data_type: String,
51        nullable: bool,
52        default_value: FieldDefault,
53    },
54
55    /// Remove a field entirely; any data in that column is discarded.
56    DropField { name: String },
57
58    /// Rename a field in-place without changing its type or nullability.
59    RenameField { from: String, to: String },
60
61    /// Change the nullability of an existing field.
62    SetNullable { name: String, nullable: bool },
63}
64
65// ---------------------------------------------------------------------------
66// SchemaMigration
67// ---------------------------------------------------------------------------
68
69/// A versioned migration script: an ordered sequence of [`FieldMigration`]
70/// operations that advance the schema from `from_version` to `to_version`.
71#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
72pub struct SchemaMigration {
73    /// The schema version this migration starts from.
74    pub from_version: u64,
75    /// The schema version this migration produces.
76    pub to_version: u64,
77    /// Human-readable description of the migration.
78    pub description: String,
79    /// Ordered list of field-level operations to apply.
80    pub operations: Vec<FieldMigration>,
81}
82
83impl SchemaMigration {
84    /// Construct a new migration.
85    pub fn new(
86        from: u64,
87        to: u64,
88        description: impl Into<String>,
89        ops: Vec<FieldMigration>,
90    ) -> Self {
91        Self {
92            from_version: from,
93            to_version: to,
94            description: description.into(),
95            operations: ops,
96        }
97    }
98
99    /// Returns `true` when this migration moves the schema *forward* (i.e.
100    /// `to_version > from_version`).
101    pub fn is_forward(&self) -> bool {
102        self.to_version > self.from_version
103    }
104
105    /// Collect the names of all fields that are *added* by this migration.
106    pub fn field_names_added(&self) -> Vec<&str> {
107        self.operations
108            .iter()
109            .filter_map(|op| {
110                if let FieldMigration::AddField { name, .. } = op {
111                    Some(name.as_str())
112                } else {
113                    None
114                }
115            })
116            .collect()
117    }
118
119    /// Collect the names of all fields that are *removed* by this migration.
120    pub fn field_names_removed(&self) -> Vec<&str> {
121        self.operations
122            .iter()
123            .filter_map(|op| {
124                if let FieldMigration::DropField { name } = op {
125                    Some(name.as_str())
126                } else {
127                    None
128                }
129            })
130            .collect()
131    }
132}
133
134// ---------------------------------------------------------------------------
135// MigrationError
136// ---------------------------------------------------------------------------
137
138/// Errors returned by [`SchemaEvolutionManager`].
139#[derive(Debug)]
140pub enum MigrationError {
141    /// There is no registered migration path between the two versions.
142    NoPath { from: u64, to: u64 },
143    /// The current version is already the requested target.
144    AlreadyAtVersion { version: u64 },
145    /// Downgrade requests are not supported.
146    Downgrade { from: u64, to: u64 },
147}
148
149impl std::fmt::Display for MigrationError {
150    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
151        match self {
152            MigrationError::NoPath { from, to } => {
153                write!(f, "no migration path from version {from} to version {to}")
154            }
155            MigrationError::AlreadyAtVersion { version } => {
156                write!(f, "schema is already at version {version}")
157            }
158            MigrationError::Downgrade { from, to } => {
159                write!(f, "downgrade from version {from} to {to} is not supported")
160            }
161        }
162    }
163}
164
165impl std::error::Error for MigrationError {}
166
167// ---------------------------------------------------------------------------
168// SchemaEvolutionManager
169// ---------------------------------------------------------------------------
170
171/// Thread-safe registry that applies versioned [`SchemaMigration`]s to an
172/// ongoing stream without reconnecting.
173///
174/// # Concurrency
175///
176/// The migration map is protected by a [`parking_lot::RwLock`].  Version
177/// counters use atomic operations so read-heavy workloads need no exclusive
178/// locks.
179pub struct SchemaEvolutionManager {
180    /// from_version → migration to apply to advance one step.
181    migrations: RwLock<HashMap<u64, SchemaMigration>>,
182    /// The version this manager was last migrated to.
183    current_version: AtomicU64,
184    /// Cumulative count of successful migrations applied.
185    applied_count: AtomicU64,
186}
187
188impl SchemaEvolutionManager {
189    /// Create a new manager starting at `initial_version`.
190    ///
191    /// Returns an [`Arc`]-wrapped instance so it can be shared across tasks.
192    pub fn new(initial_version: u64) -> Arc<Self> {
193        Arc::new(Self {
194            migrations: RwLock::new(HashMap::new()),
195            current_version: AtomicU64::new(initial_version),
196            applied_count: AtomicU64::new(0),
197        })
198    }
199
200    /// Register a migration.
201    ///
202    /// Returns `false` if a migration for `migration.from_version` is already
203    /// registered (duplicate registration is silently rejected).
204    pub fn register_migration(&self, migration: SchemaMigration) -> bool {
205        let mut map = self.migrations.write();
206        if map.contains_key(&migration.from_version) {
207            return false;
208        }
209        map.insert(migration.from_version, migration);
210        true
211    }
212
213    /// Compute the ordered sequence of migrations needed to go from
214    /// `from_version` to `to_version`.
215    ///
216    /// Only *forward* paths (strictly increasing version numbers) are
217    /// supported.  Returns `None` when no complete path exists.
218    pub fn migration_path(
219        &self,
220        from_version: u64,
221        to_version: u64,
222    ) -> Option<Vec<SchemaMigration>> {
223        if from_version == to_version {
224            return Some(Vec::new());
225        }
226        if from_version > to_version {
227            return None;
228        }
229
230        let map = self.migrations.read();
231        let mut path = Vec::new();
232        let mut current = from_version;
233
234        while current < to_version {
235            let step = map.get(&current)?;
236            if step.to_version <= current {
237                // guard against non-advancing or backward step
238                return None;
239            }
240            path.push(step.clone());
241            current = step.to_version;
242        }
243
244        if current == to_version {
245            Some(path)
246        } else {
247            None
248        }
249    }
250
251    /// Apply all migrations necessary to reach `target_version`.
252    ///
253    /// # Errors
254    ///
255    /// - [`MigrationError::AlreadyAtVersion`] — current version equals target.
256    /// - [`MigrationError::Downgrade`] — target is less than current version.
257    /// - [`MigrationError::NoPath`] — no registered path leads to target.
258    pub fn migrate_to(&self, target_version: u64) -> Result<Vec<SchemaMigration>, MigrationError> {
259        let current = self.current_version.load(Ordering::Acquire);
260
261        if current == target_version {
262            return Err(MigrationError::AlreadyAtVersion {
263                version: target_version,
264            });
265        }
266
267        if target_version < current {
268            return Err(MigrationError::Downgrade {
269                from: current,
270                to: target_version,
271            });
272        }
273
274        let path = self
275            .migration_path(current, target_version)
276            .ok_or(MigrationError::NoPath {
277                from: current,
278                to: target_version,
279            })?;
280
281        let steps = path.len() as u64;
282
283        // Advance current version atomically (single writer, so a plain store
284        // after the read is safe here because migrate_to uses an exclusive
285        // logical sequence).
286        self.current_version
287            .store(target_version, Ordering::Release);
288        self.applied_count.fetch_add(steps, Ordering::Relaxed);
289
290        Ok(path)
291    }
292
293    /// Return the current schema version.
294    pub fn current_version(&self) -> u64 {
295        self.current_version.load(Ordering::Acquire)
296    }
297
298    /// Return the total number of migrations applied since creation.
299    pub fn applied_count(&self) -> u64 {
300        self.applied_count.load(Ordering::Relaxed)
301    }
302
303    /// Return `true` when a migration path from `from_version` to
304    /// `to_version` is available.
305    pub fn can_migrate(&self, from_version: u64, to_version: u64) -> bool {
306        self.migration_path(from_version, to_version).is_some()
307    }
308
309    /// Return all registered `from_version` values, in ascending order.
310    pub fn registered_versions(&self) -> Vec<u64> {
311        let map = self.migrations.read();
312        let mut versions: Vec<u64> = map.keys().copied().collect();
313        versions.sort_unstable();
314        versions
315    }
316}
317
318// ---------------------------------------------------------------------------
319// Tests
320// ---------------------------------------------------------------------------
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    fn make_add_op(name: &str) -> FieldMigration {
327        FieldMigration::AddField {
328            name: name.to_owned(),
329            data_type: "Int64".to_owned(),
330            nullable: true,
331            default_value: FieldDefault::Null,
332        }
333    }
334
335    fn make_drop_op(name: &str) -> FieldMigration {
336        FieldMigration::DropField {
337            name: name.to_owned(),
338        }
339    }
340
341    fn simple_migration(from: u64, to: u64, op: FieldMigration) -> SchemaMigration {
342        SchemaMigration::new(from, to, format!("v{from}->v{to}"), vec![op])
343    }
344
345    #[test]
346    fn test_register_migration() {
347        let mgr = SchemaEvolutionManager::new(1);
348        let m = simple_migration(1, 2, make_add_op("score"));
349        assert!(mgr.register_migration(m));
350        assert_eq!(mgr.registered_versions(), vec![1]);
351    }
352
353    #[test]
354    fn test_register_duplicate_returns_false() {
355        let mgr = SchemaEvolutionManager::new(1);
356        let m1 = simple_migration(1, 2, make_add_op("a"));
357        let m2 = simple_migration(1, 3, make_add_op("b"));
358        assert!(mgr.register_migration(m1));
359        // Second registration for from_version=1 must be rejected.
360        assert!(!mgr.register_migration(m2));
361    }
362
363    #[test]
364    fn test_migration_path_direct() {
365        let mgr = SchemaEvolutionManager::new(1);
366        mgr.register_migration(simple_migration(1, 2, make_add_op("x")));
367
368        let path = mgr.migration_path(1, 2).expect("path should exist");
369        assert_eq!(path.len(), 1);
370        assert_eq!(path[0].from_version, 1);
371        assert_eq!(path[0].to_version, 2);
372    }
373
374    #[test]
375    fn test_migration_path_chained() {
376        let mgr = SchemaEvolutionManager::new(1);
377        mgr.register_migration(simple_migration(1, 2, make_add_op("a")));
378        mgr.register_migration(simple_migration(2, 3, make_add_op("b")));
379
380        let path = mgr.migration_path(1, 3).expect("chained path should exist");
381        assert_eq!(path.len(), 2);
382        assert_eq!(path[0].from_version, 1);
383        assert_eq!(path[1].from_version, 2);
384    }
385
386    #[test]
387    fn test_migration_path_missing() {
388        let mgr = SchemaEvolutionManager::new(1);
389        mgr.register_migration(simple_migration(1, 2, make_add_op("a")));
390        // No migration registered for v2→v3
391        assert!(mgr.migration_path(1, 3).is_none());
392    }
393
394    #[test]
395    fn test_migrate_to_success() {
396        let mgr = SchemaEvolutionManager::new(1);
397        mgr.register_migration(simple_migration(1, 2, make_add_op("score")));
398
399        let applied = mgr.migrate_to(2).expect("migration should succeed");
400        assert_eq!(applied.len(), 1);
401        assert_eq!(mgr.current_version(), 2);
402    }
403
404    #[test]
405    fn test_migrate_to_already_at_version() {
406        let mgr = SchemaEvolutionManager::new(2);
407        let err = mgr.migrate_to(2).expect_err("should fail");
408        assert!(matches!(
409            err,
410            MigrationError::AlreadyAtVersion { version: 2 }
411        ));
412    }
413
414    #[test]
415    fn test_migrate_to_downgrade() {
416        let mgr = SchemaEvolutionManager::new(5);
417        let err = mgr.migrate_to(3).expect_err("downgrade should fail");
418        assert!(matches!(err, MigrationError::Downgrade { from: 5, to: 3 }));
419    }
420
421    #[test]
422    fn test_migrate_increments_applied_count() {
423        let mgr = SchemaEvolutionManager::new(1);
424        mgr.register_migration(simple_migration(1, 2, make_add_op("a")));
425        mgr.register_migration(simple_migration(2, 3, make_add_op("b")));
426
427        assert_eq!(mgr.applied_count(), 0);
428        mgr.migrate_to(3).expect("ok");
429        assert_eq!(mgr.applied_count(), 2);
430    }
431
432    #[test]
433    fn test_can_migrate() {
434        let mgr = SchemaEvolutionManager::new(1);
435        mgr.register_migration(simple_migration(1, 2, make_add_op("x")));
436
437        assert!(mgr.can_migrate(1, 2));
438        assert!(!mgr.can_migrate(1, 3));
439        assert!(!mgr.can_migrate(2, 1)); // backward
440    }
441
442    #[test]
443    fn test_field_names_added_removed() {
444        let ops = vec![
445            make_add_op("new_col"),
446            make_drop_op("old_col"),
447            FieldMigration::RenameField {
448                from: "alpha".to_owned(),
449                to: "beta".to_owned(),
450            },
451        ];
452        let m = SchemaMigration::new(1, 2, "mixed", ops);
453        assert_eq!(m.field_names_added(), vec!["new_col"]);
454        assert_eq!(m.field_names_removed(), vec!["old_col"]);
455    }
456
457    #[test]
458    fn test_registered_versions() {
459        let mgr = SchemaEvolutionManager::new(1);
460        mgr.register_migration(simple_migration(3, 4, make_add_op("z")));
461        mgr.register_migration(simple_migration(1, 2, make_add_op("a")));
462        mgr.register_migration(simple_migration(2, 3, make_add_op("b")));
463
464        assert_eq!(mgr.registered_versions(), vec![1, 2, 3]);
465    }
466
467    #[test]
468    fn test_is_forward() {
469        let forward = SchemaMigration::new(1, 2, "fwd", vec![]);
470        assert!(forward.is_forward());
471        let same = SchemaMigration::new(2, 2, "same", vec![]);
472        assert!(!same.is_forward());
473    }
474}