1#![expect(
17 clippy::missing_errors_doc,
18 reason = "Feather writer public methods forward encoding and object-store errors directly"
19)]
20
21use std::{
22 any::Any,
23 cell::RefCell,
24 collections::{BTreeMap, HashMap, HashSet},
25 fmt::Debug,
26 rc::Rc,
27 sync::{
28 Arc,
29 atomic::{AtomicU64, Ordering},
30 },
31};
32
33use ahash::AHashMap;
34use datafusion::arrow::{
35 array::StringArray,
36 datatypes::{DataType, Field, Schema},
37 error::ArrowError,
38 ipc::writer::StreamWriter,
39 record_batch::RecordBatch,
40};
41use jiff::{
42 SignedDuration,
43 civil::Time,
44 tz::{AmbiguousOffset, TimeZone},
45};
46use nautilus_common::{
47 clock::Clock,
48 live::{LiveClock, block_on_nautilus_with},
49};
50use nautilus_core::{UnixNanos, time::nanos_since_unix_epoch};
51use nautilus_model::{
52 data::{
53 Bar, CustomData, CustomDataTrait, Data, DataBatch, FundingRateUpdate, IndexPriceUpdate,
54 InstrumentStatus, MarkPriceUpdate, OptionGreeks, OrderBookDelta, OrderBookDeltas,
55 OrderBookDepth, QuoteTick, TradeTick, close::InstrumentClose, encode_custom_to_arrow,
56 get_arrow_schema,
57 },
58 events::{
59 AccountState, OrderAccepted, OrderCancelRejected, OrderCanceled, OrderDenied,
60 OrderEmulated, OrderEventAny, OrderExpired, OrderFillVoided, OrderFilled, OrderInitialized,
61 OrderModifyRejected, OrderPendingCancel, OrderPendingUpdate, OrderRejected, OrderReleased,
62 OrderSnapshot, OrderSubmitted, OrderTriggered, OrderUpdated, PositionAdjusted,
63 PositionChanged, PositionClosed, PositionEvent, PositionOpened, PositionSnapshot,
64 },
65 instruments::InstrumentAny,
66 reports::{ExecutionMassStatus, FillReport, OrderStatusReport, PositionStatusReport},
67};
68use nautilus_serialization::arrow::{
69 EncodeToRecordBatch, KEY_INSTRUMENT_ID, catalog_identifier_from_metadata,
70 record_batch_with_identifier_column, schema_with_identifier_column,
71};
72use object_store::{ObjectStore, ObjectStoreExt, path::Path};
73
74use crate::{
75 common::{
76 custom::{
77 augment_batch_with_data_type_column, schema_with_data_type_column,
78 validate_custom_catalog_schema,
79 },
80 paths::{CatalogPathPrefix, urisafe_instrument_id},
81 },
82 writer::{
83 filter::WriterRecordFilter,
84 subscription::StreamingSinkSubscription,
85 traits::{StreamingDataSink, StreamingSink},
86 },
87};
88
89pub(crate) type FeatherWriteCommand =
90 Box<dyn FnOnce(&mut FeatherWriter) -> Result<(), Box<dyn std::error::Error>> + Send + 'static>;
91
92#[expect(
93 clippy::needless_pass_by_value,
94 reason = "map_err transfers ownership of the boxed writer error"
95)]
96pub(crate) fn feather_error(e: Box<dyn std::error::Error>) -> anyhow::Error {
97 anyhow::anyhow!("{e}")
98}
99
100macro_rules! define_builtin_data_batch_dispatch {
101 ($(($variant:ident, $type:ident, $data:ident, $batch:ident, $prefix:literal)),+ $(,)?) => {
102 fn write_builtin_data_batch(
103 writer: &mut FeatherWriter,
104 batch: &DataBatch,
105 ) -> Option<Result<(), Box<dyn std::error::Error>>> {
106 match batch {
107 $(
108 DataBatch::$batch(data) => Some(writer.write_batch(data.as_ref().to_vec())),
109 )+
110 _ => None,
111 }
112 }
113 };
114}
115
116nautilus_model::for_each_data_type!(define_builtin_data_batch_dispatch);
117
118pub(crate) const NAUTILUS_ARROW_METADATA_ID_COLUMN: &str = "nautilus_metadata_id";
119pub(crate) const NAUTILUS_ARROW_METADATA_JSON_COLUMN: &str = "nautilus_metadata_json";
120
121#[derive(Clone, Debug, Eq, PartialEq)]
122struct StagedArrowMetadataRow {
123 metadata_id: String,
124 metadata_json: String,
125}
126
127#[derive(Debug, Default, PartialEq, PartialOrd, Hash, Eq, Clone)]
128pub struct FileWriterPath {
129 path: Path,
130 type_str: String,
131 instrument_id: Option<String>,
132}
133
134#[derive(Clone, Debug)]
140pub enum WriterClock {
141 Live,
143 Test(Arc<AtomicU64>),
145}
146
147impl WriterClock {
148 #[must_use]
150 pub fn timestamp_ns(&self) -> UnixNanos {
151 match self {
152 Self::Live => UnixNanos::from(nanos_since_unix_epoch()),
153 Self::Test(shared) => UnixNanos::from(shared.load(Ordering::Relaxed)),
154 }
155 }
156
157 #[must_use]
164 pub fn from_shared_clock(clock: &Rc<RefCell<dyn Clock>>) -> (Self, Option<Arc<AtomicU64>>) {
165 let borrowed = clock.borrow();
166 let any_ref: &dyn Any = &*borrowed;
167 if any_ref.downcast_ref::<LiveClock>().is_some() {
168 (Self::Live, None)
169 } else {
170 let shared = Arc::new(AtomicU64::new(borrowed.timestamp_ns().as_u64()));
171 (Self::Test(Arc::clone(&shared)), Some(shared))
172 }
173 }
174}
175
176fn record_batch_with_delta_staged_metadata(
177 batch: &RecordBatch,
178 metadata_rows: &[StagedArrowMetadataRow],
179) -> anyhow::Result<RecordBatch> {
180 anyhow::ensure!(
181 batch.num_rows() == metadata_rows.len(),
182 "Delta Feather staging metadata row count {} does not match batch row count {}",
183 metadata_rows.len(),
184 batch.num_rows()
185 );
186 anyhow::ensure!(
187 batch
188 .schema()
189 .index_of(NAUTILUS_ARROW_METADATA_ID_COLUMN)
190 .is_err(),
191 "Delta Feather staging schema already has {NAUTILUS_ARROW_METADATA_ID_COLUMN}"
192 );
193 anyhow::ensure!(
194 batch
195 .schema()
196 .index_of(NAUTILUS_ARROW_METADATA_JSON_COLUMN)
197 .is_err(),
198 "Delta Feather staging schema already has {NAUTILUS_ARROW_METADATA_JSON_COLUMN}"
199 );
200
201 let mut fields = batch
202 .schema()
203 .fields()
204 .iter()
205 .map(|field| {
206 Arc::new(Field::new(
207 field.name().clone(),
208 field.data_type().clone(),
209 field.is_nullable(),
210 ))
211 })
212 .collect::<Vec<_>>();
213 fields.push(Arc::new(Field::new(
214 NAUTILUS_ARROW_METADATA_ID_COLUMN,
215 DataType::Utf8,
216 false,
217 )));
218 fields.push(Arc::new(Field::new(
219 NAUTILUS_ARROW_METADATA_JSON_COLUMN,
220 DataType::Utf8,
221 false,
222 )));
223
224 let mut columns = batch.columns().to_vec();
225 columns.push(Arc::new(StringArray::from(
226 metadata_rows
227 .iter()
228 .map(|row| row.metadata_id.clone())
229 .collect::<Vec<_>>(),
230 )));
231 columns.push(Arc::new(StringArray::from(
232 metadata_rows
233 .iter()
234 .map(|row| row.metadata_json.clone())
235 .collect::<Vec<_>>(),
236 )));
237
238 Ok(RecordBatch::try_new(
239 Arc::new(Schema::new(fields)),
240 columns,
241 )?)
242}
243
244fn arrow_metadata_row(
245 metadata: &HashMap<String, String>,
246 fields: &arrow::datatypes::Fields,
247) -> anyhow::Result<StagedArrowMetadataRow> {
248 let schema_metadata = metadata
249 .iter()
250 .map(|(key, value)| (key.clone(), value.clone()))
251 .collect::<BTreeMap<_, _>>();
252 let field_metadata = fields
253 .iter()
254 .filter(|field| !field.metadata().is_empty())
255 .map(|field| {
256 (
257 field.name().clone(),
258 field
259 .metadata()
260 .iter()
261 .map(|(key, value)| (key.clone(), value.clone()))
262 .collect::<BTreeMap<_, _>>(),
263 )
264 })
265 .collect::<BTreeMap<_, _>>();
266 let metadata_json = serde_json::to_string(&serde_json::json!({
267 "format_version": 1,
268 "schema_metadata": schema_metadata,
269 "field_metadata": field_metadata,
270 }))?;
271 let metadata_id = staged_metadata_id(&canonical_metadata_json(metadata)?);
272
273 Ok(StagedArrowMetadataRow {
274 metadata_id,
275 metadata_json,
276 })
277}
278
279pub(crate) fn canonical_metadata_json(
284 metadata: &HashMap<String, String>,
285) -> anyhow::Result<String> {
286 Ok(serde_json::to_string(
287 &metadata
288 .iter()
289 .map(|(key, value)| (key.clone(), value.clone()))
290 .collect::<BTreeMap<_, _>>(),
291 )?)
292}
293
294pub(crate) fn staged_metadata_id(metadata_json: &str) -> String {
295 format!("blake3:{}", blake3::hash(metadata_json.as_bytes()).to_hex())
296}
297
298pub struct FeatherBuffer {
302 writer: StreamWriter<Vec<u8>>,
304 size: u64,
306 rows: u64,
308 schema: Schema,
310 max_buffer_size: u64,
312}
313
314impl FeatherBuffer {
315 pub fn new(schema: &Schema, rotation_config: &RotationConfig) -> Result<Self, ArrowError> {
317 let writer = StreamWriter::try_new(Vec::new(), schema)?;
318 let mut max_buffer_size = 1_073_741_824; if let RotationConfig::Size { max_size } = &rotation_config {
321 max_buffer_size = *max_size;
322 }
323
324 Ok(Self {
325 writer,
326 size: 0,
327 rows: 0,
328 max_buffer_size,
329 schema: schema.clone(),
330 })
331 }
332
333 pub fn write_record_batch(&mut self, batch: &RecordBatch) -> Result<bool, ArrowError> {
337 let batch = if batch.schema().as_ref() == &self.schema {
338 batch.clone()
339 } else {
340 RecordBatch::try_new(Arc::new(self.schema.clone()), batch.columns().to_vec())?
341 };
342 self.writer.write(&batch)?;
343 self.size += batch.get_array_memory_size() as u64;
344 self.rows += batch.num_rows() as u64;
345 Ok(self.size >= self.max_buffer_size)
346 }
347
348 pub fn take_buffer(&mut self) -> Result<Vec<u8>, ArrowError> {
350 let mut writer = StreamWriter::try_new(Vec::new(), &self.schema)?;
351 std::mem::swap(&mut self.writer, &mut writer);
352 let buffer = writer.into_inner()?;
353 self.size = 0;
354 self.rows = 0;
355 Ok(buffer)
356 }
357}
358
359#[derive(Debug, Default)]
364struct PendingIo {
365 rotate_paths: Vec<FileWriterPath>,
366 flush_due: bool,
367}
368
369#[derive(Debug, Clone)]
371pub enum RotationConfig {
372 Size {
374 max_size: u64,
376 },
377 Interval {
379 interval_ns: u64,
381 },
382 ScheduledDates {
384 interval_ns: u64,
386 rotation_time: UnixNanos,
388 rotation_timezone: TimeZone,
390 },
391 NoRotation,
393}
394
395impl RotationConfig {
396 #[must_use]
401 pub const fn scheduled_utc(interval_ns: u64, rotation_time: UnixNanos) -> Self {
402 Self::ScheduledDates {
403 interval_ns,
404 rotation_time,
405 rotation_timezone: jiff::tz::TimeZone::UTC,
406 }
407 }
408}
409
410pub struct FeatherWriter {
417 base_path: String,
419 store: Arc<dyn ObjectStore>,
421 clock: WriterClock,
423 rotation_config: RotationConfig,
425 included_types: Option<HashSet<String>>,
427 record_filter: Option<WriterRecordFilter>,
429 per_instrument_types: HashSet<String>,
431 writers: HashMap<FileWriterPath, FeatherBuffer>,
433 reserved_paths: HashSet<Path>,
435 next_rotation_times: HashMap<FileWriterPath, UnixNanos>,
437 flush_interval_ms: u64,
439 last_flush_ns: UnixNanos,
441 catalog_identifier_column: bool,
443 pending_write_error: Option<String>,
444}
445
446impl FeatherWriter {
447 pub fn new(
449 base_path: String,
450 store: Arc<dyn ObjectStore>,
451 clock: WriterClock,
452 rotation_config: RotationConfig,
453 included_types: Option<HashSet<String>>,
454 per_instrument_types: Option<HashSet<String>>,
455 flush_interval_ms: Option<u64>,
456 ) -> Self {
457 let flush_interval_ms = flush_interval_ms.unwrap_or(1000); if flush_interval_ms == 0 && matches!(rotation_config, RotationConfig::NoRotation) {
459 log::warn!(
460 "FeatherWriter has auto-flush disabled (flush_interval_ms=0) with \
461 RotationConfig::NoRotation; buffers grow until the 1 GiB fallback cap \
462 per stream - configure size rotation or a flush interval for live use"
463 );
464 }
465 let last_flush_ns = clock.timestamp_ns();
466
467 Self {
468 base_path,
469 store,
470 clock,
471 rotation_config,
472 included_types,
473 record_filter: None,
474 per_instrument_types: per_instrument_types.unwrap_or_default(),
475 writers: HashMap::new(),
476 reserved_paths: HashSet::new(),
477 next_rotation_times: HashMap::new(),
478 flush_interval_ms,
479 last_flush_ns,
480 catalog_identifier_column: false,
481 pending_write_error: None,
482 }
483 }
484
485 #[must_use]
487 pub fn with_record_filter(mut self, record_filter: Option<WriterRecordFilter>) -> Self {
488 self.record_filter = record_filter;
489 self
490 }
491
492 #[must_use]
494 pub fn with_catalog_identifier_column(mut self) -> Self {
495 self.catalog_identifier_column = true;
496 self
497 }
498
499 pub fn write<T>(&mut self, data: T) -> Result<(), Box<dyn std::error::Error>>
505 where
506 T: EncodeToRecordBatch + CatalogPathPrefix + 'static,
507 {
508 let metadata = T::metadata(&data);
509 let identifier = catalog_identifier_from_metadata(&metadata);
510 let instrument_type = if T::path_prefix() == InstrumentAny::path_prefix() {
511 metadata.get("class").map(String::as_str)
512 } else {
513 None
514 };
515
516 if !self.should_write_record(T::path_prefix(), identifier.as_deref(), instrument_type) {
517 return Ok(());
518 }
519
520 let path = self.get_writer_path(&data)?;
521
522 if !self.writers.contains_key(&path) {
524 self.create_writer::<T>(path.clone(), &data)?;
525 }
526
527 let mut batch = T::encode_batch(&metadata, &[data])?;
529 let stage_delta_metadata =
530 self.catalog_identifier_column && T::path_prefix() != InstrumentAny::path_prefix();
531
532 if stage_delta_metadata {
533 let metadata_row = arrow_metadata_row(&metadata, batch.schema().fields())?;
534 batch = record_batch_with_identifier_column(batch, identifier.as_deref())?;
535 batch = record_batch_with_delta_staged_metadata(
536 &batch,
537 std::slice::from_ref(&metadata_row),
538 )?;
539 }
540
541 let mut pending = PendingIo::default();
543
544 self.stage_batch_write(path, &batch, &mut pending)?;
545 pending.flush_due = self.flush_is_due();
546
547 self.complete_pending_io(&pending)
548 }
549
550 pub fn write_batch<T>(&mut self, data: Vec<T>) -> Result<(), Box<dyn std::error::Error>>
560 where
561 T: EncodeToRecordBatch + CatalogPathPrefix + 'static,
562 {
563 if data.is_empty() || !self.should_write_type::<T>() {
564 return Ok(());
565 }
566
567 let type_str = T::path_prefix();
573 let needs_instrument = type_str == InstrumentAny::path_prefix()
574 || self.per_instrument_types.contains(type_str)
575 || type_str.starts_with("custom_");
576
577 let mut groups: AHashMap<Option<String>, Vec<T>> = AHashMap::new();
578
579 for item in data {
580 let metadata = T::metadata(&item);
581 let identifier = catalog_identifier_from_metadata(&metadata);
582 let instrument_type = if type_str == InstrumentAny::path_prefix() {
583 metadata.get("class").map(String::as_str)
584 } else {
585 None
586 };
587
588 if !self.should_write_record(type_str, identifier.as_deref(), instrument_type) {
589 continue;
590 }
591 let group_identifier = if self.catalog_identifier_column
592 && type_str != InstrumentAny::path_prefix()
593 {
594 identifier.clone()
595 } else if type_str == InstrumentAny::path_prefix() || !self.catalog_identifier_column {
596 needs_instrument
597 .then(|| {
598 metadata.get(KEY_INSTRUMENT_ID).cloned().ok_or_else(|| {
599 format!(
600 "Data {type_str} expected instrument_id metadata for per instrument writer"
601 )
602 })
603 })
604 .transpose()?
605 } else {
606 None
607 };
608 groups.entry(group_identifier).or_default().push(item);
609 }
610
611 if groups.is_empty() {
612 return Ok(());
613 }
614
615 let mut pending = PendingIo::default();
616
617 for group in groups.into_values() {
618 let path = self.get_writer_path(&group[0])?;
619 let metadata = T::chunk_metadata(&group);
620
621 if !self.writers.contains_key(&path) {
622 self.create_writer_with_metadata::<T>(path.clone(), metadata.clone())?;
623 }
624
625 let identifier = catalog_identifier_from_metadata(&metadata);
626 let stage_delta_metadata =
627 self.catalog_identifier_column && type_str != InstrumentAny::path_prefix();
628 let mut batch = T::encode_batch(&metadata, &group)?;
629 let metadata_rows = if stage_delta_metadata {
630 group
631 .iter()
632 .map(T::metadata)
633 .map(|metadata| arrow_metadata_row(&metadata, batch.schema().fields()))
634 .collect::<anyhow::Result<Vec<_>>>()?
635 } else {
636 Vec::new()
637 };
638
639 if stage_delta_metadata {
640 batch = record_batch_with_identifier_column(batch, identifier.as_deref())?;
641 batch = record_batch_with_delta_staged_metadata(&batch, &metadata_rows)?;
642 }
643
644 self.stage_batch_write(path, &batch, &mut pending)?;
645 }
646
647 pending.flush_due = self.flush_is_due();
648
649 self.complete_pending_io(&pending)
650 }
651
652 fn stage_batch_write(
653 &mut self,
654 path: FileWriterPath,
655 batch: &RecordBatch,
656 pending: &mut PendingIo,
657 ) -> Result<(), Box<dyn std::error::Error>> {
658 if let Some(writer) = self.writers.get_mut(&path) {
659 let should_rotate = writer.write_record_batch(batch)?;
660 if should_rotate || self.check_scheduled_rotation(&path) {
661 pending.rotate_paths.push(path);
662 }
663 }
664 Ok(())
665 }
666
667 fn flush_is_due(&self) -> bool {
669 if self.flush_interval_ms == 0 {
670 return false; }
672
673 let now_ns = self.clock.timestamp_ns();
674 let elapsed_ms = now_ns.as_u64().saturating_sub(self.last_flush_ns.as_u64()) / 1_000_000;
675 elapsed_ms >= self.flush_interval_ms
676 }
677
678 fn complete_pending_io(
680 &mut self,
681 pending: &PendingIo,
682 ) -> Result<(), Box<dyn std::error::Error>> {
683 if pending.rotate_paths.is_empty() && !pending.flush_due {
684 return Ok(());
685 }
686
687 block_on_nautilus_with(|| async {
688 for path in &pending.rotate_paths {
689 self.rotate_writer(path).await.map_err(feather_error)?;
690 }
691
692 if pending.flush_due {
693 self.flush().await.map_err(feather_error)?;
694 }
695 Ok::<(), anyhow::Error>(())
696 })
697 .map_err(Into::into)
698 }
699
700 fn check_scheduled_rotation(&mut self, path: &FileWriterPath) -> bool {
701 match &self.rotation_config {
702 RotationConfig::Interval { interval_ns } => {
703 let now = self.clock.timestamp_ns();
704 let next_rotation = self.next_rotation_times.get(path).copied();
705
706 match next_rotation {
707 None => {
708 self.next_rotation_times.insert(
709 path.clone(),
710 now + nautilus_core::DurationNanos::new(*interval_ns),
711 );
712 false
713 }
714 Some(next) if now >= next => {
715 self.next_rotation_times.insert(
716 path.clone(),
717 now + nautilus_core::DurationNanos::new(*interval_ns),
718 );
719 true
720 }
721 _ => false,
722 }
723 }
724 RotationConfig::ScheduledDates {
725 interval_ns,
726 rotation_time,
727 rotation_timezone,
728 } => {
729 let now = self.clock.timestamp_ns();
730 let next_rotation = self.next_rotation_times.get(path).copied();
731
732 match next_rotation {
733 None => {
734 let next = self.calculate_next_scheduled_rotation(
735 *rotation_time,
736 rotation_timezone,
737 *interval_ns,
738 );
739 self.next_rotation_times.insert(path.clone(), next);
740 false
741 }
742 Some(next) if now >= next => {
743 let next = self.calculate_next_scheduled_rotation(
744 *rotation_time,
745 rotation_timezone,
746 *interval_ns,
747 );
748 self.next_rotation_times.insert(path.clone(), next);
749 true
750 }
751 _ => false,
752 }
753 }
754 _ => false,
755 }
756 }
757
758 fn calculate_next_scheduled_rotation(
759 &self,
760 rotation_time: UnixNanos,
761 rotation_timezone: &TimeZone,
762 interval_ns: u64,
763 ) -> UnixNanos {
764 let now_utc = self.clock.timestamp_ns().to_datetime_utc();
765 let now_local = rotation_timezone.to_datetime(now_utc);
766
767 let rotation_time_secs = u32::try_from(*rotation_time / 1_000_000_000).unwrap_or(0);
768 let rotation_time_nanos = i32::try_from(*rotation_time % 1_000_000_000).unwrap_or(0);
769 let rotation_time = if rotation_time_secs < 86_400 {
770 Time::new(
771 i8::try_from(rotation_time_secs / 3_600).unwrap_or(0),
772 i8::try_from(rotation_time_secs % 3_600 / 60).unwrap_or(0),
773 i8::try_from(rotation_time_secs % 60).unwrap_or(0),
774 rotation_time_nanos,
775 )
776 .unwrap_or(Time::MIN)
777 } else {
778 Time::MIN
779 };
780
781 let local_rotation = now_local.date().to_datetime(rotation_time);
782 let ambiguous = rotation_timezone.to_ambiguous_timestamp(local_rotation);
783 let mut next_rotation = match ambiguous.offset() {
784 AmbiguousOffset::Gap { .. } => now_utc,
785 _ => ambiguous.earlier().unwrap_or(now_utc),
786 };
787
788 if next_rotation <= now_utc {
789 while next_rotation <= now_utc {
792 next_rotation += SignedDuration::from_nanos_i128(i128::from(interval_ns));
793 }
794 }
795
796 UnixNanos::from(u64::try_from(next_rotation.as_nanosecond()).unwrap_or(0))
797 }
798
799 async fn rotate_writer(
801 &mut self,
802 path: &FileWriterPath,
803 ) -> Result<(), Box<dyn std::error::Error>> {
804 let mut writer = self.writers.remove(path).unwrap();
805 let bytes = writer.take_buffer()?;
806 self.store.put(&path.path, bytes.into()).await?;
807 let new_path = self.regen_writer_path(path);
808 self.writers.insert(new_path, writer);
809 Ok(())
810 }
811
812 fn create_writer<T>(&mut self, path: FileWriterPath, data: &T) -> Result<(), ArrowError>
814 where
815 T: EncodeToRecordBatch + CatalogPathPrefix + 'static,
816 {
817 self.create_writer_with_metadata::<T>(path, T::metadata(data))
818 }
819
820 fn create_writer_with_metadata<T>(
825 &mut self,
826 path: FileWriterPath,
827 metadata: HashMap<String, String>,
828 ) -> Result<(), ArrowError>
829 where
830 T: EncodeToRecordBatch + CatalogPathPrefix + 'static,
831 {
832 let type_str = T::path_prefix();
833 let stage_delta_metadata =
834 self.catalog_identifier_column && type_str != InstrumentAny::path_prefix();
835 let schema = if self.catalog_identifier_column
836 || type_str == InstrumentAny::path_prefix()
837 || self.per_instrument_types.contains(type_str)
838 {
839 T::get_schema(Some(metadata))
840 } else {
841 T::get_schema(None)
842 };
843
844 let schema = if stage_delta_metadata {
845 Self::schema_with_delta_staging_columns(&schema_with_identifier_column(&schema))
846 } else {
847 schema
848 };
849 let writer = FeatherBuffer::new(&schema, &self.rotation_config)?;
850 self.writers.insert(path, writer);
851 Ok(())
852 }
853
854 fn create_custom_writer(
856 &mut self,
857 path: FileWriterPath,
858 type_name: &str,
859 ) -> Result<(), Box<dyn std::error::Error>> {
860 if self.writers.contains_key(&path) {
861 return Ok(());
862 }
863 let base_schema = get_arrow_schema(type_name).ok_or_else(|| {
864 format!("Custom data type \"{type_name}\" is not registered for Arrow encoding")
865 })?;
866 let schema = schema_with_data_type_column(base_schema.as_ref(), type_name);
867 let schema = if self.catalog_identifier_column {
868 Self::schema_with_delta_staging_columns(&schema_with_identifier_column(&schema))
869 } else {
870 schema
871 };
872 let writer = FeatherBuffer::new(&schema, &self.rotation_config)
873 .map_err(|e| format!("Failed to create feather buffer for custom {type_name}: {e}"))?;
874 self.writers.insert(path, writer);
875 Ok(())
876 }
877
878 pub(crate) fn encode_custom_to_batch(
880 custom: &CustomData,
881 ) -> Result<RecordBatch, Box<dyn std::error::Error>> {
882 let type_name = custom.data.type_name();
883 let data_type_json = custom
884 .data_type
885 .to_persistence_json()
886 .map_err(|e| format!("Failed to serialize data_type for persistence: {e}"))?;
887 let dt_meta = custom.data_type.metadata_string_map();
888 let items: [Arc<dyn CustomDataTrait>; 1] = [Arc::clone(&custom.data)];
889 let batch = encode_custom_to_arrow(type_name, &items)
890 .map_err(|e| format!("Failed to encode custom data: {e}"))?
891 .ok_or_else(|| {
892 format!("Custom data type \"{type_name}\" is not registered for Arrow")
893 })?;
894 let batch = augment_batch_with_data_type_column(
895 &batch,
896 &data_type_json,
897 type_name,
898 dt_meta.as_ref(),
899 )
900 .map_err(|e| e.to_string())?;
901 Ok(batch)
902 }
903
904 fn schema_with_delta_staging_columns(schema: &Schema) -> Schema {
905 let mut fields = schema
906 .fields()
907 .iter()
908 .map(|field| {
909 Arc::new(Field::new(
910 field.name().clone(),
911 field.data_type().clone(),
912 field.is_nullable(),
913 ))
914 })
915 .collect::<Vec<_>>();
916
917 if schema.index_of(NAUTILUS_ARROW_METADATA_ID_COLUMN).is_err() {
918 fields.push(Arc::new(Field::new(
919 NAUTILUS_ARROW_METADATA_ID_COLUMN,
920 DataType::Utf8,
921 false,
922 )));
923 }
924
925 if schema
926 .index_of(NAUTILUS_ARROW_METADATA_JSON_COLUMN)
927 .is_err()
928 {
929 fields.push(Arc::new(Field::new(
930 NAUTILUS_ARROW_METADATA_JSON_COLUMN,
931 DataType::Utf8,
932 false,
933 )));
934 }
935
936 Schema::new(fields)
937 }
938
939 pub async fn flush(&mut self) -> Result<(), Box<dyn std::error::Error>> {
948 let paths_to_flush: Vec<FileWriterPath> = self.writers.keys().cloned().collect();
950
951 for path in paths_to_flush {
953 if let Some(mut writer) = self.writers.remove(&path) {
954 if writer.rows == 0 {
955 continue;
956 }
957 let bytes = writer.take_buffer()?;
958 if !bytes.is_empty() {
959 self.store.put(&path.path, bytes.into()).await?;
961 }
962 }
963 }
964
965 self.last_flush_ns = self.clock.timestamp_ns();
966
967 if let Some(error) = self.pending_write_error.take() {
968 return Err(error.into());
969 }
970
971 Ok(())
972 }
973
974 pub async fn close(&mut self) -> Result<(), Box<dyn std::error::Error>> {
978 self.flush().await?;
979 self.writers.clear();
980 Ok(())
981 }
982
983 #[must_use]
985 pub fn is_closed(&self) -> bool {
986 self.writers.is_empty()
987 }
988
989 #[must_use]
994 pub fn get_current_file_info(&self) -> HashMap<String, (u64, String)> {
995 let mut info = HashMap::new();
996
997 for (path, buffer) in &self.writers {
998 let key = match &path.instrument_id {
999 Some(id) => format!("{}:{}", path.type_str, id),
1000 None => path.type_str.clone(),
1001 };
1002 info.insert(key, (buffer.size, path.path.to_string()));
1003 }
1004 info
1005 }
1006
1007 #[must_use]
1009 pub fn buffered_totals(&self) -> (u64, u64) {
1010 self.writers.values().fold((0, 0), |(bytes, rows), buffer| {
1011 (bytes + buffer.size, rows + buffer.rows)
1012 })
1013 }
1014
1015 #[must_use]
1017 pub fn get_next_rotation_time(
1018 &self,
1019 type_str: &str,
1020 instrument_id: Option<&str>,
1021 ) -> Option<UnixNanos> {
1022 self.next_rotation_times
1023 .iter()
1024 .find(|(k, _)| k.type_str == type_str && k.instrument_id.as_deref() == instrument_id)
1025 .map(|(_, &v)| v)
1026 }
1027
1028 fn should_write_type<T: CatalogPathPrefix>(&self) -> bool {
1030 self.should_write_prefix(T::path_prefix())
1031 }
1032
1033 fn should_write_prefix(&self, record_prefix: &str) -> bool {
1034 self.included_types
1035 .as_ref()
1036 .is_none_or(|included| included.contains(record_prefix))
1037 && self
1038 .record_filter
1039 .as_ref()
1040 .is_none_or(|filter| filter.contains_prefix(record_prefix))
1041 }
1042
1043 fn should_write_record(
1044 &self,
1045 record_prefix: &str,
1046 identifier: Option<&str>,
1047 instrument_type: Option<&str>,
1048 ) -> bool {
1049 self.included_types
1050 .as_ref()
1051 .is_none_or(|included| included.contains(record_prefix))
1052 && self
1053 .record_filter
1054 .as_ref()
1055 .is_none_or(|filter| filter.allows(record_prefix, identifier, instrument_type))
1056 }
1057
1058 fn regen_writer_path(&mut self, path: &FileWriterPath) -> FileWriterPath {
1059 self.reserve_writer_path(&path.type_str, path.instrument_id.clone())
1060 }
1061
1062 fn reserve_writer_path(
1063 &mut self,
1064 type_str: &str,
1065 instrument_id: Option<String>,
1066 ) -> FileWriterPath {
1067 let timestamp = self.clock.timestamp_ns();
1068
1069 for sequence in 0.. {
1070 let path =
1071 self.build_writer_path(type_str, instrument_id.as_deref(), timestamp, sequence);
1072
1073 if self.reserved_paths.insert(path.clone()) {
1074 return FileWriterPath {
1075 path,
1076 type_str: type_str.to_string(),
1077 instrument_id,
1078 };
1079 }
1080 }
1081
1082 unreachable!("unbounded writer path sequence exhausted")
1083 }
1084
1085 fn build_writer_path(
1086 &self,
1087 type_str: &str,
1088 instrument_id: Option<&str>,
1089 timestamp: UnixNanos,
1090 sequence: u64,
1091 ) -> Path {
1092 let mut path = Path::from(self.base_path.clone());
1094
1095 if type_str.starts_with("data/custom/") {
1096 let type_name = type_str.strip_prefix("data/custom/").unwrap_or(type_str);
1097 path = path.join("data").join("custom").join(type_name.to_string());
1098
1099 let safe_id = instrument_id
1102 .map(urisafe_instrument_id)
1103 .filter(|safe| !safe.is_empty());
1104
1105 if let Some(safe) = &safe_id {
1106 path = path.join(safe.clone());
1107 }
1108 let file_stem = safe_id.as_deref().unwrap_or(type_name);
1109 path = path.join(Self::timestamped_feather_file_name(
1110 file_stem, timestamp, sequence,
1111 ));
1112 } else if let Some(instrument_id) = instrument_id {
1113 let safe_id = urisafe_instrument_id(instrument_id);
1114 path = path.join(type_str);
1115 path = path.join(safe_id);
1116 path = path.join(Self::timestamped_feather_file_name(
1117 type_str, timestamp, sequence,
1118 ));
1119 } else {
1120 path = path.join(Self::timestamped_feather_file_name(
1121 type_str, timestamp, sequence,
1122 ));
1123 }
1124
1125 path
1126 }
1127
1128 fn timestamped_feather_file_name(stem: &str, timestamp: UnixNanos, sequence: u64) -> String {
1129 if sequence == 0 {
1130 format!("{stem}_{timestamp}.feather")
1131 } else {
1132 format!("{stem}_{timestamp}-{sequence}.feather")
1133 }
1134 }
1135
1136 fn get_writer_path_custom(
1138 &mut self,
1139 type_name: &str,
1140 identifier: Option<&str>,
1141 ) -> Result<FileWriterPath, Box<dyn std::error::Error>> {
1142 let type_str = format!("data/custom/{type_name}");
1143
1144 if let Some(existing) = self
1145 .writers
1146 .keys()
1147 .find(|path| path.type_str == type_str && path.instrument_id.as_deref() == identifier)
1148 {
1149 return Ok(existing.clone());
1150 }
1151
1152 if let Some(schema) = get_arrow_schema(type_name) {
1153 validate_custom_catalog_schema(type_name, &schema)?;
1154 }
1155
1156 Ok(self.reserve_writer_path(&type_str, identifier.map(String::from)))
1157 }
1158
1159 fn get_writer_path<T>(&mut self, data: &T) -> Result<FileWriterPath, Box<dyn std::error::Error>>
1163 where
1164 T: EncodeToRecordBatch + CatalogPathPrefix,
1165 {
1166 let type_str = T::path_prefix();
1167 let metadata = T::metadata(data);
1168
1169 let instrument_id = if type_str == InstrumentAny::path_prefix()
1170 || self.per_instrument_types.contains(type_str)
1171 || (type_str.starts_with("custom_") && metadata.contains_key(KEY_INSTRUMENT_ID))
1172 {
1173 Some(metadata.get(KEY_INSTRUMENT_ID).cloned().ok_or_else(|| {
1174 format!("Data {type_str} expected instrument_id metadata for per instrument writer")
1175 })?)
1176 } else {
1177 None
1178 };
1179
1180 if let Some(existing) = self
1182 .writers
1183 .keys()
1184 .find(|k| k.type_str == type_str && k.instrument_id == instrument_id)
1185 {
1186 return Ok(existing.clone());
1187 }
1188
1189 Ok(self.reserve_writer_path(type_str, instrument_id))
1190 }
1191
1192 pub fn write_data(&mut self, data: Data) -> Result<(), Box<dyn std::error::Error>> {
1197 match data {
1198 Data::Instrument(instrument) => self.write(*instrument),
1199 Data::Quote(quote) => self.write(quote),
1200 Data::Trade(trade) => self.write(trade),
1201 Data::Bar(bar) => self.write(bar),
1202 Data::BookDelta(delta) => self.write(delta),
1203 Data::BookDepth(depth) => self.write(*depth),
1204 Data::IndexPrice(price) => self.write(price),
1205 Data::MarkPrice(price) => self.write(price),
1206 Data::FundingRate(funding) => self.write(funding),
1207 Data::InstrumentStatus(status) => self.write(status),
1208 Data::OptionGreeks(greeks) => self.write(greeks),
1209 Data::InstrumentClose(close) => self.write(close),
1210 Data::Custom(custom) => self.write_custom_data(&custom),
1211 Data::BookDeltas(deltas_api) => {
1212 self.write_batch(deltas_api.deltas.clone())
1214 }
1215 #[cfg(feature = "defi")]
1216 Data::Defi(_) => Err("Unsupported DeFi data variant for feather writes".into()),
1217 }
1218 }
1219
1220 #[expect(
1226 clippy::needless_pass_by_value,
1227 reason = "the public writer API accepts ownership of each submitted data batch"
1228 )]
1229 pub fn write_data_batch(&mut self, data: Vec<Data>) -> Result<(), Box<dyn std::error::Error>> {
1230 for batch in DataBatch::from_data_vec_grouped(&data)? {
1231 match &batch {
1232 DataBatch::Custom(data) => {
1233 for custom in data.as_ref() {
1234 self.write_custom_data(custom)?;
1235 }
1236 }
1237 batch => write_builtin_data_batch(self, batch)
1238 .expect("built-in data batch dispatch is exhaustive")?,
1239 }
1240 }
1241 Ok(())
1242 }
1243
1244 pub fn write_any_message(
1246 &mut self,
1247 message: &dyn Any,
1248 ) -> Result<bool, Box<dyn std::error::Error>> {
1249 let Some(command) = Self::write_command(message) else {
1250 return Ok(false);
1251 };
1252
1253 if let Err(e) = command(self) {
1254 self.record_write_error(e.to_string());
1255 return Err(e);
1256 }
1257
1258 Ok(true)
1259 }
1260
1261 pub(crate) fn record_write_error(&mut self, error: String) {
1262 self.pending_write_error.get_or_insert(error);
1263 }
1264
1265 pub(crate) fn write_command(message: &dyn Any) -> Option<FeatherWriteCommand> {
1266 macro_rules! try_write {
1267 ($message:expr, $type:ty) => {
1268 if let Some(value) = $message.downcast_ref::<$type>() {
1269 let value = value.clone();
1270 return Some(Box::new(move |writer| writer.write(value)));
1271 }
1272 };
1273 }
1274
1275 try_write!(message, QuoteTick);
1276 try_write!(message, TradeTick);
1277 try_write!(message, Bar);
1278 try_write!(message, OrderBookDelta);
1279 try_write!(message, OrderBookDepth);
1280 try_write!(message, IndexPriceUpdate);
1281 try_write!(message, MarkPriceUpdate);
1282 try_write!(message, FundingRateUpdate);
1283 try_write!(message, InstrumentStatus);
1284 try_write!(message, OptionGreeks);
1285 try_write!(message, InstrumentClose);
1286 try_write!(message, InstrumentAny);
1287 try_write!(message, AccountState);
1288 try_write!(message, OrderInitialized);
1289 try_write!(message, OrderDenied);
1290 try_write!(message, OrderEmulated);
1291 try_write!(message, OrderSubmitted);
1292 try_write!(message, OrderAccepted);
1293 try_write!(message, OrderRejected);
1294 try_write!(message, OrderPendingCancel);
1295 try_write!(message, OrderCanceled);
1296 try_write!(message, OrderCancelRejected);
1297 try_write!(message, OrderExpired);
1298 try_write!(message, OrderTriggered);
1299 try_write!(message, OrderPendingUpdate);
1300 try_write!(message, OrderReleased);
1301 try_write!(message, OrderModifyRejected);
1302 try_write!(message, OrderUpdated);
1303 try_write!(message, OrderFilled);
1304 try_write!(message, OrderFillVoided);
1305 try_write!(message, PositionOpened);
1306 try_write!(message, PositionChanged);
1307 try_write!(message, PositionClosed);
1308 try_write!(message, PositionAdjusted);
1309 try_write!(message, OrderSnapshot);
1310 try_write!(message, PositionSnapshot);
1311 try_write!(message, OrderStatusReport);
1312 try_write!(message, FillReport);
1313 try_write!(message, PositionStatusReport);
1314 try_write!(message, ExecutionMassStatus);
1315
1316 if let Some(deltas) = message.downcast_ref::<OrderBookDeltas>() {
1317 let deltas = deltas.deltas.clone();
1318 return Some(Box::new(move |writer| writer.write_batch(deltas)));
1319 }
1320
1321 if let Some(data) = message.downcast_ref::<Data>() {
1322 let data = data.clone();
1323 return Some(Box::new(move |writer| writer.write_data(data)));
1324 }
1325
1326 if let Some(custom) = message.downcast_ref::<CustomData>() {
1327 let custom = custom.clone();
1328 return Some(Box::new(move |writer| {
1329 writer.write_data(Data::Custom(custom))
1330 }));
1331 }
1332
1333 if let Some(event) = message.downcast_ref::<OrderEventAny>() {
1334 let event = event.clone();
1335 return Some(Box::new(move |writer| writer.write_order_event(&event)));
1336 }
1337
1338 if let Some(event) = message.downcast_ref::<PositionEvent>() {
1339 let event = event.clone();
1340 return Some(Box::new(move |writer| writer.write_position_event(&event)));
1341 }
1342
1343 None
1344 }
1345
1346 fn write_order_event(
1347 &mut self,
1348 event: &OrderEventAny,
1349 ) -> Result<(), Box<dyn std::error::Error>> {
1350 match event {
1351 OrderEventAny::Initialized(event) => self.write(event.clone()),
1352 OrderEventAny::Denied(event) => self.write(*event),
1353 OrderEventAny::Emulated(event) => self.write(*event),
1354 OrderEventAny::Released(event) => self.write(*event),
1355 OrderEventAny::Submitted(event) => self.write(*event),
1356 OrderEventAny::Accepted(event) => self.write(*event),
1357 OrderEventAny::Rejected(event) => self.write(*event),
1358 OrderEventAny::Canceled(event) => self.write(*event),
1359 OrderEventAny::Expired(event) => self.write(*event),
1360 OrderEventAny::Triggered(event) => self.write(*event),
1361 OrderEventAny::PendingUpdate(event) => self.write(*event),
1362 OrderEventAny::PendingCancel(event) => self.write(*event),
1363 OrderEventAny::ModifyRejected(event) => self.write(*event),
1364 OrderEventAny::CancelRejected(event) => self.write(*event),
1365 OrderEventAny::Updated(event) => self.write(*event),
1366 OrderEventAny::Filled(event) => self.write(event.clone()),
1367 OrderEventAny::FillVoided(event) => self.write(event.clone()),
1368 }
1369 }
1370
1371 fn write_position_event(
1372 &mut self,
1373 event: &PositionEvent,
1374 ) -> Result<(), Box<dyn std::error::Error>> {
1375 match event {
1376 PositionEvent::PositionOpened(event) => self.write(event.clone()),
1377 PositionEvent::PositionChanged(event) => self.write(event.clone()),
1378 PositionEvent::PositionClosed(event) => self.write(event.clone()),
1379 PositionEvent::PositionAdjusted(event) => self.write(*event),
1380 }
1381 }
1382
1383 fn write_custom_data(&mut self, custom: &CustomData) -> Result<(), Box<dyn std::error::Error>> {
1385 let batch = Self::encode_custom_to_batch(custom)?;
1386 self.write_custom_batch(custom, batch)
1387 }
1388
1389 pub(crate) fn write_custom_batch(
1390 &mut self,
1391 custom: &CustomData,
1392 mut batch: RecordBatch,
1393 ) -> Result<(), Box<dyn std::error::Error>> {
1394 let type_name = custom.data.type_name();
1395 let identifier = custom.data_type.identifier().map(String::from);
1396
1397 if !self.should_write_custom(type_name, identifier.as_deref()) {
1398 return Ok(());
1399 }
1400
1401 let path = self.get_writer_path_custom(type_name, identifier.as_deref())?;
1402 if !self.writers.contains_key(&path) {
1403 self.create_custom_writer(path.clone(), type_name)?;
1404 }
1405
1406 if self.catalog_identifier_column {
1407 let metadata_row =
1408 arrow_metadata_row(batch.schema().metadata(), batch.schema().fields())?;
1409 batch = record_batch_with_identifier_column(batch, custom.data_type.identifier())?;
1410 batch = record_batch_with_delta_staged_metadata(
1411 &batch,
1412 std::slice::from_ref(&metadata_row),
1413 )?;
1414 }
1415
1416 let mut pending = PendingIo::default();
1417
1418 self.stage_batch_write(path, &batch, &mut pending)?;
1419 pending.flush_due = self.flush_is_due();
1420
1421 self.complete_pending_io(&pending)
1422 }
1423
1424 pub(crate) fn should_write_custom(&self, type_name: &str, identifier: Option<&str>) -> bool {
1425 let record_prefix = format!("custom/{type_name}");
1426 self.included_types.as_ref().is_none_or(|included| {
1427 included.contains(type_name)
1428 || included.contains("custom")
1429 || included.contains(&record_prefix)
1430 }) && self
1431 .record_filter
1432 .as_ref()
1433 .is_none_or(|filter| filter.allows(&record_prefix, identifier, None))
1434 }
1435
1436 pub fn write_instrument(
1441 &mut self,
1442 instrument: InstrumentAny,
1443 ) -> Result<(), Box<dyn std::error::Error>> {
1444 self.write(instrument)
1445 }
1446
1447 pub fn subscribe_to_message_bus(
1457 writer: Rc<RefCell<Self>>,
1458 ) -> Result<StreamingSinkSubscription, Box<dyn std::error::Error>> {
1459 let sink: StreamingDataSink = Box::new(writer);
1460 Ok(StreamingSinkSubscription::subscribe(
1461 Rc::new(RefCell::new(sink)),
1462 None,
1463 ))
1464 }
1465
1466 pub fn unsubscribe_from_message_bus(handler: &StreamingSinkSubscription) {
1468 handler.unsubscribe();
1469 }
1470}
1471
1472impl Debug for FeatherWriter {
1473 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1474 f.debug_struct(stringify!(FeatherWriter))
1475 .finish_non_exhaustive()
1476 }
1477}
1478
1479impl StreamingSink for FeatherWriter {
1480 fn write_data(&mut self, data: Data) -> anyhow::Result<()> {
1481 Self::write_data(self, data).map_err(feather_error)
1482 }
1483
1484 fn write_batch(&mut self, data: Vec<Data>) -> anyhow::Result<()> {
1485 Self::write_data_batch(self, data).map_err(feather_error)
1486 }
1487
1488 fn write_any(&mut self, message: &dyn Any) -> anyhow::Result<bool> {
1489 self.write_any_message(message).map_err(feather_error)
1490 }
1491
1492 fn flush(&mut self) -> anyhow::Result<()> {
1493 block_on_nautilus_with(|| async { self.flush().await.map_err(feather_error) })
1494 }
1495
1496 fn close(&mut self) -> anyhow::Result<()> {
1497 block_on_nautilus_with(|| async { self.close().await.map_err(feather_error) })
1498 }
1499}
1500
1501impl StreamingSink for Rc<RefCell<FeatherWriter>> {
1502 fn write_data(&mut self, data: Data) -> anyhow::Result<()> {
1503 StreamingSink::write_data(&mut *self.borrow_mut(), data)
1504 }
1505
1506 fn write_batch(&mut self, data: Vec<Data>) -> anyhow::Result<()> {
1507 StreamingSink::write_batch(&mut *self.borrow_mut(), data)
1508 }
1509
1510 fn write_any(&mut self, message: &dyn Any) -> anyhow::Result<bool> {
1511 StreamingSink::write_any(&mut *self.borrow_mut(), message)
1512 }
1513
1514 fn flush(&mut self) -> anyhow::Result<()> {
1515 StreamingSink::flush(&mut *self.borrow_mut())
1516 }
1517
1518 fn close(&mut self) -> anyhow::Result<()> {
1519 StreamingSink::close(&mut *self.borrow_mut())
1520 }
1521}
1522
1523#[cfg(test)]
1524mod tests {
1525 use std::{io::Cursor, sync::Arc};
1526
1527 use datafusion::arrow::ipc::reader::StreamReader;
1528 use nautilus_common::{
1529 clock::VirtualClock,
1530 live::{LiveClock, get_runtime},
1531 };
1532 use nautilus_model::{
1533 data::{Data, QuoteTick, TradeTick},
1534 enums::AggressorSide,
1535 identifiers::{InstrumentId, TradeId},
1536 types::{ERROR_PRICE, Price, Quantity},
1537 };
1538 use nautilus_serialization::arrow::{
1539 ArrowSchemaProvider, DecodeDataFromRecordBatch, EncodeToRecordBatch,
1540 };
1541 use object_store::{ObjectStore, local::LocalFileSystem};
1542 use rstest::rstest;
1543 use tempfile::TempDir;
1544
1545 use super::*;
1546
1547 #[rstest]
1548 fn test_subscription_receives_typed_quotes_and_unsubscribes() {
1549 use nautilus_common::msgbus::{MStr, publish_quote};
1550
1551 let writer = Rc::new(RefCell::new(FeatherWriter::new(
1552 "run".to_string(),
1553 Arc::new(object_store::memory::InMemory::new()),
1554 WriterClock::Test(Arc::new(AtomicU64::new(0))),
1555 RotationConfig::NoRotation,
1556 None,
1557 None,
1558 Some(0),
1559 )));
1560
1561 let quote = QuoteTick::new(
1562 InstrumentId::from("AUD/USD.SIM"),
1563 Price::from("1.0"),
1564 Price::from("1.1"),
1565 Quantity::from("2"),
1566 Quantity::from("3"),
1567 4.into(),
1568 5.into(),
1569 );
1570 let handler = FeatherWriter::subscribe_to_message_bus(writer.clone()).unwrap();
1571 publish_quote(MStr::topic("data.quotes.AUD/USD.SIM").unwrap(), "e);
1572 FeatherWriter::unsubscribe_from_message_bus(&handler);
1573 publish_quote(MStr::topic("data.quotes.AUD/USD.SIM").unwrap(), "e);
1574 assert_eq!(
1575 writer
1576 .borrow()
1577 .writers
1578 .values()
1579 .map(|buffer| buffer.rows)
1580 .sum::<u64>(),
1581 1
1582 );
1583 }
1584
1585 #[rstest]
1586 #[case(false)]
1587 #[case(true)]
1588 fn test_message_write_error_reaches_flush_or_close(#[case] close: bool) {
1589 let mut writer = FeatherWriter::new(
1590 "run".to_string(),
1591 Arc::new(object_store::memory::InMemory::new()),
1592 WriterClock::Test(Arc::new(AtomicU64::new(0))),
1593 RotationConfig::NoRotation,
1594 None,
1595 None,
1596 Some(0),
1597 );
1598
1599 let quote = QuoteTick::new(
1600 InstrumentId::from("AUD/USD.SIM"),
1601 ERROR_PRICE,
1602 ERROR_PRICE,
1603 Quantity::from("1"),
1604 Quantity::from("2"),
1605 3.into(),
1606 4.into(),
1607 );
1608 let write_error = writer.write_any_message("e).unwrap_err().to_string();
1609
1610 let error = if close {
1611 StreamingSink::close(&mut writer)
1612 } else {
1613 StreamingSink::flush(&mut writer)
1614 }
1615 .unwrap_err();
1616
1617 assert_eq!(error.to_string(), write_error);
1618 }
1619
1620 #[rstest]
1621 fn test_writer_manager_keys() {
1622 let temp_dir = TempDir::new().unwrap();
1624 let base_path = temp_dir.path().to_str().unwrap().to_string();
1625
1626 let local_fs = LocalFileSystem::new_with_prefix(temp_dir.path()).unwrap();
1628 let store: Arc<dyn ObjectStore> = Arc::new(local_fs);
1629
1630 let clock = WriterClock::Test(Arc::new(AtomicU64::new(0)));
1632 let timestamp = clock.timestamp_ns();
1633
1634 let quote_type_str = QuoteTick::path_prefix();
1635
1636 let mut per_instrument = HashSet::new();
1637 per_instrument.insert(quote_type_str.to_string());
1638
1639 let mut manager = FeatherWriter::new(
1640 base_path.clone(),
1641 store,
1642 clock,
1643 RotationConfig::NoRotation,
1644 None,
1645 Some(per_instrument),
1646 None, );
1648
1649 let instrument_id = "AAPL.AAPL";
1650 let quote = QuoteTick::new(
1652 InstrumentId::from(instrument_id),
1653 Price::from("100.0"),
1654 Price::from("100.0"),
1655 Quantity::from("100.0"),
1656 Quantity::from("100.0"),
1657 UnixNanos::from(1_000_000_000_000_000_000),
1658 UnixNanos::from(1_000_000_000_000_000_000),
1659 );
1660
1661 let trade = TradeTick::new(
1662 InstrumentId::from(instrument_id),
1663 Price::from("100.0"),
1664 Quantity::from("100.0"),
1665 AggressorSide::Buy,
1666 TradeId::from("1"),
1667 UnixNanos::from(1_000_000_000_000_000_000),
1668 UnixNanos::from(1_000_000_000_000_000_000),
1669 );
1670
1671 manager.write(quote).unwrap();
1672 manager.write(trade).unwrap();
1673
1674 let path = manager.get_writer_path("e).unwrap();
1676 let safe_id = instrument_id.replace('/', "");
1677 let expected_path = Path::from(format!(
1678 "{base_path}/quotes/{safe_id}/quotes_{timestamp}.feather"
1679 ));
1680 assert_eq!(path.path, expected_path);
1681 assert!(manager.writers.contains_key(&path));
1682 let writer = manager.writers.get(&path).unwrap();
1683 assert!(writer.size > 0);
1684
1685 let path = manager.get_writer_path(&trade).unwrap();
1686 let expected_path = Path::from(format!("{base_path}/trades_{timestamp}.feather"));
1687 assert_eq!(path.path, expected_path);
1688 assert!(manager.writers.contains_key(&path));
1689 let writer = manager.writers.get(&path).unwrap();
1690 assert!(writer.size > 0);
1691 }
1692
1693 #[rstest]
1694 fn test_per_instrument_path_keeps_long_id_out_of_filename() {
1695 let temp_dir = TempDir::new().unwrap();
1696 let base_path = temp_dir.path().to_str().unwrap().to_string();
1697 let local_fs = LocalFileSystem::new_with_prefix(temp_dir.path()).unwrap();
1698 let store: Arc<dyn ObjectStore> = Arc::new(local_fs);
1699 let clock = WriterClock::Test(Arc::new(AtomicU64::new(0)));
1700 let manager = FeatherWriter::new(
1701 base_path.clone(),
1702 store,
1703 clock,
1704 RotationConfig::NoRotation,
1705 None,
1706 None,
1707 None,
1708 );
1709 let instrument_id = format!("{}.VENUE", "A".repeat(240));
1710
1711 let path = manager.build_writer_path(
1712 QuoteTick::path_prefix(),
1713 Some(&instrument_id),
1714 UnixNanos::default(),
1715 0,
1716 );
1717
1718 let safe_id = urisafe_instrument_id(&instrument_id);
1719 let expected = Path::from(format!("{base_path}/quotes/{safe_id}/quotes_0.feather"));
1720 assert_eq!(path, expected);
1721 }
1722
1723 #[rstest]
1724 fn existing_feather_writer_implements_streaming_data_sink() {
1725 let temp_dir = TempDir::new().unwrap();
1726 let storage = crate::common::storage::create_storage_backend_from_path(
1727 temp_dir.path().to_str().unwrap(),
1728 None,
1729 )
1730 .unwrap();
1731 let clock = WriterClock::Test(Arc::new(AtomicU64::new(0)));
1732 let mut writer = FeatherWriter::new(
1733 storage.base_path.clone(),
1734 storage.object_store.clone(),
1735 clock,
1736 RotationConfig::NoRotation,
1737 None,
1738 Some(HashSet::from(["quotes".to_string()])),
1739 None,
1740 );
1741 let quote = QuoteTick::new(
1742 InstrumentId::from("AUD/USD.SIM"),
1743 Price::from("1.0"),
1744 Price::from("1.1"),
1745 Quantity::from("1000"),
1746 Quantity::from("1000"),
1747 UnixNanos::from(1_000),
1748 UnixNanos::from(1_000),
1749 );
1750
1751 StreamingSink::write_data(&mut writer, Data::Quote(quote)).unwrap();
1752 StreamingSink::flush(&mut writer).unwrap();
1753
1754 let files = get_runtime()
1755 .block_on(storage.list_files("quotes", Some(".feather")))
1756 .unwrap();
1757 assert_eq!(files.len(), 1);
1758 assert!(
1759 std::path::PathBuf::from(&files[0])
1760 .components()
1761 .any(|component| component.as_os_str() == "AUDUSD.SIM"),
1762 );
1763 }
1764
1765 #[rstest]
1766 fn scheduled_rotation_keeps_time_of_day_anchor_after_late_rotation() {
1767 let temp_dir = TempDir::new().unwrap();
1768 let storage = crate::common::storage::create_storage_backend_from_path(
1769 temp_dir.path().to_str().unwrap(),
1770 None,
1771 )
1772 .unwrap();
1773 let now = Arc::new(AtomicU64::new(
1774 1_767_258_000_000_000_000, ));
1776 let path = FileWriterPath {
1777 path: Path::from("quotes.feather"),
1778 type_str: "quotes".to_string(),
1779 instrument_id: None,
1780 };
1781 let mut writer = FeatherWriter::new(
1782 storage.base_path,
1783 storage.object_store,
1784 WriterClock::Test(Arc::clone(&now)),
1785 RotationConfig::ScheduledDates {
1786 interval_ns: 86_400_000_000_000,
1787 rotation_time: UnixNanos::from(36_000_000_000_000u64),
1788 rotation_timezone: jiff::tz::TimeZone::UTC,
1789 },
1790 None,
1791 None,
1792 None,
1793 );
1794
1795 assert!(!writer.check_scheduled_rotation(&path));
1796 assert_eq!(
1797 writer.next_rotation_times[&path],
1798 UnixNanos::from(1_767_261_600_000_000_000u64),
1799 );
1800
1801 now.store(1_767_263_400_000_000_000, Ordering::Relaxed); assert!(writer.check_scheduled_rotation(&path));
1803 assert_eq!(
1804 writer.next_rotation_times[&path],
1805 UnixNanos::from(1_767_348_000_000_000_000u64),
1806 );
1807 }
1808
1809 #[rstest]
1810 fn test_file_writer_round_trip() {
1811 let instrument_id = "AAPL.AAPL";
1812 let quote = QuoteTick::new(
1814 InstrumentId::from(instrument_id),
1815 Price::from("100.0"),
1816 Price::from("100.0"),
1817 Quantity::from("100.0"),
1818 Quantity::from("100.0"),
1819 UnixNanos::from(100),
1820 UnixNanos::from(100),
1821 );
1822 let metadata = QuoteTick::metadata("e);
1823 let schema = QuoteTick::get_schema(Some(metadata.clone()));
1824 let batch = QuoteTick::encode_batch(&QuoteTick::metadata("e), &[quote]).unwrap();
1825
1826 let mut writer = FeatherBuffer::new(&schema, &RotationConfig::NoRotation).unwrap();
1827 writer.write_record_batch(&batch).unwrap();
1828
1829 let buffer = writer.take_buffer().unwrap();
1830 let mut reader = StreamReader::try_new(Cursor::new(buffer.as_slice()), None).unwrap();
1831
1832 let read_metadata = reader.schema().metadata().clone();
1833 assert_eq!(read_metadata, metadata);
1834
1835 let read_batch = reader.next().unwrap().unwrap();
1836 assert_eq!(read_batch.column(0), batch.column(0));
1837
1838 let decoded = QuoteTick::decode_data_batch(&metadata, batch).unwrap();
1839 assert_eq!(decoded[0], Data::from(quote));
1840 }
1841
1842 #[rstest]
1843 fn test_round_trip() {
1844 let temp_dir = TempDir::new_in(".").unwrap();
1846 let base_path = temp_dir.path().to_str().unwrap().to_string();
1847
1848 let local_fs = LocalFileSystem::new_with_prefix(&base_path).unwrap();
1850 let store: Arc<dyn ObjectStore> = Arc::new(local_fs);
1851
1852 let clock = WriterClock::Test(Arc::new(AtomicU64::new(0)));
1854
1855 let quote_type_str = QuoteTick::path_prefix();
1856 let trade_type_str = TradeTick::path_prefix();
1857
1858 let mut per_instrument = HashSet::new();
1859 per_instrument.insert(quote_type_str.to_string());
1860 per_instrument.insert(trade_type_str.to_string());
1861
1862 let mut manager = FeatherWriter::new(
1863 base_path.clone(),
1864 store,
1865 clock,
1866 RotationConfig::NoRotation,
1867 None,
1868 Some(per_instrument),
1869 None, );
1871
1872 let instrument_id = "AAPL.AAPL";
1873 let quote = QuoteTick::new(
1875 InstrumentId::from(instrument_id),
1876 Price::from("100.0"),
1877 Price::from("100.0"),
1878 Quantity::from("100.0"),
1879 Quantity::from("100.0"),
1880 UnixNanos::from(100),
1881 UnixNanos::from(100),
1882 );
1883
1884 let trade = TradeTick::new(
1885 InstrumentId::from(instrument_id),
1886 Price::from("100.0"),
1887 Quantity::from("100.0"),
1888 AggressorSide::Buy,
1889 TradeId::from("1"),
1890 UnixNanos::from(100),
1891 UnixNanos::from(100),
1892 );
1893
1894 manager.write(quote).unwrap();
1895 manager.write(trade).unwrap();
1896
1897 let paths = manager.writers.keys().cloned().collect::<Vec<_>>();
1898 assert_eq!(paths.len(), 2);
1899
1900 get_runtime().block_on(manager.flush()).unwrap();
1902
1903 let mut recovered_quotes = Vec::new();
1905 let mut recovered_trades = Vec::new();
1906 let local_fs = LocalFileSystem::new_with_prefix(&base_path).unwrap();
1907 for path in paths {
1908 let path_str = local_fs.path_to_filesystem(&path.path).unwrap();
1909 let buffer = std::fs::File::open(&path_str).unwrap();
1910 let reader = StreamReader::try_new(buffer, None).unwrap();
1911 let metadata = reader.schema().metadata().clone();
1912 for batch in reader {
1913 let batch = batch.unwrap();
1914 if path_str.to_str().unwrap().contains("quotes") {
1915 let decoded = QuoteTick::decode_data_batch(&metadata, batch).unwrap();
1916 recovered_quotes.extend(decoded);
1917 } else if path_str.to_str().unwrap().contains("trades") {
1918 let decoded = TradeTick::decode_data_batch(&metadata, batch).unwrap();
1919 recovered_trades.extend(decoded);
1920 }
1921 }
1922 }
1923
1924 assert_eq!(recovered_quotes.len(), 1, "Expected one QuoteTick record");
1926 assert_eq!(recovered_trades.len(), 1, "Expected one TradeTick record");
1927
1928 assert_eq!(recovered_quotes[0], Data::from(quote));
1930 assert_eq!(recovered_trades[0], Data::from(trade));
1931 }
1932
1933 #[rstest]
1934 fn test_write_data_enum() {
1935 let temp_dir = TempDir::new().unwrap();
1936 let base_path = temp_dir.path().to_str().unwrap().to_string();
1937 let local_fs = LocalFileSystem::new_with_prefix(temp_dir.path()).unwrap();
1938 let store: Arc<dyn ObjectStore> = Arc::new(local_fs);
1939 let clock = WriterClock::Test(Arc::new(AtomicU64::new(0)));
1940
1941 let mut writer = FeatherWriter::new(
1942 base_path,
1943 store,
1944 clock,
1945 RotationConfig::NoRotation,
1946 None,
1947 None,
1948 None,
1949 );
1950
1951 let quote = QuoteTick::new(
1952 InstrumentId::from("AUD/USD.SIM"),
1953 Price::from("1.0"),
1954 Price::from("1.0"),
1955 Quantity::from("1000"),
1956 Quantity::from("1000"),
1957 UnixNanos::from(1000),
1958 UnixNanos::from(1000),
1959 );
1960
1961 writer.write_data(Data::Quote(quote)).unwrap();
1963 get_runtime().block_on(writer.flush()).unwrap();
1964
1965 assert!(!writer.writers.is_empty() || temp_dir.path().read_dir().unwrap().count() > 0);
1967 }
1968
1969 #[rstest]
1970 fn test_write_data_all_types() {
1971 let temp_dir = TempDir::new().unwrap();
1972 let base_path = temp_dir.path().to_str().unwrap().to_string();
1973 let local_fs = LocalFileSystem::new_with_prefix(temp_dir.path()).unwrap();
1974 let store: Arc<dyn ObjectStore> = Arc::new(local_fs);
1975 let clock = WriterClock::Test(Arc::new(AtomicU64::new(0)));
1976
1977 let mut writer = FeatherWriter::new(
1978 base_path,
1979 store,
1980 clock,
1981 RotationConfig::NoRotation,
1982 None,
1983 None,
1984 None,
1985 );
1986
1987 let instrument_id = InstrumentId::from("AUD/USD.SIM");
1988
1989 let quote = QuoteTick::new(
1991 instrument_id,
1992 Price::from("1.0"),
1993 Price::from("1.0"),
1994 Quantity::from("1000"),
1995 Quantity::from("1000"),
1996 UnixNanos::from(1000),
1997 UnixNanos::from(1000),
1998 );
1999 writer.write_data(Data::Quote(quote)).unwrap();
2000
2001 let trade = TradeTick::new(
2002 instrument_id,
2003 Price::from("1.0"),
2004 Quantity::from("1000"),
2005 AggressorSide::Buy,
2006 TradeId::from("1"),
2007 UnixNanos::from(2000),
2008 UnixNanos::from(2000),
2009 );
2010 writer.write_data(Data::Trade(trade)).unwrap();
2011
2012 let delta = OrderBookDelta::clear(
2013 instrument_id,
2014 0,
2015 UnixNanos::from(3000),
2016 UnixNanos::from(3000),
2017 );
2018 writer.write_data(Data::BookDelta(delta)).unwrap();
2019
2020 get_runtime().block_on(writer.flush()).unwrap();
2021 }
2022
2023 #[rstest]
2024 fn test_auto_flush() {
2025 let temp_dir = TempDir::new().unwrap();
2026 let base_path = temp_dir.path().to_str().unwrap().to_string();
2027 let local_fs = LocalFileSystem::new_with_prefix(temp_dir.path()).unwrap();
2028 let store: Arc<dyn ObjectStore> = Arc::new(local_fs);
2029 let shared_time = Arc::new(AtomicU64::new(0));
2030 let clock = WriterClock::Test(Arc::clone(&shared_time));
2031
2032 let mut writer = FeatherWriter::new(
2033 base_path,
2034 store,
2035 clock,
2036 RotationConfig::NoRotation,
2037 None,
2038 None,
2039 Some(100), );
2041
2042 let quote = QuoteTick::new(
2043 InstrumentId::from("AUD/USD.SIM"),
2044 Price::from("1.0"),
2045 Price::from("1.0"),
2046 Quantity::from("1000"),
2047 Quantity::from("1000"),
2048 UnixNanos::from(1000),
2049 UnixNanos::from(1000),
2050 );
2051
2052 writer.write(quote).unwrap();
2054 assert_eq!(writer.writers.len(), 1);
2055 assert_eq!(writer.last_flush_ns, UnixNanos::from(0));
2056
2057 shared_time.store(200_000_000, Ordering::Relaxed);
2059
2060 let quote2 = QuoteTick::new(
2062 InstrumentId::from("AUD/USD.SIM"),
2063 Price::from("1.1"),
2064 Price::from("1.1"),
2065 Quantity::from("1000"),
2066 Quantity::from("1000"),
2067 UnixNanos::from(2000),
2068 UnixNanos::from(2000),
2069 );
2070 writer.write(quote2).unwrap();
2071
2072 assert_eq!(writer.writers.len(), 0);
2073 assert_eq!(writer.last_flush_ns, UnixNanos::from(200_000_000));
2074 assert_eq!(temp_dir.path().read_dir().unwrap().count(), 1);
2075 }
2076
2077 #[rstest]
2078 fn test_close() {
2079 let temp_dir = TempDir::new().unwrap();
2080 let base_path = temp_dir.path().to_str().unwrap().to_string();
2081 let local_fs = LocalFileSystem::new_with_prefix(temp_dir.path()).unwrap();
2082 let store: Arc<dyn ObjectStore> = Arc::new(local_fs);
2083 let clock = WriterClock::Test(Arc::new(AtomicU64::new(0)));
2084
2085 let mut writer = FeatherWriter::new(
2086 base_path,
2087 store,
2088 clock,
2089 RotationConfig::NoRotation,
2090 None,
2091 None,
2092 None,
2093 );
2094
2095 let quote = QuoteTick::new(
2096 InstrumentId::from("AUD/USD.SIM"),
2097 Price::from("1.0"),
2098 Price::from("1.0"),
2099 Quantity::from("1000"),
2100 Quantity::from("1000"),
2101 UnixNanos::from(1000),
2102 UnixNanos::from(1000),
2103 );
2104
2105 writer.write(quote).unwrap();
2106 assert!(!writer.writers.is_empty());
2107
2108 get_runtime().block_on(writer.close()).unwrap();
2109 assert!(writer.writers.is_empty());
2110 }
2111
2112 #[rstest]
2113 fn test_write_data_orderbook_deltas() {
2114 let temp_dir = TempDir::new().unwrap();
2115 let base_path = temp_dir.path().to_str().unwrap().to_string();
2116 let local_fs = LocalFileSystem::new_with_prefix(temp_dir.path()).unwrap();
2117 let store: Arc<dyn ObjectStore> = Arc::new(local_fs);
2118 let clock = WriterClock::Test(Arc::new(AtomicU64::new(0)));
2119
2120 let mut writer = FeatherWriter::new(
2121 base_path,
2122 store,
2123 clock,
2124 RotationConfig::NoRotation,
2125 None,
2126 None,
2127 None,
2128 );
2129
2130 let instrument_id = InstrumentId::from("AUD/USD.SIM");
2131 let delta1 = OrderBookDelta::clear(
2132 instrument_id,
2133 0,
2134 UnixNanos::from(1000),
2135 UnixNanos::from(1000),
2136 );
2137 let delta2 = OrderBookDelta::clear(
2138 instrument_id,
2139 0,
2140 UnixNanos::from(2000),
2141 UnixNanos::from(2000),
2142 );
2143
2144 let deltas = OrderBookDeltas::new(instrument_id, vec![delta1, delta2]);
2145 writer
2147 .write_data(Data::BookDeltas(Box::new(deltas)))
2148 .unwrap();
2149 get_runtime().block_on(writer.flush()).unwrap();
2150 }
2151
2152 #[rstest]
2153 fn feather_writer_is_send() {
2154 fn assert_send<T: Send>() {}
2155 assert_send::<FeatherWriter>();
2156 assert_send::<WriterClock>();
2157 }
2158
2159 #[rstest]
2160 fn writer_clock_test_source_reads_shared_atomic() {
2161 let shared = Arc::new(AtomicU64::new(7));
2162 let clock = WriterClock::Test(Arc::clone(&shared));
2163 assert_eq!(clock.timestamp_ns(), UnixNanos::from(7));
2164
2165 shared.store(42, Ordering::Relaxed);
2166 assert_eq!(clock.timestamp_ns(), UnixNanos::from(42));
2167 }
2168
2169 #[rstest]
2170 fn writer_clock_from_shared_clock_wires_test_clock() {
2171 let test_clock = Rc::new(RefCell::new(VirtualClock::new()));
2172 test_clock
2173 .borrow_mut()
2174 .advance_time(UnixNanos::from(42), true);
2175 let clock: Rc<RefCell<dyn Clock>> = test_clock.clone();
2176
2177 let (writer_clock, shared) = WriterClock::from_shared_clock(&clock);
2178 let shared = shared.expect("non-live clocks must return a shared atomic");
2179
2180 assert_eq!(writer_clock.timestamp_ns(), UnixNanos::from(42));
2182
2183 test_clock
2186 .borrow_mut()
2187 .advance_time(UnixNanos::from(99), true);
2188 shared.store(clock.borrow().timestamp_ns().as_u64(), Ordering::Relaxed);
2189 assert_eq!(writer_clock.timestamp_ns(), UnixNanos::from(99));
2190 }
2191
2192 #[rstest]
2193 fn writer_clock_from_shared_clock_live_clock_is_live() {
2194 let clock: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(LiveClock::new(None)));
2195
2196 let (writer_clock, shared) = WriterClock::from_shared_clock(&clock);
2197
2198 assert!(matches!(writer_clock, WriterClock::Live));
2199 assert!(shared.is_none());
2200 }
2201
2202 #[rstest]
2203 fn buffered_totals_tracks_bytes_and_rows() {
2204 let temp_dir = TempDir::new().unwrap();
2205 let base_path = temp_dir.path().to_str().unwrap().to_string();
2206 let local_fs = LocalFileSystem::new_with_prefix(temp_dir.path()).unwrap();
2207 let store: Arc<dyn ObjectStore> = Arc::new(local_fs);
2208 let clock = WriterClock::Test(Arc::new(AtomicU64::new(0)));
2209
2210 let mut writer = FeatherWriter::new(
2211 base_path,
2212 store,
2213 clock,
2214 RotationConfig::NoRotation,
2215 None,
2216 None,
2217 None,
2218 );
2219 assert_eq!(writer.buffered_totals(), (0, 0));
2220
2221 let quote = QuoteTick::new(
2222 InstrumentId::from("AUD/USD.SIM"),
2223 Price::from("1.0"),
2224 Price::from("1.0"),
2225 Quantity::from("1000"),
2226 Quantity::from("1000"),
2227 UnixNanos::from(1000),
2228 UnixNanos::from(1000),
2229 );
2230 let metadata = QuoteTick::metadata("e);
2231 let batch = QuoteTick::encode_batch(&metadata, &[quote]).unwrap();
2232 let expected_bytes = batch.get_array_memory_size() as u64;
2233
2234 writer.write(quote).unwrap();
2235 assert_eq!(writer.buffered_totals(), (expected_bytes, 1));
2236
2237 get_runtime().block_on(writer.flush()).unwrap();
2238 assert_eq!(writer.buffered_totals(), (0, 0));
2239 }
2240
2241 #[tokio::test]
2242 async fn size_rotation_with_due_flush_does_not_persist_empty_file() {
2243 use futures::StreamExt;
2244
2245 let temp_dir = TempDir::new().unwrap();
2246 let local_fs = LocalFileSystem::new_with_prefix(temp_dir.path()).unwrap();
2247 let store: Arc<dyn ObjectStore> = Arc::new(local_fs);
2248 let shared_clock = Arc::new(AtomicU64::new(0));
2249 let mut writer = FeatherWriter::new(
2250 temp_dir.path().to_str().unwrap().to_string(),
2251 Arc::clone(&store),
2252 WriterClock::Test(Arc::clone(&shared_clock)),
2253 RotationConfig::Size { max_size: 1 },
2254 None,
2255 None,
2256 Some(1),
2257 );
2258 shared_clock.store(1_000_000, Ordering::Relaxed);
2259
2260 writer
2261 .write(QuoteTick::new(
2262 InstrumentId::from("AUD/USD.SIM"),
2263 Price::from("1.0"),
2264 Price::from("1.0"),
2265 Quantity::from("1000"),
2266 Quantity::from("1000"),
2267 UnixNanos::from(1000),
2268 UnixNanos::from(1000),
2269 ))
2270 .unwrap();
2271
2272 let mut objects = store.list(None);
2273 let mut object_count = 0;
2274
2275 while let Some(object) = objects.next().await {
2276 object.unwrap();
2277 object_count += 1;
2278 }
2279
2280 assert_eq!(object_count, 1);
2281 }
2282
2283 #[rstest]
2284 #[case(
2285 "FeatherMissingTimestamp",
2286 Schema::empty(),
2287 "registered without an Arrow schema containing ts_init"
2288 )]
2289 #[case(
2290 "FeatherLegacyTimestamp",
2291 Schema::new(vec![Field::new("ts_init", DataType::UInt64, false)]),
2292 "registered with ts_init as UInt64",
2293 )]
2294 fn test_custom_writer_path_rejects_unqueryable_schema(
2295 #[case] type_name: &str,
2296 #[case] schema: Schema,
2297 #[case] expected_error: &str,
2298 ) {
2299 nautilus_model::data::registry::ensure_arrow_registered(
2300 type_name,
2301 Arc::new(schema),
2302 Box::new(|_| unreachable!("writer creation does not encode data")),
2303 Box::new(|_, _| unreachable!("writer creation does not decode data")),
2304 )
2305 .unwrap();
2306
2307 let mut writer = FeatherWriter::new(
2308 "run".to_string(),
2309 Arc::new(object_store::memory::InMemory::new()),
2310 WriterClock::Test(Arc::new(AtomicU64::new(0))),
2311 RotationConfig::NoRotation,
2312 None,
2313 None,
2314 None,
2315 );
2316
2317 for _ in 0..2 {
2318 let error = writer.get_writer_path_custom(type_name, None).unwrap_err();
2319
2320 assert!(error.to_string().contains(expected_error));
2321 assert!(writer.writers.is_empty());
2322 assert!(writer.reserved_paths.is_empty());
2323 }
2324 }
2325
2326 #[tokio::test]
2327 #[cfg(feature = "python")]
2328 async fn test_write_custom_data_round_trip() {
2329 use std::sync::Arc;
2330
2331 use futures::StreamExt;
2332 use nautilus_model::{
2333 data::{CustomData, Data, DataType},
2334 identifiers::InstrumentId,
2335 };
2336 use nautilus_serialization::{
2337 arrow::custom::CustomDataDecoder, ensure_custom_data_registered,
2338 };
2339
2340 use crate::test_data::RustTestCustomData;
2341
2342 ensure_custom_data_registered::<RustTestCustomData>();
2343
2344 let temp_dir = TempDir::new().unwrap();
2345 let base_path = temp_dir.path().to_str().unwrap().to_string();
2346 let local_fs = LocalFileSystem::new_with_prefix(temp_dir.path()).unwrap();
2347 let store: Arc<dyn ObjectStore> = Arc::new(local_fs);
2348 let clock = WriterClock::Test(Arc::new(AtomicU64::new(0)));
2349
2350 let mut writer = FeatherWriter::new(
2351 base_path.clone(),
2352 store.clone(),
2353 clock,
2354 RotationConfig::NoRotation,
2355 None,
2356 None,
2357 None,
2358 );
2359
2360 let instrument_id = InstrumentId::from("RUST.TEST");
2361 let data_type = DataType::new("RustTestCustomData", None, Some(instrument_id.to_string()));
2362 let original = RustTestCustomData {
2363 instrument_id,
2364 value: 1.23,
2365 flag: true,
2366 ts_event: UnixNanos::from(1000),
2367 ts_init: UnixNanos::from(1000),
2368 };
2369 let custom = CustomData::new(Arc::new(original.clone()), data_type);
2370
2371 writer
2372 .write_data(Data::Custom(custom))
2373 .expect("write_data CustomData");
2374 writer.flush().await.expect("flush");
2375
2376 let prefix = Path::from(format!("{base_path}/data/custom/RustTestCustomData"));
2377 let mut list_stream = store.list(Some(&prefix));
2378 let first = list_stream.next().await.expect("at least one object");
2379 let meta = first.expect("list item");
2380 let bytes = store
2381 .get(&meta.location)
2382 .await
2383 .expect("get")
2384 .bytes()
2385 .await
2386 .expect("bytes");
2387 let mut reader =
2388 StreamReader::try_new(Cursor::new(bytes.as_ref()), None).expect("StreamReader");
2389 let schema = reader.schema();
2390 let metadata: std::collections::HashMap<String, String> = schema
2391 .metadata()
2392 .iter()
2393 .map(|(k, v)| (k.clone(), v.clone()))
2394 .collect();
2395 let batch = reader.next().expect("batch").expect("batch ok");
2396 let decoded =
2397 CustomDataDecoder::decode_data_batch(&metadata, batch).expect("decode_data_batch");
2398 assert_eq!(decoded.len(), 1);
2399 if let Data::Custom(decoded_custom) = &decoded[0] {
2400 assert_eq!(decoded_custom.data_type.type_name(), "RustTestCustomData");
2401 let rust: &RustTestCustomData = decoded_custom
2402 .data
2403 .as_any()
2404 .downcast_ref::<RustTestCustomData>()
2405 .expect("RustTestCustomData");
2406 assert_eq!(rust, &original);
2407 } else {
2408 panic!("Expected Data::Custom");
2409 }
2410 }
2411
2412 #[tokio::test]
2413 #[cfg(feature = "python")]
2414 async fn test_write_custom_data_reuses_writer_until_rotation() {
2415 use futures::StreamExt;
2416 use nautilus_model::data::{CustomData, DataType};
2417 use nautilus_serialization::ensure_custom_data_registered;
2418
2419 use crate::test_data::RustTestCustomData;
2420
2421 ensure_custom_data_registered::<RustTestCustomData>();
2422 let temp_dir = TempDir::new().unwrap();
2423 let storage = crate::common::storage::create_storage_backend_from_path(
2424 temp_dir.path().to_str().unwrap(),
2425 None,
2426 )
2427 .unwrap();
2428 let mut writer = FeatherWriter::new(
2429 storage.base_path.clone(),
2430 storage.object_store.clone(),
2431 WriterClock::Test(Arc::new(AtomicU64::new(0))),
2432 RotationConfig::NoRotation,
2433 None,
2434 None,
2435 None,
2436 );
2437 let instrument_id = InstrumentId::from("RUST.TEST");
2438 let data_type = DataType::new("RustTestCustomData", None, Some(instrument_id.to_string()));
2439
2440 for (ts, value) in [(1_000, 1_000.0), (2_000, 2_000.0)] {
2441 writer
2442 .write_data(Data::Custom(CustomData::new(
2443 Arc::new(RustTestCustomData {
2444 instrument_id,
2445 value,
2446 flag: true,
2447 ts_event: UnixNanos::from(ts),
2448 ts_init: UnixNanos::from(ts),
2449 }),
2450 data_type.clone(),
2451 )))
2452 .unwrap();
2453 }
2454 assert_eq!(writer.get_current_file_info().len(), 1);
2455 writer.flush().await.unwrap();
2456
2457 let prefix = Path::from(format!(
2458 "{}/data/custom/RustTestCustomData",
2459 storage.base_path
2460 ));
2461 let files = storage
2462 .object_store
2463 .list(Some(&prefix))
2464 .collect::<Vec<_>>()
2465 .await
2466 .into_iter()
2467 .collect::<Result<Vec<_>, _>>()
2468 .unwrap()
2469 .into_iter()
2470 .filter(|meta| meta.location.as_ref().ends_with(".feather"))
2471 .collect::<Vec<_>>();
2472 assert_eq!(files.len(), 1);
2473 let bytes = storage
2474 .object_store
2475 .get(&files[0].location)
2476 .await
2477 .unwrap()
2478 .bytes()
2479 .await
2480 .unwrap();
2481 let rows = StreamReader::try_new(Cursor::new(bytes.as_ref()), None)
2482 .unwrap()
2483 .map(|batch| batch.unwrap().num_rows())
2484 .sum::<usize>();
2485 assert_eq!(rows, 2);
2486 }
2487}