Skip to main content

nautilus_persistence/backend/parquet/
io.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#![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
55/// Reads only the Arrow schema (including key/value metadata) of a Parquet object.
56///
57/// Avoids decoding any record batches; use when only schema metadata is needed.
58///
59/// # Errors
60///
61/// Returns an error if the object cannot be fetched or its footer cannot be parsed.
62pub 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
76/// Reads a Parquet file from an object store and returns all record batches plus
77/// the Arrow schema from the builder. The builder's schema includes metadata restored
78/// from the file's `ARROW:schema` `key_value_metadata`; use it for decoding instead of
79/// each batch's schema (which has metadata stripped).
80///
81/// # Errors
82///
83/// Returns an error if the path cannot be read or Parquet parsing fails.
84pub 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
100/// Writes multiple `RecordBatch` items to an object store URI, with optional compression,
101/// row group sizing, and `key_value_metadata` (e.g. for instrument `type_name` so it survives roundtrip).
102///
103/// # Errors
104///
105/// Returns an error if `batches` is empty, writing to Parquet fails, or any I/O operation fails.
106pub 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    // Create a temporary buffer to write the parquet data
160    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    // Delta encoding stores near-unique timestamps more compactly than a dictionary
178    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    // Upload the buffer to object store
196    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
248/// Deduplicates a slice of `RecordBatch` items, removing rows that are identical across all columns.
249///
250/// Rows are compared by encoding each row to a canonical byte sequence using Arrow's row format.
251/// Only the first occurrence of each unique row is retained; the relative order of unique rows
252/// is preserved.
253///
254/// # Errors
255///
256/// Returns an error if the row converter cannot be constructed or if the `take` kernel fails.
257fn 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
298/// Combines multiple Parquet files from object store
299///
300/// # Errors
301///
302/// Returns an error if file reading or writing fails.
303pub 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    // Read all files from object store
321    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    // Re-apply the preserved schema metadata to all collected batches so that
359    // write_batches_to_object_store (which uses batches[0].schema()) can encode
360    // the correct Arrow schema metadata into the combined output file.
361    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    // Deduplicate rows if requested
369    let batches_to_write = if deduplicate.unwrap_or(false) {
370        deduplicate_record_batches(&all_batches)?
371    } else {
372        all_batches
373    };
374
375    // Write combined batches to new location
376    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    // Remove the merged files
387    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
521/// Extracts the minimum and maximum i64 values for the specified `column_name` from a Parquet file's metadata in object store.
522///
523/// # Errors
524///
525/// Returns an error if the file cannot be read, metadata parsing fails, or the column is missing or has no statistics.
526pub 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    // Download the parquet file from object store
532    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    // Iterate through all row groups
540    for i in 0..metadata.num_row_groups() {
541        let row_group = metadata.row_group(i);
542
543        // Iterate through all columns in this row group
544        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                    // Check if we have Int64 statistics
550                    if let Statistics::Int64(int64_stats) = stats {
551                        // Extract min value if available
552                        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                        // Extract max value if available
560                        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    // Return the min/max pair if both are available
579    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
588/// Creates an object store from a URI string with optional storage options.
589///
590/// Supports multiple cloud storage providers:
591/// - AWS S3: `s3://bucket/path`
592/// - Google Cloud Storage: `gs://bucket/path` or `gcs://bucket/path`
593/// - Azure Blob Storage: `az://account/container/path` or `abfs://container@account.dfs.core.windows.net/path`
594/// - HTTP/WebDAV: `http://` or `https://`
595/// - Local files: `file://path` or plain paths
596///
597/// # Parameters
598///
599/// - `path`: The URI string for the storage location.
600/// - `storage_options`: Optional `HashMap` of `object_store` configuration keys for the scheme,
601///   under their prefixed or short names (`aws_region` or `region`):
602///   - For S3: `endpoint_url`, region, `access_key_id`, `secret_access_key`, `session_token`, etc.
603///     The legacy `key` and `secret` names are also accepted.
604///   - For GCS: `service_account_path`, `service_account_key`, `application_credentials`, etc.
605///   - For Azure: `account_name`, `account_key`, `sas_token`, etc.
606///   - For HTTP: client options such as `timeout`.
607///
608/// Returns a tuple of (`ObjectStore`, `base_path`, `normalized_uri`)
609///
610/// # Errors
611///
612/// Returns an error if the URI is invalid, a storage option key is unknown for the scheme, or the
613/// object store cannot be built.
614pub 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// `storage_options` is only consumed by the cloud-feature arms,
627// so keep the allow scoped to the no-cloud build.
628#[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), // Fallback: assume local path
666    }?;
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
702/// Appends an encoded object-store path to a local or remote storage URI.
703/// Preserve the encoded names used by the native object-store backend.
704pub(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
725/// Decodes a percent-encoded `object_store` path segment back to its logical form.
726///
727/// `object_store` lists path segments in URL-encoded form, so a non-ASCII instrument
728/// directory reads back with each non-ASCII byte as a `%XX` sequence. Decoding recovers the
729/// original id for matching against `urisafe_instrument_id`. Returns the input unchanged when
730/// it is not valid percent-encoded UTF-8.
731pub(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/// Helper function to create S3 object store with options.
757#[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    // Apply storage options if provided
768    if let Some(options) = storage_options {
769        for (key, value) in options {
770            // The `object_store` parser lacks the legacy fsspec `key` and `secret` names
771            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/// Helper function to create GCS object store with options.
785#[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    // Apply storage options if provided
796    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/// Helper function to create Azure object store with options.
808#[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    // Apply storage options if provided
820    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/// Helper function to create Azure object store from abfs:// URI with options.
829#[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    // Extract account from host (account.dfs.core.windows.net)
838    let account = host
839        .split('.')
840        .next()
841        .ok_or_else(|| anyhow::anyhow!("Invalid ABFS URI: cannot extract account from host"))?;
842
843    // Extract container from username part
844    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    // Apply storage options if provided (same as Azure store)
855    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/// Applies shared Azure storage options to the builder for the `az` and `abfs` schemes.
864#[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/// Helper function to create HTTP object store with options.
877#[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    // Apply storage options if provided
891    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/// Parses a remote storage URI into its URL and object-store base path.
902///
903/// Catalog listings return keys under the encoded base path while lookups use the path as
904/// written, so a base path that URL parsing or object-store path encoding changes is rejected.
905#[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/// Helper function to extract host from URL with error handling.
924#[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(&current, &current_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(&current, &current_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        // Create a temporary directory for testing
1476        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        // The URI should be normalized to file:// format
1488        assert_eq!(uri, format!("file://{}", temp_dir.to_str().unwrap()));
1489
1490        // Clean up
1491        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        // Use a valid base64 encoded key for testing
1530        options.insert("account_key".to_string(), "dGVzdGtleQ==".to_string()); // "testkey" in base64
1531
1532        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}