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