1#![expect(
17 clippy::missing_panics_doc,
18 reason = "Parquet I/O functions use validated schema paths"
19)]
20
21use std::{collections::HashMap, sync::Arc};
22
23use ahash::AHashMap;
24use anyhow::Context;
25use arrow::{datatypes::Schema, record_batch::RecordBatch};
26use nautilus_serialization::arrow::{KEY_IDENTIFIER, KEY_PRICE_PRECISION, KEY_SIZE_PRECISION};
27use object_store::{
28 ObjectStore, ObjectStoreExt, PutMode, PutOptions, buffered::BufReader, path::Path as ObjectPath,
29};
30use parquet::{
31 arrow::{
32 ArrowSchemaConverter, ArrowWriter, ParquetRecordBatchStreamBuilder,
33 arrow_reader::ParquetRecordBatchReaderBuilder,
34 },
35 basic::{Compression, Encoding, ZstdLevel},
36 file::{
37 metadata::{KeyValue, SortingColumn},
38 properties::WriterProperties,
39 reader::{FileReader, SerializedFileReader},
40 statistics::Statistics,
41 },
42 schema::types::ColumnPath,
43};
44use url::Url;
45
46pub(crate) use crate::common::paths::file_uri_to_native_path;
47pub use crate::common::paths::normalize_path_to_uri;
48
49pub(crate) struct ObjectStoreLocation {
50 pub object_store: Arc<dyn ObjectStore>,
51 pub base_path: String,
52 pub original_uri: String,
53}
54
55pub async fn read_parquet_schema_from_object_store(
63 object_store: Arc<dyn ObjectStore>,
64 path: &ObjectPath,
65) -> anyhow::Result<Arc<Schema>> {
66 let object = object_store.head(path).await?;
67 if object.size == 0 {
68 return Ok(Arc::new(Schema::empty()));
69 }
70
71 let reader = BufReader::new(object_store, &object);
72 let builder = ParquetRecordBatchStreamBuilder::new(reader).await?;
73 Ok(builder.schema().clone())
74}
75
76pub async fn read_parquet_from_object_store(
85 object_store: Arc<dyn ObjectStore>,
86 path: &ObjectPath,
87) -> anyhow::Result<(Vec<RecordBatch>, Arc<Schema>)> {
88 let data = object_store.get(path).await?.bytes().await?;
89 if data.is_empty() {
90 return Ok((Vec::new(), Arc::new(Schema::empty())));
91 }
92
93 let builder = ParquetRecordBatchReaderBuilder::try_new(data)?;
94 let schema = builder.schema().clone();
95 let batches = builder.build()?.collect::<Result<Vec<_>, _>>()?;
96
97 Ok((batches, schema))
98}
99
100pub async fn write_batches_to_object_store(
107 batches: &[RecordBatch],
108 object_store: Arc<dyn ObjectStore>,
109 path: &ObjectPath,
110 compression: Option<Compression>,
111 max_row_group_size: Option<usize>,
112 key_value_metadata: Option<Vec<KeyValue>>,
113) -> anyhow::Result<()> {
114 write_batches_to_object_store_with_mode(
115 batches,
116 object_store,
117 path,
118 compression,
119 max_row_group_size,
120 key_value_metadata,
121 PutMode::Overwrite,
122 )
123 .await
124}
125
126pub(crate) async fn write_batches_to_object_store_create(
127 batches: &[RecordBatch],
128 object_store: Arc<dyn ObjectStore>,
129 path: &ObjectPath,
130 compression: Option<Compression>,
131 max_row_group_size: Option<usize>,
132 key_value_metadata: Option<Vec<KeyValue>>,
133) -> anyhow::Result<()> {
134 write_batches_to_object_store_with_mode(
135 batches,
136 object_store,
137 path,
138 compression,
139 max_row_group_size,
140 key_value_metadata,
141 PutMode::Create,
142 )
143 .await
144}
145
146async fn write_batches_to_object_store_with_mode(
147 batches: &[RecordBatch],
148 object_store: Arc<dyn ObjectStore>,
149 path: &ObjectPath,
150 compression: Option<Compression>,
151 max_row_group_size: Option<usize>,
152 key_value_metadata: Option<Vec<KeyValue>>,
153 put_mode: PutMode,
154) -> anyhow::Result<()> {
155 let Some(first) = batches.first() else {
156 anyhow::bail!("Cannot write Parquet file {path} with no record batches");
157 };
158
159 let mut buffer = Vec::new();
161
162 let schema = first.schema();
163 let sorting_columns = parquet_sorting_columns(schema.as_ref())?;
164 let mut props_builder = WriterProperties::builder()
165 .set_compression(compression.unwrap_or(Compression::ZSTD(ZstdLevel::default())))
166 .set_max_row_group_row_count(Some(
167 max_row_group_size.unwrap_or(super::DEFAULT_ROW_GROUP_SIZE),
168 ))
169 .set_sorting_columns(sorting_columns)
170 .set_key_value_metadata(key_value_metadata);
171
172 if schema.index_of(KEY_IDENTIFIER).is_ok() {
173 props_builder =
174 props_builder.set_column_bloom_filter_enabled(ColumnPath::from(KEY_IDENTIFIER), true);
175 }
176
177 for name in ["ts_event", "ts_init"] {
179 if schema.index_of(name).is_ok() {
180 props_builder = props_builder
181 .set_column_dictionary_enabled(ColumnPath::from(name), false)
182 .set_column_encoding(ColumnPath::from(name), Encoding::DELTA_BINARY_PACKED);
183 }
184 }
185
186 let writer_props = props_builder.build();
187
188 let mut writer = ArrowWriter::try_new(&mut buffer, schema, Some(writer_props))?;
189 for batch in batches {
190 writer.write(batch)?;
191 }
192
193 writer.close()?;
194
195 object_store
197 .put_opts(
198 path,
199 buffer.into(),
200 PutOptions {
201 mode: put_mode,
202 ..Default::default()
203 },
204 )
205 .await?;
206
207 Ok(())
208}
209
210fn parquet_sorting_columns(schema: &Schema) -> anyhow::Result<Option<Vec<SortingColumn>>> {
211 if schema.index_of("ts_init").is_err() {
212 return Ok(None);
213 }
214
215 let parquet_schema = ArrowSchemaConverter::new().convert(schema)?;
216 let mut names = Vec::with_capacity(2);
217 names.push("ts_init");
218 if schema.index_of(KEY_IDENTIFIER).is_ok() {
219 names.push(KEY_IDENTIFIER);
220 }
221
222 let columns = names
223 .into_iter()
224 .map(|name| {
225 let index = parquet_schema
226 .columns()
227 .iter()
228 .position(|column| {
229 column
230 .path()
231 .parts()
232 .first()
233 .is_some_and(|part| part == name)
234 })
235 .ok_or_else(|| anyhow::anyhow!("Parquet schema is missing sort column {name}"))?;
236
237 Ok(SortingColumn {
238 column_idx: i32::try_from(index)?,
239 descending: false,
240 nulls_first: false,
241 })
242 })
243 .collect::<anyhow::Result<Vec<_>>>()?;
244
245 Ok(Some(columns))
246}
247
248fn deduplicate_record_batches(batches: &[RecordBatch]) -> anyhow::Result<Vec<RecordBatch>> {
258 if batches.is_empty() {
259 return Ok(Vec::new());
260 }
261
262 let schema = batches[0].schema();
263
264 let fields: Vec<arrow::row::SortField> = schema
265 .fields()
266 .iter()
267 .map(|f| arrow::row::SortField::new(f.data_type().clone()))
268 .collect();
269
270 let converter = arrow::row::RowConverter::new(fields)?;
271 let mut seen: std::collections::HashSet<Vec<u8>> = std::collections::HashSet::new();
272 let mut result: Vec<RecordBatch> = Vec::new();
273
274 for batch in batches {
275 let rows = converter.convert_columns(batch.columns())?;
276 let mut indices: Vec<u32> = Vec::new();
277
278 for (i, row) in rows.iter().enumerate() {
279 if seen.insert(row.as_ref().to_vec()) {
280 indices.push(u32::try_from(i)?);
281 }
282 }
283
284 if !indices.is_empty() {
285 let index_array = arrow::array::UInt32Array::from(indices);
286 let deduped_columns: Vec<arrow::array::ArrayRef> = batch
287 .columns()
288 .iter()
289 .map(|col| arrow::compute::take(col.as_ref(), &index_array, None))
290 .collect::<Result<_, _>>()?;
291 result.push(RecordBatch::try_new(schema.clone(), deduped_columns)?);
292 }
293 }
294
295 Ok(result)
296}
297
298pub async fn combine_parquet_files_from_object_store(
304 object_store: Arc<dyn ObjectStore>,
305 file_paths: Vec<ObjectPath>,
306 new_file_path: &ObjectPath,
307 compression: Option<Compression>,
308 max_row_group_size: Option<usize>,
309 deduplicate: Option<bool>,
310) -> anyhow::Result<()> {
311 if file_paths.len() <= 1 {
312 return Ok(());
313 }
314
315 let mut all_batches: Vec<RecordBatch> = Vec::new();
316 let mut schema_with_metadata: Option<Arc<arrow::datatypes::Schema>> = None;
317 let mut schema_source: Option<&ObjectPath> = None;
318 let mut field_metadata_sources = HashMap::new();
319
320 for path in &file_paths {
322 let data = object_store.get(path).await?.bytes().await?;
323 let builder = ParquetRecordBatchReaderBuilder::try_new(data)?;
324
325 let candidate_schema = builder.schema().clone();
326 schema_with_metadata = Some(
327 if let (Some(schema), Some(source)) = (&schema_with_metadata, schema_source) {
328 let reconciled = reconcile_consolidation_schema_with_sources(
329 schema,
330 source,
331 &field_metadata_sources,
332 &candidate_schema,
333 path,
334 )?;
335
336 if reconciled.schema_source == ConsolidationSchemaSource::Candidate {
337 schema_source = Some(path);
338 }
339
340 for key in reconciled.candidate_field_metadata {
341 field_metadata_sources.insert(key, path.clone());
342 }
343
344 reconciled.schema
345 } else {
346 schema_source = Some(path);
347 field_metadata_sources
348 .extend(field_metadata_keys(&candidate_schema).map(|key| (key, path.clone())));
349 candidate_schema
350 },
351 );
352
353 for batch in builder.build()? {
354 all_batches.push(batch?);
355 }
356 }
357
358 if let Some(schema) = &schema_with_metadata {
362 all_batches = all_batches
363 .into_iter()
364 .map(|b| RecordBatch::try_new(schema.clone(), b.columns().to_vec()))
365 .collect::<Result<Vec<_>, _>>()?;
366 }
367
368 let batches_to_write = if deduplicate.unwrap_or(false) {
370 deduplicate_record_batches(&all_batches)?
371 } else {
372 all_batches
373 };
374
375 write_batches_to_object_store(
377 &batches_to_write,
378 object_store.clone(),
379 new_file_path,
380 compression,
381 max_row_group_size,
382 None,
383 )
384 .await?;
385
386 for path in &file_paths {
388 if path != new_file_path {
389 object_store.delete(path).await?;
390 }
391 }
392
393 Ok(())
394}
395
396#[derive(Clone, Copy, Debug, Eq, PartialEq)]
397enum ConsolidationSchemaSource {
398 Current,
399 Candidate,
400}
401
402#[derive(Debug)]
403struct ReconciledConsolidationSchema {
404 schema: Arc<Schema>,
405 schema_source: ConsolidationSchemaSource,
406 candidate_field_metadata: Vec<(String, String)>,
407}
408
409fn reconcile_consolidation_schema_with_sources(
410 current: &Arc<Schema>,
411 current_path: &ObjectPath,
412 current_field_metadata_sources: &HashMap<(String, String), ObjectPath>,
413 candidate: &Arc<Schema>,
414 candidate_path: &ObjectPath,
415) -> anyhow::Result<ReconciledConsolidationSchema> {
416 anyhow::ensure!(
417 current.fields().len() == candidate.fields().len(),
418 "Cannot consolidate Parquet files {current_path} and {candidate_path}: field schemas differ"
419 );
420 let mut candidate_field_metadata = Vec::new();
421
422 let fields = current
423 .fields()
424 .iter()
425 .zip(candidate.fields())
426 .map(|(current, candidate)| {
427 anyhow::ensure!(
428 current.name() == candidate.name()
429 && current.data_type() == candidate.data_type()
430 && current.is_nullable() == candidate.is_nullable(),
431 "Cannot consolidate Parquet files {current_path} and {candidate_path}: field schemas differ"
432 );
433 let mut metadata = current.metadata().clone();
434 for (key, value) in candidate.metadata() {
435 if let Some(current_value) = metadata.get(key) {
436 let source = current_field_metadata_sources
437 .get(&(current.name().clone(), key.clone()))
438 .unwrap_or(current_path);
439 anyhow::ensure!(
440 current_value == value,
441 "Cannot consolidate Parquet files {source} and {candidate_path}: field '{}' metadata differs",
442 current.name(),
443 );
444 } else {
445 metadata.insert(key.clone(), value.clone());
446 candidate_field_metadata.push((current.name().clone(), key.clone()));
447 }
448 }
449
450 Ok(Arc::new(current.as_ref().clone().with_metadata(metadata)))
451 })
452 .collect::<anyhow::Result<Vec<_>>>()?;
453
454 let schema_with_fields =
455 |metadata| Arc::new(Schema::new_with_metadata(fields.clone(), metadata));
456
457 if current.metadata() == candidate.metadata() {
458 return Ok(ReconciledConsolidationSchema {
459 schema: schema_with_fields(current.metadata().clone()),
460 schema_source: ConsolidationSchemaSource::Current,
461 candidate_field_metadata,
462 });
463 }
464
465 let without_precision = |schema: &Schema| {
466 let mut metadata = schema.metadata().clone();
467 metadata.remove(KEY_PRICE_PRECISION);
468 metadata.remove(KEY_SIZE_PRECISION);
469 metadata
470 };
471
472 let is_precision_fallback = |metadata: &HashMap<String, String>| {
473 metadata.get(KEY_PRICE_PRECISION).map(String::as_str) == Some("0")
474 && metadata.get(KEY_SIZE_PRECISION).map(String::as_str) == Some("0")
475 };
476
477 let has_precision = |metadata: &HashMap<String, String>| {
478 metadata.contains_key(KEY_PRICE_PRECISION) && metadata.contains_key(KEY_SIZE_PRECISION)
479 };
480
481 let current_metadata = current.metadata();
482 let candidate_metadata = candidate.metadata();
483
484 if without_precision(current) == without_precision(candidate) {
485 match (
486 is_precision_fallback(current_metadata),
487 is_precision_fallback(candidate_metadata),
488 ) {
489 (true, false) if has_precision(candidate_metadata) => {
490 return Ok(ReconciledConsolidationSchema {
491 schema: schema_with_fields(candidate.metadata().clone()),
492 schema_source: ConsolidationSchemaSource::Candidate,
493 candidate_field_metadata,
494 });
495 }
496 (false, true) if has_precision(current_metadata) => {
497 return Ok(ReconciledConsolidationSchema {
498 schema: schema_with_fields(current.metadata().clone()),
499 schema_source: ConsolidationSchemaSource::Current,
500 candidate_field_metadata,
501 });
502 }
503 _ => {}
504 }
505 }
506
507 anyhow::bail!(
508 "Cannot consolidate Parquet files {current_path} and {candidate_path}: schema metadata differs: {current_metadata:?} versus {candidate_metadata:?}"
509 )
510}
511
512fn field_metadata_keys(schema: &Schema) -> impl Iterator<Item = (String, String)> + '_ {
513 schema.fields().iter().flat_map(|field| {
514 field
515 .metadata()
516 .keys()
517 .map(|key| (field.name().clone(), key.clone()))
518 })
519}
520
521pub async fn min_max_from_parquet_metadata_object_store(
527 object_store: Arc<dyn ObjectStore>,
528 file_path: &ObjectPath,
529 column_name: &str,
530) -> anyhow::Result<(u64, u64)> {
531 let data = object_store.get(file_path).await?.bytes().await?;
533 let reader = SerializedFileReader::new(data)?;
534
535 let metadata = reader.metadata();
536 let mut overall_min_value: Option<i64> = None;
537 let mut overall_max_value: Option<i64> = None;
538
539 for i in 0..metadata.num_row_groups() {
541 let row_group = metadata.row_group(i);
542
543 for j in 0..row_group.num_columns() {
545 let col_metadata = row_group.column(j);
546
547 if col_metadata.column_path().string() == column_name {
548 if let Some(stats) = col_metadata.statistics() {
549 if let Statistics::Int64(int64_stats) = stats {
551 if let Some(&min_value) = int64_stats.min_opt()
553 && (overall_min_value.is_none()
554 || min_value < overall_min_value.unwrap())
555 {
556 overall_min_value = Some(min_value);
557 }
558
559 if let Some(&max_value) = int64_stats.max_opt()
561 && (overall_max_value.is_none()
562 || max_value > overall_max_value.unwrap())
563 {
564 overall_max_value = Some(max_value);
565 }
566 } else {
567 anyhow::bail!("Warning: Column name '{column_name}' is not of type i64.");
568 }
569 } else {
570 anyhow::bail!(
571 "Warning: Statistics not available for column '{column_name}' in row group {i}."
572 );
573 }
574 }
575 }
576 }
577
578 if let (Some(min), Some(max)) = (overall_min_value, overall_max_value) {
580 Ok((u64::try_from(min)?, u64::try_from(max)?))
581 } else {
582 anyhow::bail!(
583 "Column '{column_name}' not found or has no Int64 statistics in any row group."
584 )
585 }
586}
587
588pub fn create_object_store_from_path(
615 path: &str,
616 storage_options: Option<AHashMap<String, String>>,
617) -> anyhow::Result<(Arc<dyn ObjectStore>, String, String)> {
618 let location = create_object_store_location_from_path(path, storage_options)?;
619 Ok((
620 location.object_store,
621 location.base_path,
622 location.original_uri,
623 ))
624}
625
626#[cfg_attr(
629 not(feature = "cloud"),
630 allow(unused_variables, clippy::needless_pass_by_value)
631)]
632pub(crate) fn create_object_store_location_from_path(
633 path: &str,
634 storage_options: Option<AHashMap<String, String>>,
635) -> anyhow::Result<ObjectStoreLocation> {
636 let uri = normalize_path_to_uri(path)?;
637
638 let (object_store, base_path, original_uri) = match uri.as_str() {
639 #[cfg(feature = "cloud")]
640 s if s.starts_with("s3://") => create_s3_store(&uri, storage_options),
641 #[cfg(feature = "cloud")]
642 s if s.starts_with("gs://") || s.starts_with("gcs://") => {
643 create_gcs_store(&uri, storage_options)
644 }
645 #[cfg(feature = "cloud")]
646 s if s.starts_with("az://") => create_azure_store(&uri, storage_options),
647 #[cfg(feature = "cloud")]
648 s if s.starts_with("abfs://") => create_abfs_store(&uri, storage_options),
649 #[cfg(feature = "cloud")]
650 s if s.starts_with("http://") || s.starts_with("https://") => {
651 create_http_store(&uri, storage_options)
652 }
653 #[cfg(not(feature = "cloud"))]
654 s if s.starts_with("s3://")
655 || s.starts_with("gs://")
656 || s.starts_with("gcs://")
657 || s.starts_with("az://")
658 || s.starts_with("abfs://")
659 || s.starts_with("http://")
660 || s.starts_with("https://") =>
661 {
662 anyhow::bail!("Cloud storage support requires the 'cloud' feature: {uri}")
663 }
664 s if s.starts_with("file://") => create_local_store(&uri, true),
665 _ => create_local_store(&uri, false), }?;
667
668 Ok(ObjectStoreLocation {
669 object_store,
670 base_path,
671 original_uri,
672 })
673}
674
675pub(crate) fn is_remote_uri_scheme(scheme: &str) -> bool {
676 matches!(
677 scheme,
678 "s3" | "gs" | "gcs" | "az" | "abfs" | "http" | "https"
679 )
680}
681
682pub(crate) fn remote_store_root_url(uri: &str) -> anyhow::Result<Url> {
683 let mut url = Url::parse(uri)?;
684 url.set_path("");
685 url.set_query(None);
686 url.set_fragment(None);
687 Ok(url)
688}
689
690pub(crate) fn remote_full_uri(uri: &str, object_path: &str) -> anyhow::Result<String> {
691 let root = remote_store_root_url(uri)?;
692 let root = root.as_str().trim_end_matches('/');
693 let object_path = object_path.trim_start_matches('/');
694
695 if object_path.is_empty() {
696 Ok(root.to_string())
697 } else {
698 Ok(format!("{root}/{object_path}"))
699 }
700}
701
702pub(crate) fn append_path_to_file_uri(base_uri: &str, path: &str) -> String {
705 if let Ok(mut url) = Url::parse(base_uri) {
706 if let Ok(mut segments) = url.path_segments_mut() {
707 segments.pop_if_empty();
708 segments.extend(
709 path.trim_end_matches('/')
710 .split('/')
711 .filter(|segment| !segment.is_empty()),
712 );
713 }
714
715 return url.to_string();
716 }
717
718 format!(
719 "{}/{}",
720 base_uri.trim_end_matches('/'),
721 path.trim_end_matches('/')
722 )
723}
724
725pub(crate) fn decode_object_store_segment(segment: &str) -> String {
732 ObjectPath::from_url_path(segment).map_or_else(|_| segment.to_string(), String::from)
733}
734
735fn create_local_store(
736 uri: &str,
737 is_file_uri: bool,
738) -> anyhow::Result<(Arc<dyn ObjectStore>, String, String)> {
739 let path = if is_file_uri {
740 file_uri_to_native_path(uri)
741 } else {
742 uri.to_string()
743 };
744
745 let local_store =
746 object_store::local::LocalFileSystem::new_with_prefix(&path).with_context(|| {
747 format!(
748 "failed to open local storage directory '{path}'; \
749 create it if it does not exist and check access permissions"
750 )
751 })?;
752
753 Ok((Arc::new(local_store), String::new(), uri.to_string()))
754}
755
756#[cfg(feature = "cloud")]
758fn create_s3_store(
759 uri: &str,
760 storage_options: Option<AHashMap<String, String>>,
761) -> anyhow::Result<(Arc<dyn ObjectStore>, String, String)> {
762 let (url, path) = parse_url_and_path(uri)?;
763 let bucket = extract_host(&url, "Invalid S3 URI: missing bucket")?;
764
765 let mut builder = object_store::aws::AmazonS3Builder::new().with_bucket_name(&bucket);
766
767 if let Some(options) = storage_options {
769 for (key, value) in options {
770 let config_key = match key.as_str() {
772 "key" => object_store::aws::AmazonS3ConfigKey::AccessKeyId,
773 "secret" => object_store::aws::AmazonS3ConfigKey::SecretAccessKey,
774 _ => key.parse()?,
775 };
776 builder = builder.with_config(config_key, value);
777 }
778 }
779
780 let s3_store = builder.build()?;
781 Ok((Arc::new(s3_store), path, uri.to_string()))
782}
783
784#[cfg(feature = "cloud")]
786fn create_gcs_store(
787 uri: &str,
788 storage_options: Option<AHashMap<String, String>>,
789) -> anyhow::Result<(Arc<dyn ObjectStore>, String, String)> {
790 let (url, path) = parse_url_and_path(uri)?;
791 let bucket = extract_host(&url, "Invalid GCS URI: missing bucket")?;
792
793 let mut builder = object_store::gcp::GoogleCloudStorageBuilder::new().with_bucket_name(&bucket);
794
795 if let Some(options) = storage_options {
797 for (key, value) in options {
798 builder =
799 builder.with_config(key.parse::<object_store::gcp::GoogleConfigKey>()?, value);
800 }
801 }
802
803 let gcs_store = builder.build()?;
804 Ok((Arc::new(gcs_store), path, uri.to_string()))
805}
806
807#[cfg(feature = "cloud")]
809fn create_azure_store(
810 uri: &str,
811 storage_options: Option<AHashMap<String, String>>,
812) -> anyhow::Result<(Arc<dyn ObjectStore>, String, String)> {
813 let (url, path) = parse_url_and_path(uri)?;
814 let container = extract_host(&url, "Invalid Azure URI: missing container")?;
815
816 let mut builder =
817 object_store::azure::MicrosoftAzureBuilder::new().with_container_name(container);
818
819 if let Some(options) = storage_options {
821 builder = apply_azure_storage_options(builder, options)?;
822 }
823
824 let azure_store = builder.build()?;
825 Ok((Arc::new(azure_store), path, uri.to_string()))
826}
827
828#[cfg(feature = "cloud")]
830fn create_abfs_store(
831 uri: &str,
832 storage_options: Option<AHashMap<String, String>>,
833) -> anyhow::Result<(Arc<dyn ObjectStore>, String, String)> {
834 let (url, path) = parse_url_and_path(uri)?;
835 let host = extract_host(&url, "Invalid ABFS URI: missing host")?;
836
837 let account = host
839 .split('.')
840 .next()
841 .ok_or_else(|| anyhow::anyhow!("Invalid ABFS URI: cannot extract account from host"))?;
842
843 let container = url
845 .username()
846 .split('@')
847 .next()
848 .ok_or_else(|| anyhow::anyhow!("Invalid ABFS URI: missing container"))?;
849
850 let mut builder = object_store::azure::MicrosoftAzureBuilder::new()
851 .with_account(account)
852 .with_container_name(container);
853
854 if let Some(options) = storage_options {
856 builder = apply_azure_storage_options(builder, options)?;
857 }
858
859 let azure_store = builder.build()?;
860 Ok((Arc::new(azure_store), path, uri.to_string()))
861}
862
863#[cfg(feature = "cloud")]
865fn apply_azure_storage_options(
866 mut builder: object_store::azure::MicrosoftAzureBuilder,
867 options: AHashMap<String, String>,
868) -> anyhow::Result<object_store::azure::MicrosoftAzureBuilder> {
869 for (key, value) in options {
870 builder = builder.with_config(key.parse::<object_store::azure::AzureConfigKey>()?, value);
871 }
872
873 Ok(builder)
874}
875
876#[cfg(feature = "cloud")]
878fn create_http_store(
879 uri: &str,
880 storage_options: Option<AHashMap<String, String>>,
881) -> anyhow::Result<(Arc<dyn ObjectStore>, String, String)> {
882 let (_, path) = parse_url_and_path(uri)?;
883 let base_url = remote_store_root_url(uri)?
884 .as_str()
885 .trim_end_matches('/')
886 .to_string();
887
888 let mut builder = object_store::http::HttpBuilder::new().with_url(base_url);
889
890 if let Some(options) = storage_options {
892 for (key, value) in options {
893 builder = builder.with_config(key.parse::<object_store::ClientConfigKey>()?, value);
894 }
895 }
896
897 let http_store = builder.build()?;
898 Ok((Arc::new(http_store), path, uri.to_string()))
899}
900
901#[cfg(feature = "cloud")]
906fn parse_url_and_path(uri: &str) -> anyhow::Result<(Url, String)> {
907 let url = Url::parse(uri)?;
908 let path = url.path().trim_start_matches('/').to_string();
909 let raw_path = uri
910 .split_once("://")
911 .and_then(|(_, rest)| rest.split_once('/'))
912 .map_or("", |(_, raw)| raw.trim_start_matches('/'));
913
914 anyhow::ensure!(
915 raw_path == path && ObjectPath::from(path.as_str()).as_ref() == path.trim_end_matches('/'),
916 "Storage URI '{uri}' has a base path that URL parsing or object-store path encoding \
917 changes; use a base path without spaces, non-ASCII, or reserved characters",
918 );
919
920 Ok((url, path))
921}
922
923#[cfg(feature = "cloud")]
925fn extract_host(url: &Url, error_msg: &str) -> anyhow::Result<String> {
926 url.host_str()
927 .map(ToString::to_string)
928 .ok_or_else(|| anyhow::anyhow!("{error_msg}"))
929}
930
931#[cfg(test)]
932mod tests {
933 #[cfg(feature = "cloud")]
934 use std::io::{BufRead, Write};
935 use std::{collections::HashMap, sync::Arc};
936
937 #[cfg(feature = "cloud")]
938 use ahash::AHashMap;
939 use arrow::{
940 array::{Array, ArrayRef, StringArray, TimestampNanosecondArray, UInt64Array},
941 datatypes::{DataType, Field, Schema},
942 };
943 use nautilus_serialization::arrow::{json_string_field, timestamp_data_type};
944 use parquet::file::{properties::ReaderProperties, serialized_reader::ReadOptionsBuilder};
945 use rstest::rstest;
946
947 use super::*;
948
949 fn consolidation_depth_schema(
950 price_precision: &str,
951 size_precision: &str,
952 instrument_id: &str,
953 ) -> Arc<Schema> {
954 Arc::new(Schema::new_with_metadata(
955 vec![Field::new("bids", DataType::Utf8, false)],
956 HashMap::from([
957 (KEY_PRICE_PRECISION.to_string(), price_precision.to_string()),
958 (KEY_SIZE_PRECISION.to_string(), size_precision.to_string()),
959 (KEY_IDENTIFIER.to_string(), instrument_id.to_string()),
960 ]),
961 ))
962 }
963
964 fn reconcile_consolidation_schema(
965 current: &Arc<Schema>,
966 current_path: &ObjectPath,
967 candidate: &Arc<Schema>,
968 candidate_path: &ObjectPath,
969 ) -> anyhow::Result<ReconciledConsolidationSchema> {
970 let field_metadata_sources = field_metadata_keys(current)
971 .map(|key| (key, current_path.clone()))
972 .collect();
973 reconcile_consolidation_schema_with_sources(
974 current,
975 current_path,
976 &field_metadata_sources,
977 candidate,
978 candidate_path,
979 )
980 }
981
982 #[rstest]
983 fn consolidation_schema_prefers_populated_depth_precision_in_either_order() {
984 let fallback = consolidation_depth_schema("0", "0", "ETHUSDT.BINANCE");
985 let populated = consolidation_depth_schema("2", "3", "ETHUSDT.BINANCE");
986 let fallback_path = ObjectPath::from("empty.parquet");
987 let populated_path = ObjectPath::from("populated.parquet");
988
989 let fallback_first =
990 reconcile_consolidation_schema(&fallback, &fallback_path, &populated, &populated_path)
991 .unwrap();
992 let populated_first =
993 reconcile_consolidation_schema(&populated, &populated_path, &fallback, &fallback_path)
994 .unwrap();
995
996 assert_eq!(
997 fallback_first.schema_source,
998 ConsolidationSchemaSource::Candidate,
999 );
1000 assert_eq!(
1001 populated_first.schema_source,
1002 ConsolidationSchemaSource::Current,
1003 );
1004
1005 for schema in [fallback_first.schema, populated_first.schema] {
1006 assert_eq!(schema.metadata()[KEY_PRICE_PRECISION], "2");
1007 assert_eq!(schema.metadata()[KEY_SIZE_PRECISION], "3");
1008 }
1009 }
1010
1011 #[rstest]
1012 fn consolidation_schema_rejects_other_metadata_mismatches() {
1013 let current = consolidation_depth_schema("2", "3", "ETHUSDT.BINANCE");
1014 let candidate = consolidation_depth_schema("2", "3", "BTCUSDT.BINANCE");
1015 let current_path = ObjectPath::from("eth.parquet");
1016 let candidate_path = ObjectPath::from("btc.parquet");
1017
1018 let error =
1019 reconcile_consolidation_schema(¤t, ¤t_path, &candidate, &candidate_path)
1020 .unwrap_err();
1021
1022 assert!(error.to_string().contains("eth.parquet and btc.parquet"));
1023 assert!(error.to_string().contains("ETHUSDT.BINANCE"));
1024 assert!(error.to_string().contains("BTCUSDT.BINANCE"));
1025 }
1026
1027 #[rstest]
1028 fn consolidation_schema_rejects_two_populated_precisions() {
1029 let current = consolidation_depth_schema("2", "3", "ETHUSDT.BINANCE");
1030 let candidate = consolidation_depth_schema("4", "5", "ETHUSDT.BINANCE");
1031 let current_path = ObjectPath::from("precision-2.parquet");
1032 let candidate_path = ObjectPath::from("precision-4.parquet");
1033
1034 let error =
1035 reconcile_consolidation_schema(¤t, ¤t_path, &candidate, &candidate_path)
1036 .unwrap_err();
1037
1038 assert!(error.to_string().contains("precision-2.parquet"));
1039 assert!(error.to_string().contains("precision-4.parquet"));
1040 }
1041
1042 #[rstest]
1043 fn consolidation_schema_keeps_fallback_for_all_empty_files() {
1044 let first = consolidation_depth_schema("0", "0", "ETHUSDT.BINANCE");
1045 let second = consolidation_depth_schema("0", "0", "ETHUSDT.BINANCE");
1046
1047 let reconciled = reconcile_consolidation_schema(
1048 &first,
1049 &ObjectPath::from("first-empty.parquet"),
1050 &second,
1051 &ObjectPath::from("second-empty.parquet"),
1052 )
1053 .unwrap();
1054
1055 assert_eq!(reconciled.schema.metadata()[KEY_PRICE_PRECISION], "0");
1056 assert_eq!(reconciled.schema.metadata()[KEY_SIZE_PRECISION], "0");
1057 }
1058
1059 #[rstest]
1060 #[case::missing_first(true)]
1061 #[case::fallback_first(false)]
1062 fn consolidation_schema_rejects_missing_precision_against_fallback(
1063 #[case] missing_first: bool,
1064 ) {
1065 let missing = Arc::new(Schema::new_with_metadata(
1066 vec![Field::new("bids", DataType::Utf8, false)],
1067 HashMap::from([(KEY_IDENTIFIER.to_string(), "ETHUSDT.BINANCE".to_string())]),
1068 ));
1069 let fallback = consolidation_depth_schema("0", "0", "ETHUSDT.BINANCE");
1070 let missing_path = ObjectPath::from("missing.parquet");
1071 let fallback_path = ObjectPath::from("fallback.parquet");
1072
1073 let error = if missing_first {
1074 reconcile_consolidation_schema(&missing, &missing_path, &fallback, &fallback_path)
1075 } else {
1076 reconcile_consolidation_schema(&fallback, &fallback_path, &missing, &missing_path)
1077 }
1078 .unwrap_err();
1079
1080 assert!(error.to_string().contains("schema metadata differs"));
1081 }
1082
1083 #[rstest]
1084 fn consolidation_schema_merges_json_field_annotation_in_either_order() {
1085 let bare = Arc::new(Schema::new(vec![Field::new("info", DataType::Utf8, true)]));
1086 let annotated = Arc::new(Schema::new(vec![json_string_field("info", true)]));
1087 let bare_path = ObjectPath::from("bare.parquet");
1088 let annotated_path = ObjectPath::from("annotated.parquet");
1089
1090 let bare_first =
1091 reconcile_consolidation_schema(&bare, &bare_path, &annotated, &annotated_path).unwrap();
1092 let annotated_first =
1093 reconcile_consolidation_schema(&annotated, &annotated_path, &bare, &bare_path).unwrap();
1094
1095 assert_eq!(bare_first.schema, annotated_first.schema);
1096 assert_eq!(
1097 bare_first.schema.field_with_name("info").unwrap(),
1098 &json_string_field("info", true),
1099 );
1100 }
1101
1102 #[rstest]
1103 fn consolidation_field_metadata_conflict_names_the_winning_file() {
1104 let bare = Arc::new(Schema::new(vec![Field::new("info", DataType::Utf8, true)]));
1105 let annotated = Arc::new(Schema::new(vec![json_string_field("info", true)]));
1106
1107 let conflicting = Arc::new(Schema::new(vec![
1108 Field::new("info", DataType::Utf8, true).with_metadata(HashMap::from([(
1109 "ARROW:extension:name".to_string(),
1110 "other.extension".to_string(),
1111 )])),
1112 ]));
1113 let bare_path = ObjectPath::from("bare.parquet");
1114 let annotated_path = ObjectPath::from("annotated.parquet");
1115 let conflicting_path = ObjectPath::from("conflicting.parquet");
1116 let reconciled =
1117 reconcile_consolidation_schema(&bare, &bare_path, &annotated, &annotated_path).unwrap();
1118
1119 assert_eq!(reconciled.candidate_field_metadata.len(), 2);
1120 assert!(
1121 reconciled
1122 .candidate_field_metadata
1123 .contains(&("info".to_string(), "ARROW:extension:name".to_string())),
1124 );
1125 assert!(
1126 reconciled
1127 .candidate_field_metadata
1128 .contains(&("info".to_string(), "ARROW:extension:metadata".to_string())),
1129 );
1130 let field_metadata_sources = reconciled
1131 .candidate_field_metadata
1132 .iter()
1133 .cloned()
1134 .map(|key| (key, annotated_path.clone()))
1135 .collect();
1136 let error = reconcile_consolidation_schema_with_sources(
1137 &reconciled.schema,
1138 &bare_path,
1139 &field_metadata_sources,
1140 &conflicting,
1141 &conflicting_path,
1142 )
1143 .unwrap_err();
1144 assert!(error.to_string().contains("annotated.parquet"));
1145 assert!(error.to_string().contains("conflicting.parquet"));
1146 assert!(error.to_string().contains("field 'info' metadata differs"));
1147 }
1148
1149 #[rstest]
1150 fn consolidation_conflict_names_the_last_winning_schema() {
1151 let fallback = consolidation_depth_schema("0", "0", "ETHUSDT.BINANCE");
1152 let precision_2 = consolidation_depth_schema("2", "3", "ETHUSDT.BINANCE");
1153 let precision_4 = consolidation_depth_schema("4", "5", "ETHUSDT.BINANCE");
1154 let fallback_path = ObjectPath::from("fallback.parquet");
1155 let precision_2_path = ObjectPath::from("precision-2.parquet");
1156 let precision_4_path = ObjectPath::from("precision-4.parquet");
1157 let reconciled = reconcile_consolidation_schema(
1158 &fallback,
1159 &fallback_path,
1160 &precision_2,
1161 &precision_2_path,
1162 )
1163 .unwrap();
1164
1165 let error = reconcile_consolidation_schema(
1166 &reconciled.schema,
1167 &precision_2_path,
1168 &precision_4,
1169 &precision_4_path,
1170 )
1171 .unwrap_err();
1172
1173 assert!(error.to_string().contains("precision-2.parquet"));
1174 assert!(error.to_string().contains("precision-4.parquet"));
1175 }
1176
1177 #[rstest]
1178 fn consolidation_field_metadata_does_not_replace_precision_source() {
1179 let precision_2 = consolidation_depth_schema("2", "3", "ETHUSDT.BINANCE");
1180
1181 let fallback = Arc::new(Schema::new_with_metadata(
1182 vec![json_string_field("bids", false)],
1183 consolidation_depth_schema("0", "0", "ETHUSDT.BINANCE")
1184 .metadata()
1185 .clone(),
1186 ));
1187 let precision_4 = consolidation_depth_schema("4", "5", "ETHUSDT.BINANCE");
1188 let precision_2_path = ObjectPath::from("precision-2.parquet");
1189 let fallback_path = ObjectPath::from("fallback-annotated.parquet");
1190 let precision_4_path = ObjectPath::from("precision-4.parquet");
1191 let reconciled = reconcile_consolidation_schema(
1192 &precision_2,
1193 &precision_2_path,
1194 &fallback,
1195 &fallback_path,
1196 )
1197 .unwrap();
1198
1199 assert_eq!(reconciled.schema_source, ConsolidationSchemaSource::Current,);
1200 assert_eq!(reconciled.candidate_field_metadata.len(), 2);
1201 assert!(
1202 reconciled
1203 .candidate_field_metadata
1204 .contains(&("bids".to_string(), "ARROW:extension:name".to_string())),
1205 );
1206 assert!(
1207 reconciled
1208 .candidate_field_metadata
1209 .contains(&("bids".to_string(), "ARROW:extension:metadata".to_string())),
1210 );
1211 let field_metadata_sources = reconciled
1212 .candidate_field_metadata
1213 .iter()
1214 .cloned()
1215 .map(|key| (key, fallback_path.clone()))
1216 .collect();
1217 let error = reconcile_consolidation_schema_with_sources(
1218 &reconciled.schema,
1219 &precision_2_path,
1220 &field_metadata_sources,
1221 &precision_4,
1222 &precision_4_path,
1223 )
1224 .unwrap_err();
1225 let message = error.to_string();
1226
1227 assert!(message.contains("precision-2.parquet"));
1228 assert!(message.contains("precision-4.parquet"));
1229 assert!(!message.contains("fallback-annotated.parquet"));
1230 }
1231
1232 #[rstest]
1233 fn consolidation_field_metadata_tracks_each_origin() {
1234 let bare = Arc::new(Schema::new(vec![
1235 Field::new("info", DataType::Utf8, true),
1236 Field::new("balances", DataType::Utf8, true),
1237 ]));
1238
1239 let info = Arc::new(Schema::new(vec![
1240 json_string_field("info", true),
1241 Field::new("balances", DataType::Utf8, true),
1242 ]));
1243
1244 let balances = Arc::new(Schema::new(vec![
1245 json_string_field("info", true),
1246 json_string_field("balances", true),
1247 ]));
1248
1249 let conflicting = Arc::new(Schema::new(vec![
1250 Field::new("info", DataType::Utf8, true).with_metadata(HashMap::from([(
1251 "ARROW:extension:name".to_string(),
1252 "other.extension".to_string(),
1253 )])),
1254 json_string_field("balances", true),
1255 ]));
1256 let bare_path = ObjectPath::from("bare.parquet");
1257 let info_path = ObjectPath::from("info.parquet");
1258 let balances_path = ObjectPath::from("balances.parquet");
1259 let conflicting_path = ObjectPath::from("conflicting.parquet");
1260 let with_info =
1261 reconcile_consolidation_schema(&bare, &bare_path, &info, &info_path).unwrap();
1262 let mut field_metadata_sources = with_info
1263 .candidate_field_metadata
1264 .iter()
1265 .cloned()
1266 .map(|key| (key, info_path.clone()))
1267 .collect::<HashMap<_, _>>();
1268 let with_balances = reconcile_consolidation_schema_with_sources(
1269 &with_info.schema,
1270 &bare_path,
1271 &field_metadata_sources,
1272 &balances,
1273 &balances_path,
1274 )
1275 .unwrap();
1276
1277 for key in with_balances.candidate_field_metadata {
1278 field_metadata_sources.insert(key, balances_path.clone());
1279 }
1280
1281 let error = reconcile_consolidation_schema_with_sources(
1282 &with_balances.schema,
1283 &bare_path,
1284 &field_metadata_sources,
1285 &conflicting,
1286 &conflicting_path,
1287 )
1288 .unwrap_err();
1289 let message = error.to_string();
1290
1291 assert!(message.contains("info.parquet"));
1292 assert!(message.contains("conflicting.parquet"));
1293 assert!(!message.contains("balances.parquet"));
1294 }
1295
1296 #[tokio::test]
1297 async fn write_batches_to_object_store_rejects_empty_input() {
1298 let object_store = Arc::new(object_store::memory::InMemory::new());
1299
1300 let error = write_batches_to_object_store(
1301 &[],
1302 object_store,
1303 &ObjectPath::from("empty.parquet"),
1304 None,
1305 None,
1306 None,
1307 )
1308 .await
1309 .unwrap_err();
1310
1311 assert_eq!(
1312 error.to_string(),
1313 "Cannot write Parquet file empty.parquet with no record batches"
1314 );
1315 }
1316
1317 #[tokio::test]
1318 async fn default_writer_sets_zstd_sorting_and_identifier_bloom_filter() {
1319 let directory = tempfile::tempdir().unwrap();
1320 let path = directory.path().join("layout.parquet");
1321
1322 let object_store = Arc::new(
1323 object_store::local::LocalFileSystem::new_with_prefix(directory.path()).unwrap(),
1324 );
1325 let object_path = ObjectPath::from("layout.parquet");
1326
1327 let schema = Arc::new(Schema::new(vec![
1328 Field::new("identifier", DataType::Utf8, false),
1329 Field::new("ts_init", DataType::UInt64, false),
1330 ]));
1331 let batch = RecordBatch::try_new(
1332 schema,
1333 vec![
1334 Arc::new(StringArray::from(vec!["AUD/USD.SIM", "AUD/USD.SIM"])) as ArrayRef,
1335 Arc::new(UInt64Array::from(vec![1_u64, 2])) as ArrayRef,
1336 ],
1337 )
1338 .unwrap();
1339
1340 write_batches_to_object_store(&[batch], object_store, &object_path, None, None, None)
1341 .await
1342 .unwrap();
1343
1344 let read_options = ReadOptionsBuilder::new()
1345 .with_reader_properties(
1346 ReaderProperties::builder()
1347 .set_read_bloom_filter(true)
1348 .build(),
1349 )
1350 .build();
1351 let reader = SerializedFileReader::new_with_options(
1352 std::fs::File::open(path).unwrap(),
1353 read_options,
1354 )
1355 .unwrap();
1356 let row_group = reader.metadata().row_group(0);
1357 let sorting = row_group.sorting_columns().unwrap();
1358
1359 assert_eq!(crate::backend::parquet::DEFAULT_ROW_GROUP_SIZE, 131_072);
1360 assert_eq!(
1361 sorting,
1362 &vec![
1363 SortingColumn {
1364 column_idx: 1,
1365 descending: false,
1366 nulls_first: false,
1367 },
1368 SortingColumn {
1369 column_idx: 0,
1370 descending: false,
1371 nulls_first: false,
1372 },
1373 ],
1374 );
1375 assert!(
1376 row_group
1377 .columns()
1378 .iter()
1379 .all(|column| column.compression() == Compression::ZSTD(ZstdLevel::default())),
1380 );
1381 assert!(
1382 reader
1383 .get_row_group(0)
1384 .unwrap()
1385 .get_column_bloom_filter(0)
1386 .is_some(),
1387 );
1388 }
1389
1390 #[tokio::test]
1391 async fn default_writer_delta_encodes_timestamps_without_dictionary() {
1392 let directory = tempfile::tempdir().unwrap();
1393 let path = directory.path().join("timestamps.parquet");
1394
1395 let object_store = Arc::new(
1396 object_store::local::LocalFileSystem::new_with_prefix(directory.path()).unwrap(),
1397 );
1398 let object_path = ObjectPath::from("timestamps.parquet");
1399
1400 let schema = Arc::new(Schema::new(vec![
1401 Field::new("identifier", DataType::Utf8, false),
1402 Field::new("ts_event", timestamp_data_type(), false),
1403 Field::new("ts_init", timestamp_data_type(), false),
1404 ]));
1405 let identifiers = StringArray::from(vec!["AUD/USD.SIM", "AUD/USD.SIM", "AUD/USD.SIM"]);
1406 let ts_event = TimestampNanosecondArray::from(vec![
1407 1_700_000_000_123_456_001_i64,
1408 1_700_000_000_123_456_789,
1409 1_700_000_001_000_000_000,
1410 ])
1411 .with_timezone("UTC");
1412 let ts_init = TimestampNanosecondArray::from(vec![
1413 1_700_000_000_123_457_000_i64,
1414 1_700_000_000_123_458_000,
1415 1_700_000_001_000_001_000,
1416 ])
1417 .with_timezone("UTC");
1418 let batch = RecordBatch::try_new(
1419 schema,
1420 vec![
1421 Arc::new(identifiers.clone()) as ArrayRef,
1422 Arc::new(ts_event.clone()) as ArrayRef,
1423 Arc::new(ts_init.clone()) as ArrayRef,
1424 ],
1425 )
1426 .unwrap();
1427
1428 write_batches_to_object_store(&[batch], object_store, &object_path, None, None, None)
1429 .await
1430 .unwrap();
1431
1432 let reader = SerializedFileReader::new(std::fs::File::open(&path).unwrap()).unwrap();
1433 let columns = reader.metadata().row_group(0).columns();
1434 let timestamp_encodings = columns[1..]
1435 .iter()
1436 .map(|column| {
1437 (
1438 column.column_path().string(),
1439 column.dictionary_page_offset(),
1440 column.encodings().collect::<Vec<_>>(),
1441 )
1442 })
1443 .collect::<Vec<_>>();
1444 let read = ParquetRecordBatchReaderBuilder::try_new(std::fs::File::open(&path).unwrap())
1445 .unwrap()
1446 .build()
1447 .unwrap()
1448 .collect::<Result<Vec<_>, _>>()
1449 .unwrap();
1450
1451 assert!(columns[0].dictionary_page_offset().is_some());
1452 assert_eq!(
1453 timestamp_encodings,
1454 vec![
1455 (
1456 "ts_event".to_string(),
1457 None,
1458 vec![Encoding::RLE, Encoding::DELTA_BINARY_PACKED],
1459 ),
1460 (
1461 "ts_init".to_string(),
1462 None,
1463 vec![Encoding::RLE, Encoding::DELTA_BINARY_PACKED],
1464 ),
1465 ],
1466 );
1467 assert_eq!(read.len(), 1);
1468 assert_eq!(read[0].column(0).as_ref(), &identifiers as &dyn Array);
1469 assert_eq!(read[0].column(1).as_ref(), &ts_event as &dyn Array);
1470 assert_eq!(read[0].column(2).as_ref(), &ts_init as &dyn Array);
1471 }
1472
1473 #[rstest]
1474 fn test_create_object_store_from_path_local() {
1475 let temp_dir = std::env::temp_dir().join("nautilus_test");
1477 std::fs::create_dir_all(&temp_dir).unwrap();
1478
1479 let result = create_object_store_from_path(temp_dir.to_str().unwrap(), None);
1480 if let Err(e) = &result {
1481 println!("Error: {e:?}");
1482 }
1483
1484 assert!(result.is_ok());
1485 let (_, base_path, uri) = result.unwrap();
1486 assert_eq!(base_path, "");
1487 assert_eq!(uri, format!("file://{}", temp_dir.to_str().unwrap()));
1489
1490 std::fs::remove_dir_all(&temp_dir).ok();
1492 }
1493
1494 #[rstest]
1495 fn append_path_to_file_uri_extends_windows_drive_catalog() {
1496 assert_eq!(
1497 append_path_to_file_uri(
1498 "file:///C:/data/catalog",
1499 "data/quotes/EURUSD.SIM/file.parquet",
1500 ),
1501 "file:///C:/data/catalog/data/quotes/EURUSD.SIM/file.parquet",
1502 );
1503 }
1504
1505 #[rstest]
1506 #[cfg(feature = "cloud")]
1507 fn test_create_object_store_from_path_s3() {
1508 let mut options = AHashMap::new();
1509 options.insert(
1510 "endpoint_url".to_string(),
1511 "https://test.endpoint.com".to_string(),
1512 );
1513 options.insert("region".to_string(), "us-west-2".to_string());
1514 options.insert("access_key_id".to_string(), "test_key".to_string());
1515 options.insert("secret_access_key".to_string(), "test_secret".to_string());
1516
1517 let result = create_object_store_from_path("s3://test-bucket/path", Some(options));
1518 assert!(result.is_ok());
1519 let (_, base_path, uri) = result.unwrap();
1520 assert_eq!(base_path, "path");
1521 assert_eq!(uri, "s3://test-bucket/path");
1522 }
1523
1524 #[rstest]
1525 #[cfg(feature = "cloud")]
1526 fn test_create_object_store_from_path_azure() {
1527 let mut options = AHashMap::new();
1528 options.insert("account_name".to_string(), "testaccount".to_string());
1529 options.insert("account_key".to_string(), "dGVzdGtleQ==".to_string()); let result = create_object_store_from_path("az://container/path", Some(options));
1533 if let Err(e) = &result {
1534 println!("Azure Error: {e:?}");
1535 }
1536
1537 assert!(result.is_ok());
1538 let (_, base_path, uri) = result.unwrap();
1539 assert_eq!(base_path, "path");
1540 assert_eq!(uri, "az://container/path");
1541 }
1542
1543 #[rstest]
1544 #[cfg(feature = "cloud")]
1545 fn test_create_object_store_from_path_gcs() {
1546 let credentials_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1547 .join("test_data/gcs_authorized_user_credentials.json");
1548 let options = AHashMap::from([
1549 ("skip_signature".to_string(), "true".to_string()),
1550 (
1551 "application_credentials".to_string(),
1552 credentials_path.to_str().unwrap().to_string(),
1553 ),
1554 ]);
1555
1556 let (_, base_path, uri) =
1557 create_object_store_from_path("gs://test-bucket/path", Some(options)).unwrap();
1558
1559 assert_eq!(base_path, "path");
1560 assert_eq!(uri, "gs://test-bucket/path");
1561 }
1562
1563 #[rstest]
1564 #[cfg(feature = "cloud")]
1565 fn test_create_object_store_gcs_reads_application_credentials_option() {
1566 let directory = tempfile::tempdir().unwrap();
1567 let credentials_path = directory.path().join("missing-credentials.json");
1568 let credentials_path = credentials_path.to_str().unwrap();
1569 let options = AHashMap::from([(
1570 "application_credentials".to_string(),
1571 credentials_path.to_string(),
1572 )]);
1573
1574 let error = create_object_store_from_path("gs://test-bucket/path", Some(options))
1575 .err()
1576 .unwrap()
1577 .to_string();
1578
1579 assert!(
1580 error.contains(&format!(
1581 "Unable to open service account file from {credentials_path}"
1582 )),
1583 "{error}"
1584 );
1585 }
1586
1587 #[rstest]
1588 #[cfg(feature = "cloud")]
1589 #[case::s3("s3://test-bucket/path", "no_such_option", "S3")]
1590 #[case::gcs_project_id("gs://test-bucket/path", "project_id", "GCS")]
1591 #[case::azure("az://container/path", "no_such_option", "MicrosoftAzure")]
1592 #[case::abfs(
1593 "abfs://container@account.dfs.core.windows.net/path",
1594 "no_such_option",
1595 "MicrosoftAzure"
1596 )]
1597 #[case::http("https://example.com/path", "no_such_option", "HTTP")]
1598 fn test_create_object_store_rejects_unknown_storage_option(
1599 #[case] uri: &str,
1600 #[case] key: &str,
1601 #[case] store: &str,
1602 ) {
1603 let options = AHashMap::from([(key.to_string(), "value".to_string())]);
1604
1605 let error = create_object_store_from_path(uri, Some(options))
1606 .err()
1607 .unwrap()
1608 .to_string();
1609
1610 assert_eq!(
1611 error,
1612 format!("Configuration key: '{key}' is not valid for store '{store}'.")
1613 );
1614 }
1615
1616 #[rstest]
1617 #[cfg(feature = "cloud")]
1618 #[case::non_ascii("s3://test-bucket/préfix")]
1619 #[case::space("s3://test-bucket/my prefix")]
1620 #[case::tilde("s3://test-bucket/~prefix")]
1621 #[case::fragment("s3://test-bucket/pre#fix")]
1622 #[case::percent_encoded("s3://test-bucket/pr%C3%A9fix")]
1623 #[case::empty_segment("s3://test-bucket/base//path")]
1624 #[case::gcs("gs://test-bucket/préfix")]
1625 #[case::azure("az://container/préfix")]
1626 #[case::abfs("abfs://container@account.dfs.core.windows.net/préfix")]
1627 #[case::http("https://example.com/préfix")]
1628 fn test_create_object_store_rejects_base_path_that_paths_change(#[case] uri: &str) {
1629 let error = create_object_store_from_path(uri, None)
1630 .err()
1631 .unwrap()
1632 .to_string();
1633
1634 assert_eq!(
1635 error,
1636 format!(
1637 "Storage URI '{uri}' has a base path that URL parsing or object-store path \
1638 encoding changes; use a base path without spaces, non-ASCII, or reserved \
1639 characters"
1640 )
1641 );
1642 }
1643
1644 #[rstest]
1645 #[cfg(feature = "cloud")]
1646 #[case::bucket_root("s3://test-bucket", "")]
1647 #[case::trailing_slash("s3://test-bucket/nautilus-data/", "nautilus-data/")]
1648 #[case::unreserved(
1649 "s3://test-bucket/team_a/v2.catalog/year=2026",
1650 "team_a/v2.catalog/year=2026"
1651 )]
1652 fn test_create_object_store_accepts_ascii_base_path(
1653 #[case] uri: &str,
1654 #[case] expected_base_path: &str,
1655 ) {
1656 let (_, base_path, original_uri) = create_object_store_from_path(uri, None).unwrap();
1657
1658 assert_eq!(base_path, expected_base_path);
1659 assert_eq!(original_uri, uri);
1660 }
1661
1662 #[rstest]
1663 #[case::native("aws_access_key_id", "aws_secret_access_key")]
1664 #[case::short("access_key_id", "secret_access_key")]
1665 #[case::legacy("key", "secret")]
1666 #[tokio::test]
1667 #[cfg(feature = "cloud")]
1668 async fn test_create_object_store_s3_applies_credential_option_names(
1669 #[case] key_id_option: &str,
1670 #[case] secret_option: &str,
1671 ) {
1672 let (endpoint, server) = capture_request_head();
1673 let options = AHashMap::from([
1674 ("aws_endpoint".to_string(), endpoint),
1675 ("aws_allow_http".to_string(), "true".to_string()),
1676 (key_id_option.to_string(), "TESTKEYID".to_string()),
1677 (secret_option.to_string(), "test-secret".to_string()),
1678 ]);
1679 let (store, base_path, _) =
1680 create_object_store_from_path("s3://test-bucket/path", Some(options)).unwrap();
1681
1682 let result = store
1683 .head(&ObjectPath::from(format!("{base_path}/probe")))
1684 .await;
1685
1686 assert!(
1687 matches!(result, Err(object_store::Error::NotFound { .. })),
1688 "{result:?}"
1689 );
1690 let head = server.join().unwrap();
1691 assert_eq!(
1692 head.lines().next(),
1693 Some("HEAD /test-bucket/path/probe HTTP/1.1")
1694 );
1695 assert!(
1696 head.contains("authorization: AWS4-HMAC-SHA256 Credential=TESTKEYID/"),
1697 "{head}"
1698 );
1699 }
1700
1701 #[tokio::test]
1702 #[cfg(feature = "cloud")]
1703 async fn test_create_object_store_http_applies_client_option() {
1704 let (endpoint, server) = capture_request_head();
1705 let options = AHashMap::from([
1706 ("allow_http".to_string(), "true".to_string()),
1707 (
1708 "user_agent".to_string(),
1709 "nautilus-catalog-test".to_string(),
1710 ),
1711 ]);
1712 let (store, base_path, _) =
1713 create_object_store_from_path(&format!("{endpoint}/path"), Some(options)).unwrap();
1714
1715 let result = store
1716 .head(&ObjectPath::from(format!("{base_path}/probe")))
1717 .await;
1718
1719 assert!(
1720 matches!(result, Err(object_store::Error::NotFound { .. })),
1721 "{result:?}"
1722 );
1723 let head = server.join().unwrap();
1724 assert_eq!(head.lines().next(), Some("HEAD /path/probe HTTP/1.1"));
1725 assert!(
1726 head.contains("user-agent: nautilus-catalog-test\r\n"),
1727 "{head}"
1728 );
1729 }
1730
1731 #[tokio::test]
1732 #[cfg(feature = "cloud")]
1733 async fn test_create_object_store_azure_sends_sas_token_signature_encoded_once() {
1734 let (endpoint, server) = capture_request_head();
1735 let options = AHashMap::from([
1736 ("account_name".to_string(), "account".to_string()),
1737 ("azure_storage_endpoint".to_string(), endpoint),
1738 ("azure_allow_http".to_string(), "true".to_string()),
1739 (
1740 "sas_token".to_string(),
1741 "sv=2022-11-02&se=2030-01-01T00:00:00Z&sig=abc%2Bdef%3D".to_string(),
1742 ),
1743 ]);
1744 let (store, base_path, _) =
1745 create_object_store_from_path("az://container/path", Some(options)).unwrap();
1746
1747 let result = store
1748 .head(&ObjectPath::from(format!("{base_path}/probe")))
1749 .await;
1750
1751 assert!(
1752 matches!(result, Err(object_store::Error::NotFound { .. })),
1753 "{result:?}"
1754 );
1755 let head = server.join().unwrap();
1756 assert_eq!(
1757 head.lines().next(),
1758 Some(
1759 "HEAD /container/path/probe?sv=2022-11-02&se=2030-01-01T00%3A00%3A00Z\
1760 &sig=abc%2Bdef%3D HTTP/1.1"
1761 )
1762 );
1763 }
1764
1765 #[rstest]
1766 #[cfg(feature = "cloud")]
1767 fn test_create_object_store_from_path_empty_options() {
1768 let result = create_object_store_from_path("s3://test-bucket/path", None);
1769 assert!(result.is_ok());
1770 let (_, base_path, uri) = result.unwrap();
1771 assert_eq!(base_path, "path");
1772 assert_eq!(uri, "s3://test-bucket/path");
1773 }
1774
1775 #[rstest]
1776 #[cfg(feature = "cloud")]
1777 fn test_remote_store_root_url_preserves_authority() {
1778 let https_root = remote_store_root_url("https://example.com:9000/base/path").unwrap();
1779 assert_eq!(
1780 https_root.as_str().trim_end_matches('/'),
1781 "https://example.com:9000"
1782 );
1783
1784 let abfs_root =
1785 remote_store_root_url("abfs://container@account.dfs.core.windows.net/base/path")
1786 .unwrap();
1787 assert_eq!(
1788 abfs_root.as_str().trim_end_matches('/'),
1789 "abfs://container@account.dfs.core.windows.net"
1790 );
1791
1792 let full_uri = remote_full_uri(
1793 "https://example.com:9000/base/path",
1794 "base/path/data/%5E/file.parquet",
1795 )
1796 .unwrap();
1797 assert_eq!(
1798 full_uri,
1799 "https://example.com:9000/base/path/data/%5E/file.parquet"
1800 );
1801
1802 let location = create_object_store_location_from_path("s3://test-bucket/path", None)
1803 .expect("S3 location should be created");
1804 assert_eq!(location.base_path, "path");
1805 assert_eq!(
1806 remote_store_root_url(&location.original_uri)
1807 .expect("S3 should be remote")
1808 .as_str()
1809 .trim_end_matches('/'),
1810 "s3://test-bucket"
1811 );
1812 }
1813
1814 #[cfg(feature = "cloud")]
1815 fn capture_request_head() -> (String, std::thread::JoinHandle<String>) {
1816 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1817 let endpoint = format!("http://{}", listener.local_addr().unwrap());
1818 let handle = std::thread::spawn(move || {
1819 let (mut stream, _) = listener.accept().unwrap();
1820 let mut reader = std::io::BufReader::new(stream.try_clone().unwrap());
1821 let mut head = String::new();
1822 loop {
1823 let mut line = String::new();
1824 reader.read_line(&mut line).unwrap();
1825 if line.trim_end().is_empty() {
1826 break;
1827 }
1828 head.push_str(&line);
1829 }
1830 stream
1831 .write_all(
1832 b"HTTP/1.1 404 Not Found\r\ncontent-length: 0\r\nconnection: close\r\n\r\n",
1833 )
1834 .unwrap();
1835 head
1836 });
1837 (endpoint, handle)
1838 }
1839}