1#![expect(
18 clippy::missing_errors_doc,
19 reason = "DataFusion session methods forward query and Arrow errors for controlled schemas"
20)]
21
22use std::sync::Arc;
23
24use ahash::AHashSet;
25use async_trait::async_trait;
26use datafusion::{
27 arrow::{
28 array::{
29 Array, ArrayRef, BinaryViewArray, FixedSizeBinaryBuilder, FixedSizeListArray,
30 ListArray, StringArray, StringViewArray, UInt32Array, new_empty_array, new_null_array,
31 },
32 buffer::{OffsetBuffer, ScalarBuffer},
33 compute::{cast, concat, take_record_batch},
34 datatypes::{DataType, Schema, SchemaRef},
35 error::ArrowError,
36 record_batch::RecordBatch,
37 },
38 catalog::{Session, TableProvider},
39 common::{Statistics, stats::Precision},
40 datasource::{
41 file_format::{
42 FileFormat, FileMeta, file_compression_type::FileCompressionType, options::ReadOptions,
43 },
44 listing::{ListingTable, ListingTableConfig, ListingTableUrl},
45 physical_plan::{FileScanConfig, FileSource},
46 table_schema::TableSchema,
47 },
48 error::{DataFusionError, Result},
49 physical_expr::LexOrdering,
50 physical_plan::{EmptyRecordBatchStream, ExecutionPlan, SendableRecordBatchStream},
51 prelude::*,
52};
53use futures::{Stream, StreamExt, TryStreamExt};
54use nautilus_common::live::{block_on_nautilus_with, get_runtime};
55use nautilus_core::UnixNanos;
56use nautilus_serialization::arrow::{KEY_IDENTIFIER, StringColumnRef};
57use object_store::{ObjectMeta, ObjectStore};
58use tokio::{
59 sync::mpsc::{self, Receiver},
60 task::JoinHandle,
61};
62use url::Url;
63
64use crate::common::{arrow::validate_catalog_schema, storage::StorageBackend};
65
66const BATCH_STREAM_BUFFER: usize = 4;
71
72pub(crate) struct BlockingBatchStream<T> {
73 receiver: Receiver<T>,
74 task: JoinHandle<()>,
75}
76
77impl<T> BlockingBatchStream<T> {
78 pub(crate) fn from_stream_with_runtime<S>(stream: S, runtime: &tokio::runtime::Handle) -> Self
79 where
80 S: Stream<Item = T> + Send + 'static,
81 T: Send + 'static,
82 {
83 let (sender, receiver) = mpsc::channel(BATCH_STREAM_BUFFER);
84
85 let task = runtime.spawn(async move {
86 futures::pin_mut!(stream);
87
88 while let Some(item) = stream.next().await {
89 if sender.send(item).await.is_err() {
90 break;
91 }
92 }
93 });
94
95 Self { receiver, task }
96 }
97}
98
99impl<T: Send> Iterator for BlockingBatchStream<T> {
100 type Item = T;
101
102 fn next(&mut self) -> Option<Self::Item> {
103 block_on_nautilus_with(|| self.receiver.recv())
104 }
105}
106
107impl<T> Drop for BlockingBatchStream<T> {
108 fn drop(&mut self) {
109 self.receiver.close();
110 self.task.abort();
111 }
112}
113
114pub struct DataBackendSession {
116 pub chunk_size: usize,
117 pub runtime: tokio::runtime::Handle,
118 pub(crate) session_ctx: SessionContext,
119 pub(crate) registered_tables: AHashSet<String>,
120}
121
122impl DataBackendSession {
123 #[must_use]
125 pub fn new(chunk_size: usize) -> Self {
126 let runtime = get_runtime().handle().clone();
127 let session_ctx = SessionContext::new_with_config(session_config());
128
129 Self {
130 chunk_size,
131 runtime,
132 session_ctx,
133 registered_tables: AHashSet::new(),
134 }
135 }
136
137 pub fn register_object_store(&mut self, url: &Url, object_store: Arc<dyn ObjectStore>) {
139 self.session_ctx.register_object_store(url, object_store);
140 }
141
142 pub fn register_storage_backend(&mut self, storage: &StorageBackend) -> anyhow::Result<()> {
151 let root_url = storage.datafusion_root_url()?;
152 self.register_object_store(&root_url, storage.object_store.clone());
153 Ok(())
154 }
155
156 pub fn register_table_provider(
161 &mut self,
162 table_name: &str,
163 provider: Arc<dyn TableProvider>,
164 ) -> Result<()> {
165 if !self.registered_tables.contains(table_name) {
166 self.session_ctx.register_table(table_name, provider)?;
167 self.registered_tables.insert(table_name.to_string());
168 }
169
170 Ok(())
171 }
172
173 pub(crate) fn collect_parquet_files_batches(
174 &mut self,
175 table_name: &str,
176 file_paths: Vec<String>,
177 sql_query: Option<&str>,
178 ) -> anyhow::Result<Vec<RecordBatch>> {
179 if file_paths.is_empty() {
180 return Ok(Vec::new());
181 }
182
183 let batch_stream = self.parquet_files_batch_stream(table_name, file_paths, sql_query)?;
184 let schema = batch_stream.schema();
185 let mut batches = block_on_nautilus_with(|| batch_stream.try_collect::<Vec<_>>())?;
186 if batches.is_empty() {
187 batches.push(RecordBatch::new_empty(schema));
188 }
189
190 Ok(batches)
191 }
192
193 pub(crate) fn parquet_files_batch_stream(
194 &mut self,
195 table_name: &str,
196 file_paths: Vec<String>,
197 sql_query: Option<&str>,
198 ) -> anyhow::Result<SendableRecordBatchStream> {
199 if file_paths.is_empty() {
200 return Ok(Box::pin(EmptyRecordBatchStream::new(Arc::new(
201 Schema::empty(),
202 ))));
203 }
204
205 self.register_parquet_files_table(table_name, file_paths)?;
206 Ok(self.execute_registered(table_name, sql_query)?)
207 }
208
209 pub(crate) fn try_register_parquet_files_table(
212 &mut self,
213 table_name: &str,
214 file_paths: Vec<String>,
215 ) -> anyhow::Result<bool> {
216 match self.register_parquet_files_table(table_name, file_paths) {
217 Err(e) if is_schema_merge_error(&e) => Ok(false),
218 result => result.map(|()| true),
219 }
220 }
221
222 fn register_parquet_files_table(
223 &mut self,
224 table_name: &str,
225 file_paths: Vec<String>,
226 ) -> anyhow::Result<()> {
227 if self.registered_tables.contains(table_name) {
228 return Ok(());
229 }
230
231 let parquet_options = ParquetReadOptions::<'_> {
234 skip_metadata: Some(false),
235 file_sort_order: vec![vec![col("ts_init").sort(true, false)]],
236 ..Default::default()
237 };
238
239 let mut listing_options = parquet_options.to_listing_options(
240 &self.session_ctx.copied_config(),
241 self.session_ctx.copied_table_options(),
242 );
243 listing_options.format = Arc::new(CatalogParquetFormat(listing_options.format));
244 let table_paths = file_paths
245 .into_iter()
246 .map(ListingTableUrl::parse)
247 .collect::<Result<Vec<_>>>()?;
248 let state = self.session_ctx.state();
249 let config = block_on_nautilus_with(|| {
250 ListingTableConfig::new_with_multi_paths(table_paths)
251 .with_listing_options(listing_options)
252 .infer_schema(&state)
253 })?;
254 let table = ListingTable::try_new(config)?.with_cache(
255 self.session_ctx
256 .runtime_env()
257 .cache_manager
258 .get_file_statistic_cache(),
259 );
260 let dataframe = self.session_ctx.read_table(Arc::new(table))?;
261
262 validate_catalog_schema(dataframe.schema().as_arrow())?;
263 self.session_ctx
264 .register_table(table_name, dataframe.into_view())?;
265 self.registered_tables.insert(table_name.to_string());
266 Ok(())
267 }
268
269 pub(crate) fn execute_registered(
270 &self,
271 table_name: &str,
272 sql_query: Option<&str>,
273 ) -> Result<SendableRecordBatchStream> {
274 let default_query = format!("SELECT * FROM {table_name} ORDER BY ts_init");
275 let sql_query = sql_query.unwrap_or(&default_query);
276 let query = block_on_nautilus_with(|| self.session_ctx.sql(sql_query))?;
277 block_on_nautilus_with(|| query.execute_stream())
278 }
279
280 pub fn clear_registered_tables(&mut self) {
285 self.registered_tables.clear();
286
287 self.session_ctx = SessionContext::new_with_config(session_config());
289 }
290}
291
292fn is_schema_merge_error(error: &anyhow::Error) -> bool {
293 error.chain().any(|cause| {
294 matches!(
295 cause.downcast_ref::<ArrowError>(),
296 Some(ArrowError::SchemaError(_))
297 )
298 })
299}
300
301pub(crate) fn session_config() -> SessionConfig {
302 SessionConfig::new()
303 .set_str("datafusion.optimizer.repartition_file_scans", "false")
304 .set_str(
306 "datafusion.optimizer.enable_round_robin_repartition",
307 "false",
308 )
309 .set_str("datafusion.optimizer.prefer_existing_sort", "true")
310}
311
312#[derive(Debug)]
317struct CatalogParquetFormat(Arc<dyn FileFormat>);
318
319#[async_trait]
320impl FileFormat for CatalogParquetFormat {
321 fn get_ext(&self) -> String {
322 self.0.get_ext()
323 }
324
325 fn get_ext_with_compression(&self, compression: &FileCompressionType) -> Result<String> {
326 self.0.get_ext_with_compression(compression)
327 }
328
329 fn compression_type(&self) -> Option<FileCompressionType> {
330 self.0.compression_type()
331 }
332
333 async fn infer_schema(
334 &self,
335 state: &dyn Session,
336 store: &Arc<dyn ObjectStore>,
337 objects: &[ObjectMeta],
338 ) -> Result<SchemaRef> {
339 self.0.infer_schema(state, store, objects).await
340 }
341
342 async fn infer_stats(
343 &self,
344 state: &dyn Session,
345 store: &Arc<dyn ObjectStore>,
346 table_schema: SchemaRef,
347 object: &ObjectMeta,
348 ) -> Result<Statistics> {
349 let statistics = self
350 .0
351 .infer_stats(state, store, Arc::clone(&table_schema), object)
352 .await?;
353 Ok(statistics_without_float_bounds(statistics, &table_schema))
354 }
355
356 async fn infer_ordering(
357 &self,
358 state: &dyn Session,
359 store: &Arc<dyn ObjectStore>,
360 table_schema: SchemaRef,
361 object: &ObjectMeta,
362 ) -> Result<Option<LexOrdering>> {
363 self.0
364 .infer_ordering(state, store, table_schema, object)
365 .await
366 }
367
368 async fn infer_stats_and_ordering(
369 &self,
370 state: &dyn Session,
371 store: &Arc<dyn ObjectStore>,
372 table_schema: SchemaRef,
373 object: &ObjectMeta,
374 ) -> Result<FileMeta> {
375 let meta = self
376 .0
377 .infer_stats_and_ordering(state, store, Arc::clone(&table_schema), object)
378 .await?;
379 let statistics = statistics_without_float_bounds(meta.statistics, &table_schema);
380 Ok(FileMeta::new(statistics).with_ordering(meta.ordering))
381 }
382
383 async fn create_physical_plan(
384 &self,
385 state: &dyn Session,
386 conf: FileScanConfig,
387 ) -> Result<Arc<dyn ExecutionPlan>> {
388 self.0.create_physical_plan(state, conf).await
389 }
390
391 fn file_source(&self, table_schema: TableSchema) -> Arc<dyn FileSource> {
392 self.0.file_source(table_schema)
393 }
394}
395
396fn statistics_without_float_bounds(mut statistics: Statistics, schema: &Schema) -> Statistics {
397 for (column, field) in statistics.column_statistics.iter_mut().zip(schema.fields()) {
398 if field.data_type().is_floating() {
399 column.min_value = Precision::Absent;
400 column.max_value = Precision::Absent;
401 }
402 }
403 statistics
404}
405
406pub(crate) fn cast_record_batch_to_schema(
407 batch: &RecordBatch,
408 schema: &Schema,
409) -> Result<RecordBatch> {
410 let batch_schema = batch.schema();
411 let mut fields = Vec::with_capacity(batch_schema.fields().len());
412 let mut columns = Vec::with_capacity(batch.columns().len());
413
414 for (column, batch_field) in batch.columns().iter().zip(batch_schema.fields()) {
415 let field = schema
416 .field_with_name(batch_field.name())
417 .unwrap_or(batch_field.as_ref());
418
419 fields.push(Arc::new(field.clone()));
420 columns.push(cast_column_to_data_type(column, field.data_type())?);
421 }
422
423 let schema = Arc::new(Schema::new_with_metadata(fields, schema.metadata().clone()));
424 Ok(RecordBatch::try_new(schema, columns)?)
425}
426
427fn cast_column_to_data_type(column: &ArrayRef, data_type: &DataType) -> Result<ArrayRef> {
428 if column.data_type() == data_type {
429 return Ok(column.clone());
430 }
431
432 if let (DataType::BinaryView, DataType::FixedSizeBinary(width)) =
433 (column.data_type(), data_type)
434 {
435 let array = column
436 .as_any()
437 .downcast_ref::<BinaryViewArray>()
438 .expect("BinaryView column should downcast to BinaryViewArray");
439 let mut builder = FixedSizeBinaryBuilder::with_capacity(array.len(), *width);
440 for row in 0..array.len() {
441 if array.is_null(row) {
442 builder.append_null();
443 } else {
444 builder.append_value(array.value(row))?;
445 }
446 }
447
448 return Ok(Arc::new(builder.finish()));
449 }
450
451 if let (DataType::FixedSizeList(_, size), DataType::List(field)) =
452 (column.data_type(), data_type)
453 {
454 let array = column
455 .as_any()
456 .downcast_ref::<FixedSizeListArray>()
457 .expect("FixedSizeList column should downcast to FixedSizeListArray");
458
459 let size = usize::try_from(*size).map_err(|e| {
460 DataFusionError::Execution(format!("Invalid fixed-size list length {size}: {e}"))
461 })?;
462
463 let mut parts = Vec::with_capacity(array.len());
464 let mut offsets = Vec::with_capacity(array.len() + 1);
465 offsets.push(0_i32);
466
467 for row in 0..array.len() {
468 if !array.is_null(row) {
469 let value = array.value(row);
470 parts.push(cast_column_to_data_type(&value, field.data_type())?);
471 }
472
473 let offset = parts
474 .len()
475 .checked_mul(size)
476 .and_then(|offset| i32::try_from(offset).ok())
477 .ok_or_else(|| {
478 DataFusionError::Execution(
479 "List offset exceeds the supported i32 range".to_string(),
480 )
481 })?;
482
483 offsets.push(offset);
484 }
485
486 let values = concat_list_values(&parts, field.data_type())?;
487 let offsets = OffsetBuffer::new(ScalarBuffer::from(offsets));
488 return Ok(Arc::new(ListArray::try_new(
489 field.clone(),
490 offsets,
491 values,
492 array.nulls().cloned(),
493 )?));
494 }
495
496 if let (DataType::List(_), DataType::FixedSizeList(field, size)) =
497 (column.data_type(), data_type)
498 {
499 let array = column
500 .as_any()
501 .downcast_ref::<ListArray>()
502 .expect("List column should downcast to ListArray");
503
504 let size_usize = usize::try_from(*size).map_err(|e| {
505 DataFusionError::Execution(format!("Invalid fixed-size list length {size}: {e}"))
506 })?;
507
508 let mut parts = Vec::with_capacity(array.len());
509 for row in 0..array.len() {
510 if array.is_null(row) {
511 parts.push(new_null_array(field.data_type(), size_usize));
512 continue;
513 }
514
515 let value = array.value(row);
516 if value.len() != size_usize {
517 return Err(DataFusionError::Execution(format!(
518 "List row {row} has length {}, expected {size}",
519 value.len(),
520 )));
521 }
522
523 parts.push(cast_column_to_data_type(&value, field.data_type())?);
524 }
525
526 let values = concat_list_values(&parts, field.data_type())?;
527 return Ok(Arc::new(FixedSizeListArray::try_new(
528 field.clone(),
529 *size,
530 values,
531 array.nulls().cloned(),
532 )?));
533 }
534
535 Ok(cast(column, data_type)?)
536}
537
538fn concat_list_values(parts: &[ArrayRef], data_type: &DataType) -> Result<ArrayRef> {
539 if parts.is_empty() {
540 return Ok(new_empty_array(data_type));
541 }
542
543 let parts = parts.iter().map(AsRef::as_ref).collect::<Vec<_>>();
544 Ok(concat(&parts)?)
545}
546
547#[must_use]
548pub fn build_query(
549 table: &str,
550 start: Option<UnixNanos>,
551 end: Option<UnixNanos>,
552 where_clause: Option<&str>,
553) -> String {
554 let filter = where_sql(start, end, where_clause);
555 format!("SELECT * FROM {table}{filter} ORDER BY ts_init")
556}
557
558#[must_use]
559pub fn build_identifier_query(
560 table: &str,
561 start: Option<UnixNanos>,
562 end: Option<UnixNanos>,
563 where_clause: Option<&str>,
564) -> String {
565 let filter = where_sql(start, end, where_clause);
566 format!("SELECT DISTINCT identifier FROM {table}{filter} ORDER BY identifier")
567}
568
569fn where_sql(
570 start: Option<UnixNanos>,
571 end: Option<UnixNanos>,
572 where_clause: Option<&str>,
573) -> String {
574 let mut conditions = Vec::new();
575
576 if let Some(clause) = where_clause {
577 conditions.push(format!("({clause})"));
580 }
581
582 if let Some(start_ts) = start {
583 conditions.push(format!("CAST(ts_init AS BIGINT) >= {start_ts}"));
584 }
585
586 if let Some(end_ts) = end {
587 conditions.push(format!("CAST(ts_init AS BIGINT) <= {end_ts}"));
588 }
589
590 if conditions.is_empty() {
591 return String::new();
592 }
593
594 format!(" WHERE {}", conditions.join(" AND "))
595}
596
597pub fn identifiers_from_record_batches(batches: &[RecordBatch]) -> anyhow::Result<Vec<String>> {
598 let mut identifiers = AHashSet::new();
599
600 for batch in batches {
601 let column = batch
602 .column_by_name("identifier")
603 .ok_or_else(|| anyhow::anyhow!("identifier column not found"))?
604 .as_any();
605
606 if let Some(array) = column.downcast_ref::<StringArray>() {
607 for row in 0..array.len() {
608 if !array.is_null(row) {
609 identifiers.insert(array.value(row).to_string());
610 }
611 }
612
613 continue;
614 }
615
616 if let Some(array) = column.downcast_ref::<StringViewArray>() {
617 for row in 0..array.len() {
618 if !array.is_null(row) {
619 identifiers.insert(array.value(row).to_string());
620 }
621 }
622
623 continue;
624 }
625
626 anyhow::bail!("identifier column must be Utf8 or Utf8View");
627 }
628
629 let mut identifiers = identifiers.into_iter().collect::<Vec<_>>();
630 identifiers.sort();
631 Ok(identifiers)
632}
633
634pub(crate) fn filter_record_batch_by_identifier(
639 batch: &RecordBatch,
640 fallback_identifier: Option<&str>,
641 matches: impl Fn(Option<&str>) -> bool,
642) -> anyhow::Result<Option<RecordBatch>> {
643 let Some(column) = batch.column_by_name(KEY_IDENTIFIER) else {
644 return Ok(matches(fallback_identifier).then(|| batch.clone()));
645 };
646
647 let identifiers = StringColumnRef::try_from_array(column.as_ref())
648 .ok_or_else(|| anyhow::anyhow!("Identifier column must be an Arrow string type"))?;
649
650 let indices = (0..batch.num_rows())
651 .filter_map(|row| {
652 let identifier = (!identifiers.is_null(row))
653 .then(|| identifiers.value(row))
654 .or(fallback_identifier);
655 matches(identifier).then(|| u32::try_from(row))
656 })
657 .collect::<Result<Vec<_>, _>>()?;
658
659 if indices.is_empty() {
660 return Ok(None);
661 }
662
663 if indices.len() == batch.num_rows() {
664 return Ok(Some(batch.clone()));
665 }
666
667 take_record_batch(batch, &UInt32Array::from(indices))
668 .map(Some)
669 .map_err(|e| anyhow::anyhow!("Failed to filter catalog batch by identifier: {e}"))
670}
671
672#[cfg(test)]
673mod tests {
674 use std::{
675 fs::File,
676 ops::Range,
677 path::Path,
678 sync::{Arc, mpsc},
679 time::Duration,
680 };
681
682 use datafusion::{
683 arrow::{
684 array::{AsArray, BooleanArray, Decimal128Array, FixedSizeBinaryArray, UInt32Array},
685 buffer::NullBuffer,
686 compute::{concat_batches, filter_record_batch},
687 datatypes::{Field, TimestampNanosecondType},
688 },
689 datasource::MemTable,
690 execution::object_store::ObjectStoreUrl,
691 physical_plan::displayable,
692 };
693 use nautilus_testkit::common::get_test_data_file_path;
694 use parquet::{
695 arrow::{ArrowWriter, arrow_reader::ParquetRecordBatchReaderBuilder},
696 file::{metadata::SortingColumn, properties::WriterProperties},
697 };
698 use rstest::rstest;
699 use tempfile::TempDir;
700
701 use super::*;
702 use crate::common::storage::create_storage_backend_from_path;
703
704 #[rstest]
705 fn register_storage_backend_accepts_memory_backend() {
706 let storage = create_storage_backend_from_path("memory://", None).unwrap();
707 let mut session = DataBackendSession::new(10);
708
709 session.register_storage_backend(&storage).unwrap();
710
711 assert_registered_object_store(&session, &storage);
712 }
713
714 #[rstest]
715 fn register_storage_backend_accepts_local_backend() {
716 let temp_dir = TempDir::new().unwrap();
717 let storage =
718 create_storage_backend_from_path(temp_dir.path().to_str().unwrap(), None).unwrap();
719 let mut session = DataBackendSession::new(10);
720
721 session.register_storage_backend(&storage).unwrap();
722
723 assert_registered_object_store(&session, &storage);
724 }
725
726 fn assert_registered_object_store(session: &DataBackendSession, storage: &StorageBackend) {
727 let root_url = ObjectStoreUrl::parse(storage.datafusion_root_url().unwrap()).unwrap();
728 let registered = session
729 .session_ctx
730 .runtime_env()
731 .object_store(root_url)
732 .unwrap();
733
734 assert!(std::ptr::addr_eq(
735 Arc::as_ptr(®istered),
736 Arc::as_ptr(&storage.object_store),
737 ));
738 }
739
740 fn memory_table(column: &str) -> Arc<dyn TableProvider> {
741 let schema = Arc::new(Schema::new(vec![Field::new(column, DataType::Utf8, true)]));
742 Arc::new(MemTable::try_new(schema, vec![Vec::new()]).unwrap())
743 }
744
745 #[rstest]
746 fn register_table_provider_keeps_first_registration() {
747 let mut session = DataBackendSession::new(10);
748
749 session
750 .register_table_provider("records", memory_table("first"))
751 .unwrap();
752 session
753 .register_table_provider("records", memory_table("second"))
754 .unwrap();
755 let provider =
756 futures::executor::block_on(session.session_ctx.table_provider("records")).unwrap();
757
758 assert_eq!(provider.schema().field(0).name(), "first");
759 }
760
761 #[rstest]
762 fn session_config_keeps_file_scan_order() {
763 let config = session_config();
764 let optimizer = &config.options().optimizer;
765
766 assert!(!optimizer.repartition_file_scans);
767 assert!(!optimizer.enable_round_robin_repartition);
768 assert!(optimizer.prefer_existing_sort);
769 }
770
771 #[rstest]
772 fn identifiers_from_record_batches_reads_utf8_view_columns() {
773 let schema = Arc::new(Schema::new(vec![Field::new(
774 "identifier",
775 DataType::Utf8View,
776 true,
777 )]));
778 let batch = RecordBatch::try_new(
779 schema,
780 vec![Arc::new(StringViewArray::from(vec![
781 Some("ESZ4.XCME"),
782 None,
783 Some("ESM4.XCME"),
784 ])) as ArrayRef],
785 )
786 .unwrap();
787
788 let identifiers = identifiers_from_record_batches(&[batch]).unwrap();
789
790 assert_eq!(identifiers, vec!["ESM4.XCME", "ESZ4.XCME"]);
791 }
792
793 #[rstest]
794 #[case::missing_column("instrument_id", DataType::Utf8, "identifier column not found")]
795 #[case::unsupported_type(
796 "identifier",
797 DataType::Int32,
798 "identifier column must be Utf8 or Utf8View"
799 )]
800 fn identifiers_from_record_batches_rejects_invalid_columns(
801 #[case] name: &str,
802 #[case] data_type: DataType,
803 #[case] expected: &str,
804 ) {
805 let column = new_null_array(&data_type, 1);
806 let schema = Arc::new(Schema::new(vec![Field::new(name, data_type, true)]));
807 let batch = RecordBatch::try_new(schema, vec![column]).unwrap();
808
809 let error = identifiers_from_record_batches(&[batch]).unwrap_err();
810
811 assert_eq!(error.to_string(), expected);
812 }
813
814 #[rstest]
815 fn binary_view_cast_to_fixed_size_binary_preserves_nulls() {
816 let values = vec![Some(b"ab".as_slice()), None, Some(b"cd".as_slice())];
817 let column = Arc::new(BinaryViewArray::from(values.clone())) as ArrayRef;
818
819 let cast = cast_column_to_data_type(&column, &DataType::FixedSizeBinary(2)).unwrap();
820
821 let expected =
822 FixedSizeBinaryArray::try_from_sparse_iter_with_size(values.into_iter(), 2).unwrap();
823 assert_eq!(cast.to_data(), expected.to_data());
824 }
825
826 #[rstest]
827 fn list_cast_to_fixed_size_list_rejects_wrong_row_length() {
828 let field = Arc::new(Field::new("element", DataType::UInt32, false));
829 let list = Arc::new(
830 ListArray::try_new(
831 field.clone(),
832 OffsetBuffer::new(ScalarBuffer::from(vec![0_i32, 3])),
833 Arc::new(UInt32Array::from(vec![1_u32, 2, 3])),
834 None,
835 )
836 .unwrap(),
837 ) as ArrayRef;
838
839 let error =
840 cast_column_to_data_type(&list, &DataType::FixedSizeList(field, 2)).unwrap_err();
841
842 assert_eq!(
843 error.to_string(),
844 "Execution error: List row 0 has length 3, expected 2"
845 );
846 }
847
848 struct DropSignal(mpsc::Sender<()>);
849
850 impl Drop for DropSignal {
851 fn drop(&mut self) {
852 let _ = self.0.send(());
853 }
854 }
855
856 #[rstest]
857 fn blocking_batch_stream_drop_stops_producer() {
858 let runtime = tokio::runtime::Runtime::new().unwrap();
859 let (dropped_sender, dropped_receiver) = mpsc::channel();
860 let signal = DropSignal(dropped_sender);
861
862 let stream = futures::stream::pending::<i32>().chain(futures::stream::once(async move {
863 drop(signal);
864 0
865 }));
866
867 let stream = BlockingBatchStream::from_stream_with_runtime(stream, runtime.handle());
868
869 drop(stream);
870
871 assert_eq!(
872 dropped_receiver.recv_timeout(Duration::from_secs(5)),
873 Ok(())
874 );
875 }
876
877 #[rstest]
878 fn build_identifier_query_projects_distinct_identifier_with_filters() {
879 let query = build_identifier_query(
880 "quotes",
881 Some(UnixNanos::from(10)),
882 Some(UnixNanos::from(20)),
883 Some("instrument_id LIKE 'ES%'"),
884 );
885
886 assert_eq!(
887 query,
888 "SELECT DISTINCT identifier FROM quotes WHERE (instrument_id LIKE 'ES%') \
889 AND CAST(ts_init AS BIGINT) >= 10 AND CAST(ts_init AS BIGINT) <= 20 ORDER BY identifier"
890 );
891 }
892
893 #[rstest]
894 fn build_query_groups_a_disjunctive_where_clause_against_timestamp_bounds() {
895 let query = build_query(
896 "bars",
897 Some(UnixNanos::from(10)),
898 Some(UnixNanos::from(20)),
899 Some("identifier = 'ES.GLBX' OR starts_with(identifier, 'ES.GLBX-')"),
900 );
901
902 assert_eq!(
903 query,
904 "SELECT * FROM bars WHERE (identifier = 'ES.GLBX' OR starts_with(identifier, 'ES.GLBX-')) \
905 AND CAST(ts_init AS BIGINT) >= 10 AND CAST(ts_init AS BIGINT) <= 20 ORDER BY ts_init"
906 );
907 }
908
909 #[rstest]
910 fn build_query_without_bounds_keeps_the_where_clause_alone() {
911 let query = build_query("bars", None, None, Some("identifier = 'ES.GLBX'"));
912
913 assert_eq!(
914 query,
915 "SELECT * FROM bars WHERE (identifier = 'ES.GLBX') ORDER BY ts_init"
916 );
917 }
918
919 #[rstest]
920 fn build_query_without_conditions_omits_the_where_keyword() {
921 let query = build_query("bars", None, None, None);
922
923 assert_eq!(query, "SELECT * FROM bars ORDER BY ts_init");
924 }
925
926 #[rstest]
927 fn identifiers_from_record_batches_returns_sorted_unique_non_null_values() {
928 let schema = Arc::new(Schema::new(vec![Field::new(
929 "identifier",
930 DataType::Utf8,
931 true,
932 )]));
933 let batch = RecordBatch::try_new(
934 schema,
935 vec![Arc::new(StringArray::from(vec![
936 Some("ESZ4.XCME"),
937 None,
938 Some("ESM4.XCME"),
939 Some("ESZ4.XCME"),
940 ])) as ArrayRef],
941 )
942 .unwrap();
943
944 let identifiers = identifiers_from_record_batches(&[batch]).unwrap();
945
946 assert_eq!(identifiers, vec!["ESM4.XCME", "ESZ4.XCME"]);
947 }
948
949 #[rstest]
950 fn fixed_size_list_cast_round_trips_through_delta_list_type() {
951 let field = Arc::new(Field::new("element", DataType::Decimal128(38, 16), true));
952 let values = Decimal128Array::from(vec![1_i128, 2, 3, 4])
953 .with_precision_and_scale(38, 16)
954 .unwrap();
955 let fixed_type = DataType::FixedSizeList(field.clone(), 2);
956 let fixed = Arc::new(
957 FixedSizeListArray::try_new(field.clone(), 2, Arc::new(values), None).unwrap(),
958 ) as ArrayRef;
959
960 let list_type = DataType::List(field);
961 let list = cast_column_to_data_type(&fixed, &list_type).unwrap();
962 let restored = cast_column_to_data_type(&list, &fixed_type).unwrap();
963
964 assert_eq!(list.data_type(), &list_type);
965 assert_eq!(restored.data_type(), &fixed_type);
966 assert_eq!(restored.to_data(), fixed.to_data());
967
968 let field = Arc::new(Field::new("element", DataType::UInt32, false));
969 let fixed = Arc::new(
970 FixedSizeListArray::try_new(
971 field.clone(),
972 2,
973 Arc::new(UInt32Array::from(vec![1_u32, 2])),
974 Some(NullBuffer::from(vec![false])),
975 )
976 .unwrap(),
977 ) as ArrayRef;
978 let list_type = DataType::List(field);
979 let list = cast_column_to_data_type(&fixed, &list_type).unwrap();
980 let list = list.as_any().downcast_ref::<ListArray>().unwrap();
981
982 assert!(list.is_null(0));
983 assert_eq!(list.values().len(), 0);
984 }
985
986 #[rstest]
987 fn data_backend_sessions_share_global_runtime() {
988 let first = DataBackendSession::new(10);
989 let second = DataBackendSession::new(10);
990
991 assert_eq!(first.runtime.id(), second.runtime.id());
992 assert_eq!(first.runtime.id(), get_runtime().handle().id());
993 }
994
995 #[rstest]
996 fn blocking_batch_stream_prefetches_first_item() {
997 let runtime = tokio::runtime::Runtime::new().unwrap();
998 let (polled_sender, polled_receiver) = mpsc::channel();
999
1000 let stream = futures::stream::once(async move {
1001 polled_sender.send(()).unwrap();
1002 42
1003 });
1004
1005 let mut stream = BlockingBatchStream::from_stream_with_runtime(stream, runtime.handle());
1006
1007 assert_eq!(polled_receiver.recv_timeout(Duration::from_secs(1)), Ok(()),);
1008 assert_eq!(stream.next(), Some(42));
1009 }
1010
1011 #[rstest]
1012 fn blocking_batch_stream_works_inside_current_thread_runtime() {
1013 let runtime = tokio::runtime::Builder::new_current_thread()
1014 .enable_all()
1015 .build()
1016 .unwrap();
1017
1018 runtime.block_on(async {
1019 let mut stream = BlockingBatchStream::from_stream_with_runtime(
1020 futures::stream::iter([42]),
1021 get_runtime().handle(),
1022 );
1023
1024 assert_eq!(stream.next(), Some(42));
1025 });
1026 }
1027
1028 const FILTER_QUERY_FIXTURE: &str = "nautilus/arrow/quotes-3-groups-filter-query.parquet";
1030 const FILTER_QUERY_START: i64 = 1_701_388_832_486_000_000;
1031
1032 #[derive(Clone, Copy, Debug)]
1033 enum Registration {
1034 File,
1035 Directory,
1036 DisjointFiles,
1037 }
1038
1039 #[rstest]
1040 fn query_preserves_equal_timestamp_order(
1041 #[values(
1042 Registration::File,
1043 Registration::Directory,
1044 Registration::DisjointFiles
1045 )]
1046 registration: Registration,
1047 #[values(false, true)] sorting_metadata: bool,
1048 #[values(false, true)] filtered: bool,
1049 #[values(1, 2, 4, 8)] partitions: usize,
1050 ) {
1051 let (fixture, row_groups) = read_filter_query_fixture();
1052 let temp_dir = TempDir::new().unwrap();
1053 let file_count = match registration {
1054 Registration::File | Registration::Directory => 1,
1055 Registration::DisjointFiles => 3,
1056 };
1057
1058 for (index, rows) in disjoint_file_ranges(&fixture, file_count)
1059 .into_iter()
1060 .enumerate()
1061 {
1062 let path = temp_dir.path().join(format!("part-{index}.parquet"));
1063 write_quotes(&path, &fixture, rows, &row_groups, sorting_metadata);
1064 }
1065
1066 let path = match registration {
1068 Registration::File => temp_dir.path().join("part-0.parquet"),
1069 Registration::Directory | Registration::DisjointFiles => temp_dir.path().join(""),
1070 };
1071 let path = path.to_str().unwrap().to_string();
1072
1073 let start = filtered.then(|| UnixNanos::from(FILTER_QUERY_START as u64));
1074 let expected = if filtered {
1075 let ts_init = fixture
1076 .column_by_name("ts_init")
1077 .unwrap()
1078 .as_primitive::<TimestampNanosecondType>();
1079 let mask = ts_init
1080 .values()
1081 .iter()
1082 .map(|value| Some(*value >= FILTER_QUERY_START))
1083 .collect::<BooleanArray>();
1084 filter_record_batch(&fixture, &mask).unwrap()
1085 } else {
1086 fixture
1087 };
1088
1089 let mut session = DataBackendSession::new(10);
1090 session.session_ctx =
1091 SessionContext::new_with_config(session_config().with_target_partitions(partitions));
1092 let query = build_query("quotes", start, None, None);
1093 let batches = session
1094 .collect_parquet_files_batches("quotes", vec![path], Some(&query))
1095 .unwrap();
1096 let actual = concat_batches(&batches[0].schema(), &batches).unwrap();
1097 let actual = cast_record_batch_to_schema(&actual, &expected.schema()).unwrap();
1098 let first_mismatch = (0..expected.num_rows().min(actual.num_rows()))
1099 .find(|&row| actual.slice(row, 1) != expected.slice(row, 1));
1100
1101 assert_eq!(expected.num_rows(), if filtered { 10_001 } else { 15_000 });
1102 assert_eq!(actual.num_rows(), expected.num_rows());
1103 assert_eq!(first_mismatch, None);
1104 }
1105
1106 #[rstest]
1109 fn query_of_disjoint_files_keeps_ordered_scan(#[values(1, 2, 8)] partitions: usize) {
1110 let (fixture, row_groups) = read_filter_query_fixture();
1111 let temp_dir = TempDir::new().unwrap();
1112
1113 for (index, rows) in disjoint_file_ranges(&fixture, 3).into_iter().enumerate() {
1114 let path = temp_dir.path().join(format!("part-{index}.parquet"));
1115 write_quotes(&path, &fixture, rows, &row_groups, true);
1116 }
1117 let path = temp_dir.path().join("").to_str().unwrap().to_string();
1118 let mut session = DataBackendSession::new(10);
1119 session.session_ctx =
1120 SessionContext::new_with_config(session_config().with_target_partitions(partitions));
1121 session
1122 .register_parquet_files_table("quotes", vec![path])
1123 .unwrap();
1124 let query = build_query("quotes", None, None, None);
1125 let plan = block_on_nautilus_with(|| async {
1126 session
1127 .session_ctx
1128 .sql(&query)
1129 .await?
1130 .create_physical_plan()
1131 .await
1132 })
1133 .unwrap();
1134 let plan = displayable(plan.as_ref()).indent(true).to_string();
1135
1136 assert!(!plan.contains("SortExec"), "{plan}");
1137 assert!(plan.contains("output_ordering=[ts_init@"), "{plan}");
1138 }
1139
1140 fn read_filter_query_fixture() -> (RecordBatch, Vec<usize>) {
1141 let path = get_test_data_file_path(FILTER_QUERY_FIXTURE);
1142 let builder = ParquetRecordBatchReaderBuilder::try_new(File::open(path).unwrap()).unwrap();
1143 let row_groups = builder
1144 .metadata()
1145 .row_groups()
1146 .iter()
1147 .map(|row_group| usize::try_from(row_group.num_rows()).unwrap())
1148 .collect();
1149 let schema = builder.schema().clone();
1150 let batches = builder
1151 .build()
1152 .unwrap()
1153 .collect::<std::result::Result<Vec<_>, _>>()
1154 .unwrap();
1155
1156 (concat_batches(&schema, &batches).unwrap(), row_groups)
1157 }
1158
1159 fn disjoint_file_ranges(batch: &RecordBatch, count: usize) -> Vec<Range<usize>> {
1161 let ts_init = batch
1162 .column_by_name("ts_init")
1163 .unwrap()
1164 .as_primitive::<TimestampNanosecondType>()
1165 .values();
1166 let mut ranges = Vec::with_capacity(count);
1167 let mut start = 0;
1168
1169 for index in 1..count {
1170 let mut end = (batch.num_rows() * index / count).max(start + 1);
1171 while end < batch.num_rows() && ts_init[end] == ts_init[end - 1] {
1172 end += 1;
1173 }
1174 ranges.push(start..end);
1175 start = end;
1176 }
1177
1178 ranges.push(start..batch.num_rows());
1179 ranges
1180 }
1181
1182 fn write_quotes(
1185 path: &Path,
1186 batch: &RecordBatch,
1187 rows: Range<usize>,
1188 row_groups: &[usize],
1189 sorting_metadata: bool,
1190 ) {
1191 let sorting_columns = sorting_metadata.then(|| {
1192 vec![SortingColumn {
1193 column_idx: i32::try_from(batch.schema().index_of("ts_init").unwrap()).unwrap(),
1194 descending: false,
1195 nulls_first: false,
1196 }]
1197 });
1198 let properties = WriterProperties::builder()
1199 .set_sorting_columns(sorting_columns)
1200 .build();
1201 let mut writer = ArrowWriter::try_new(
1202 File::create(path).unwrap(),
1203 batch.schema(),
1204 Some(properties),
1205 )
1206 .unwrap();
1207 let mut boundary = 0;
1208
1209 for row_group in row_groups {
1210 let group = boundary..boundary + row_group;
1211 boundary = group.end;
1212 let start = group.start.max(rows.start);
1213 let end = group.end.min(rows.end);
1214 if start < end {
1215 writer.write(&batch.slice(start, end - start)).unwrap();
1216 writer.flush().unwrap();
1217 }
1218 }
1219
1220 writer.close().unwrap();
1221 }
1222}