1use std::{
19 collections::BTreeMap,
20 fmt::{Debug, Display},
21 sync::Arc,
22};
23
24use arrow::{
25 array::UInt32Array,
26 compute::take,
27 datatypes::{DataType as ArrowDataType, Schema, TimeUnit},
28 record_batch::RecordBatch,
29};
30use futures::{StreamExt, TryStreamExt};
31use nautilus_model::data::NautilusRecordType;
32use nautilus_serialization::arrow::{
33 KEY_IDENTIFIER, KEY_INSTRUMENT_ID, StringColumnRef,
34 legacy::{
35 LegacyArrowError, LegacySchemaResolution, LegacyTranscodeKind, LegacyTranscodeState,
36 SchemaFingerprint, resolve_legacy_schema, schema_fingerprint,
37 transcode_legacy_record_batch_with_state,
38 },
39 record_batch_with_identifier_column,
40};
41use object_store::{ObjectMeta, ObjectStore, ObjectStoreExt, path::Path as ObjectPath};
42use parquet::{
43 normalize_legacy_parquet_columns, normalize_legacy_parquet_schema,
44 read_parquet_from_object_store, read_parquet_schema_from_object_store,
45};
46use serde::Serialize;
47use strum::IntoEnumIterator;
48
49use crate::{
50 backend::parquet::io as parquet,
51 catalog::types::{
52 INSTRUMENT_PATH_PREFIXES, data_path_prefix, data_type_from_data_path_prefix,
53 record_path_prefix,
54 },
55 common::{
56 arrow::catalog_record_schema, paths::normalize_path_separators,
57 storage::normalize_storage_location,
58 },
59};
60
61const SCHEMA_READ_CONCURRENCY: usize = 16;
62
63pub trait ParquetCatalogSource: Sync {
65 fn object_store(&self) -> Arc<dyn ObjectStore>;
67 fn base_path(&self) -> &str;
69 fn original_uri(&self) -> &str;
71
72 fn to_object_path_parsed(&self, path: &str) -> anyhow::Result<ObjectPath> {
78 let normalized = normalize_path_separators(path);
79 let base = self.base_path().trim_matches('/');
80 let full = if base.is_empty() {
81 normalized
82 } else {
83 format!("{base}/{}", normalized.trim_start_matches('/'))
84 };
85 ObjectPath::parse(full.trim_start_matches('/')).map_err(anyhow::Error::from)
86 }
87}
88
89pub const DEFAULT_MIGRATION_COMMIT_ROWS: usize = 500_000;
91
92#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
94pub struct CatalogMigrationTypeReport {
95 pub planned_files: usize,
96 pub migrated_files: usize,
97 pub skipped_files: usize,
98 pub migrated_rows: usize,
99 pub transcoded_rows: usize,
100 pub path_identifier_rows: usize,
101}
102
103#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
105pub struct CatalogMigrationReport {
106 pub dry_run: bool,
107 pub total_leaf_files: usize,
108 pub migrated_files: usize,
109 pub skipped_files: usize,
110 pub migrated_rows: usize,
111 pub transcoded_rows: usize,
112 pub path_identifier_rows: usize,
113 pub unmigrated: Vec<UnmigratedFile>,
114 pub types: BTreeMap<String, CatalogMigrationTypeReport>,
115}
116
117impl CatalogMigrationReport {
118 #[must_use]
120 pub fn from_plan(plan: &CatalogMigrationPlan, dry_run: bool) -> Self {
121 let mut types = BTreeMap::new();
122 for file in &plan.files {
123 types
124 .entry(file.target_type_name.clone())
125 .or_insert_with(CatalogMigrationTypeReport::default)
126 .planned_files += 1;
127 }
128 Self {
129 dry_run,
130 total_leaf_files: plan.total_leaf_files,
131 unmigrated: plan.unmigrated.clone(),
132 types,
133 ..Self::default()
134 }
135 }
136
137 pub(crate) fn record_migrated_file(
138 &mut self,
139 file: &PlannedMigrationFile,
140 rows: usize,
141 path_identifier_rows: usize,
142 ) {
143 let transcoded_rows = if file.transcode_kind == LegacyTranscodeKind::PassThrough {
144 0
145 } else {
146 rows
147 };
148 self.migrated_files += 1;
149 self.migrated_rows += rows;
150 self.transcoded_rows += transcoded_rows;
151 self.path_identifier_rows += path_identifier_rows;
152 let type_report = self.types.entry(file.target_type_name.clone()).or_default();
153 type_report.migrated_files += 1;
154 type_report.migrated_rows += rows;
155 type_report.transcoded_rows += transcoded_rows;
156 type_report.path_identifier_rows += path_identifier_rows;
157 }
158
159 pub(crate) fn record_skipped_file(&mut self, file: &PlannedMigrationFile) {
160 self.skipped_files += 1;
161 self.types
162 .entry(file.target_type_name.clone())
163 .or_default()
164 .skipped_files += 1;
165 }
166}
167
168impl Display for CatalogMigrationReport {
169 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
170 writeln!(
171 f,
172 "{}: {} planned leaf files, {} migrated files, {} migrated rows, {} skipped files, {} \
173 unmigrated files",
174 if self.dry_run {
175 "Migration dry-run report"
176 } else {
177 "Migration report"
178 },
179 self.total_leaf_files,
180 self.migrated_files,
181 self.migrated_rows,
182 self.skipped_files,
183 self.unmigrated.len(),
184 )?;
185
186 for (type_name, report) in &self.types {
187 writeln!(
188 f,
189 "{type_name}: {} planned files, {} migrated files, {} rows, {} transcoded rows, {} \
190 path-derived identifier rows, {} skipped files",
191 report.planned_files,
192 report.migrated_files,
193 report.migrated_rows,
194 report.transcoded_rows,
195 report.path_identifier_rows,
196 report.skipped_files,
197 )?;
198 }
199
200 let mut unmigrated_directories = BTreeMap::new();
201
202 for file in &self.unmigrated {
203 let directory = file
204 .path
205 .rsplit_once('/')
206 .map_or(".", |(directory, _)| directory);
207 *unmigrated_directories.entry(directory).or_insert(0_usize) += 1;
208 }
209
210 for (directory, file_count) in unmigrated_directories {
211 writeln!(f, "Unmigrated directory {directory}: {file_count} files")?;
212 }
213
214 for file in &self.unmigrated {
215 writeln!(f, "Unmigrated {}: {}", file.path, file.reason)?;
216 }
217 Ok(())
218 }
219}
220
221pub fn parse_storage_option(option: &str) -> Result<(String, String), String> {
227 let (key, value) = option
228 .split_once('=')
229 .ok_or_else(|| format!("Storage option must use key=value: {option}"))?;
230 if key.is_empty() || value.is_empty() {
231 return Err(format!(
232 "Storage option must use non-empty key=value: {option}"
233 ));
234 }
235 Ok((key.to_string(), value.to_string()))
236}
237
238pub(crate) fn ensure_distinct_migration_locations(
239 source_uri: &str,
240 target_uri: &str,
241) -> anyhow::Result<()> {
242 let source_uri = normalize_storage_location(source_uri)?;
243 let target_uri = normalize_storage_location(target_uri)?;
244 let source_uri = source_uri.trim_end_matches('/');
245 let target_uri = target_uri.trim_end_matches('/');
246 anyhow::ensure!(
247 source_uri != target_uri
248 && !target_uri.starts_with(&format!("{source_uri}/"))
249 && !source_uri.starts_with(&format!("{target_uri}/")),
250 "Migration source and target must be distinct, non-overlapping locations",
251 );
252 Ok(())
253}
254
255#[derive(Clone, Debug)]
257pub struct PlannedMigrationFile {
258 pub path: String,
259 pub relative_path: String,
260 pub source_type_name: String,
261 pub target_type_name: String,
262 pub target_table: String,
263 pub size: u64,
264 pub e_tag: Option<String>,
265 pub version: Option<String>,
266 pub last_modified: String,
267 pub source_fingerprint: SchemaFingerprint,
268 pub target_fingerprint: SchemaFingerprint,
269 pub transcode_kind: LegacyTranscodeKind,
270}
271
272#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
274pub struct UnmigratedFile {
275 pub path: String,
276 pub reason: String,
277}
278
279#[derive(Clone, Debug, Eq, PartialEq)]
281pub struct SchemaConflictExample {
282 pub fingerprint: SchemaFingerprint,
283 pub path: String,
284}
285
286#[derive(Clone, Debug, Eq, PartialEq)]
288pub struct SchemaConflict {
289 pub target_table: String,
290 pub examples: Vec<SchemaConflictExample>,
291}
292
293#[derive(Clone, Debug, Eq, PartialEq)]
295pub struct UnresolvedSchema {
296 pub path: String,
297 pub message: String,
298}
299
300#[derive(Clone, Debug, Default)]
302pub struct CatalogMigrationPlan {
303 pub files: Vec<PlannedMigrationFile>,
304 pub unmigrated: Vec<UnmigratedFile>,
305 pub conflicts: Vec<SchemaConflict>,
306 pub unresolved_schemas: Vec<UnresolvedSchema>,
307 pub total_leaf_files: usize,
308}
309
310impl CatalogMigrationPlan {
311 #[must_use]
313 pub const fn has_errors(&self) -> bool {
314 !self.conflicts.is_empty() || !self.unresolved_schemas.is_empty()
315 }
316
317 pub fn ensure_ready(&self) -> anyhow::Result<()> {
323 if !self.has_errors() {
324 return Ok(());
325 }
326
327 let mut messages = self
328 .conflicts
329 .iter()
330 .map(|conflict| {
331 let examples = conflict
332 .examples
333 .iter()
334 .map(|example| format!("{} ({})", example.path, example.fingerprint))
335 .collect::<Vec<_>>()
336 .join(", ");
337 format!(
338 "target table {} has conflicting schemas: {examples}",
339 conflict.target_table
340 )
341 })
342 .collect::<Vec<_>>();
343 messages.extend(
344 self.unresolved_schemas
345 .iter()
346 .map(|schema| schema.message.clone()),
347 );
348 anyhow::bail!(
349 "Catalog migration preflight failed:\n{}",
350 messages.join("\n")
351 );
352 }
353}
354
355#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
357pub enum IdentifierSource {
358 Metadata,
359 Row,
360 Path,
361 Absent,
362}
363
364#[derive(Debug)]
366pub struct PreparedMigrationPart {
367 pub identifier: Option<String>,
368 pub identifier_source: IdentifierSource,
369 pub batches: Vec<RecordBatch>,
370 pub row_count: usize,
371}
372
373#[derive(Clone, Debug)]
374enum SourceClassification {
375 Migratable {
376 source_type_name: String,
377 target_type_name: String,
378 },
379 Unmigrated(String),
380}
381
382#[derive(Clone, Debug)]
383struct SchemaCandidate {
384 object: ObjectMeta,
385 relative_path: String,
386 source_type_name: String,
387 target_type_name: String,
388 object_path: ObjectPath,
389}
390
391#[derive(Debug)]
392struct ResolvedCandidate {
393 candidate: SchemaCandidate,
394 target_table: String,
395 resolution: LegacySchemaResolution,
396}
397
398pub fn build_catalog_migration_plan(
404 source: &dyn ParquetCatalogSource,
405) -> anyhow::Result<CatalogMigrationPlan> {
406 let objects = list_source_objects(source)?;
407 let total_leaf_files = objects.len();
408 let mut candidates = Vec::new();
409 let mut unmigrated = Vec::new();
410
411 for object in objects {
412 let relative_path = relative_object_path(source, &object.location);
413 match classify_source_path(&relative_path) {
414 SourceClassification::Migratable {
415 source_type_name,
416 target_type_name,
417 } if relative_path.ends_with(".parquet") => {
418 candidates.push(SchemaCandidate {
419 object_path: object.location.clone(),
420 object,
421 relative_path,
422 source_type_name,
423 target_type_name,
424 });
425 }
426 SourceClassification::Migratable { .. } => {
427 unmigrated.push(UnmigratedFile {
428 path: relative_path,
429 reason: "recognized catalog directory contains a non-Parquet leaf".to_string(),
430 });
431 }
432 SourceClassification::Unmigrated(reason) => {
433 unmigrated.push(UnmigratedFile {
434 path: relative_path,
435 reason,
436 });
437 }
438 }
439 }
440
441 let (mut files, unresolved_schemas) = resolve_candidate_schemas(source, candidates)?;
442 files.sort_by(|left, right| left.path.cmp(&right.path));
443 unmigrated.sort_by(|left, right| left.path.cmp(&right.path));
444 let conflicts = schema_conflicts(&files);
445
446 Ok(CatalogMigrationPlan {
447 files,
448 unmigrated,
449 conflicts,
450 unresolved_schemas,
451 total_leaf_files,
452 })
453}
454
455pub fn read_planned_migration_file(
461 source: &dyn ParquetCatalogSource,
462 file: &PlannedMigrationFile,
463) -> anyhow::Result<Vec<RecordBatch>> {
464 let object_path = source.to_object_path_parsed(&file.path)?;
465 let (batches, schema) = execute_async(|| async {
466 read_parquet_from_object_store(source.object_store(), &object_path).await
467 })?;
468 ensure_planned_file_unchanged(source, file, &object_path)?;
469 let mut state = LegacyTranscodeState::default();
470 let mut transcoded = Vec::new();
471
472 for batch in record_batches_with_schema(batches, &schema)? {
473 let batch = with_inferred_custom_type_name(file, batch)?;
474 let batch = normalize_legacy_parquet_columns(&batch)?;
475 let result = transcode_legacy_record_batch_with_state(
476 &file.target_type_name,
477 &file.path,
478 batch,
479 &mut state,
480 )?;
481 transcoded.extend(result.batches);
482 }
483 let batches = transcoded;
484 Ok(batches)
485}
486
487pub fn prepare_migration_parts(
494 file: &PlannedMigrationFile,
495 batches: Vec<RecordBatch>,
496) -> anyhow::Result<Vec<PreparedMigrationPart>> {
497 let mut grouped: BTreeMap<(Option<String>, IdentifierSource), Vec<RecordBatch>> =
498 BTreeMap::new();
499
500 for batch in batches {
501 for (identifier, source, batch) in split_batch_by_identifier(file, batch)? {
502 let batch =
503 batch_with_identifier(&file.target_type_name, identifier.as_deref(), batch)?;
504 grouped.entry((identifier, source)).or_default().push(batch);
505 }
506 }
507
508 Ok(grouped
509 .into_iter()
510 .map(
511 |((identifier, identifier_source), batches)| PreparedMigrationPart {
512 row_count: batches.iter().map(RecordBatch::num_rows).sum(),
513 identifier,
514 identifier_source,
515 batches,
516 },
517 )
518 .collect())
519}
520
521pub(crate) fn feather_replay_identity(
522 source_uri: &str,
523 source_path: &str,
524 content_hash: &str,
525 identifiers: Option<&[String]>,
526) -> String {
527 let mut identifiers = identifiers.map(<[String]>::to_vec);
528 if let Some(identifiers) = identifiers.as_mut() {
529 identifiers.sort();
530 identifiers.dedup();
531 }
532 let identity = serde_json::json!({
533 "source_uri": source_uri,
534 "source_path": source_path,
535 "content_hash": content_hash,
536 "identifiers": identifiers,
537 });
538 format!(
539 "nautilus-feather:{}",
540 blake3::hash(identity.to_string().as_bytes()).to_hex(),
541 )
542}
543
544fn list_source_objects(source: &dyn ParquetCatalogSource) -> anyhow::Result<Vec<ObjectMeta>> {
545 let prefix =
546 (!source.base_path().is_empty()).then(|| ObjectPath::from(source.base_path().to_string()));
547 let mut objects = execute_async(|| async {
548 Ok(source
549 .object_store()
550 .list(prefix.as_ref())
551 .try_collect::<Vec<_>>()
552 .await?)
553 })?;
554 objects.sort_by(|left, right| left.location.as_ref().cmp(right.location.as_ref()));
557 let parents = objects
558 .windows(2)
559 .filter(|pair| {
560 pair[1]
561 .location
562 .as_ref()
563 .starts_with(&format!("{}/", pair[0].location.as_ref()))
564 })
565 .map(|pair| pair[0].location.clone())
566 .collect::<std::collections::HashSet<_>>();
567 objects.retain(|object| {
568 !parents.contains(&object.location)
569 && !relative_object_path(source, &object.location).is_empty()
570 });
571 Ok(objects)
572}
573
574fn execute_async<C, F, R>(create_future: C) -> anyhow::Result<R>
575where
576 C: FnOnce() -> F + Send,
577 F: std::future::Future<Output = anyhow::Result<R>>,
578 R: Send,
579{
580 nautilus_common::live::block_on_nautilus_with(create_future)
581}
582
583fn relative_object_path(source: &dyn ParquetCatalogSource, path: &ObjectPath) -> String {
584 let path = path.as_ref();
585 let base = source.base_path().trim_matches('/');
586 if base.is_empty() {
587 return path.to_string();
588 }
589 path.strip_prefix(&format!("{base}/"))
590 .unwrap_or(path)
591 .to_string()
592}
593
594fn classify_source_path(path: &str) -> SourceClassification {
595 let parts = path.split('/').collect::<Vec<_>>();
596 let Some(root) = parts.first().copied() else {
597 return SourceClassification::Unmigrated("empty source path".to_string());
598 };
599
600 if matches!(root, "backtest" | "live") {
601 return SourceClassification::Unmigrated(format!(
602 "{root} Feather trees are outside catalog migration scope"
603 ));
604 }
605
606 if root != "data" || parts.len() < 2 {
607 return SourceClassification::Unmigrated(
608 "path is outside the recognized data catalog tree".to_string(),
609 );
610 }
611
612 let source_type_name = parts[1];
613 if INSTRUMENT_PATH_PREFIXES.contains(&source_type_name) {
614 return SourceClassification::Migratable {
615 source_type_name: source_type_name.to_string(),
616 target_type_name: "instruments".to_string(),
617 };
618 }
619
620 if source_type_name == "custom" || source_type_name.starts_with("custom_") {
621 return SourceClassification::Migratable {
622 source_type_name: source_type_name.to_string(),
623 target_type_name: "custom".to_string(),
624 };
625 }
626
627 if let Ok(data_type) = data_type_from_data_path_prefix(source_type_name) {
628 return SourceClassification::Migratable {
629 source_type_name: source_type_name.to_string(),
630 target_type_name: data_path_prefix(&data_type).into_owned(),
631 };
632 }
633
634 if NautilusRecordType::iter()
635 .any(|record_type| record_path_prefix(&record_type).as_ref() == source_type_name)
636 {
637 return SourceClassification::Migratable {
638 source_type_name: source_type_name.to_string(),
639 target_type_name: source_type_name.to_string(),
640 };
641 }
642
643 let reason = if source_type_name == "portfolio_snapshot" {
644 "no NautilusRecordType maps to data/portfolio_snapshot"
645 } else {
646 "unrecognized catalog data directory"
647 };
648 SourceClassification::Unmigrated(reason.to_string())
649}
650
651fn legacy_custom_type_name(source_type_name: &str) -> Option<String> {
658 let legacy = source_type_name.strip_prefix("custom_")?;
659 let mut pascal = String::with_capacity(legacy.len());
660 for part in legacy.split('_') {
661 let mut chars = part.chars();
662 if let Some(first) = chars.next() {
663 pascal.extend(first.to_uppercase());
664 pascal.extend(chars);
665 }
666 }
667
668 (!pascal.is_empty()).then_some(pascal)
669}
670
671fn custom_timestamps_convertible(schema: &Schema) -> bool {
675 ["ts_event", "ts_init"].iter().all(|name| {
676 schema.field_with_name(name).is_ok_and(|field| {
677 matches!(
678 field.data_type(),
679 ArrowDataType::UInt64 | ArrowDataType::Timestamp(TimeUnit::Nanosecond, _)
680 )
681 })
682 })
683}
684
685fn inject_type_name_metadata(schema: &Schema, type_name: &str) -> Schema {
688 if schema.metadata().contains_key("type_name") {
689 return schema.clone();
690 }
691
692 let mut metadata = schema.metadata().clone();
693 metadata.insert("type_name".to_string(), type_name.to_string());
694 Schema::new_with_metadata(
695 schema.fields().iter().cloned().collect::<Vec<_>>(),
696 metadata,
697 )
698}
699
700fn with_target_custom_type_name(candidate: &SchemaCandidate, schema: &Schema) -> Schema {
703 let Some(type_name) = candidate.target_type_name.strip_prefix("custom/") else {
704 return schema.clone();
705 };
706
707 inject_type_name_metadata(schema, type_name)
708}
709
710#[expect(
711 clippy::too_many_lines,
712 reason = "Preflight resolves every candidate before assembling the migration plan"
713)]
714fn resolve_candidate_schemas(
715 source: &dyn ParquetCatalogSource,
716 candidates: Vec<SchemaCandidate>,
717) -> anyhow::Result<(Vec<PlannedMigrationFile>, Vec<UnresolvedSchema>)> {
718 let object_store = source.object_store();
719 let resolved = execute_async(|| async move {
720 futures::stream::iter(candidates)
721 .map(|candidate| {
722 let object_store = object_store.clone();
723 async move {
724 let schema =
725 read_parquet_schema_from_object_store(object_store, &candidate.object_path)
726 .await?;
727 Ok::<_, anyhow::Error>((candidate, schema))
728 }
729 })
730 .buffer_unordered(SCHEMA_READ_CONCURRENCY)
731 .try_collect::<Vec<_>>()
732 .await
733 })?;
734 let mut files = Vec::new();
735 let mut unresolved = Vec::new();
736
737 for (mut candidate, schema) in resolved {
738 if candidate.object.size == 0 {
739 if candidate.target_type_name == "custom"
741 && let Some(inferred) = legacy_custom_type_name(&candidate.source_type_name)
742 {
743 candidate.target_type_name = format!("custom/{inferred}");
744 }
745
746 let fingerprint = schema_fingerprint(&Schema::empty());
747 files.push(ResolvedCandidate {
748 target_table: candidate.target_type_name.clone(),
749 candidate,
750 resolution: LegacySchemaResolution {
751 kind: LegacyTranscodeKind::PassThrough,
752 source_fingerprint: fingerprint.clone(),
753 target_fingerprint: fingerprint,
754 },
755 });
756 continue;
757 }
758
759 if candidate.target_type_name == "custom" {
762 if let Some(type_name) = schema.metadata().get("type_name") {
763 candidate.target_type_name = format!("custom/{type_name}");
764 } else if let Some(inferred) = legacy_custom_type_name(&candidate.source_type_name) {
765 candidate.target_type_name = format!("custom/{inferred}");
766 } else {
767 unresolved.push(UnresolvedSchema {
768 path: candidate.relative_path.clone(),
769 message: format!(
770 "Parquet custom data file {} is missing type_name metadata",
771 candidate.relative_path
772 ),
773 });
774 continue;
775 }
776 }
777
778 if candidate.target_type_name.starts_with("custom/")
781 && !custom_timestamps_convertible(&schema)
782 {
783 let detail = if schema.metadata().contains_key("type_name") {
784 "has non-UInt64 timestamps with no transcoder"
785 } else {
786 "is missing type_name metadata and has non-UInt64 timestamps with no transcoder"
787 };
788
789 unresolved.push(UnresolvedSchema {
790 path: candidate.relative_path.clone(),
791 message: format!(
792 "Parquet custom data file {} {detail}",
793 candidate.relative_path
794 ),
795 });
796
797 continue;
798 }
799
800 let schema = with_target_custom_type_name(&candidate, &schema);
801 let schema = normalize_legacy_parquet_schema(&schema);
802
803 let target_table = if candidate.target_type_name == "instruments" {
804 let Some(class) = schema.metadata().get("class") else {
805 unresolved.push(UnresolvedSchema {
806 path: candidate.relative_path.clone(),
807 message: format!(
808 "Parquet instrument file {} is missing class metadata",
809 candidate.relative_path
810 ),
811 });
812 continue;
813 };
814 format!("instruments/{class}")
815 } else {
816 candidate.target_type_name.clone()
817 };
818
819 if let Ok(record_type) = candidate.target_type_name.parse::<NautilusRecordType>()
820 && let Ok(current) = catalog_record_schema(record_type)
821 {
822 let expected =
823 nautilus_serialization::arrow::schema_without_identifier_column(¤t);
824 let actual = nautilus_serialization::arrow::schema_without_identifier_column(&schema);
825
826 if schema_fingerprint(&actual) != schema_fingerprint(&expected) {
827 unresolved.push(UnresolvedSchema {
828 path: candidate.relative_path.clone(),
829 message: format!(
830 "Record file {} does not match the registered Arrow schema",
831 candidate.relative_path
832 ),
833 });
834 continue;
835 }
836 }
837
838 if schema
839 .fields()
840 .iter()
841 .any(|field| contains_legacy_fixed_binary(field.data_type()))
842 {
843 unresolved.push(UnresolvedSchema {
844 path: candidate.relative_path.clone(),
845 message: format!(
846 "No final-format transcoder is registered for fixed binary columns in {}",
847 candidate.relative_path
848 ),
849 });
850 continue;
851 }
852
853 match resolve_legacy_schema(
854 &candidate.target_type_name,
855 &candidate.relative_path,
856 &schema,
857 ) {
858 Ok(resolution) => files.push(ResolvedCandidate {
859 candidate,
860 target_table,
861 resolution,
862 }),
863 Err(error @ LegacyArrowError::UnknownSchema { .. }) => {
864 unresolved.push(UnresolvedSchema {
865 path: candidate.relative_path,
866 message: error.to_string(),
867 });
868 }
869 Err(e) => return Err(e.into()),
870 }
871 }
872
873 let files = files
874 .into_iter()
875 .map(|resolved| PlannedMigrationFile {
876 path: resolved.candidate.object.location.to_string(),
877 relative_path: resolved.candidate.relative_path,
878 source_type_name: resolved.candidate.source_type_name,
879 target_type_name: resolved.candidate.target_type_name,
880 target_table: resolved.target_table,
881 size: resolved.candidate.object.size,
882 e_tag: resolved.candidate.object.e_tag,
883 version: resolved.candidate.object.version,
884 last_modified: resolved.candidate.object.last_modified.to_rfc3339(),
885 source_fingerprint: resolved.resolution.source_fingerprint,
886 target_fingerprint: resolved.resolution.target_fingerprint,
887 transcode_kind: resolved.resolution.kind,
888 })
889 .collect();
890 Ok((files, unresolved))
891}
892
893fn contains_legacy_fixed_binary(data_type: &ArrowDataType) -> bool {
894 match data_type {
895 ArrowDataType::FixedSizeBinary(_) => true,
896 ArrowDataType::List(field)
897 | ArrowDataType::LargeList(field)
898 | ArrowDataType::FixedSizeList(field, _)
899 | ArrowDataType::Map(field, _) => contains_legacy_fixed_binary(field.data_type()),
900 ArrowDataType::Struct(fields) => fields
901 .iter()
902 .any(|field| contains_legacy_fixed_binary(field.data_type())),
903 ArrowDataType::Dictionary(_, value) => contains_legacy_fixed_binary(value),
904 _ => false,
905 }
906}
907
908pub(crate) fn ensure_planned_file_unchanged(
909 source: &dyn ParquetCatalogSource,
910 file: &PlannedMigrationFile,
911 object_path: &ObjectPath,
912) -> anyhow::Result<()> {
913 let object = execute_async(|| async { Ok(source.object_store().head(object_path).await?) })?;
914 anyhow::ensure!(
915 object.size == file.size
916 && object.e_tag == file.e_tag
917 && object.version == file.version
918 && object.last_modified.to_rfc3339() == file.last_modified,
919 "Source file changed after migration preflight: {}",
920 file.relative_path,
921 );
922 Ok(())
923}
924
925fn schema_conflicts(files: &[PlannedMigrationFile]) -> Vec<SchemaConflict> {
926 let mut by_table: BTreeMap<String, BTreeMap<String, SchemaConflictExample>> = BTreeMap::new();
927 for file in files.iter().filter(|file| file.size != 0) {
928 by_table
929 .entry(file.target_table.clone())
930 .or_default()
931 .entry(file.target_fingerprint.to_string())
932 .or_insert_with(|| SchemaConflictExample {
933 fingerprint: file.target_fingerprint.clone(),
934 path: file.relative_path.clone(),
935 });
936 }
937
938 by_table
939 .into_iter()
940 .filter_map(|(target_table, examples)| {
941 (examples.len() > 1).then(|| SchemaConflict {
942 target_table,
943 examples: examples.into_values().collect(),
944 })
945 })
946 .collect()
947}
948
949fn split_batch_by_identifier(
950 file: &PlannedMigrationFile,
951 batch: RecordBatch,
952) -> anyhow::Result<Vec<(Option<String>, IdentifierSource, RecordBatch)>> {
953 if let Some(identifier) = metadata_identifier(&file.target_type_name, &batch) {
954 return Ok(vec![(Some(identifier), IdentifierSource::Metadata, batch)]);
955 }
956
957 if let Some(column) = identifier_column(&file.target_type_name, &batch)? {
958 let groups = row_identifier_groups(&column, batch.num_rows())?;
959 return groups
960 .into_iter()
961 .map(|(identifier, indices)| {
962 let batch = take_record_batch(&batch, &indices)?;
963 Ok((identifier, IdentifierSource::Row, batch))
964 })
965 .collect();
966 }
967
968 if let Some(identifier) =
969 record_identifier_from_path(&file.relative_path, &file.source_type_name)
970 {
971 return Ok(vec![(Some(identifier), IdentifierSource::Path, batch)]);
972 }
973 Ok(vec![(None, IdentifierSource::Absent, batch)])
974}
975
976fn metadata_identifier(type_name: &str, batch: &RecordBatch) -> Option<String> {
977 let key = if type_name == "bars" {
978 "bar_type"
979 } else {
980 KEY_INSTRUMENT_ID
981 };
982 batch.schema().metadata().get(key).cloned()
983}
984
985fn identifier_column<'a>(
986 type_name: &str,
987 batch: &'a RecordBatch,
988) -> anyhow::Result<Option<StringColumnRef<'a>>> {
989 let candidates = if type_name == "bars" {
990 [KEY_IDENTIFIER, "bar_type", KEY_INSTRUMENT_ID, "id"]
991 } else {
992 [KEY_IDENTIFIER, KEY_INSTRUMENT_ID, "bar_type", "id"]
993 };
994
995 for name in candidates {
996 if let Some(column) = batch.column_by_name(name) {
997 return StringColumnRef::try_from_array(column.as_ref())
998 .map(Some)
999 .ok_or_else(|| anyhow::anyhow!("Identifier column {name} is not string-like"));
1000 }
1001 }
1002 Ok(None)
1003}
1004
1005fn row_identifier_groups(
1006 column: &StringColumnRef<'_>,
1007 row_count: usize,
1008) -> anyhow::Result<BTreeMap<Option<String>, Vec<u32>>> {
1009 let mut groups: BTreeMap<Option<String>, Vec<u32>> = BTreeMap::new();
1010 for row in 0..row_count {
1011 groups
1012 .entry(column.value_opt(row).map(ToString::to_string))
1013 .or_default()
1014 .push(u32::try_from(row)?);
1015 }
1016 Ok(groups)
1017}
1018
1019fn take_record_batch(batch: &RecordBatch, indices: &[u32]) -> anyhow::Result<RecordBatch> {
1020 let indices = UInt32Array::from(indices.to_vec());
1021 let columns = batch
1022 .columns()
1023 .iter()
1024 .map(|column| take(column.as_ref(), &indices, None))
1025 .collect::<Result<Vec<_>, _>>()?;
1026 Ok(RecordBatch::try_new(batch.schema(), columns)?)
1027}
1028
1029fn batch_with_identifier(
1030 type_name: &str,
1031 identifier: Option<&str>,
1032 batch: RecordBatch,
1033) -> anyhow::Result<RecordBatch> {
1034 let batch = record_batch_with_identifier_column(batch, identifier)?;
1035 let Some(identifier) = identifier else {
1036 return Ok(batch);
1037 };
1038 let metadata_key = if type_name == "bars" {
1039 "bar_type"
1040 } else if type_name.starts_with("custom/") {
1041 return Ok(batch);
1042 } else {
1043 KEY_INSTRUMENT_ID
1044 };
1045 let mut metadata = batch.schema().metadata().clone();
1046 metadata.insert(metadata_key.to_string(), identifier.to_string());
1047 let schema = Arc::new(Schema::new_with_metadata(
1048 batch.schema().fields().iter().cloned().collect::<Vec<_>>(),
1049 metadata,
1050 ));
1051 Ok(RecordBatch::try_new(schema, batch.columns().to_vec())?)
1052}
1053
1054fn record_identifier_from_path(file_path: &str, type_name: &str) -> Option<String> {
1055 let path_parts = file_path.split('/').collect::<Vec<_>>();
1056 let type_parts = type_name.split('/').collect::<Vec<_>>();
1057
1058 for start in 0..path_parts.len() {
1059 if path_parts.get(start) != Some(&"data") {
1060 continue;
1061 }
1062 let type_start = start + 1;
1063 let type_end = type_start + type_parts.len();
1064 if path_parts.get(type_start..type_end) != Some(type_parts.as_slice()) {
1065 continue;
1066 }
1067 let remaining = &path_parts[type_end..];
1068 if remaining.len() <= 1 {
1069 return None;
1070 }
1071 return Some(remaining[0].to_string());
1072 }
1073 None
1074}
1075
1076fn record_batches_with_schema(
1077 batches: Vec<RecordBatch>,
1078 schema: &Arc<Schema>,
1079) -> anyhow::Result<Vec<RecordBatch>> {
1080 batches
1081 .into_iter()
1082 .map(|batch| {
1083 RecordBatch::try_new(schema.clone(), batch.columns().to_vec()).map_err(Into::into)
1084 })
1085 .collect()
1086}
1087
1088fn with_inferred_custom_type_name(
1094 file: &PlannedMigrationFile,
1095 batch: RecordBatch,
1096) -> anyhow::Result<RecordBatch> {
1097 let Some(type_name) = file.target_type_name.strip_prefix("custom/") else {
1098 return Ok(batch);
1099 };
1100
1101 let schema = Arc::new(inject_type_name_metadata(&batch.schema(), type_name));
1102 Ok(RecordBatch::try_new(schema, batch.columns().to_vec())?)
1103}
1104
1105#[cfg(test)]
1106mod tests {
1107 use std::{fs, sync::Arc};
1108
1109 use ::parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
1110 use arrow::{
1111 array::Int64Array,
1112 datatypes::{DataType, Field, Schema},
1113 record_batch::RecordBatch,
1114 };
1115 use rstest::rstest;
1116 use tempfile::TempDir;
1117
1118 use super::*;
1119
1120 #[rstest]
1121 fn storage_options_require_non_empty_key_and_value() {
1122 assert_eq!(
1123 parse_storage_option("region=us-east-1"),
1124 Ok(("region".to_string(), "us-east-1".to_string()))
1125 );
1126 assert_eq!(
1127 parse_storage_option("region").unwrap_err(),
1128 "Storage option must use key=value: region"
1129 );
1130 assert_eq!(
1131 parse_storage_option("=us-east-1").unwrap_err(),
1132 "Storage option must use non-empty key=value: =us-east-1"
1133 );
1134 assert_eq!(
1135 parse_storage_option("region=").unwrap_err(),
1136 "Storage option must use non-empty key=value: region="
1137 );
1138 }
1139
1140 #[rstest]
1141 fn dry_run_report_lists_unmigrated_files_and_reasons() {
1142 let plan = CatalogMigrationPlan {
1143 total_leaf_files: 1,
1144 unmigrated: vec![UnmigratedFile {
1145 path: "backtest/run/data.feather".to_string(),
1146 reason: "Feather trees are outside catalog migration scope".to_string(),
1147 }],
1148 ..CatalogMigrationPlan::default()
1149 };
1150
1151 let report = CatalogMigrationReport::from_plan(&plan, true).to_string();
1152
1153 assert!(report.contains("Migration dry-run report: 1 planned leaf files"));
1154 assert!(report.contains("Unmigrated directory backtest/run: 1 files"));
1155 assert!(report.contains(
1156 "Unmigrated backtest/run/data.feather: Feather trees are outside catalog migration scope"
1157 ));
1158 }
1159
1160 #[rstest]
1161 #[case("/tmp/source", "/tmp/source")]
1162 #[case("/tmp/source/", "/tmp/source")]
1163 #[case("/tmp/source", "/tmp/source/target")]
1164 #[case("/tmp/source/child", "/tmp/source")]
1165 fn migration_locations_must_not_overlap(#[case] source: &str, #[case] target: &str) {
1166 assert_eq!(
1167 ensure_distinct_migration_locations(source, target)
1168 .unwrap_err()
1169 .to_string(),
1170 "Migration source and target must be distinct, non-overlapping locations"
1171 );
1172 }
1173
1174 #[rstest]
1175 fn migration_locations_normalize_local_paths() {
1176 let source = std::env::current_dir().unwrap().join("catalog");
1177 let source_uri = normalize_storage_location(source.to_str().unwrap()).unwrap();
1178
1179 assert!(ensure_distinct_migration_locations("catalog", &source_uri).is_err());
1180 assert!(ensure_distinct_migration_locations("catalog-a", "catalog-b").is_ok());
1181 }
1182
1183 #[rstest]
1184 #[case("depths.parquet", "order_book_depths")]
1185 #[case("quotes.parquet", "quotes")]
1186 #[case("trades.parquet", "trades")]
1187 #[case("bars.parquet", "bars")]
1188 #[case("deltas.parquet", "order_book_deltas")]
1189 fn committed_legacy_fixture_plans_as_pass_through(
1190 #[case] file_name: &str,
1191 #[case] type_name: &str,
1192 ) {
1193 let precision_dir = if cfg!(feature = "high-precision") {
1194 "128-bit"
1195 } else {
1196 "64-bit"
1197 };
1198 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1199 .join("../../test_data/nautilus/legacy")
1200 .join(precision_dir)
1201 .join(file_name);
1202 let file = std::fs::File::open(path).unwrap();
1203 let builder = ParquetRecordBatchReaderBuilder::try_new(file).unwrap();
1204 let schema = normalize_legacy_parquet_schema(builder.schema().as_ref());
1205
1206 let resolution = resolve_legacy_schema(type_name, file_name, &schema).unwrap();
1207
1208 assert_eq!(resolution.kind, LegacyTranscodeKind::PassThrough);
1209 }
1210
1211 #[rstest]
1212 fn migration_plan_discovers_bar_file_under_bare_instrument_folder() {
1213 let temp = TempDir::new().unwrap();
1214 let source_path = temp.path().join("source");
1215 let bar_dir = source_path.join("data").join("bars").join("AUDUSD.SIM");
1216 fs::create_dir_all(&bar_dir).unwrap();
1217 let precision_dir = if cfg!(feature = "high-precision") {
1218 "128-bit"
1219 } else {
1220 "64-bit"
1221 };
1222 let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1223 .join("../../test_data/nautilus/legacy")
1224 .join(precision_dir)
1225 .join("bars.parquet");
1226 fs::copy(fixture, bar_dir.join("bars.parquet")).unwrap();
1227 let source = crate::backend::parquet::catalog::ParquetDataCatalog::from_uri(
1228 source_path.to_str().unwrap(),
1229 None,
1230 None,
1231 None,
1232 None,
1233 )
1234 .unwrap();
1235
1236 let plan = build_catalog_migration_plan(&source).unwrap();
1237
1238 assert_eq!(plan.total_leaf_files, 1);
1239 assert_eq!(plan.files.len(), 1);
1240 assert_eq!(
1241 plan.files[0].relative_path,
1242 "data/bars/AUDUSD.SIM/bars.parquet",
1243 );
1244 assert_eq!(plan.files[0].source_type_name, "bars");
1245 assert_eq!(plan.files[0].target_type_name, "bars");
1246 assert!(plan.unmigrated.is_empty());
1247 assert!(plan.unresolved_schemas.is_empty());
1248 plan.ensure_ready().unwrap();
1249 }
1250
1251 #[rstest]
1252 fn migration_plan_rejects_unrecognized_schema_during_preflight() {
1253 let temp = TempDir::new().unwrap();
1254 let source_path = temp.path().join("source");
1255 let quote_dir = source_path.join("data").join("quotes").join("AUDUSD.SIM");
1256 fs::create_dir_all("e_dir).unwrap();
1257 let schema = Arc::new(Schema::new(vec![Field::new(
1258 "unknown",
1259 DataType::Int64,
1260 false,
1261 )]));
1262 let batch = RecordBatch::try_new(
1263 schema.clone(),
1264 vec![Arc::new(Int64Array::from(vec![1_i64]))],
1265 )
1266 .unwrap();
1267 let file = fs::File::create(quote_dir.join("unknown.parquet")).unwrap();
1268 let mut writer = ::parquet::arrow::ArrowWriter::try_new(file, schema, None).unwrap();
1269 writer.write(&batch).unwrap();
1270 writer.close().unwrap();
1271 let source = crate::backend::parquet::catalog::ParquetDataCatalog::from_uri(
1272 source_path.to_str().unwrap(),
1273 None,
1274 None,
1275 None,
1276 None,
1277 )
1278 .unwrap();
1279
1280 let plan = build_catalog_migration_plan(&source).unwrap();
1281 let error = plan.ensure_ready().unwrap_err();
1282
1283 assert_eq!(plan.total_leaf_files, 1);
1284 assert!(plan.files.is_empty());
1285 assert_eq!(plan.unresolved_schemas.len(), 1);
1286 assert_eq!(
1287 plan.unresolved_schemas[0].path,
1288 "data/quotes/AUDUSD.SIM/unknown.parquet",
1289 );
1290 assert_eq!(
1291 error.to_string(),
1292 format!(
1293 "Catalog migration preflight failed:\n{}",
1294 plan.unresolved_schemas[0].message,
1295 ),
1296 );
1297 }
1298}