ipfrs_transport/schema_registry.rs
1//! Arrow IPC schema evolution support for TensorSwap protocol.
2//!
3//! This module provides:
4//! - [`SchemaVersion`]: a versioned identifier for an Arrow schema.
5//! - [`SchemaRegistry`]: a registry that tracks multiple named schema versions
6//! and validates compatibility when schemas evolve.
7//! - [`SchemaEvolutionFrame`]: the wire frame used to signal a schema change
8//! mid-stream inside a TensorSwap session.
9//! - [`EvolutionStrategy`]: the policy that governs how schemas may evolve.
10//!
11//! # Compatibility rules
12//!
13//! Two schema versions are *compatible* (reader can decode writer data) when:
14//! 1. They share the same name.
15//! 2. Every field present in the *writer* schema exists with the same name and
16//! data type in the *reader* schema.
17//! 3. New fields added by the reader (absent in the writer) must be nullable so
18//! that missing values can be represented as null.
19
20use arrow::datatypes::{DataType, Field, Schema};
21use std::collections::HashMap;
22use std::sync::Arc;
23
24// ---------------------------------------------------------------------------
25// FNV-1a implementation (pure Rust, no external dep)
26// ---------------------------------------------------------------------------
27
28/// Compute a 64-bit FNV-1a fingerprint over an arbitrary byte slice.
29fn fnv1a_64(data: &[u8]) -> u64 {
30 const OFFSET_BASIS: u64 = 14_695_981_039_346_656_037;
31 const FNV_PRIME: u64 = 1_099_511_628_211;
32 let mut hash = OFFSET_BASIS;
33 for byte in data {
34 hash ^= *byte as u64;
35 hash = hash.wrapping_mul(FNV_PRIME);
36 }
37 hash
38}
39
40// ---------------------------------------------------------------------------
41// SchemaVersion
42// ---------------------------------------------------------------------------
43
44/// A versioned schema identifier that enables fast equality and compatibility
45/// checks via a FNV-1a fingerprint of field names and data types.
46#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
47pub struct SchemaVersion {
48 /// Logical name of the schema (e.g. `"embeddings"`, `"attention_layer"`).
49 pub name: String,
50 /// Monotonically increasing version number within the same named schema.
51 pub version: u32,
52 /// FNV-1a hash of every `"<field_name>:<data_type>"` string concatenated
53 /// together, in field declaration order, for fast inequality detection.
54 pub fingerprint: u64,
55}
56
57impl SchemaVersion {
58 /// Build a `SchemaVersion` from a name and an Arrow [`Schema`].
59 ///
60 /// The version starts at 1 for newly created versions; use
61 /// [`SchemaRegistry::register`] / [`SchemaRegistry::upgrade_schema`] to
62 /// obtain properly sequenced versions.
63 pub fn new(name: &str, schema: &Schema) -> Self {
64 Self {
65 name: name.to_owned(),
66 version: 1,
67 fingerprint: Self::fingerprint_of(schema),
68 }
69 }
70
71 /// Compute the FNV-1a fingerprint for a given [`Schema`].
72 ///
73 /// The fingerprint covers field names and their serialised data-type
74 /// strings in declaration order.
75 pub fn fingerprint_of(schema: &Schema) -> u64 {
76 let mut buf = String::new();
77 for field in schema.fields() {
78 buf.push_str(field.name());
79 buf.push(':');
80 buf.push_str(&format!("{:?}", field.data_type()));
81 buf.push(';');
82 }
83 fnv1a_64(buf.as_bytes())
84 }
85
86 /// Returns `true` when `self` is compatible with (can be read by) `other`.
87 ///
88 /// Compatibility requires:
89 /// - Same schema name.
90 /// - `self.version <= other.version` (older writer readable by newer reader).
91 pub fn is_compatible_with(&self, other: &SchemaVersion) -> bool {
92 self.name == other.name && self.version <= other.version
93 }
94}
95
96// ---------------------------------------------------------------------------
97// SchemaError
98// ---------------------------------------------------------------------------
99
100/// Errors that can occur during schema registration or evolution.
101#[derive(Debug, thiserror::Error)]
102pub enum SchemaError {
103 /// The requested schema name is not registered.
104 #[error("Schema '{0}' not found")]
105 NotFound(String),
106
107 /// The proposed schema change violates the active evolution strategy.
108 #[error("Incompatible schema change: {0}")]
109 Incompatible(String),
110
111 /// A schema with this version number is already registered.
112 #[error("Schema version {0} already registered")]
113 AlreadyRegistered(u32),
114}
115
116// ---------------------------------------------------------------------------
117// EvolutionStrategy
118// ---------------------------------------------------------------------------
119
120/// Governs which kinds of schema changes are permitted during an ongoing
121/// TensorSwap session.
122#[derive(Debug, Clone)]
123pub enum EvolutionStrategy {
124 /// Only nullable columns may be appended to the schema; existing fields
125 /// must remain unchanged. Always backward-compatible.
126 AddColumnsOnly,
127
128 /// Columns may be renamed via an explicit old→new mapping. All renames
129 /// must be listed; unlisted fields must be identical.
130 RenameColumns {
131 /// Map from old field name to new field name.
132 mapping: HashMap<String, String>,
133 },
134
135 /// The schema may change arbitrarily. If the remote peer does not
136 /// advertise support for `FullReplacement`, reconnection is required.
137 FullReplacement,
138}
139
140// ---------------------------------------------------------------------------
141// SchemaRegistry
142// ---------------------------------------------------------------------------
143
144/// Registry that maps versioned [`SchemaVersion`] keys to Arrow [`Schema`]
145/// objects and enforces evolution constraints.
146pub struct SchemaRegistry {
147 /// Primary store: version key → schema.
148 schemas: HashMap<SchemaVersion, Arc<Schema>>,
149 /// Tracks the latest version number for each named schema.
150 latest: HashMap<String, u32>,
151}
152
153impl Default for SchemaRegistry {
154 fn default() -> Self {
155 Self::new()
156 }
157}
158
159impl SchemaRegistry {
160 /// Create an empty registry.
161 pub fn new() -> Self {
162 Self {
163 schemas: HashMap::new(),
164 latest: HashMap::new(),
165 }
166 }
167
168 /// Register a schema under `name`, automatically assigning the next
169 /// version number. Returns the [`SchemaVersion`] that was assigned.
170 ///
171 /// If this is the first registration for `name` the version is set to 1.
172 pub fn register(&mut self, name: &str, schema: Arc<Schema>) -> SchemaVersion {
173 let version = self.latest.get(name).copied().unwrap_or(0) + 1;
174 let sv = SchemaVersion {
175 name: name.to_owned(),
176 version,
177 fingerprint: SchemaVersion::fingerprint_of(&schema),
178 };
179 self.schemas.insert(sv.clone(), schema);
180 self.latest.insert(name.to_owned(), version);
181 sv
182 }
183
184 /// Look up the [`Schema`] for a specific [`SchemaVersion`].
185 pub fn get(&self, version: &SchemaVersion) -> Option<Arc<Schema>> {
186 self.schemas.get(version).cloned()
187 }
188
189 /// Return the [`SchemaVersion`] with the highest version number for
190 /// `name`, or `None` if the name has never been registered.
191 pub fn latest_version(&self, name: &str) -> Option<SchemaVersion> {
192 let ver = *self.latest.get(name)?;
193 self.schemas
194 .keys()
195 .find(|sv| sv.name == name && sv.version == ver)
196 .cloned()
197 }
198
199 /// Attempt to register a new version of `name` using the
200 /// [`EvolutionStrategy::AddColumnsOnly`] rule: only nullable fields may be
201 /// added; no existing field may be removed or have its type changed.
202 ///
203 /// For richer strategies call [`Self::upgrade_schema_with_strategy`].
204 pub fn upgrade_schema(
205 &mut self,
206 name: &str,
207 new_schema: Arc<Schema>,
208 ) -> Result<SchemaVersion, SchemaError> {
209 self.upgrade_schema_with_strategy(name, new_schema, &EvolutionStrategy::AddColumnsOnly)
210 }
211
212 /// Upgrade a schema using an explicit [`EvolutionStrategy`].
213 ///
214 /// - [`EvolutionStrategy::AddColumnsOnly`]: new fields must be nullable;
215 /// existing fields must be unchanged.
216 /// - [`EvolutionStrategy::RenameColumns`]: applies the rename mapping then
217 /// validates that all other fields are unchanged.
218 /// - [`EvolutionStrategy::FullReplacement`]: always succeeds (the caller
219 /// is responsible for ensuring the peer supports this).
220 pub fn upgrade_schema_with_strategy(
221 &mut self,
222 name: &str,
223 new_schema: Arc<Schema>,
224 strategy: &EvolutionStrategy,
225 ) -> Result<SchemaVersion, SchemaError> {
226 // Retrieve the current latest schema for comparison.
227 let current_sv = self
228 .latest_version(name)
229 .ok_or_else(|| SchemaError::NotFound(name.to_owned()))?;
230
231 let current_schema = self
232 .get(¤t_sv)
233 .ok_or_else(|| SchemaError::NotFound(name.to_owned()))?;
234
235 match strategy {
236 EvolutionStrategy::AddColumnsOnly => {
237 validate_add_columns_only(¤t_schema, &new_schema)?;
238 }
239 EvolutionStrategy::RenameColumns { mapping } => {
240 validate_rename_columns(¤t_schema, &new_schema, mapping)?;
241 }
242 EvolutionStrategy::FullReplacement => {
243 // No structural validation; any schema is accepted.
244 }
245 }
246
247 // Assign the next version and store.
248 Ok(self.register(name, new_schema))
249 }
250
251 /// Determine whether data written with `writer_version` can be decoded by
252 /// a reader holding `reader_version`.
253 ///
254 /// This performs a structural check on the stored schemas in addition to
255 /// the version number check in [`SchemaVersion::is_compatible_with`].
256 pub fn can_read_with(
257 &self,
258 writer_version: &SchemaVersion,
259 reader_version: &SchemaVersion,
260 ) -> bool {
261 if !writer_version.is_compatible_with(reader_version) {
262 return false;
263 }
264
265 // Obtain both schemas; if either is missing we cannot confirm.
266 let (Some(writer_schema), Some(reader_schema)) =
267 (self.get(writer_version), self.get(reader_version))
268 else {
269 return false;
270 };
271
272 // Every field the writer produced must be present and type-compatible
273 // in the reader schema.
274 for writer_field in writer_schema.fields() {
275 match reader_schema.field_with_name(writer_field.name()) {
276 Ok(reader_field) => {
277 if !types_compatible(writer_field.data_type(), reader_field.data_type()) {
278 return false;
279 }
280 }
281 Err(_) => {
282 // Writer field missing from reader schema — incompatible.
283 return false;
284 }
285 }
286 }
287
288 true
289 }
290}
291
292// ---------------------------------------------------------------------------
293// SchemaEvolutionFrame
294// ---------------------------------------------------------------------------
295
296/// Wire frame that signals a schema change mid-stream in a TensorSwap session.
297///
298/// When a sender wants to start emitting data using a different Arrow schema it
299/// first serialises a `SchemaEvolutionFrame` and transmits it to the receiver.
300/// The receiver must acknowledge the new schema before the sender continues
301/// with Arrow IPC batches encoded against `new_version`.
302#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
303pub struct SchemaEvolutionFrame {
304 /// Identifier of the TensorSwap session this evolution applies to.
305 pub session_id: String,
306 /// The schema version that was active before the change.
307 pub old_version: SchemaVersion,
308 /// The schema version that will be used after the change.
309 pub new_version: SchemaVersion,
310 /// Serialised Arrow IPC `Schema` message bytes for `new_version`.
311 ///
312 /// Encoded using `arrow::ipc::writer::schema_to_bytes` so that the
313 /// receiver can reconstruct the full [`Schema`] without registry access.
314 pub schema_ipc: Vec<u8>,
315}
316
317impl SchemaEvolutionFrame {
318 /// Construct a `SchemaEvolutionFrame`, serialising `new_schema` into Arrow
319 /// IPC format automatically.
320 pub fn new(
321 session_id: impl Into<String>,
322 old_version: SchemaVersion,
323 new_version: SchemaVersion,
324 new_schema: &Schema,
325 ) -> Self {
326 let schema_ipc = schema_to_ipc_bytes(new_schema);
327 Self {
328 session_id: session_id.into(),
329 old_version,
330 new_version,
331 schema_ipc,
332 }
333 }
334
335 /// Serialise this frame to JSON bytes suitable for transmission.
336 pub fn to_bytes(&self) -> Result<Vec<u8>, SchemaError> {
337 serde_json::to_vec(self)
338 .map_err(|e| SchemaError::Incompatible(format!("serialisation error: {e}")))
339 }
340
341 /// Deserialise a `SchemaEvolutionFrame` from JSON bytes.
342 pub fn from_bytes(bytes: &[u8]) -> Result<Self, SchemaError> {
343 serde_json::from_slice(bytes)
344 .map_err(|e| SchemaError::Incompatible(format!("deserialisation error: {e}")))
345 }
346
347 /// Recover the Arrow [`Schema`] embedded in `schema_ipc`.
348 pub fn decode_schema(&self) -> Result<Arc<Schema>, SchemaError> {
349 ipc_bytes_to_schema(&self.schema_ipc)
350 }
351}
352
353// ---------------------------------------------------------------------------
354// Arrow IPC helpers
355// ---------------------------------------------------------------------------
356
357/// Serialise an Arrow [`Schema`] to IPC `Schema` message bytes.
358///
359/// The bytes include the 4-byte continuation marker and 4-byte length prefix
360/// that Arrow IPC streaming format prepends to every message.
361pub fn schema_to_ipc_bytes(schema: &Schema) -> Vec<u8> {
362 use arrow::ipc::writer::{DictionaryTracker, IpcDataGenerator, IpcWriteOptions};
363
364 let opts = IpcWriteOptions::default();
365 let gen = IpcDataGenerator {};
366 let mut tracker = DictionaryTracker::new(false);
367 let encoded = gen.schema_to_bytes_with_dictionary_tracker(schema, &mut tracker, &opts);
368 // `ipc_message` is the flatbuffer bytes for the Message; prepend the
369 // standard IPC continuation marker (4×0xFF) and the 32-bit length.
370 let msg = &encoded.ipc_message;
371 let len = msg.len() as u32;
372 let mut out = Vec::with_capacity(8 + msg.len());
373 out.extend_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF]);
374 out.extend_from_slice(&len.to_le_bytes());
375 out.extend_from_slice(msg);
376 out
377}
378
379/// Deserialise an Arrow [`Schema`] from IPC `Schema` message bytes.
380///
381/// Accepts bytes produced by [`schema_to_ipc_bytes`] (with or without the
382/// 8-byte continuation+length prefix).
383pub fn ipc_bytes_to_schema(bytes: &[u8]) -> Result<Arc<Schema>, SchemaError> {
384 use arrow::ipc::convert::fb_to_schema;
385 use arrow::ipc::root_as_message;
386
387 // The IPC format prepends a 4-byte continuation marker (0xFF×4) followed
388 // by a 4-byte little-endian message length. Strip them when present.
389 let payload = if bytes.len() >= 8 && bytes[0..4] == [0xFF, 0xFF, 0xFF, 0xFF] {
390 &bytes[8..]
391 } else {
392 bytes
393 };
394
395 let message = root_as_message(payload)
396 .map_err(|e| SchemaError::Incompatible(format!("invalid Arrow IPC message: {e}")))?;
397
398 let schema_ref = message.header_as_schema().ok_or_else(|| {
399 SchemaError::Incompatible("IPC message does not contain a Schema header".to_owned())
400 })?;
401
402 Ok(Arc::new(fb_to_schema(schema_ref)))
403}
404
405// ---------------------------------------------------------------------------
406// Validation helpers
407// ---------------------------------------------------------------------------
408
409/// Validate an [`EvolutionStrategy::AddColumnsOnly`] transition.
410///
411/// Rules:
412/// 1. All fields present in `old` must exist with the same name and data type
413/// in `new`.
414/// 2. Any field that appears only in `new` must be nullable.
415fn validate_add_columns_only(old: &Schema, new: &Schema) -> Result<(), SchemaError> {
416 // Rule 1: every old field must survive.
417 for old_field in old.fields() {
418 match new.field_with_name(old_field.name()) {
419 Ok(new_field) => {
420 if !types_compatible(old_field.data_type(), new_field.data_type()) {
421 return Err(SchemaError::Incompatible(format!(
422 "field '{}' changed type from {:?} to {:?}",
423 old_field.name(),
424 old_field.data_type(),
425 new_field.data_type(),
426 )));
427 }
428 }
429 Err(_) => {
430 return Err(SchemaError::Incompatible(format!(
431 "existing field '{}' was removed",
432 old_field.name()
433 )));
434 }
435 }
436 }
437
438 // Rule 2: new fields must be nullable.
439 for new_field in new.fields() {
440 if old.field_with_name(new_field.name()).is_err() && !new_field.is_nullable() {
441 return Err(SchemaError::Incompatible(format!(
442 "new field '{}' must be nullable when using AddColumnsOnly strategy",
443 new_field.name()
444 )));
445 }
446 }
447
448 Ok(())
449}
450
451/// Validate an [`EvolutionStrategy::RenameColumns`] transition.
452///
453/// After applying the rename mapping the resulting set of fields must be
454/// structurally identical to `new` (same names, same types).
455fn validate_rename_columns(
456 old: &Schema,
457 new: &Schema,
458 mapping: &HashMap<String, String>,
459) -> Result<(), SchemaError> {
460 // Build a renamed view of the old schema.
461 let renamed_fields: Vec<Arc<Field>> = old
462 .fields()
463 .iter()
464 .map(|f| {
465 let new_name = mapping
466 .get(f.name())
467 .cloned()
468 .unwrap_or_else(|| f.name().clone());
469 Arc::new(Field::new(new_name, f.data_type().clone(), f.is_nullable()))
470 })
471 .collect();
472
473 // Every field in `new` must appear in the renamed view with matching type.
474 for new_field in new.fields() {
475 let found = renamed_fields
476 .iter()
477 .find(|rf| rf.name() == new_field.name());
478 match found {
479 Some(rf) => {
480 if !types_compatible(rf.data_type(), new_field.data_type()) {
481 return Err(SchemaError::Incompatible(format!(
482 "field '{}' changed type after rename",
483 new_field.name()
484 )));
485 }
486 }
487 None => {
488 // A brand-new field is only allowed if it is nullable
489 // (AddColumnsOnly rule re-applied after renaming).
490 if !new_field.is_nullable() {
491 return Err(SchemaError::Incompatible(format!(
492 "new non-nullable field '{}' not covered by rename mapping",
493 new_field.name()
494 )));
495 }
496 }
497 }
498 }
499
500 // Every renamed field must still exist in `new`.
501 for rf in &renamed_fields {
502 if new.field_with_name(rf.name()).is_err() {
503 return Err(SchemaError::Incompatible(format!(
504 "renamed field '{}' is absent from the new schema",
505 rf.name()
506 )));
507 }
508 }
509
510 Ok(())
511}
512
513/// Return `true` when the two [`DataType`]s are considered equivalent for the
514/// purpose of read compatibility. Currently requires exact equality; extend
515/// this function for widening casts if needed.
516fn types_compatible(writer: &DataType, reader: &DataType) -> bool {
517 writer == reader
518}
519
520// ---------------------------------------------------------------------------
521// Tests
522// ---------------------------------------------------------------------------
523
524#[cfg(test)]
525mod tests {
526 use super::*;
527 use arrow::datatypes::{DataType, Field, Schema};
528 use std::sync::Arc;
529
530 fn make_schema(fields: Vec<(&str, DataType, bool)>) -> Arc<Schema> {
531 let arrow_fields: Vec<Field> = fields
532 .into_iter()
533 .map(|(name, dt, nullable)| Field::new(name, dt, nullable))
534 .collect();
535 Arc::new(Schema::new(arrow_fields))
536 }
537
538 // -----------------------------------------------------------------------
539 // SchemaVersion fingerprint tests
540 // -----------------------------------------------------------------------
541
542 #[test]
543 fn test_schema_version_fingerprint_deterministic() {
544 let schema = make_schema(vec![
545 ("id", DataType::Int64, false),
546 ("embedding", DataType::Float32, false),
547 ]);
548 let fp1 = SchemaVersion::fingerprint_of(&schema);
549 let fp2 = SchemaVersion::fingerprint_of(&schema);
550 assert_eq!(fp1, fp2, "fingerprint must be deterministic");
551 }
552
553 #[test]
554 fn test_schema_version_different_schemas() {
555 let schema_a = make_schema(vec![("id", DataType::Int64, false)]);
556 let schema_b = make_schema(vec![("id", DataType::Int32, false)]);
557 let fp_a = SchemaVersion::fingerprint_of(&schema_a);
558 let fp_b = SchemaVersion::fingerprint_of(&schema_b);
559 assert_ne!(
560 fp_a, fp_b,
561 "different schemas must produce different fingerprints"
562 );
563 }
564
565 // -----------------------------------------------------------------------
566 // SchemaRegistry: register and get
567 // -----------------------------------------------------------------------
568
569 #[test]
570 fn test_registry_register_and_get() {
571 let mut reg = SchemaRegistry::new();
572 let schema = make_schema(vec![("x", DataType::Float32, false)]);
573 let sv = reg.register("model", schema.clone());
574 assert_eq!(sv.name, "model");
575 assert_eq!(sv.version, 1);
576 let retrieved = reg.get(&sv).expect("schema should be retrievable");
577 assert_eq!(retrieved.fields().len(), 1);
578 }
579
580 // -----------------------------------------------------------------------
581 // SchemaRegistry: upgrade – add nullable column (compatible)
582 // -----------------------------------------------------------------------
583
584 #[test]
585 fn test_registry_upgrade_add_column() {
586 let mut reg = SchemaRegistry::new();
587 let schema_v1 = make_schema(vec![("x", DataType::Float32, false)]);
588 reg.register("model", schema_v1);
589
590 let schema_v2 = make_schema(vec![
591 ("x", DataType::Float32, false),
592 ("bias", DataType::Float32, true), // nullable — compatible
593 ]);
594 let sv2 = reg
595 .upgrade_schema("model", schema_v2)
596 .expect("adding nullable column must succeed");
597 assert_eq!(sv2.version, 2);
598 }
599
600 // -----------------------------------------------------------------------
601 // SchemaRegistry: upgrade – incompatible (non-nullable new column)
602 // -----------------------------------------------------------------------
603
604 #[test]
605 fn test_registry_upgrade_incompatible() {
606 let mut reg = SchemaRegistry::new();
607 let schema_v1 = make_schema(vec![("x", DataType::Float32, false)]);
608 reg.register("model", schema_v1);
609
610 // Removing an existing field is incompatible.
611 let schema_bad = make_schema(vec![
612 ("y", DataType::Float32, false), // "x" is gone — not allowed
613 ]);
614 let result = reg.upgrade_schema("model", schema_bad);
615 assert!(
616 matches!(result, Err(SchemaError::Incompatible(_))),
617 "removing a field must be incompatible"
618 );
619 }
620
621 // -----------------------------------------------------------------------
622 // SchemaRegistry: can_read_with
623 // -----------------------------------------------------------------------
624
625 #[test]
626 fn test_can_read_with() {
627 let mut reg = SchemaRegistry::new();
628 let schema_v1 = make_schema(vec![("x", DataType::Float32, false)]);
629 let sv1 = reg.register("model", schema_v1);
630
631 let schema_v2 = make_schema(vec![
632 ("x", DataType::Float32, false),
633 ("y", DataType::Float32, true),
634 ]);
635 let sv2 = reg
636 .upgrade_schema("model", schema_v2)
637 .expect("test: upgrade schema");
638
639 // A reader at v2 can read data written by v1 (older writer).
640 assert!(
641 reg.can_read_with(&sv1, &sv2),
642 "newer reader must be able to read older writer data"
643 );
644
645 // A reader at v1 cannot read data written by v2 (newer writer has extra field).
646 assert!(
647 !reg.can_read_with(&sv2, &sv1),
648 "older reader must NOT be able to read newer writer data with extra fields"
649 );
650 }
651
652 // -----------------------------------------------------------------------
653 // SchemaEvolutionFrame: serialise / deserialise round-trip
654 // -----------------------------------------------------------------------
655
656 #[test]
657 fn test_schema_evolution_frame_roundtrip() {
658 let mut reg = SchemaRegistry::new();
659 let schema_v1 = make_schema(vec![("a", DataType::Int32, false)]);
660 let sv1 = reg.register("tensors", schema_v1);
661
662 let schema_v2 = make_schema(vec![
663 ("a", DataType::Int32, false),
664 ("b", DataType::Float64, true),
665 ]);
666 let sv2 = reg
667 .upgrade_schema("tensors", schema_v2.clone())
668 .expect("test: upgrade schema");
669
670 let frame = SchemaEvolutionFrame::new("session-42", sv1.clone(), sv2.clone(), &schema_v2);
671
672 let bytes = frame.to_bytes().expect("serialisation must succeed");
673 let decoded =
674 SchemaEvolutionFrame::from_bytes(&bytes).expect("deserialisation must succeed");
675
676 assert_eq!(decoded.session_id, "session-42");
677 assert_eq!(decoded.old_version, sv1);
678 assert_eq!(decoded.new_version, sv2);
679
680 let recovered = decoded.decode_schema().expect("IPC decode must succeed");
681 assert_eq!(recovered.fields().len(), 2);
682 assert_eq!(recovered.field(0).name(), "a");
683 assert_eq!(recovered.field(1).name(), "b");
684 }
685
686 // -----------------------------------------------------------------------
687 // latest_version
688 // -----------------------------------------------------------------------
689
690 #[test]
691 fn test_latest_version() {
692 let mut reg = SchemaRegistry::new();
693 assert!(reg.latest_version("missing").is_none());
694
695 let s = make_schema(vec![("v", DataType::Utf8, true)]);
696 reg.register("ns", s.clone());
697 let sv = reg.latest_version("ns").expect("test: get latest version");
698 assert_eq!(sv.version, 1);
699
700 let s2 = make_schema(vec![
701 ("v", DataType::Utf8, true),
702 ("w", DataType::Int8, true),
703 ]);
704 reg.upgrade_schema("ns", s2).expect("test: upgrade schema");
705 let sv2 = reg.latest_version("ns").expect("test: get latest version");
706 assert_eq!(sv2.version, 2);
707 }
708
709 // -----------------------------------------------------------------------
710 // RenameColumns strategy
711 // -----------------------------------------------------------------------
712
713 #[test]
714 fn test_upgrade_rename_columns() {
715 let mut reg = SchemaRegistry::new();
716 let schema_v1 = make_schema(vec![("old_name", DataType::Float32, false)]);
717 reg.register("layer", schema_v1);
718
719 let schema_v2 = make_schema(vec![("new_name", DataType::Float32, false)]);
720 let mut map = HashMap::new();
721 map.insert("old_name".to_owned(), "new_name".to_owned());
722 let result = reg.upgrade_schema_with_strategy(
723 "layer",
724 schema_v2,
725 &EvolutionStrategy::RenameColumns { mapping: map },
726 );
727 assert!(result.is_ok(), "valid rename must succeed: {result:?}");
728 }
729
730 // -----------------------------------------------------------------------
731 // FullReplacement strategy
732 // -----------------------------------------------------------------------
733
734 #[test]
735 fn test_upgrade_full_replacement() {
736 let mut reg = SchemaRegistry::new();
737 let schema_v1 = make_schema(vec![("a", DataType::Int64, false)]);
738 reg.register("data", schema_v1);
739
740 // Completely different schema — only allowed under FullReplacement.
741 let schema_v2 = make_schema(vec![("z", DataType::Boolean, false)]);
742 let result = reg.upgrade_schema_with_strategy(
743 "data",
744 schema_v2,
745 &EvolutionStrategy::FullReplacement,
746 );
747 assert!(result.is_ok(), "FullReplacement must accept any new schema");
748 }
749}