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 datafusion::{
26 arrow::{
27 array::{
28 Array, ArrayRef, BinaryViewArray, FixedSizeBinaryBuilder, FixedSizeListArray,
29 ListArray, StringArray, StringViewArray, new_empty_array, new_null_array,
30 },
31 buffer::{OffsetBuffer, ScalarBuffer},
32 compute::{cast, concat},
33 datatypes::{DataType, Field, Schema},
34 record_batch::RecordBatch,
35 },
36 catalog::TableProvider,
37 error::{DataFusionError, Result},
38 physical_plan::{EmptyRecordBatchStream, SendableRecordBatchStream},
39 prelude::*,
40};
41use futures::{Stream, StreamExt, TryStreamExt};
42use nautilus_common::live::{block_on_nautilus_with, get_runtime};
43use nautilus_core::UnixNanos;
44use object_store::ObjectStore;
45use tokio::{
46 sync::mpsc::{self, Receiver},
47 task::JoinHandle,
48};
49use url::Url;
50
51use crate::common::{arrow::validate_catalog_schema, storage::StorageBackend};
52
53const BATCH_STREAM_BUFFER: usize = 4;
58
59pub(crate) struct BlockingBatchStream<T> {
60 receiver: Receiver<T>,
61 task: JoinHandle<()>,
62}
63
64impl<T> BlockingBatchStream<T> {
65 pub(crate) fn from_stream_with_runtime<S>(stream: S, runtime: &tokio::runtime::Handle) -> Self
66 where
67 S: Stream<Item = T> + Send + 'static,
68 T: Send + 'static,
69 {
70 let (sender, receiver) = mpsc::channel(BATCH_STREAM_BUFFER);
71
72 let task = runtime.spawn(async move {
73 futures::pin_mut!(stream);
74 while let Some(item) = stream.next().await {
75 if sender.send(item).await.is_err() {
76 break;
77 }
78 }
79 });
80
81 Self { receiver, task }
82 }
83}
84
85impl<T: Send> Iterator for BlockingBatchStream<T> {
86 type Item = T;
87
88 fn next(&mut self) -> Option<Self::Item> {
89 block_on_nautilus_with(|| self.receiver.recv())
90 }
91}
92
93impl<T> Drop for BlockingBatchStream<T> {
94 fn drop(&mut self) {
95 self.receiver.close();
96 self.task.abort();
97 }
98}
99
100pub struct DataBackendSession {
102 pub chunk_size: usize,
103 pub runtime: tokio::runtime::Handle,
104 pub(crate) session_ctx: SessionContext,
105 pub(crate) registered_tables: AHashSet<String>,
106}
107
108impl DataBackendSession {
109 #[must_use]
111 pub fn new(chunk_size: usize) -> Self {
112 let session_ctx = SessionContext::new_with_config(session_config());
113 Self {
114 session_ctx,
115 chunk_size,
116 runtime: get_runtime().handle().clone(),
117 registered_tables: AHashSet::new(),
118 }
119 }
120
121 pub fn register_object_store(&mut self, url: &Url, object_store: Arc<dyn ObjectStore>) {
123 self.session_ctx.register_object_store(url, object_store);
124 }
125
126 pub fn register_storage_backend(&mut self, storage: &StorageBackend) -> anyhow::Result<()> {
135 let root_url = storage.datafusion_root_url()?;
136 self.register_object_store(&root_url, storage.object_store.clone());
137 Ok(())
138 }
139
140 pub fn register_table_provider(
145 &mut self,
146 table_name: &str,
147 provider: Arc<dyn TableProvider>,
148 ) -> Result<()> {
149 if !self.registered_tables.contains(table_name) {
150 self.session_ctx.register_table(table_name, provider)?;
151 self.registered_tables.insert(table_name.to_string());
152 }
153
154 Ok(())
155 }
156
157 pub(crate) fn collect_parquet_files_batches(
158 &mut self,
159 table_name: &str,
160 file_paths: Vec<String>,
161 sql_query: Option<&str>,
162 ) -> anyhow::Result<Vec<RecordBatch>> {
163 if file_paths.is_empty() {
164 return Ok(Vec::new());
165 }
166
167 let batch_stream = self.parquet_files_batch_stream(table_name, file_paths, sql_query)?;
168 let schema = batch_stream.schema();
169 let mut batches = block_on_nautilus_with(|| batch_stream.try_collect::<Vec<_>>())?;
170 if batches.is_empty() {
171 batches.push(RecordBatch::new_empty(schema));
172 }
173 Ok(batches)
174 }
175
176 pub(crate) fn parquet_files_batch_stream(
177 &mut self,
178 table_name: &str,
179 file_paths: Vec<String>,
180 sql_query: Option<&str>,
181 ) -> anyhow::Result<SendableRecordBatchStream> {
182 if file_paths.is_empty() {
183 return Ok(Box::pin(EmptyRecordBatchStream::new(Arc::new(
184 Schema::empty(),
185 ))));
186 }
187
188 self.register_parquet_files_table(table_name, file_paths)?;
189 Ok(self.execute_registered(table_name, sql_query)?)
190 }
191
192 fn register_parquet_files_table(
193 &mut self,
194 table_name: &str,
195 file_paths: Vec<String>,
196 ) -> anyhow::Result<()> {
197 if !self.registered_tables.contains(table_name) {
198 let parquet_options = ParquetReadOptions::<'_> {
199 skip_metadata: Some(false),
200 ..Default::default()
201 };
202 let dataframe = block_on_nautilus_with(|| {
203 self.session_ctx.read_parquet(file_paths, parquet_options)
204 })?;
205 validate_catalog_schema(dataframe.schema().as_arrow())?;
206 self.session_ctx
207 .register_table(table_name, dataframe.into_view())?;
208 self.registered_tables.insert(table_name.to_string());
209 }
210
211 Ok(())
212 }
213
214 pub(crate) fn execute_registered(
215 &self,
216 table_name: &str,
217 sql_query: Option<&str>,
218 ) -> Result<SendableRecordBatchStream> {
219 let default_query = format!("SELECT * FROM {table_name} ORDER BY ts_init");
220 let sql_query = sql_query.unwrap_or(&default_query);
221 let query = block_on_nautilus_with(|| self.session_ctx.sql(sql_query))?;
222 block_on_nautilus_with(|| query.execute_stream())
223 }
224
225 pub fn clear_registered_tables(&mut self) {
230 self.registered_tables.clear();
231
232 self.session_ctx = SessionContext::new_with_config(session_config());
234 }
235}
236
237fn session_config() -> SessionConfig {
238 SessionConfig::new()
239 .set_str("datafusion.optimizer.repartition_file_scans", "false")
240 .set_str("datafusion.optimizer.prefer_existing_sort", "true")
241}
242
243pub(crate) fn cast_record_batch_to_schema(
244 batch: &RecordBatch,
245 schema: &Schema,
246) -> Result<RecordBatch> {
247 let batch_schema = batch.schema();
248 let mut fields = Vec::with_capacity(batch_schema.fields().len());
249 let mut columns = Vec::with_capacity(batch.columns().len());
250
251 for (column, batch_field) in batch.columns().iter().zip(batch_schema.fields()) {
252 let field = schema
253 .field_with_name(batch_field.name())
254 .unwrap_or(batch_field.as_ref());
255
256 fields.push(Arc::new(field.clone()));
257 columns.push(cast_column_to_field(column, field)?);
258 }
259
260 let schema = Arc::new(Schema::new_with_metadata(fields, schema.metadata().clone()));
261 Ok(RecordBatch::try_new(schema, columns)?)
262}
263
264fn cast_column_to_field(column: &ArrayRef, field: &Field) -> Result<ArrayRef> {
265 cast_column_to_data_type(column, field.data_type())
266}
267
268#[expect(
269 clippy::too_many_lines,
270 reason = "the function keeps recursive Arrow cast rules in one exhaustive type dispatcher"
271)]
272pub(crate) fn cast_column_to_data_type(
273 column: &ArrayRef,
274 data_type: &DataType,
275) -> Result<ArrayRef> {
276 if column.data_type() == data_type {
277 return Ok(column.clone());
278 }
279
280 if let (DataType::BinaryView, DataType::FixedSizeBinary(width)) =
281 (column.data_type(), data_type)
282 {
283 let array = column
284 .as_any()
285 .downcast_ref::<BinaryViewArray>()
286 .expect("BinaryView column should downcast to BinaryViewArray");
287 let mut builder = FixedSizeBinaryBuilder::with_capacity(array.len(), *width);
288 for row in 0..array.len() {
289 if array.is_null(row) {
290 builder.append_null();
291 } else {
292 builder.append_value(array.value(row))?;
293 }
294 }
295 return Ok(Arc::new(builder.finish()));
296 }
297
298 if let (DataType::FixedSizeList(_, size), DataType::List(field)) =
299 (column.data_type(), data_type)
300 {
301 let array = column
302 .as_any()
303 .downcast_ref::<FixedSizeListArray>()
304 .expect("FixedSizeList column should downcast to FixedSizeListArray");
305 let size = usize::try_from(*size).map_err(|e| {
306 DataFusionError::Execution(format!("Invalid fixed-size list length {size}: {e}"))
307 })?;
308 let mut parts = Vec::with_capacity(array.len());
309 let mut offsets = Vec::with_capacity(array.len() + 1);
310 offsets.push(0_i32);
311
312 for row in 0..array.len() {
313 if !array.is_null(row) {
314 let value = array.value(row);
315 parts.push(cast_column_to_data_type(&value, field.data_type())?);
316 }
317 let offset = parts
318 .len()
319 .checked_mul(size)
320 .and_then(|offset| i32::try_from(offset).ok())
321 .ok_or_else(|| {
322 DataFusionError::Execution(
323 "List offset exceeds the supported i32 range".to_string(),
324 )
325 })?;
326 offsets.push(offset);
327 }
328 let values = if parts.is_empty() {
329 new_empty_array(field.data_type())
330 } else {
331 let parts = parts
332 .iter()
333 .map(std::convert::AsRef::as_ref)
334 .collect::<Vec<_>>();
335 concat(&parts)?
336 };
337 let offsets = OffsetBuffer::new(ScalarBuffer::from(offsets));
338 return Ok(Arc::new(ListArray::try_new(
339 field.clone(),
340 offsets,
341 values,
342 array.nulls().cloned(),
343 )?));
344 }
345
346 if let (DataType::List(_), DataType::FixedSizeList(field, size)) =
347 (column.data_type(), data_type)
348 {
349 let array = column
350 .as_any()
351 .downcast_ref::<ListArray>()
352 .expect("List column should downcast to ListArray");
353 let size_usize = usize::try_from(*size).map_err(|e| {
354 DataFusionError::Execution(format!("Invalid fixed-size list length {size}: {e}"))
355 })?;
356 let mut parts = Vec::with_capacity(array.len());
357 for row in 0..array.len() {
358 if array.is_null(row) {
359 parts.push(new_null_array(field.data_type(), size_usize));
360 continue;
361 }
362 let value = array.value(row);
363 if value.len() != size_usize {
364 return Err(DataFusionError::Execution(format!(
365 "List row {row} has length {}, expected {size}",
366 value.len(),
367 )));
368 }
369 parts.push(cast_column_to_data_type(&value, field.data_type())?);
370 }
371 let values = if parts.is_empty() {
372 new_empty_array(field.data_type())
373 } else {
374 let parts = parts
375 .iter()
376 .map(std::convert::AsRef::as_ref)
377 .collect::<Vec<_>>();
378 concat(&parts)?
379 };
380 return Ok(Arc::new(FixedSizeListArray::try_new(
381 field.clone(),
382 *size,
383 values,
384 array.nulls().cloned(),
385 )?));
386 }
387
388 Ok(cast(column, data_type)?)
389}
390
391#[must_use]
392pub fn build_query(
393 table: &str,
394 start: Option<UnixNanos>,
395 end: Option<UnixNanos>,
396 where_clause: Option<&str>,
397) -> String {
398 let conditions = query_conditions(start, end, where_clause);
399 let mut query = format!("SELECT * FROM {table}");
400
401 if !conditions.is_empty() {
402 query.push_str(" WHERE ");
403 query.push_str(&conditions.join(" AND "));
404 }
405
406 query.push_str(" ORDER BY ts_init");
407 query
408}
409
410#[must_use]
411pub fn build_identifier_query(
412 table: &str,
413 start: Option<UnixNanos>,
414 end: Option<UnixNanos>,
415 where_clause: Option<&str>,
416) -> String {
417 let conditions = query_conditions(start, end, where_clause);
418 let mut query = format!("SELECT DISTINCT identifier FROM {table}");
419
420 if !conditions.is_empty() {
421 query.push_str(" WHERE ");
422 query.push_str(&conditions.join(" AND "));
423 }
424
425 query.push_str(" ORDER BY identifier");
426 query
427}
428
429fn query_conditions(
430 start: Option<UnixNanos>,
431 end: Option<UnixNanos>,
432 where_clause: Option<&str>,
433) -> Vec<String> {
434 let mut conditions = Vec::new();
435
436 if let Some(clause) = where_clause {
437 conditions.push(format!("({clause})"));
440 }
441
442 if let Some(start_ts) = start {
443 conditions.push(format!("CAST(ts_init AS BIGINT) >= {start_ts}"));
444 }
445
446 if let Some(end_ts) = end {
447 conditions.push(format!("CAST(ts_init AS BIGINT) <= {end_ts}"));
448 }
449
450 conditions
451}
452
453pub fn identifiers_from_record_batches(batches: &[RecordBatch]) -> anyhow::Result<Vec<String>> {
454 let mut identifiers = AHashSet::new();
455
456 for batch in batches {
457 let column = batch
458 .column_by_name("identifier")
459 .ok_or_else(|| anyhow::anyhow!("identifier column not found"))?
460 .as_any();
461
462 if let Some(array) = column.downcast_ref::<StringArray>() {
463 for row in 0..array.len() {
464 if !array.is_null(row) {
465 identifiers.insert(array.value(row).to_string());
466 }
467 }
468 continue;
469 }
470
471 if let Some(array) = column.downcast_ref::<StringViewArray>() {
472 for row in 0..array.len() {
473 if !array.is_null(row) {
474 identifiers.insert(array.value(row).to_string());
475 }
476 }
477 continue;
478 }
479
480 anyhow::bail!("identifier column must be Utf8 or Utf8View");
481 }
482
483 let mut identifiers = identifiers.into_iter().collect::<Vec<_>>();
484 identifiers.sort();
485 Ok(identifiers)
486}
487
488#[cfg(test)]
489mod tests {
490 use std::{
491 sync::{Arc, mpsc},
492 time::Duration,
493 };
494
495 use nautilus_common::live::get_runtime;
496 use nautilus_model::{
497 data::{DataBatch, QuoteTick},
498 identifiers::InstrumentId,
499 types::{Price, Quantity},
500 };
501 use rstest::rstest;
502 use tempfile::TempDir;
503
504 use super::*;
505 use crate::{
506 catalog::session::{DataBatchQuery, TypedDataBatchSession},
507 common::storage::create_storage_backend_from_path,
508 };
509
510 fn typed_quote(ts_init: u64) -> QuoteTick {
511 QuoteTick::new(
512 InstrumentId::from("AUD/USD.SIM"),
513 Price::from("1.0"),
514 Price::from("1.1"),
515 Quantity::from("1000"),
516 Quantity::from("1000"),
517 UnixNanos::from(ts_init),
518 UnixNanos::from(ts_init),
519 )
520 }
521
522 fn batch_ts(batch: &DataBatch) -> Vec<u64> {
523 match batch {
524 DataBatch::Quote(quotes) => quotes
525 .as_ref()
526 .iter()
527 .map(|quote| quote.ts_init.as_u64())
528 .collect(),
529 other => panic!("expected quote batch, found {other:?}"),
530 }
531 }
532
533 #[rstest]
534 fn typed_session_chunks_pages_with_carry_across_pulls() {
535 let pages: Vec<anyhow::Result<Vec<QuoteTick>>> = vec![
536 Ok(vec![typed_quote(1), typed_quote(2), typed_quote(3)]),
537 Ok(Vec::new()),
538 Ok(vec![typed_quote(4), typed_quote(5)]),
539 ];
540 let mut session = TypedDataBatchSession::new(Box::new(pages.into_iter()), Some(2));
541
542 assert_eq!(batch_ts(&session.next_batch().unwrap().unwrap()), [1, 2]);
543 assert_eq!(batch_ts(&session.next_batch().unwrap().unwrap()), [3, 4]);
544 assert_eq!(batch_ts(&session.next_batch().unwrap().unwrap()), [5]);
545 assert!(session.next_batch().unwrap().is_none());
546 }
547
548 #[rstest]
549 fn typed_session_extends_chunk_across_equal_boundary_ts() {
550 let data = vec![
551 typed_quote(1),
552 typed_quote(2),
553 typed_quote(2),
554 typed_quote(2),
555 typed_quote(3),
556 ];
557 let mut session = TypedDataBatchSession::from_vec(data, Some(2));
558
559 assert_eq!(
560 batch_ts(&session.next_batch().unwrap().unwrap()),
561 [1, 2, 2, 2]
562 );
563 assert_eq!(batch_ts(&session.next_batch().unwrap().unwrap()), [3]);
564 assert!(session.next_batch().unwrap().is_none());
565 }
566
567 #[rstest]
568 fn typed_session_empty_source_yields_none() {
569 let mut session = TypedDataBatchSession::<QuoteTick>::from_vec(Vec::new(), None);
570
571 assert!(session.next_batch().unwrap().is_none());
572 }
573
574 #[rstest]
575 fn typed_session_propagates_page_error() {
576 let pages: Vec<anyhow::Result<Vec<QuoteTick>>> = vec![
577 Ok(vec![typed_quote(1)]),
578 Err(anyhow::anyhow!("page failed")),
579 ];
580 let mut session = TypedDataBatchSession::new(Box::new(pages.into_iter()), Some(4));
581
582 assert_eq!(session.next_batch().unwrap_err().to_string(), "page failed");
583 }
584
585 #[rstest]
586 fn register_storage_backend_accepts_memory_backend() {
587 let storage = create_storage_backend_from_path("memory://", None).unwrap();
588 let mut session = DataBackendSession::new(10);
589
590 session.register_storage_backend(&storage).unwrap();
591 }
592
593 #[rstest]
594 fn register_storage_backend_accepts_local_backend() {
595 let temp_dir = TempDir::new().unwrap();
596 let storage =
597 create_storage_backend_from_path(temp_dir.path().to_str().unwrap(), None).unwrap();
598 let mut session = DataBackendSession::new(10);
599
600 session.register_storage_backend(&storage).unwrap();
601 }
602
603 #[rstest]
604 fn build_identifier_query_projects_distinct_identifier_with_filters() {
605 let query = build_identifier_query(
606 "quotes",
607 Some(UnixNanos::from(10)),
608 Some(UnixNanos::from(20)),
609 Some("instrument_id LIKE 'ES%'"),
610 );
611
612 assert_eq!(
613 query,
614 "SELECT DISTINCT identifier FROM quotes WHERE (instrument_id LIKE 'ES%') \
615 AND CAST(ts_init AS BIGINT) >= 10 AND CAST(ts_init AS BIGINT) <= 20 ORDER BY identifier"
616 );
617 }
618
619 #[rstest]
620 fn build_query_groups_a_disjunctive_where_clause_against_timestamp_bounds() {
621 let query = build_query(
622 "bars",
623 Some(UnixNanos::from(10)),
624 Some(UnixNanos::from(20)),
625 Some("identifier = 'ES.GLBX' OR starts_with(identifier, 'ES.GLBX-')"),
626 );
627
628 assert_eq!(
629 query,
630 "SELECT * FROM bars WHERE (identifier = 'ES.GLBX' OR starts_with(identifier, 'ES.GLBX-')) \
631 AND CAST(ts_init AS BIGINT) >= 10 AND CAST(ts_init AS BIGINT) <= 20 ORDER BY ts_init"
632 );
633 }
634
635 #[rstest]
636 fn build_query_without_bounds_keeps_the_where_clause_alone() {
637 let query = build_query("bars", None, None, Some("identifier = 'ES.GLBX'"));
638
639 assert_eq!(
640 query,
641 "SELECT * FROM bars WHERE (identifier = 'ES.GLBX') ORDER BY ts_init"
642 );
643 }
644
645 #[rstest]
646 fn build_query_without_conditions_omits_the_where_keyword() {
647 let query = build_query("bars", None, None, None);
648
649 assert_eq!(query, "SELECT * FROM bars ORDER BY ts_init");
650 }
651
652 #[rstest]
653 fn identifiers_from_record_batches_returns_sorted_unique_non_null_values() {
654 let schema = Arc::new(Schema::new(vec![Field::new(
655 "identifier",
656 DataType::Utf8,
657 true,
658 )]));
659 let batch = RecordBatch::try_new(
660 schema,
661 vec![Arc::new(StringArray::from(vec![
662 Some("ESZ4.XCME"),
663 None,
664 Some("ESM4.XCME"),
665 Some("ESZ4.XCME"),
666 ])) as ArrayRef],
667 )
668 .unwrap();
669
670 let identifiers = identifiers_from_record_batches(&[batch]).unwrap();
671
672 assert_eq!(identifiers, vec!["ESM4.XCME", "ESZ4.XCME"]);
673 }
674
675 #[rstest]
676 fn fixed_size_list_cast_round_trips_through_delta_list_type() {
677 use arrow::{
678 array::{Decimal128Array, UInt32Array},
679 buffer::NullBuffer,
680 };
681
682 let field = Arc::new(Field::new("element", DataType::Decimal128(38, 16), true));
683 let values = Decimal128Array::from(vec![1_i128, 2, 3, 4])
684 .with_precision_and_scale(38, 16)
685 .unwrap();
686 let fixed_type = DataType::FixedSizeList(field.clone(), 2);
687 let fixed = Arc::new(
688 FixedSizeListArray::try_new(field.clone(), 2, Arc::new(values), None).unwrap(),
689 ) as ArrayRef;
690
691 let list_type = DataType::List(field);
692 let list = cast_column_to_data_type(&fixed, &list_type).unwrap();
693 let restored = cast_column_to_data_type(&list, &fixed_type).unwrap();
694
695 assert_eq!(list.data_type(), &list_type);
696 assert_eq!(restored.data_type(), &fixed_type);
697 assert_eq!(restored.to_data(), fixed.to_data());
698
699 let field = Arc::new(Field::new("element", DataType::UInt32, false));
700 let fixed = Arc::new(
701 FixedSizeListArray::try_new(
702 field.clone(),
703 2,
704 Arc::new(UInt32Array::from(vec![1_u32, 2])),
705 Some(NullBuffer::from(vec![false])),
706 )
707 .unwrap(),
708 ) as ArrayRef;
709 let list_type = DataType::List(field);
710 let list = cast_column_to_data_type(&fixed, &list_type).unwrap();
711 let list = list.as_any().downcast_ref::<ListArray>().unwrap();
712
713 assert!(list.is_null(0));
714 assert_eq!(list.values().len(), 0);
715 }
716
717 #[rstest]
718 fn data_backend_sessions_share_global_runtime() {
719 let first = DataBackendSession::new(10);
720 let second = DataBackendSession::new(10);
721
722 assert_eq!(first.runtime.id(), second.runtime.id());
723 assert_eq!(first.runtime.id(), get_runtime().handle().id());
724 }
725
726 #[rstest]
727 fn blocking_batch_stream_prefetches_first_item() {
728 let runtime = tokio::runtime::Runtime::new().unwrap();
729 let (polled_sender, polled_receiver) = mpsc::channel();
730 let stream = futures::stream::once(async move {
731 polled_sender.send(()).unwrap();
732 42
733 });
734 let mut stream = BlockingBatchStream::from_stream_with_runtime(stream, runtime.handle());
735
736 assert_eq!(polled_receiver.recv_timeout(Duration::from_secs(1)), Ok(()),);
737 assert_eq!(stream.next(), Some(42));
738 }
739
740 #[rstest]
741 fn blocking_batch_stream_works_inside_current_thread_runtime() {
742 let runtime = tokio::runtime::Builder::new_current_thread()
743 .enable_all()
744 .build()
745 .unwrap();
746
747 runtime.block_on(async {
748 let mut stream = BlockingBatchStream::from_stream_with_runtime(
749 futures::stream::iter([42]),
750 get_runtime().handle(),
751 );
752
753 assert_eq!(stream.next(), Some(42));
754 });
755 }
756}