nautilus_persistence/backend/parquet/
migration.rs1use futures::StreamExt;
19use nautilus_core::UnixNanos;
20use nautilus_model::instruments::NautilusInstrumentType;
21use nautilus_serialization::arrow::record_batch_without_identifier_column;
22use object_store::{PutMode, PutOptions, path::Path as ObjectPath};
23
24use super::{
25 catalog::ParquetDataCatalog, io::write_batches_to_object_store_create,
26 paths::timestamps_to_filename,
27};
28use crate::{
29 backend::migration::{
30 CatalogMigrationPlan, CatalogMigrationReport, IdentifierSource, ParquetCatalogSource,
31 build_catalog_migration_plan, ensure_distinct_migration_locations,
32 ensure_planned_file_unchanged, prepare_migration_parts, read_planned_migration_file,
33 },
34 catalog::types::instrument_path_prefix,
35 common::metadata::record_batch_ts_init_range,
36};
37
38#[derive(Debug)]
40pub struct ParquetMigrationConfig {
41 pub source_uri: String,
42 pub target_uri: String,
43 pub source_options: Vec<(String, String)>,
44 pub target_options: Vec<(String, String)>,
45 pub dry_run: bool,
46}
47
48pub fn migrate_parquet_catalog(
58 config: ParquetMigrationConfig,
59) -> anyhow::Result<CatalogMigrationReport> {
60 let source_uri = crate::common::storage::normalize_storage_location(&config.source_uri)?;
61 let target_uri = crate::common::storage::normalize_storage_location(&config.target_uri)?;
62 ensure_distinct_migration_locations(&source_uri, &target_uri)?;
63 let source = ParquetDataCatalog::from_uri(
64 &source_uri,
65 Some(config.source_options.into_iter().collect()),
66 None,
67 None,
68 None,
69 )?;
70 let plan = build_catalog_migration_plan(&source)?;
71 plan.ensure_ready()?;
72 if config.dry_run {
73 return Ok(CatalogMigrationReport::from_plan(&plan, true));
74 }
75 let url = url::Url::parse(&target_uri)?;
76 if url.scheme() == "file" {
77 let path = url
78 .to_file_path()
79 .map_err(|()| anyhow::anyhow!("Invalid destination file URI"))?;
80 std::fs::create_dir_all(path)?;
81 }
82 let target = ParquetDataCatalog::from_uri(
83 &target_uri,
84 Some(config.target_options.into_iter().collect()),
85 None,
86 None,
87 None,
88 )?;
89 target.migrate_from_legacy_parquet_catalog_plan(&source, &plan)
90}
91
92impl ParquetCatalogSource for ParquetDataCatalog {
93 fn object_store(&self) -> std::sync::Arc<dyn object_store::ObjectStore> {
94 self.object_store.clone()
95 }
96 fn base_path(&self) -> &str {
97 &self.base_path
98 }
99 fn original_uri(&self) -> &str {
100 &self.original_uri
101 }
102
103 fn to_object_path_parsed(&self, path: &str) -> anyhow::Result<ObjectPath> {
104 Self::to_object_path_parsed(self, path)
105 }
106}
107
108impl ParquetDataCatalog {
109 pub fn migrate_from_legacy_parquet_catalog(
116 &self,
117 source: &Self,
118 ) -> anyhow::Result<CatalogMigrationReport> {
119 ensure_distinct_migration_locations(&source.original_uri, &self.original_uri)?;
120 let plan = build_catalog_migration_plan(source)?;
121 plan.ensure_ready()?;
122 self.migrate_from_legacy_parquet_catalog_plan(source, &plan)
123 }
124
125 fn migrate_from_legacy_parquet_catalog_plan(
126 &self,
127 source: &Self,
128 plan: &CatalogMigrationPlan,
129 ) -> anyhow::Result<CatalogMigrationReport> {
130 self.ensure_migration_target_empty()?;
131 let mut report = CatalogMigrationReport::from_plan(plan, false);
132
133 for file in &plan.files {
134 if file.size == 0 {
135 let source_path = source.to_object_path_parsed(&file.path)?;
136 ensure_planned_file_unchanged(source, file, &source_path)?;
137 let target_prefix = match file.target_type_name.as_str() {
138 "instruments" => file.source_type_name.clone(),
139 "custom" => file
140 .source_type_name
141 .strip_prefix("custom_")
142 .map_or_else(|| "custom".to_string(), |name| format!("custom/{name}")),
143 _ => file.target_type_name.clone(),
144 };
145 let relative_path = file.relative_path.replacen(
146 &format!("data/{}/", file.source_type_name),
147 &format!("data/{target_prefix}/"),
148 1,
149 );
150 let target_path = self.to_object_path_parsed(&relative_path)?;
151 self.execute_async(|| async {
152 self.object_store
153 .put_opts(
154 &target_path,
155 Vec::new().into(),
156 PutOptions {
157 mode: PutMode::Create,
158 ..Default::default()
159 },
160 )
161 .await?;
162 Ok(())
163 })?;
164 report.record_migrated_file(file, 0, 0);
165 continue;
166 }
167 let batches = read_planned_migration_file(source, file)?;
168 let mut migrated_rows = 0;
169 let mut path_identifier_rows = 0;
170
171 for part in prepare_migration_parts(file, batches)? {
172 if part.row_count == 0 {
173 continue;
174 }
175 let directory = if let Some(custom_type_name) =
176 file.target_type_name.strip_prefix("custom/")
177 {
178 self.make_path_custom_data(custom_type_name, part.identifier.as_deref())?
179 } else {
180 {
181 let prefix =
182 if let Some(class) = file.target_table.strip_prefix("instruments/") {
183 instrument_path_prefix(&class.parse::<NautilusInstrumentType>()?)
184 } else {
185 &file.target_type_name
186 };
187 self.make_path(prefix, part.identifier.as_deref())?
188 }
189 };
190 let (start_ts, end_ts) = record_batch_ts_init_range(&part.batches)?;
191 let filename =
192 timestamps_to_filename(UnixNanos::from(start_ts), UnixNanos::from(end_ts));
193 let object_path = self.to_object_path(&format!("{directory}/{filename}"))?;
194 let batches = part
195 .batches
196 .into_iter()
197 .map(record_batch_without_identifier_column)
198 .collect::<Result<Vec<_>, _>>()?;
199
200 self.execute_async(|| async {
201 write_batches_to_object_store_create(
202 &batches,
203 self.object_store.clone(),
204 &object_path,
205 Some(self.compression),
206 Some(self.max_row_group_size),
207 None,
208 )
209 .await
210 })
211 .map_err(|e| {
212 anyhow::anyhow!(
213 "Parquet migration target object already exists or cannot be created: \
214 {object_path}: {e}"
215 )
216 })?;
217 migrated_rows += part.row_count;
218 if part.identifier_source == IdentifierSource::Path {
219 path_identifier_rows += part.row_count;
220 }
221 }
222
223 if migrated_rows == 0 {
224 report.record_skipped_file(file);
225 } else {
226 report.record_migrated_file(file, migrated_rows, path_identifier_rows);
227 }
228 }
229
230 Ok(report)
231 }
232
233 fn ensure_migration_target_empty(&self) -> anyhow::Result<()> {
234 let prefix = (!self.base_path.is_empty()).then(|| ObjectPath::from(self.base_path.clone()));
235 self.execute_async(|| async {
236 let mut objects = self.object_store.list(prefix.as_ref());
237 anyhow::ensure!(
238 objects.next().await.transpose()?.is_none(),
239 "Parquet migration target must be new or empty",
240 );
241 Ok(())
242 })
243 }
244}