Skip to main content

nautilus_persistence/backend/
migration.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Target-neutral planning and reading for legacy Parquet catalog migration.
17
18use 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
63/// Object-store source used to plan and read a legacy Parquet migration.
64pub trait ParquetCatalogSource: Sync {
65    /// Returns the object store containing the source catalog.
66    fn object_store(&self) -> Arc<dyn ObjectStore>;
67    /// Returns the catalog path within the object store.
68    fn base_path(&self) -> &str;
69    /// Returns the URI identifying the source catalog.
70    fn original_uri(&self) -> &str;
71
72    /// Resolves a plan-relative path within the source catalog.
73    ///
74    /// # Errors
75    ///
76    /// Returns an error if the combined object-store path is invalid.
77    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
89/// Default maximum number of source rows written in one open-catalog migration commit.
90pub const DEFAULT_MIGRATION_COMMIT_ROWS: usize = 500_000;
91
92/// Migration counters for one current target type.
93#[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/// Structured result shared by Parquet and Delta migration entry points.
104#[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    /// Builds a report with preflight file accounting and no writes.
119    #[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
221/// Parses a storage option expressed as `key=value`.
222///
223/// # Errors
224///
225/// Returns an error when the separator or either side is absent.
226pub 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/// One source file accepted by migration preflight.
256#[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/// One source path excluded from migration.
273#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
274pub struct UnmigratedFile {
275    pub path: String,
276    pub reason: String,
277}
278
279/// One schema and example source path in a target-table conflict.
280#[derive(Clone, Debug, Eq, PartialEq)]
281pub struct SchemaConflictExample {
282    pub fingerprint: SchemaFingerprint,
283    pub path: String,
284}
285
286/// Incompatible final schemas targeting one output table.
287#[derive(Clone, Debug, Eq, PartialEq)]
288pub struct SchemaConflict {
289    pub target_table: String,
290    pub examples: Vec<SchemaConflictExample>,
291}
292
293/// One source file whose schema needs an unregistered transcoder.
294#[derive(Clone, Debug, Eq, PartialEq)]
295pub struct UnresolvedSchema {
296    pub path: String,
297    pub message: String,
298}
299
300/// Complete read-only result of source discovery and schema preflight.
301#[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    /// Returns whether preflight found a condition that prevents writing.
312    #[must_use]
313    pub const fn has_errors(&self) -> bool {
314        !self.conflicts.is_empty() || !self.unresolved_schemas.is_empty()
315    }
316
317    /// Rejects a plan that cannot be written safely.
318    ///
319    /// # Errors
320    ///
321    /// Returns one summary error containing every schema conflict and unresolved schema.
322    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/// How a migrated identifier was resolved.
356#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
357pub enum IdentifierSource {
358    Metadata,
359    Row,
360    Path,
361    Absent,
362}
363
364/// Batches from one source file that share one resolved identifier.
365#[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
398/// Enumerates and resolves every source leaf file without writing a target.
399///
400/// # Errors
401///
402/// Returns an error if source listing or Parquet schema reading fails.
403pub 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
455/// Reads, normalizes, and transcodes one planned source file.
456///
457/// # Errors
458///
459/// Returns an error if the source file cannot be read, normalized, or transcoded.
460pub 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
487/// Resolves identifiers and groups batches from one source file.
488///
489/// # Errors
490///
491/// Returns an error when an identifier column is not string-like or a batch cannot be sliced or
492/// rebuilt with current identifier metadata.
493pub 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    // The OpenDAL filesystem adapter lists directory entries alongside leaves; an entry
555    // that is the parent of another listed entry is a directory, not a migratable leaf.
556    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
651/// Infers a custom type name from a legacy `custom_<snake_case>` directory.
652///
653/// Old Python-written catalogs stored custom data under `data/custom_<snake_case>` without
654/// `type_name` schema metadata. Best-effort reversal to PascalCase; acronyms do not survive
655/// the round trip, but the known legacy layouts (e.g. `custom_binance_bar` -> `BinanceBar`)
656/// map exactly. Returns `None` for the canonical `custom` directory, which carries no name.
657fn 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
671/// Returns true when a custom schema can migrate: timestamp normalization converts
672/// `UInt64` `ts_event`/`ts_init` and passes timestamps through, so any other
673/// physical type (notably legacy `Int64`) has no transcoder.
674fn 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
685/// Attaches a custom `type_name` to a schema that lacks it, leaving other
686/// schemas untouched.
687fn 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
700/// Attaches the planned custom `type_name` to a preflight schema that lacks it,
701/// mirroring the execution-time injection so fingerprints match written output.
702fn 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            // Mirror the data-file inference so markers land beside their data files
740            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        // Resolve the custom target before normalization, so the preflight fingerprint
760        // reflects the same type_name the execution path injects.
761        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        // Custom targets skip fingerprint validation, so reject unconvertible
779        // timestamps here instead of migrating to an unreadable destination.
780        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(&current);
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
1088/// Attaches the planned custom `type_name` to batches that lack it.
1089///
1090/// Legacy Python-written custom files predate `type_name` schema metadata. Timestamp
1091/// normalization keys off that metadata, so without it `uint64` timestamps would pass
1092/// through unconverted. Files that already carry `type_name` are returned unchanged.
1093fn 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(&quote_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}