1#![expect(
17 clippy::missing_errors_doc,
18 reason = "Feather writer public methods forward encoding and file IO errors directly"
19)]
20
21use std::{
22 any::Any,
23 borrow::Cow,
24 cell::RefCell,
25 collections::{BTreeMap, HashMap, HashSet},
26 fmt::Debug,
27 fs::{self, File, OpenOptions, TryLockError},
28 io::{self, BufReader, BufWriter, Seek, Write},
29 path::{Path, PathBuf},
30 rc::Rc,
31 sync::{
32 Arc,
33 atomic::{AtomicU64, Ordering},
34 mpsc::Sender,
35 },
36};
37
38use ahash::AHashMap;
39use datafusion::arrow::{
40 array::{ArrayRef, DictionaryArray},
41 compute::concat_batches,
42 datatypes::{DataType, Field, Int32Type, Schema, SchemaRef},
43 error::ArrowError,
44 ipc::{reader::StreamReader, writer::StreamWriter},
45 record_batch::RecordBatch,
46};
47use jiff::{
48 SignedDuration,
49 civil::Time,
50 tz::{AmbiguousOffset, TimeZone},
51};
52use nautilus_common::{clock::Clock, live::LiveClock};
53use nautilus_core::{DurationNanos, UnixNanos, time::nanos_since_unix_epoch};
54use nautilus_model::{
55 data::{
56 Bar, CustomData, CustomDataTrait, Data, DataBatch, FundingRateUpdate, IndexPriceUpdate,
57 InstrumentStatus, MarkPriceUpdate, NautilusDataType, OptionGreeks, OrderBookDelta,
58 OrderBookDeltas, OrderBookDepth, QuoteTick, TradeTick,
59 close::InstrumentClose,
60 encode_custom_to_arrow, get_arrow_schema,
61 greeks::{GreeksData, PortfolioGreeks},
62 },
63 events::{
64 AccountState, OrderAccepted, OrderCancelRejected, OrderCanceled, OrderDenied,
65 OrderEmulated, OrderEventAny, OrderExpired, OrderFillVoided, OrderFilled, OrderInitialized,
66 OrderModifyRejected, OrderPendingCancel, OrderPendingUpdate, OrderRejected, OrderReleased,
67 OrderSnapshot, OrderSubmitted, OrderTriggered, OrderUpdated, PositionAdjusted,
68 PositionChanged, PositionClosed, PositionEvent, PositionOpened, PositionSnapshot,
69 },
70 instruments::{InstrumentAny, NautilusInstrumentType},
71 reports::{ExecutionMassStatus, FillReport, OrderStatusReport, PositionStatusReport},
72};
73use nautilus_serialization::arrow::{
74 EncodeToRecordBatch, KEY_TYPE_NAME, catalog_identifier_from_metadata,
75 record_batch_with_identifier_column, schema_with_identifier_column,
76};
77
78use crate::{
79 catalog::{
80 session::DEFAULT_DATA_BATCH_CHUNK_SIZE,
81 types::{
82 CatalogDataType, CatalogFamily, data_path_prefix, instrument_path_prefix,
83 record_path_prefix,
84 },
85 },
86 common::custom::{
87 augment_batch_with_data_type_column, schema_with_data_type_column,
88 validate_custom_catalog_schema,
89 },
90 writer::{
91 filter::{WriterRecordFilter, catalog_family},
92 subscription::StreamingSinkSubscription,
93 traits::{StreamingDataSink, StreamingSink},
94 },
95};
96
97pub(crate) type FeatherWriteCommand =
98 Box<dyn FnOnce(&mut FeatherWriter) -> Result<(), Box<dyn std::error::Error>> + Send + 'static>;
99
100#[expect(
101 clippy::needless_pass_by_value,
102 reason = "map_err transfers ownership of the boxed writer error"
103)]
104pub(crate) fn feather_error(e: Box<dyn std::error::Error>) -> anyhow::Error {
105 anyhow::anyhow!("{e}")
106}
107
108macro_rules! define_builtin_data_batch_dispatch {
109 ($(($variant:ident, $type:ident, $data:ident, $batch:ident, $prefix:literal)),+ $(,)?) => {
110 fn write_builtin_data_batch(
111 writer: &mut FeatherWriter,
112 batch: &DataBatch,
113 ) -> Option<Result<(), Box<dyn std::error::Error>>> {
114 match batch {
115 $(
116 DataBatch::$batch(data) => Some(writer.write_batch(data.as_ref().to_vec())),
117 )+
118 _ => None,
119 }
120 }
121 };
122}
123
124nautilus_model::for_each_data_type!(define_builtin_data_batch_dispatch);
125
126pub(crate) const NAUTILUS_ARROW_METADATA_ID_COLUMN: &str = "nautilus_metadata_id";
127pub(crate) const NAUTILUS_ARROW_METADATA_JSON_COLUMN: &str = "nautilus_metadata_json";
128
129pub(crate) fn staged_metadata_data_type() -> DataType {
131 DataType::Dictionary(Box::new(DataType::Int32), Box::new(DataType::Utf8))
132}
133
134pub(crate) fn staged_metadata_array<'a>(values: impl IntoIterator<Item = &'a str>) -> ArrayRef {
135 Arc::new(values.into_iter().collect::<DictionaryArray<Int32Type>>())
136}
137
138#[derive(Clone, Debug, Eq, PartialEq)]
139struct StagedArrowMetadataRow {
140 metadata_id: String,
141 metadata_json: String,
142}
143
144#[derive(Debug, Clone, PartialEq, Eq, Hash)]
146pub struct FileWriterPath {
147 path: PathBuf,
148 data_type: CatalogDataType,
149}
150
151#[derive(Clone, Debug)]
157pub enum WriterClock {
158 Live,
160 Test(Arc<AtomicU64>),
162}
163
164impl WriterClock {
165 #[must_use]
167 pub fn timestamp_ns(&self) -> UnixNanos {
168 match self {
169 Self::Live => UnixNanos::from(nanos_since_unix_epoch()),
170 Self::Test(shared) => UnixNanos::from(shared.load(Ordering::Relaxed)),
171 }
172 }
173
174 #[must_use]
181 pub fn from_shared_clock(clock: &Rc<RefCell<dyn Clock>>) -> (Self, Option<Arc<AtomicU64>>) {
182 let borrowed = clock.borrow();
183 let any_ref: &dyn Any = &*borrowed;
184 if any_ref.downcast_ref::<LiveClock>().is_some() {
185 (Self::Live, None)
186 } else {
187 let shared = Arc::new(AtomicU64::new(borrowed.timestamp_ns().as_u64()));
188 (Self::Test(Arc::clone(&shared)), Some(shared))
189 }
190 }
191}
192
193fn record_batch_with_delta_staged_metadata(
194 batch: &RecordBatch,
195 metadata_rows: &[StagedArrowMetadataRow],
196) -> anyhow::Result<RecordBatch> {
197 anyhow::ensure!(
198 batch.num_rows() == metadata_rows.len(),
199 "Delta Feather staging metadata row count {} does not match batch row count {}",
200 metadata_rows.len(),
201 batch.num_rows()
202 );
203 anyhow::ensure!(
204 batch
205 .schema()
206 .index_of(NAUTILUS_ARROW_METADATA_ID_COLUMN)
207 .is_err(),
208 "Delta Feather staging schema already has {NAUTILUS_ARROW_METADATA_ID_COLUMN}"
209 );
210 anyhow::ensure!(
211 batch
212 .schema()
213 .index_of(NAUTILUS_ARROW_METADATA_JSON_COLUMN)
214 .is_err(),
215 "Delta Feather staging schema already has {NAUTILUS_ARROW_METADATA_JSON_COLUMN}"
216 );
217
218 let mut fields = batch
219 .schema()
220 .fields()
221 .iter()
222 .map(|field| {
223 Arc::new(Field::new(
224 field.name().clone(),
225 field.data_type().clone(),
226 field.is_nullable(),
227 ))
228 })
229 .collect::<Vec<_>>();
230
231 fields.push(Arc::new(Field::new(
232 NAUTILUS_ARROW_METADATA_ID_COLUMN,
233 staged_metadata_data_type(),
234 false,
235 )));
236 fields.push(Arc::new(Field::new(
237 NAUTILUS_ARROW_METADATA_JSON_COLUMN,
238 staged_metadata_data_type(),
239 false,
240 )));
241
242 let mut columns = batch.columns().to_vec();
243 columns.push(staged_metadata_array(
244 metadata_rows.iter().map(|row| row.metadata_id.as_str()),
245 ));
246 columns.push(staged_metadata_array(
247 metadata_rows.iter().map(|row| row.metadata_json.as_str()),
248 ));
249
250 Ok(RecordBatch::try_new(
251 Arc::new(Schema::new(fields)),
252 columns,
253 )?)
254}
255
256fn arrow_metadata_row(
257 metadata: &HashMap<String, String>,
258 fields: &arrow::datatypes::Fields,
259) -> anyhow::Result<StagedArrowMetadataRow> {
260 let schema_metadata = metadata
261 .iter()
262 .map(|(key, value)| (key.clone(), value.clone()))
263 .collect::<BTreeMap<_, _>>();
264
265 let field_metadata = fields
266 .iter()
267 .filter(|field| !field.metadata().is_empty())
268 .map(|field| {
269 (
270 field.name().clone(),
271 field
272 .metadata()
273 .iter()
274 .map(|(key, value)| (key.clone(), value.clone()))
275 .collect::<BTreeMap<_, _>>(),
276 )
277 })
278 .collect::<BTreeMap<_, _>>();
279
280 let metadata_json = serde_json::to_string(&serde_json::json!({
281 "format_version": 1,
282 "schema_metadata": schema_metadata,
283 "field_metadata": field_metadata,
284 }))?;
285 let metadata_id = staged_metadata_id(&canonical_metadata_json(metadata)?);
286
287 Ok(StagedArrowMetadataRow {
288 metadata_id,
289 metadata_json,
290 })
291}
292
293pub(crate) fn canonical_metadata_json(
298 metadata: &HashMap<String, String>,
299) -> anyhow::Result<String> {
300 Ok(serde_json::to_string(
301 &metadata
302 .iter()
303 .map(|(key, value)| (key.clone(), value.clone()))
304 .collect::<BTreeMap<_, _>>(),
305 )?)
306}
307
308pub(crate) fn staged_metadata_id(metadata_json: &str) -> String {
309 format!("blake3:{}", blake3::hash(metadata_json.as_bytes()).to_hex())
310}
311
312pub(crate) const FEATHER_EXTENSION: &str = "feather";
314
315pub(crate) const FEATHER_PARTIAL_EXTENSION: &str = "feather.partial";
317
318struct FeatherFile {
328 writer: StreamWriter<CountingWriter<BufWriter<File>>>,
329 partial_path: PathBuf,
330 path: PathBuf,
331 schema: SchemaRef,
332 max_size: Option<u64>,
333 buffer: Vec<RecordBatch>,
334 buffer_rows: usize,
335 buffer_bytes: u64,
336}
337
338impl FeatherFile {
339 fn create(
340 path: &Path,
341 schema: &Schema,
342 rotation_config: &RotationConfig,
343 ) -> Result<Self, Box<dyn std::error::Error>> {
344 let partial_path = path.with_extension(FEATHER_PARTIAL_EXTENSION);
345 if let Some(parent) = path.parent() {
346 fs::create_dir_all(parent)?;
347 }
348
349 let file = OpenOptions::new()
350 .write(true)
351 .create_new(true)
352 .open(&partial_path)?;
353
354 file.lock().map_err(|e| {
356 format!(
357 "Failed to lock Feather file {}: {e}",
358 partial_path.display()
359 )
360 })?;
361
362 let writer = StreamWriter::try_new(CountingWriter::new(BufWriter::new(file)), schema)?;
363
364 let max_size = match rotation_config {
365 RotationConfig::Size { max_size } => Some(*max_size),
366 _ => None,
367 };
368
369 Ok(Self {
370 writer,
371 partial_path,
372 path: path.to_path_buf(),
373 schema: Arc::new(schema.clone()),
374 max_size,
375 buffer: Vec::new(),
376 buffer_rows: 0,
377 buffer_bytes: 0,
378 })
379 }
380
381 fn write_record_batch(&mut self, batch: &RecordBatch) -> Result<bool, ArrowError> {
383 let batch = if batch.schema() == self.schema {
384 batch.clone()
385 } else {
386 RecordBatch::try_new(self.schema.clone(), batch.columns().to_vec())?
387 };
388
389 self.buffer_rows += batch.num_rows();
390 self.buffer_bytes += batch.get_array_memory_size() as u64;
391 self.buffer.push(batch);
392
393 if self.buffer_rows >= DEFAULT_DATA_BATCH_CHUNK_SIZE {
394 self.write_buffer()?;
395 }
396
397 let Some(max_size) = self.max_size else {
398 return Ok(false);
399 };
400
401 if self.size() + self.buffer_bytes >= max_size {
403 self.write_buffer()?;
404 }
405
406 Ok(self.size() >= max_size)
407 }
408
409 fn write_buffer(&mut self) -> Result<(), ArrowError> {
410 if self.buffer.is_empty() {
411 return Ok(());
412 }
413
414 let batch = concat_batches(&self.schema, &self.buffer)?;
415 self.buffer.clear();
416 self.buffer_rows = 0;
417 self.buffer_bytes = 0;
418 self.writer.write(&batch)
419 }
420
421 fn size(&self) -> u64 {
422 self.writer.get_ref().bytes
423 }
424
425 fn flush(&mut self) -> Result<(), ArrowError> {
426 self.write_buffer()?;
427 self.writer.flush()
428 }
429
430 fn seal(mut self) -> Result<(), Box<dyn std::error::Error>> {
431 self.write_buffer()?;
432 self.writer.finish()?;
433 let file = self
434 .writer
435 .into_inner()?
436 .inner
437 .into_inner()
438 .map_err(io::IntoInnerError::into_error)?;
439 file.sync_all()?;
440
441 fs::rename(&self.partial_path, &self.path)?;
443 Ok(())
444 }
445}
446
447struct CountingWriter<W> {
449 inner: W,
450 bytes: u64,
451}
452
453impl<W> CountingWriter<W> {
454 const fn new(inner: W) -> Self {
455 Self { inner, bytes: 0 }
456 }
457}
458
459impl<W: Write> Write for CountingWriter<W> {
460 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
461 let written = self.inner.write(buf)?;
462 self.bytes += written as u64;
463 Ok(written)
464 }
465
466 fn flush(&mut self) -> io::Result<()> {
467 self.inner.flush()
468 }
469}
470
471#[derive(Debug, Default)]
473struct PendingIo {
474 rotate_paths: Vec<FileWriterPath>,
475 flush_due: bool,
476}
477
478#[derive(Debug, Clone)]
480pub enum RotationConfig {
481 Size {
483 max_size: u64,
485 },
486 Interval {
488 interval_ns: u64,
490 },
491 ScheduledDates {
493 interval_ns: u64,
495 rotation_time: UnixNanos,
497 rotation_timezone: TimeZone,
499 },
500 NoRotation,
502}
503
504pub struct FeatherWriter {
526 directory: PathBuf,
527 clock: WriterClock,
528 rotation_config: RotationConfig,
529 included_types: Option<HashSet<CatalogDataType>>,
530 record_filter: Option<WriterRecordFilter>,
531 writers: HashMap<FileWriterPath, FeatherFile>,
532 path_sequences: HashMap<CatalogDataType, (UnixNanos, u64)>,
533 sealed_tx: Option<Sender<PathBuf>>,
534 next_rotation_times: HashMap<FileWriterPath, UnixNanos>,
535 flush_interval_ms: u64,
536 last_flush_ns: UnixNanos,
537 pending_write_error: Option<String>,
538}
539
540impl FeatherWriter {
541 #[must_use]
543 pub fn new(
544 directory: PathBuf,
545 clock: WriterClock,
546 rotation_config: RotationConfig,
547 included_types: Option<HashSet<CatalogDataType>>,
548 flush_interval_ms: Option<u64>,
549 ) -> Self {
550 let flush_interval_ms = flush_interval_ms.unwrap_or(1000); let last_flush_ns = clock.timestamp_ns();
552
553 Self {
554 directory,
555 clock,
556 rotation_config,
557 included_types,
558 record_filter: None,
559 writers: HashMap::new(),
560 path_sequences: HashMap::new(),
561 sealed_tx: None,
562 next_rotation_times: HashMap::new(),
563 flush_interval_ms,
564 last_flush_ns,
565 pending_write_error: None,
566 }
567 }
568
569 #[must_use]
571 pub fn with_record_filter(mut self, record_filter: Option<WriterRecordFilter>) -> Self {
572 self.record_filter = record_filter;
573 self
574 }
575
576 pub(crate) fn with_sealed_sender(mut self, sealed_tx: Sender<PathBuf>) -> Self {
577 self.sealed_tx = Some(sealed_tx);
578 self
579 }
580
581 pub fn write<T>(&mut self, data: T) -> Result<(), Box<dyn std::error::Error>>
586 where
587 T: EncodeToRecordBatch + CatalogFamily + 'static,
588 {
589 let metadata = T::metadata(&data);
590 let identifier = catalog_identifier_from_metadata(&metadata);
591 let data_type = Self::staged_data_type::<T>(&metadata)?;
592
593 if !self.should_write_record(&data_type, identifier.as_deref()) {
594 return Ok(());
595 }
596
597 let path = self.get_writer_path(data_type);
598
599 if !self.writers.contains_key(&path) {
601 self.create_writer::<T>(path.clone(), &data)?;
602 }
603
604 let batch = T::encode_batch(&metadata, &[data])?;
606 let metadata_row = arrow_metadata_row(&metadata, batch.schema().fields())?;
607 let batch = record_batch_with_identifier_column(batch, identifier.as_deref())?;
608 let batch =
609 record_batch_with_delta_staged_metadata(&batch, std::slice::from_ref(&metadata_row))?;
610
611 let mut pending = PendingIo::default();
613
614 self.stage_batch_write(path, &batch, &mut pending)?;
615 pending.flush_due = self.flush_is_due();
616
617 self.complete_pending_io(&pending)
618 }
619
620 pub fn write_batch<T>(&mut self, data: Vec<T>) -> Result<(), Box<dyn std::error::Error>>
630 where
631 T: EncodeToRecordBatch + CatalogFamily + 'static,
632 {
633 if data.is_empty() || !self.should_write_family(&T::catalog_family()) {
634 return Ok(());
635 }
636
637 let mut groups: AHashMap<Option<String>, (CatalogDataType, Vec<T>)> = AHashMap::new();
638
639 for item in data {
640 let metadata = T::metadata(&item);
641 let identifier = catalog_identifier_from_metadata(&metadata);
642 let data_type = Self::staged_data_type::<T>(&metadata)?;
643
644 if !self.should_write_record(&data_type, identifier.as_deref()) {
645 continue;
646 }
647
648 groups
649 .entry(identifier)
650 .or_insert_with(|| (data_type, Vec::new()))
651 .1
652 .push(item);
653 }
654
655 if groups.is_empty() {
656 return Ok(());
657 }
658
659 let mut pending = PendingIo::default();
660
661 for (data_type, group) in groups.into_values() {
662 let path = self.get_writer_path(data_type);
663 let metadata = T::chunk_metadata(&group);
664
665 if !self.writers.contains_key(&path) {
666 self.create_writer_with_metadata::<T>(path.clone(), metadata.clone())?;
667 }
668
669 let identifier = catalog_identifier_from_metadata(&metadata);
670 let batch = T::encode_batch(&metadata, &group)?;
671 let metadata_rows = group
672 .iter()
673 .map(T::metadata)
674 .map(|metadata| arrow_metadata_row(&metadata, batch.schema().fields()))
675 .collect::<anyhow::Result<Vec<_>>>()?;
676 let batch = record_batch_with_identifier_column(batch, identifier.as_deref())?;
677 let batch = record_batch_with_delta_staged_metadata(&batch, &metadata_rows)?;
678
679 self.stage_batch_write(path, &batch, &mut pending)?;
680 }
681
682 pending.flush_due = self.flush_is_due();
683
684 self.complete_pending_io(&pending)
685 }
686
687 fn staged_data_type<T: CatalogFamily>(
689 metadata: &HashMap<String, String>,
690 ) -> Result<CatalogDataType, Box<dyn std::error::Error>> {
691 let family = T::catalog_family();
692 if family != CatalogDataType::Data(NautilusDataType::Instrument) {
693 return Ok(family);
694 }
695
696 let class = metadata
697 .get(KEY_TYPE_NAME)
698 .ok_or("Instrument metadata has no type_name")?
699 .parse::<NautilusInstrumentType>()?;
700 Ok(CatalogDataType::Instrument(class))
701 }
702
703 fn stage_batch_write(
704 &mut self,
705 path: FileWriterPath,
706 batch: &RecordBatch,
707 pending: &mut PendingIo,
708 ) -> Result<(), Box<dyn std::error::Error>> {
709 let Some(file) = self.writers.get_mut(&path) else {
710 return Ok(());
711 };
712
713 let should_rotate = match file.write_record_batch(batch) {
714 Ok(should_rotate) => should_rotate,
715 Err(e) => {
716 self.abandon_file(&path);
717 return Err(e.into());
718 }
719 };
720
721 if should_rotate || self.check_scheduled_rotation(&path) {
722 pending.rotate_paths.push(path);
723 }
724
725 Ok(())
726 }
727
728 fn flush_is_due(&self) -> bool {
730 if self.flush_interval_ms == 0 {
731 return false; }
733
734 let now_ns = self.clock.timestamp_ns();
735 let elapsed_ms = now_ns.as_u64().saturating_sub(self.last_flush_ns.as_u64()) / 1_000_000;
736 elapsed_ms >= self.flush_interval_ms
737 }
738
739 fn complete_pending_io(
740 &mut self,
741 pending: &PendingIo,
742 ) -> Result<(), Box<dyn std::error::Error>> {
743 for path in &pending.rotate_paths {
744 self.seal_file(path)?;
745 }
746
747 if pending.flush_due {
748 self.flush()?;
749 }
750
751 Ok(())
752 }
753
754 fn check_scheduled_rotation(&mut self, path: &FileWriterPath) -> bool {
755 match &self.rotation_config {
756 RotationConfig::Interval { interval_ns } => {
757 let now = self.clock.timestamp_ns();
758 let next_rotation = self.next_rotation_times.get(path).copied();
759
760 match next_rotation {
761 None => {
762 self.next_rotation_times
763 .insert(path.clone(), now + DurationNanos::new(*interval_ns));
764 false
765 }
766 Some(next) if now >= next => {
767 self.next_rotation_times
768 .insert(path.clone(), now + DurationNanos::new(*interval_ns));
769 true
770 }
771 _ => false,
772 }
773 }
774 RotationConfig::ScheduledDates {
775 interval_ns,
776 rotation_time,
777 rotation_timezone,
778 } => {
779 let now = self.clock.timestamp_ns();
780 let next_rotation = self.next_rotation_times.get(path).copied();
781
782 match next_rotation {
783 None => {
784 let next = self.calculate_next_scheduled_rotation(
785 *rotation_time,
786 rotation_timezone,
787 *interval_ns,
788 );
789 self.next_rotation_times.insert(path.clone(), next);
790 false
791 }
792 Some(next) if now >= next => {
793 let next = self.calculate_next_scheduled_rotation(
794 *rotation_time,
795 rotation_timezone,
796 *interval_ns,
797 );
798 self.next_rotation_times.insert(path.clone(), next);
799 true
800 }
801 _ => false,
802 }
803 }
804 _ => false,
805 }
806 }
807
808 fn calculate_next_scheduled_rotation(
809 &self,
810 rotation_time: UnixNanos,
811 rotation_timezone: &TimeZone,
812 interval_ns: u64,
813 ) -> UnixNanos {
814 let now_utc = self.clock.timestamp_ns().to_datetime_utc();
815 let now_local = rotation_timezone.to_datetime(now_utc);
816
817 let rotation_time_secs = u32::try_from(*rotation_time / 1_000_000_000).unwrap_or(0);
818 let rotation_time_nanos = i32::try_from(*rotation_time % 1_000_000_000).unwrap_or(0);
819
820 let rotation_time = if rotation_time_secs < 86_400 {
821 Time::new(
822 i8::try_from(rotation_time_secs / 3_600).unwrap_or(0),
823 i8::try_from(rotation_time_secs % 3_600 / 60).unwrap_or(0),
824 i8::try_from(rotation_time_secs % 60).unwrap_or(0),
825 rotation_time_nanos,
826 )
827 .unwrap_or(Time::MIN)
828 } else {
829 Time::MIN
830 };
831
832 let local_rotation = now_local.date().to_datetime(rotation_time);
833 let ambiguous = rotation_timezone.to_ambiguous_timestamp(local_rotation);
834
835 let mut next_rotation = match ambiguous.offset() {
836 AmbiguousOffset::Gap { .. } => now_utc,
837 _ => ambiguous.earlier().unwrap_or(now_utc),
838 };
839
840 if next_rotation <= now_utc {
841 while next_rotation <= now_utc {
844 next_rotation += SignedDuration::from_nanos_i128(i128::from(interval_ns));
845 }
846 }
847
848 UnixNanos::from(u64::try_from(next_rotation.as_nanosecond()).unwrap_or(0))
849 }
850
851 fn seal_file(&mut self, path: &FileWriterPath) -> Result<(), Box<dyn std::error::Error>> {
854 self.next_rotation_times.remove(path);
855
856 let Some(file) = self.writers.remove(path) else {
857 return Ok(());
858 };
859
860 let partial_path = file.partial_path.clone();
861 let result = file
862 .seal()
863 .inspect_err(|_| recover_released_feather_file(&partial_path));
864 self.notify_sealed(path);
865 result
866 }
867
868 fn abandon_file(&mut self, path: &FileWriterPath) {
871 self.next_rotation_times.remove(path);
872
873 let Some(file) = self.writers.remove(path) else {
874 return;
875 };
876
877 let partial_path = file.partial_path.clone();
878 drop(file);
879
880 log::error!(
881 "Abandoned Feather file {} after a failed write",
882 path.path.display()
883 );
884
885 recover_released_feather_file(&partial_path);
886 self.notify_sealed(path);
887 }
888
889 fn notify_sealed(&self, path: &FileWriterPath) {
890 if let Some(sealed_tx) = &self.sealed_tx
891 && path.path.exists()
892 {
893 let _ = sealed_tx.send(path.path.clone());
894 }
895 }
896
897 fn create_writer<T>(
899 &mut self,
900 path: FileWriterPath,
901 data: &T,
902 ) -> Result<(), Box<dyn std::error::Error>>
903 where
904 T: EncodeToRecordBatch + CatalogFamily + 'static,
905 {
906 self.create_writer_with_metadata::<T>(path, T::metadata(data))
907 }
908
909 fn create_writer_with_metadata<T>(
914 &mut self,
915 path: FileWriterPath,
916 metadata: HashMap<String, String>,
917 ) -> Result<(), Box<dyn std::error::Error>>
918 where
919 T: EncodeToRecordBatch + CatalogFamily + 'static,
920 {
921 let schema = Self::schema_with_delta_staging_columns(&schema_with_identifier_column(
922 &T::get_schema(Some(metadata)),
923 ));
924
925 let file = FeatherFile::create(&path.path, &schema, &self.rotation_config)?;
926 self.writers.insert(path, file);
927 Ok(())
928 }
929
930 fn create_custom_writer(
932 &mut self,
933 path: FileWriterPath,
934 type_name: &str,
935 ) -> Result<(), Box<dyn std::error::Error>> {
936 if self.writers.contains_key(&path) {
937 return Ok(());
938 }
939
940 let base_schema = get_arrow_schema(type_name).ok_or_else(|| {
941 format!("Custom data type \"{type_name}\" is not registered for Arrow encoding")
942 })?;
943
944 let schema = schema_with_data_type_column(base_schema.as_ref(), type_name);
945
946 let schema =
947 Self::schema_with_delta_staging_columns(&schema_with_identifier_column(&schema));
948
949 let file = FeatherFile::create(&path.path, &schema, &self.rotation_config)
950 .map_err(|e| format!("Failed to create Feather file for custom {type_name}: {e}"))?;
951 self.writers.insert(path, file);
952 Ok(())
953 }
954
955 pub(crate) fn encode_custom_to_batch(
957 custom: &CustomData,
958 ) -> Result<RecordBatch, Box<dyn std::error::Error>> {
959 let type_name = custom.data.type_name();
960 let data_type_json = custom
961 .data_type
962 .to_persistence_json()
963 .map_err(|e| format!("Failed to serialize data_type for persistence: {e}"))?;
964 let dt_meta = custom.data_type.metadata_string_map();
965 let items: [Arc<dyn CustomDataTrait>; 1] = [Arc::clone(&custom.data)];
966
967 let batch = encode_custom_to_arrow(type_name, &items)
968 .map_err(|e| format!("Failed to encode custom data: {e}"))?
969 .ok_or_else(|| {
970 format!("Custom data type \"{type_name}\" is not registered for Arrow")
971 })?;
972
973 let batch = augment_batch_with_data_type_column(
974 &batch,
975 &data_type_json,
976 type_name,
977 dt_meta.as_ref(),
978 )
979 .map_err(|e| e.to_string())?;
980 Ok(batch)
981 }
982
983 fn schema_with_delta_staging_columns(schema: &Schema) -> Schema {
984 let mut fields = schema
985 .fields()
986 .iter()
987 .map(|field| {
988 Arc::new(Field::new(
989 field.name().clone(),
990 field.data_type().clone(),
991 field.is_nullable(),
992 ))
993 })
994 .collect::<Vec<_>>();
995
996 if schema.index_of(NAUTILUS_ARROW_METADATA_ID_COLUMN).is_err() {
997 fields.push(Arc::new(Field::new(
998 NAUTILUS_ARROW_METADATA_ID_COLUMN,
999 staged_metadata_data_type(),
1000 false,
1001 )));
1002 }
1003
1004 if schema
1005 .index_of(NAUTILUS_ARROW_METADATA_JSON_COLUMN)
1006 .is_err()
1007 {
1008 fields.push(Arc::new(Field::new(
1009 NAUTILUS_ARROW_METADATA_JSON_COLUMN,
1010 staged_metadata_data_type(),
1011 false,
1012 )));
1013 }
1014
1015 Schema::new(fields)
1016 }
1017
1018 pub fn flush(&mut self) -> Result<(), Box<dyn std::error::Error>> {
1023 let paths = self.writers.keys().cloned().collect::<Vec<_>>();
1024
1025 for path in paths {
1026 if let Some(file) = self.writers.get_mut(&path)
1027 && let Err(e) = file.flush()
1028 {
1029 self.abandon_file(&path);
1030 return Err(e.into());
1031 }
1032 }
1033
1034 self.last_flush_ns = self.clock.timestamp_ns();
1035
1036 if let Some(error) = self.pending_write_error.take() {
1037 return Err(error.into());
1038 }
1039
1040 Ok(())
1041 }
1042
1043 pub fn seal(&mut self) -> Result<(), Box<dyn std::error::Error>> {
1047 let paths = self.writers.keys().cloned().collect::<Vec<_>>();
1048 let mut first_error = None;
1049
1050 for path in paths {
1051 if let Err(e) = self.seal_file(&path) {
1052 log::error!("Failed to seal Feather file {}: {e}", path.path.display());
1053 first_error.get_or_insert(e);
1054 }
1055 }
1056
1057 match first_error {
1058 Some(e) => Err(e),
1059 None => Ok(()),
1060 }
1061 }
1062
1063 pub fn close(&mut self) -> Result<(), Box<dyn std::error::Error>> {
1067 self.seal()?;
1068
1069 if let Some(error) = self.pending_write_error.take() {
1070 return Err(error.into());
1071 }
1072
1073 Ok(())
1074 }
1075
1076 #[must_use]
1078 pub fn is_closed(&self) -> bool {
1079 self.writers.is_empty()
1080 }
1081
1082 #[must_use]
1084 pub fn get_current_file_info(&self) -> HashMap<CatalogDataType, (u64, String)> {
1085 self.writers
1086 .iter()
1087 .map(|(path, file)| {
1088 (
1089 path.data_type.clone(),
1090 (file.size(), path.path.display().to_string()),
1091 )
1092 })
1093 .collect()
1094 }
1095
1096 #[must_use]
1100 pub fn get_next_rotation_time(&self, data_type: &CatalogDataType) -> Option<UnixNanos> {
1101 self.next_rotation_times
1102 .iter()
1103 .find(|(path, _)| path.data_type == *data_type)
1104 .map(|(_, &next)| next)
1105 }
1106
1107 fn should_write_family(&self, family: &CatalogDataType) -> bool {
1109 self.included_types
1110 .as_ref()
1111 .is_none_or(|included| included.contains(family))
1112 && self
1113 .record_filter
1114 .as_ref()
1115 .is_none_or(|filter| filter.contains(family))
1116 }
1117
1118 fn should_write_record(&self, data_type: &CatalogDataType, identifier: Option<&str>) -> bool {
1119 self.included_types
1120 .as_ref()
1121 .is_none_or(|included| included.contains(&catalog_family(data_type)))
1122 && self
1123 .record_filter
1124 .as_ref()
1125 .is_none_or(|filter| filter.allows(data_type, identifier))
1126 }
1127
1128 fn reserve_writer_path(&mut self, data_type: CatalogDataType) -> FileWriterPath {
1129 let now = self.clock.timestamp_ns();
1130 let (timestamp, first_sequence) = self
1131 .path_sequences
1132 .get(&data_type)
1133 .copied()
1134 .filter(|(timestamp, _)| *timestamp >= now)
1135 .unwrap_or((now, 0));
1136
1137 for sequence in first_sequence.. {
1138 let path = self.build_writer_path(&data_type, timestamp, sequence);
1139
1140 if path.exists() || path.with_extension(FEATHER_PARTIAL_EXTENSION).exists() {
1142 continue;
1143 }
1144
1145 self.path_sequences
1146 .insert(data_type.clone(), (timestamp, sequence + 1));
1147 return FileWriterPath { path, data_type };
1148 }
1149
1150 unreachable!("unbounded writer path sequence exhausted")
1151 }
1152
1153 fn build_writer_path(
1154 &self,
1155 data_type: &CatalogDataType,
1156 timestamp: UnixNanos,
1157 sequence: u64,
1158 ) -> PathBuf {
1159 let (directory, file_stem) = match data_type {
1160 CatalogDataType::Data(NautilusDataType::Custom { type_name }) => (
1161 self.directory.join("data").join("custom").join(type_name),
1162 Cow::Borrowed(type_name.as_str()),
1163 ),
1164 CatalogDataType::Data(data_type) => {
1165 let prefix = data_path_prefix(data_type);
1166 (self.directory.join(prefix.as_ref()), prefix)
1167 }
1168 CatalogDataType::Record(record_type) => {
1169 let prefix = record_path_prefix(record_type);
1170 (self.directory.join(prefix.as_ref()), prefix)
1171 }
1172 CatalogDataType::Instrument(class) => {
1173 let prefix = data_path_prefix(&NautilusDataType::Instrument);
1174 (
1175 self.directory
1176 .join(prefix.as_ref())
1177 .join(instrument_path_prefix(class)),
1178 prefix,
1179 )
1180 }
1181 };
1182
1183 directory.join(Self::timestamped_feather_file_name(
1184 &file_stem, timestamp, sequence,
1185 ))
1186 }
1187
1188 fn timestamped_feather_file_name(stem: &str, timestamp: UnixNanos, sequence: u64) -> String {
1189 if sequence == 0 {
1190 format!("{stem}_{timestamp}.{FEATHER_EXTENSION}")
1191 } else {
1192 format!("{stem}_{timestamp}-{sequence}.{FEATHER_EXTENSION}")
1193 }
1194 }
1195
1196 fn get_writer_path_custom(
1198 &mut self,
1199 type_name: &str,
1200 ) -> Result<FileWriterPath, Box<dyn std::error::Error>> {
1201 let data_type = CatalogDataType::from(NautilusDataType::Custom {
1202 type_name: type_name.to_string(),
1203 });
1204
1205 if let Some(existing) = self.writers.keys().find(|path| path.data_type == data_type) {
1206 return Ok(existing.clone());
1207 }
1208
1209 if let Some(schema) = get_arrow_schema(type_name) {
1210 validate_custom_catalog_schema(type_name, &schema)?;
1211 }
1212
1213 Ok(self.reserve_writer_path(data_type))
1214 }
1215
1216 fn get_writer_path(&mut self, data_type: CatalogDataType) -> FileWriterPath {
1218 if let Some(existing) = self.writers.keys().find(|path| path.data_type == data_type) {
1219 return existing.clone();
1220 }
1221
1222 self.reserve_writer_path(data_type)
1223 }
1224
1225 pub fn write_data(&mut self, data: Data) -> Result<(), Box<dyn std::error::Error>> {
1230 match data {
1231 Data::Instrument(instrument) => self.write(*instrument),
1232 Data::Quote(quote) => self.write(quote),
1233 Data::Trade(trade) => self.write(trade),
1234 Data::Bar(bar) => self.write(bar),
1235 Data::BookDelta(delta) => self.write(delta),
1236 Data::BookDepth(depth) => self.write(*depth),
1237 Data::IndexPrice(price) => self.write(price),
1238 Data::MarkPrice(price) => self.write(price),
1239 Data::FundingRate(funding) => self.write(funding),
1240 Data::InstrumentStatus(status) => self.write(status),
1241 Data::OptionGreeks(greeks) => self.write(greeks),
1242 Data::InstrumentClose(close) => self.write(close),
1243 Data::Custom(custom) => self.write_custom_data(&custom),
1244 Data::BookDeltas(deltas_api) => {
1245 self.write_batch(deltas_api.deltas.clone())
1247 }
1248 #[cfg(feature = "defi")]
1249 Data::Defi(_) => Err("Unsupported DeFi data variant for feather writes".into()),
1250 #[cfg(not(feature = "defi"))]
1251 #[allow(
1252 unreachable_patterns,
1253 reason = "DeFi variants can exist without this crate's defi feature"
1254 )]
1255 _ => Err("Unsupported DeFi data variant for feather writes".into()),
1256 }
1257 }
1258
1259 #[expect(
1265 clippy::needless_pass_by_value,
1266 reason = "the public writer API accepts ownership of each submitted data batch"
1267 )]
1268 pub fn write_data_batch(&mut self, data: Vec<Data>) -> Result<(), Box<dyn std::error::Error>> {
1269 for batch in DataBatch::from_data_vec_grouped(&data)? {
1270 match &batch {
1271 DataBatch::Custom(data) => {
1272 for custom in data.as_ref() {
1273 self.write_custom_data(custom)?;
1274 }
1275 }
1276 batch => write_builtin_data_batch(self, batch)
1277 .expect("built-in data batch dispatch is exhaustive")?,
1278 }
1279 }
1280
1281 Ok(())
1282 }
1283
1284 pub fn write_any_message(
1286 &mut self,
1287 message: &dyn Any,
1288 ) -> Result<bool, Box<dyn std::error::Error>> {
1289 let Some(command) = Self::write_command(message) else {
1290 return Ok(false);
1291 };
1292
1293 if let Err(e) = command(self) {
1294 self.record_write_error(e.to_string());
1295 return Err(e);
1296 }
1297
1298 Ok(true)
1299 }
1300
1301 pub(crate) fn record_write_error(&mut self, error: String) {
1302 self.pending_write_error.get_or_insert(error);
1303 }
1304
1305 pub(crate) fn write_command(message: &dyn Any) -> Option<FeatherWriteCommand> {
1306 macro_rules! try_write {
1307 ($message:expr, $type:ty) => {
1308 if let Some(value) = $message.downcast_ref::<$type>() {
1309 let value = value.clone();
1310 return Some(Box::new(move |writer| writer.write(value)));
1311 }
1312 };
1313 }
1314
1315 try_write!(message, QuoteTick);
1316 try_write!(message, TradeTick);
1317 try_write!(message, Bar);
1318 try_write!(message, OrderBookDelta);
1319 try_write!(message, OrderBookDepth);
1320 try_write!(message, IndexPriceUpdate);
1321 try_write!(message, MarkPriceUpdate);
1322 try_write!(message, FundingRateUpdate);
1323 try_write!(message, InstrumentStatus);
1324 try_write!(message, OptionGreeks);
1325 try_write!(message, InstrumentClose);
1326 try_write!(message, InstrumentAny);
1327 try_write!(message, AccountState);
1328 try_write!(message, OrderInitialized);
1329 try_write!(message, OrderDenied);
1330 try_write!(message, OrderEmulated);
1331 try_write!(message, OrderSubmitted);
1332 try_write!(message, OrderAccepted);
1333 try_write!(message, OrderRejected);
1334 try_write!(message, OrderPendingCancel);
1335 try_write!(message, OrderCanceled);
1336 try_write!(message, OrderCancelRejected);
1337 try_write!(message, OrderExpired);
1338 try_write!(message, OrderTriggered);
1339 try_write!(message, OrderPendingUpdate);
1340 try_write!(message, OrderReleased);
1341 try_write!(message, OrderModifyRejected);
1342 try_write!(message, OrderUpdated);
1343 try_write!(message, OrderFilled);
1344 try_write!(message, OrderFillVoided);
1345 try_write!(message, PositionOpened);
1346 try_write!(message, PositionChanged);
1347 try_write!(message, PositionClosed);
1348 try_write!(message, PositionAdjusted);
1349 try_write!(message, OrderSnapshot);
1350 try_write!(message, PositionSnapshot);
1351 try_write!(message, OrderStatusReport);
1352 try_write!(message, FillReport);
1353 try_write!(message, PositionStatusReport);
1354 try_write!(message, ExecutionMassStatus);
1355 try_write!(message, GreeksData);
1356 try_write!(message, PortfolioGreeks);
1357
1358 if let Some(deltas) = message.downcast_ref::<OrderBookDeltas>() {
1359 let deltas = deltas.deltas.clone();
1360 return Some(Box::new(move |writer| writer.write_batch(deltas)));
1361 }
1362
1363 if let Some(data) = message.downcast_ref::<Data>() {
1364 let data = data.clone();
1365 return Some(Box::new(move |writer| writer.write_data(data)));
1366 }
1367
1368 if let Some(custom) = message.downcast_ref::<CustomData>() {
1369 let custom = custom.clone();
1370 return Some(Box::new(move |writer| {
1371 writer.write_data(Data::Custom(custom))
1372 }));
1373 }
1374
1375 if let Some(event) = message.downcast_ref::<OrderEventAny>() {
1376 let event = event.clone();
1377 return Some(Box::new(move |writer| writer.write_order_event(&event)));
1378 }
1379
1380 if let Some(event) = message.downcast_ref::<PositionEvent>() {
1381 let event = event.clone();
1382 return Some(Box::new(move |writer| writer.write_position_event(&event)));
1383 }
1384
1385 None
1386 }
1387
1388 fn write_order_event(
1389 &mut self,
1390 event: &OrderEventAny,
1391 ) -> Result<(), Box<dyn std::error::Error>> {
1392 match event {
1393 OrderEventAny::Initialized(event) => self.write(event.clone()),
1394 OrderEventAny::Denied(event) => self.write(*event),
1395 OrderEventAny::Emulated(event) => self.write(*event),
1396 OrderEventAny::Released(event) => self.write(*event),
1397 OrderEventAny::Submitted(event) => self.write(*event),
1398 OrderEventAny::Accepted(event) => self.write(*event),
1399 OrderEventAny::Rejected(event) => self.write(*event),
1400 OrderEventAny::Canceled(event) => self.write(*event),
1401 OrderEventAny::Expired(event) => self.write(*event),
1402 OrderEventAny::Triggered(event) => self.write(*event),
1403 OrderEventAny::PendingUpdate(event) => self.write(*event),
1404 OrderEventAny::PendingCancel(event) => self.write(*event),
1405 OrderEventAny::ModifyRejected(event) => self.write(*event),
1406 OrderEventAny::CancelRejected(event) => self.write(*event),
1407 OrderEventAny::Updated(event) => self.write(*event),
1408 OrderEventAny::Filled(event) => self.write(event.clone()),
1409 OrderEventAny::FillVoided(event) => self.write(event.clone()),
1410 }
1411 }
1412
1413 fn write_position_event(
1414 &mut self,
1415 event: &PositionEvent,
1416 ) -> Result<(), Box<dyn std::error::Error>> {
1417 match event {
1418 PositionEvent::PositionOpened(event) => self.write(event.clone()),
1419 PositionEvent::PositionChanged(event) => self.write(event.clone()),
1420 PositionEvent::PositionClosed(event) => self.write(event.clone()),
1421 PositionEvent::PositionAdjusted(event) => self.write(*event),
1422 }
1423 }
1424
1425 fn write_custom_data(&mut self, custom: &CustomData) -> Result<(), Box<dyn std::error::Error>> {
1427 let batch = Self::encode_custom_to_batch(custom)?;
1428 self.write_custom_batch(custom, batch)
1429 }
1430
1431 pub(crate) fn write_custom_batch(
1432 &mut self,
1433 custom: &CustomData,
1434 mut batch: RecordBatch,
1435 ) -> Result<(), Box<dyn std::error::Error>> {
1436 let type_name = custom.data.type_name();
1437 let identifier = custom.data_type.identifier().map(String::from);
1438
1439 if !self.should_write_custom(type_name, identifier.as_deref()) {
1440 return Ok(());
1441 }
1442
1443 let path = self.get_writer_path_custom(type_name)?;
1444 if !self.writers.contains_key(&path) {
1445 self.create_custom_writer(path.clone(), type_name)?;
1446 }
1447
1448 let metadata_row = arrow_metadata_row(batch.schema().metadata(), batch.schema().fields())?;
1449 batch = record_batch_with_identifier_column(batch, custom.data_type.identifier())?;
1450 batch =
1451 record_batch_with_delta_staged_metadata(&batch, std::slice::from_ref(&metadata_row))?;
1452
1453 let mut pending = PendingIo::default();
1454
1455 self.stage_batch_write(path, &batch, &mut pending)?;
1456 pending.flush_due = self.flush_is_due();
1457
1458 self.complete_pending_io(&pending)
1459 }
1460
1461 pub(crate) fn should_write_custom(&self, type_name: &str, identifier: Option<&str>) -> bool {
1462 self.should_write_record(
1463 &CatalogDataType::from(NautilusDataType::Custom {
1464 type_name: type_name.to_string(),
1465 }),
1466 identifier,
1467 )
1468 }
1469
1470 pub fn write_instrument(
1474 &mut self,
1475 instrument: InstrumentAny,
1476 ) -> Result<(), Box<dyn std::error::Error>> {
1477 self.write(instrument)
1478 }
1479
1480 pub fn subscribe_to_message_bus(
1489 writer: Rc<RefCell<Self>>,
1490 ) -> Result<StreamingSinkSubscription, Box<dyn std::error::Error>> {
1491 let sink: StreamingDataSink = Box::new(writer);
1492 Ok(StreamingSinkSubscription::subscribe(
1493 Rc::new(RefCell::new(sink)),
1494 None,
1495 ))
1496 }
1497
1498 pub fn unsubscribe_from_message_bus(handler: &StreamingSinkSubscription) {
1500 handler.unsubscribe();
1501 }
1502}
1503
1504impl Debug for FeatherWriter {
1505 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1506 f.debug_struct(stringify!(FeatherWriter))
1507 .finish_non_exhaustive()
1508 }
1509}
1510
1511impl Drop for FeatherWriter {
1515 fn drop(&mut self) {
1516 if !self.writers.is_empty() {
1517 let _ = self.seal();
1518 }
1519 }
1520}
1521
1522impl StreamingSink for FeatherWriter {
1523 fn write_data(&mut self, data: Data) -> anyhow::Result<()> {
1524 Self::write_data(self, data).map_err(feather_error)
1525 }
1526
1527 fn write_batch(&mut self, data: Vec<Data>) -> anyhow::Result<()> {
1528 Self::write_data_batch(self, data).map_err(feather_error)
1529 }
1530
1531 fn write_any(&mut self, message: &dyn Any) -> anyhow::Result<bool> {
1532 self.write_any_message(message).map_err(feather_error)
1533 }
1534
1535 fn flush(&mut self) -> anyhow::Result<()> {
1536 Self::flush(self).map_err(feather_error)
1537 }
1538
1539 fn close(&mut self) -> anyhow::Result<()> {
1540 Self::close(self).map_err(feather_error)
1541 }
1542}
1543
1544impl StreamingSink for Rc<RefCell<FeatherWriter>> {
1545 fn write_data(&mut self, data: Data) -> anyhow::Result<()> {
1546 StreamingSink::write_data(&mut *self.borrow_mut(), data)
1547 }
1548
1549 fn write_batch(&mut self, data: Vec<Data>) -> anyhow::Result<()> {
1550 StreamingSink::write_batch(&mut *self.borrow_mut(), data)
1551 }
1552
1553 fn write_any(&mut self, message: &dyn Any) -> anyhow::Result<bool> {
1554 StreamingSink::write_any(&mut *self.borrow_mut(), message)
1555 }
1556
1557 fn flush(&mut self) -> anyhow::Result<()> {
1558 StreamingSink::flush(&mut *self.borrow_mut())
1559 }
1560
1561 fn close(&mut self) -> anyhow::Result<()> {
1562 StreamingSink::close(&mut *self.borrow_mut())
1563 }
1564}
1565
1566pub(crate) fn recover_partial_feather_files(directory: &Path) {
1575 let suffix = format!(".{FEATHER_PARTIAL_EXTENSION}");
1576 let mut directories = vec![directory.to_path_buf()];
1577
1578 while let Some(directory) = directories.pop() {
1579 let entries = match fs::read_dir(&directory) {
1580 Ok(entries) => entries,
1581 Err(e) if e.kind() == io::ErrorKind::NotFound => continue,
1582 Err(e) => {
1583 log::warn!(
1584 "Failed to list {} for partial Feather files: {e}",
1585 directory.display()
1586 );
1587 continue;
1588 }
1589 };
1590
1591 for entry in entries.flatten() {
1592 let path = entry.path();
1593
1594 if entry.file_type().is_ok_and(|file_type| file_type.is_dir()) {
1595 directories.push(path);
1596 } else if path.to_string_lossy().ends_with(&suffix)
1597 && let Err(e) = recover_partial_feather_file(&path)
1598 {
1599 log::warn!("Failed to recover Feather file {}: {e}", path.display());
1600 }
1601 }
1602 }
1603}
1604
1605fn recover_released_feather_file(partial_path: &Path) {
1607 if let Err(e) = recover_partial_feather_file(partial_path) {
1608 log::error!(
1609 "Failed to recover Feather file {}: {e}",
1610 partial_path.display()
1611 );
1612 }
1613}
1614
1615fn recover_partial_feather_file(partial_path: &Path) -> anyhow::Result<()> {
1616 let metadata = match fs::symlink_metadata(partial_path) {
1617 Ok(metadata) => metadata,
1618 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
1619 Err(e) => return Err(e.into()),
1620 };
1621
1622 anyhow::ensure!(
1623 metadata.is_file(),
1624 "Partial Feather path must be a regular file: {}",
1625 partial_path.display(),
1626 );
1627
1628 let file = match OpenOptions::new().read(true).write(true).open(partial_path) {
1629 Ok(file) => file,
1630 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
1631 Err(e) => return Err(e.into()),
1632 };
1633
1634 match file.try_lock() {
1635 Ok(()) => {}
1636 Err(TryLockError::WouldBlock) => return Ok(()),
1637 Err(TryLockError::Error(e)) => return Err(e.into()),
1638 }
1639
1640 let len = file.metadata()?.len();
1641
1642 if !partial_path.try_exists()? || len == 0 {
1645 return Ok(());
1646 }
1647
1648 let mut batches = 0;
1649 let mut complete_len = 0;
1650 let mut tail_error = None;
1651
1652 match StreamReader::try_new(BufReader::new(&file), None) {
1653 Ok(mut reader) => loop {
1654 match reader.next() {
1655 Some(Ok(_)) => {
1656 batches += 1;
1657 complete_len = reader.get_mut().stream_position()?;
1658 }
1659 Some(Err(e)) => {
1660 tail_error = Some(e);
1661 break;
1662 }
1663 None => break,
1664 }
1665 },
1666 Err(e) => tail_error = Some(e),
1667 }
1668
1669 if let Some(e) = tail_error.take_if(|e| {
1672 !matches!(e, ArrowError::IoError(_, source) if source.kind() == io::ErrorKind::UnexpectedEof)
1673 }) {
1674 return Err(e.into());
1675 }
1676
1677 let cause = tail_error.map_or_else(String::new, |e| format!(": {e}"));
1678
1679 if batches == 0 {
1680 fs::remove_file(partial_path)?;
1681
1682 log::warn!(
1683 "Removed Feather file {} with no complete record batch{cause}",
1684 partial_path.display()
1685 );
1686 return Ok(());
1687 }
1688
1689 let dropped = len.saturating_sub(complete_len);
1690
1691 if dropped > 0 {
1694 file.set_len(complete_len)?;
1695 }
1696
1697 file.sync_all()?;
1698
1699 let sealed_path = partial_path.with_extension("");
1700 fs::rename(partial_path, &sealed_path)?;
1701
1702 let tail = if dropped == 0 {
1703 String::new()
1704 } else {
1705 format!(", dropped {dropped} trailing byte(s){cause}")
1706 };
1707
1708 log::warn!(
1709 "Recovered {batches} record batch(es) into Feather file {}{tail}",
1710 sealed_path.display()
1711 );
1712 Ok(())
1713}
1714
1715#[cfg(test)]
1716mod tests {
1717 use std::sync::{Arc, Mutex, atomic::Ordering};
1718
1719 use nautilus_common::{clock::VirtualClock, live::LiveClock};
1720 #[cfg(target_os = "linux")]
1721 use nautilus_model::data::HasTsInit;
1722 use nautilus_model::{
1723 data::{Data, NautilusRecordType, QuoteTick, TradeTick},
1724 enums::AggressorSide,
1725 identifiers::{InstrumentId, TradeId},
1726 types::{ERROR_PRICE, Price, Quantity},
1727 };
1728 use nautilus_serialization::arrow::{
1729 ArrowSchemaProvider, DecodeDataFromRecordBatch, EncodeToRecordBatch,
1730 };
1731 use rstest::rstest;
1732 use tempfile::TempDir;
1733
1734 use super::*;
1735 use crate::{
1736 common::datafusion::identifiers_from_record_batches,
1737 writer::materializer::restore_staged_record_batches,
1738 };
1739
1740 fn feather_files(directory: &Path, extension: &str) -> Vec<PathBuf> {
1741 let mut files = Vec::new();
1742 let mut stack = vec![directory.to_path_buf()];
1743
1744 while let Some(path) = stack.pop() {
1745 for entry in fs::read_dir(&path).unwrap().flatten() {
1746 let path = entry.path();
1747 if path.is_dir() {
1748 stack.push(path);
1749 } else if path.to_string_lossy().ends_with(&format!(".{extension}")) {
1750 files.push(path);
1751 }
1752 }
1753 }
1754
1755 files.sort();
1756 files
1757 }
1758
1759 fn read_feather_batches(path: &Path) -> Vec<RecordBatch> {
1761 StreamReader::try_new(File::open(path).unwrap(), None)
1762 .unwrap()
1763 .map(Result::unwrap)
1764 .flat_map(|batch| restore_staged_record_batches(batch).unwrap())
1765 .collect()
1766 }
1767
1768 fn read_quotes(path: &Path) -> Vec<Data> {
1769 read_feather_batches(path)
1770 .into_iter()
1771 .flat_map(|batch| {
1772 let metadata = batch.schema().metadata().clone();
1773 QuoteTick::decode_data_batch(&metadata, batch).unwrap()
1774 })
1775 .collect()
1776 }
1777
1778 fn quote_at(ts: u64) -> QuoteTick {
1779 QuoteTick::new(
1780 InstrumentId::from("AUD/USD.SIM"),
1781 Price::from("1.0"),
1782 Price::from("1.1"),
1783 Quantity::from("1000"),
1784 Quantity::from("1000"),
1785 UnixNanos::from(ts),
1786 UnixNanos::from(ts),
1787 )
1788 }
1789
1790 fn quotes_at(timestamps: &[u64]) -> Vec<Data> {
1791 timestamps
1792 .iter()
1793 .map(|&ts| Data::from(quote_at(ts)))
1794 .collect()
1795 }
1796
1797 fn write_partial_file(directory: &Path, timestamps: &[u64]) -> PathBuf {
1800 let mut writer = FeatherWriter::new(
1801 directory.to_path_buf(),
1802 WriterClock::Test(Arc::new(AtomicU64::new(0))),
1803 RotationConfig::NoRotation,
1804 None,
1805 Some(0),
1806 );
1807
1808 for &ts in timestamps {
1809 writer.write(quote_at(ts)).unwrap();
1810 writer.flush().unwrap();
1811 }
1812
1813 writer.close().unwrap();
1814
1815 let sealed = feather_files(directory, FEATHER_EXTENSION).remove(0);
1816 let partial = sealed.with_extension(FEATHER_PARTIAL_EXTENSION);
1817 fs::rename(&sealed, &partial).unwrap();
1818 partial
1819 }
1820
1821 const TORN_TAIL: &str = ": Io error: failed to fill whole buffer";
1822
1823 static RECOVERY_LOGS: RecoveryLogCapture = RecoveryLogCapture(Mutex::new(Vec::new()));
1824
1825 fn recover_with_warnings(directory: &Path) -> Vec<String> {
1827 let _ = log::set_logger(&RECOVERY_LOGS);
1828 log::set_max_level(log::LevelFilter::Warn);
1829
1830 recover_partial_feather_files(directory);
1831
1832 let directory = directory.display().to_string();
1833 RECOVERY_LOGS
1834 .0
1835 .lock()
1836 .unwrap()
1837 .iter()
1838 .filter(|(level, message)| *level == log::Level::Warn && message.contains(&directory))
1839 .map(|(_, message)| message.clone())
1840 .collect()
1841 }
1842
1843 struct RecoveryLogCapture(Mutex<Vec<(log::Level, String)>>);
1844
1845 impl log::Log for RecoveryLogCapture {
1846 fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
1847 metadata.target() == "nautilus_persistence::writer::feather"
1848 }
1849
1850 fn log(&self, record: &log::Record<'_>) {
1851 if self.enabled(record.metadata()) {
1852 self.0
1853 .lock()
1854 .unwrap()
1855 .push((record.level(), record.args().to_string()));
1856 }
1857 }
1858
1859 fn flush(&self) {}
1860 }
1861
1862 #[rstest]
1863 fn test_subscription_receives_typed_quotes_and_unsubscribes() {
1864 use nautilus_common::msgbus::{MStr, publish_quote};
1865
1866 let temp_dir = TempDir::new().unwrap();
1867
1868 let writer = Rc::new(RefCell::new(FeatherWriter::new(
1869 temp_dir.path().to_path_buf(),
1870 WriterClock::Test(Arc::new(AtomicU64::new(0))),
1871 RotationConfig::NoRotation,
1872 None,
1873 Some(0),
1874 )));
1875
1876 let quote = QuoteTick::new(
1877 InstrumentId::from("AUD/USD.SIM"),
1878 Price::from("1.0"),
1879 Price::from("1.1"),
1880 Quantity::from("2"),
1881 Quantity::from("3"),
1882 4.into(),
1883 5.into(),
1884 );
1885 let handler = FeatherWriter::subscribe_to_message_bus(writer.clone()).unwrap();
1886 publish_quote(MStr::topic("data.quotes.AUD/USD.SIM").unwrap(), "e);
1887 FeatherWriter::unsubscribe_from_message_bus(&handler);
1888 publish_quote(MStr::topic("data.quotes.AUD/USD.SIM").unwrap(), "e);
1889 writer.borrow_mut().close().unwrap();
1890
1891 let rows = feather_files(temp_dir.path(), FEATHER_EXTENSION)
1892 .iter()
1893 .flat_map(|path| read_feather_batches(path))
1894 .map(|batch| batch.num_rows())
1895 .sum::<usize>();
1896 assert_eq!(rows, 1);
1897 }
1898
1899 #[rstest]
1900 fn test_subscription_records_greeks_once_and_unsubscribes() {
1901 use nautilus_common::msgbus::{MStr, publish_any, publish_greeks};
1902 use nautilus_model::data::greeks::GreeksData;
1903 use nautilus_serialization::arrow::DecodeTypedFromRecordBatch;
1904 let temp_dir = TempDir::new().unwrap();
1905
1906 let writer = Rc::new(RefCell::new(FeatherWriter::new(
1907 temp_dir.path().to_path_buf(),
1908 WriterClock::Test(Arc::new(AtomicU64::new(0))),
1909 RotationConfig::NoRotation,
1910 None,
1911 Some(0),
1912 )));
1913 let greeks = GreeksData::from_delta("AUD/USD.SIM".parse().unwrap(), 1.25, 8.0, 11.into());
1914 let portfolio = greeks.to_portfolio_greeks();
1915 let greeks_topic = MStr::topic("data.GreeksData.instrument_id=AUD/USD.SIM").unwrap();
1916 let portfolio_topic = MStr::topic("data.PortfolioGreeks").unwrap();
1917 let handler = FeatherWriter::subscribe_to_message_bus(writer.clone()).unwrap();
1918 publish_greeks(greeks_topic, &greeks);
1919 publish_any(portfolio_topic, &portfolio);
1920 FeatherWriter::unsubscribe_from_message_bus(&handler);
1921 publish_greeks(greeks_topic, &greeks);
1922 publish_any(portfolio_topic, &portfolio);
1923 writer.borrow_mut().close().unwrap();
1924 let mut counts = HashMap::new();
1925
1926 for batch in feather_files(temp_dir.path(), FEATHER_EXTENSION)
1927 .iter()
1928 .flat_map(|path| read_feather_batches(path))
1929 {
1930 let schema = batch.schema();
1931 let name = schema.metadata()["type_name"].clone();
1932 *counts.entry(name.clone()).or_insert(0) += batch.num_rows();
1933 match name.as_str() {
1934 "GreeksData" => {
1935 let values = GreeksData::decode_typed_batch(schema.metadata(), batch).unwrap();
1936 assert_eq!(values[0].instrument_id, greeks.instrument_id);
1937 assert_eq!(values[0].greeks, greeks.greeks);
1938 assert_eq!(values[0].ts_init, greeks.ts_init);
1939 }
1940 "PortfolioGreeks" => {
1941 let values =
1942 PortfolioGreeks::decode_typed_batch(schema.metadata(), batch).unwrap();
1943 assert_eq!(values[0].greeks, portfolio.greeks);
1944 assert_eq!(values[0].ts_init, portfolio.ts_init);
1945 }
1946 _ => panic!("unexpected record type {name}"),
1947 }
1948 }
1949
1950 assert_eq!(
1951 counts,
1952 HashMap::from([
1953 ("GreeksData".to_string(), 1),
1954 ("PortfolioGreeks".to_string(), 1),
1955 ])
1956 );
1957 }
1958
1959 #[rstest]
1960 #[case(false)]
1961 #[case(true)]
1962 fn test_message_write_error_reaches_flush_or_close(#[case] close: bool) {
1963 let temp_dir = TempDir::new().unwrap();
1964
1965 let mut writer = FeatherWriter::new(
1966 temp_dir.path().to_path_buf(),
1967 WriterClock::Test(Arc::new(AtomicU64::new(0))),
1968 RotationConfig::NoRotation,
1969 None,
1970 Some(0),
1971 );
1972
1973 let quote = QuoteTick::new(
1974 InstrumentId::from("AUD/USD.SIM"),
1975 ERROR_PRICE,
1976 ERROR_PRICE,
1977 Quantity::from("1"),
1978 Quantity::from("2"),
1979 3.into(),
1980 4.into(),
1981 );
1982 let write_error = writer.write_any_message("e).unwrap_err().to_string();
1983
1984 let error = if close {
1985 StreamingSink::close(&mut writer)
1986 } else {
1987 StreamingSink::flush(&mut writer)
1988 }
1989 .unwrap_err();
1990
1991 assert_eq!(error.to_string(), write_error);
1992 }
1993
1994 #[rstest]
1995 fn test_writer_manager_keys() {
1996 let temp_dir = TempDir::new().unwrap();
1997
1998 let clock = WriterClock::Test(Arc::new(AtomicU64::new(0)));
2000 let timestamp = clock.timestamp_ns();
2001
2002 let mut manager = FeatherWriter::new(
2003 temp_dir.path().to_path_buf(),
2004 clock,
2005 RotationConfig::NoRotation,
2006 None,
2007 None, );
2009
2010 let instrument_id = "AAPL.AAPL";
2011
2012 let quote = QuoteTick::new(
2014 InstrumentId::from(instrument_id),
2015 Price::from("100.0"),
2016 Price::from("100.0"),
2017 Quantity::from("100.0"),
2018 Quantity::from("100.0"),
2019 UnixNanos::from(1_000_000_000_000_000_000),
2020 UnixNanos::from(1_000_000_000_000_000_000),
2021 );
2022
2023 let trade = TradeTick::new(
2024 InstrumentId::from(instrument_id),
2025 Price::from("100.0"),
2026 Quantity::from("100.0"),
2027 AggressorSide::Buy,
2028 TradeId::from("1"),
2029 UnixNanos::from(1_000_000_000_000_000_000),
2030 UnixNanos::from(1_000_000_000_000_000_000),
2031 );
2032
2033 manager.write(quote).unwrap();
2034 manager.write(trade).unwrap();
2035
2036 let path = manager.get_writer_path(QuoteTick::catalog_family());
2038 let expected_path = temp_dir
2039 .path()
2040 .join("quotes")
2041 .join(format!("quotes_{timestamp}.feather"));
2042 assert_eq!(path.path, expected_path);
2043 assert!(manager.writers.contains_key(&path));
2044 let writer = manager.writers.get(&path).unwrap();
2045 assert!(writer.size() > 0);
2046
2047 let path = manager.get_writer_path(TradeTick::catalog_family());
2048 let expected_path = temp_dir
2049 .path()
2050 .join("trades")
2051 .join(format!("trades_{timestamp}.feather"));
2052 assert_eq!(path.path, expected_path);
2053 assert!(manager.writers.contains_key(&path));
2054 let writer = manager.writers.get(&path).unwrap();
2055 assert!(writer.size() > 0);
2056 }
2057
2058 #[rstest]
2059 #[case::quotes(NautilusDataType::QuoteTick.into(), "quotes/quotes_0.feather")]
2060 #[case::record(NautilusRecordType::OrderFilled.into(), "order_filled/order_filled_0.feather")]
2061 #[case::instrument_class(
2062 CatalogDataType::Instrument(NautilusInstrumentType::CurrencyPair),
2063 "instruments/currency_pair/instruments_0.feather"
2064 )]
2065 #[case::custom(
2066 NautilusDataType::Custom { type_name: "Signal".to_string() }.into(),
2067 "data/custom/Signal/Signal_0.feather"
2068 )]
2069 fn writer_paths_hold_one_file_per_type(
2070 #[case] data_type: CatalogDataType,
2071 #[case] expected: &str,
2072 ) {
2073 let temp_dir = TempDir::new().unwrap();
2074
2075 let manager = FeatherWriter::new(
2076 temp_dir.path().to_path_buf(),
2077 WriterClock::Test(Arc::new(AtomicU64::new(0))),
2078 RotationConfig::NoRotation,
2079 None,
2080 None,
2081 );
2082
2083 let path = manager.build_writer_path(&data_type, UnixNanos::default(), 0);
2084
2085 assert_eq!(path, temp_dir.path().join(expected));
2086 }
2087
2088 #[rstest]
2089 fn existing_feather_writer_implements_streaming_data_sink() {
2090 let temp_dir = TempDir::new().unwrap();
2091 let clock = WriterClock::Test(Arc::new(AtomicU64::new(0)));
2092
2093 let mut writer = FeatherWriter::new(
2094 temp_dir.path().to_path_buf(),
2095 clock,
2096 RotationConfig::NoRotation,
2097 None,
2098 None,
2099 );
2100
2101 let quote = QuoteTick::new(
2102 InstrumentId::from("AUD/USD.SIM"),
2103 Price::from("1.0"),
2104 Price::from("1.1"),
2105 Quantity::from("1000"),
2106 Quantity::from("1000"),
2107 UnixNanos::from(1_000),
2108 UnixNanos::from(1_000),
2109 );
2110
2111 StreamingSink::write_data(&mut writer, Data::Quote(quote)).unwrap();
2112 StreamingSink::flush(&mut writer).unwrap();
2113 StreamingSink::close(&mut writer).unwrap();
2114
2115 let files = feather_files(&temp_dir.path().join("quotes"), FEATHER_EXTENSION);
2116 assert_eq!(files.len(), 1);
2117 let identifiers =
2118 identifiers_from_record_batches(&read_feather_batches(&files[0])).unwrap();
2119 assert_eq!(identifiers, vec!["AUD/USD.SIM".to_string()]);
2120 }
2121
2122 #[rstest]
2123 fn scheduled_rotation_keeps_time_of_day_anchor_after_late_rotation() {
2124 let temp_dir = TempDir::new().unwrap();
2125
2126 let now = Arc::new(AtomicU64::new(
2127 1_767_258_000_000_000_000, ));
2129
2130 let path = FileWriterPath {
2131 path: PathBuf::from("quotes.feather"),
2132 data_type: NautilusDataType::QuoteTick.into(),
2133 };
2134
2135 let mut writer = FeatherWriter::new(
2136 temp_dir.path().to_path_buf(),
2137 WriterClock::Test(Arc::clone(&now)),
2138 RotationConfig::ScheduledDates {
2139 interval_ns: 86_400_000_000_000,
2140 rotation_time: UnixNanos::from(36_000_000_000_000u64),
2141 rotation_timezone: jiff::tz::TimeZone::UTC,
2142 },
2143 None,
2144 None,
2145 );
2146
2147 assert!(!writer.check_scheduled_rotation(&path));
2148 assert_eq!(
2149 writer.next_rotation_times[&path],
2150 UnixNanos::from(1_767_261_600_000_000_000u64),
2151 );
2152
2153 now.store(1_767_263_400_000_000_000, Ordering::Relaxed); assert!(writer.check_scheduled_rotation(&path));
2155 assert_eq!(
2156 writer.next_rotation_times[&path],
2157 UnixNanos::from(1_767_348_000_000_000_000u64),
2158 );
2159 }
2160
2161 #[rstest]
2162 fn test_file_writer_round_trip() {
2163 let instrument_id = "AAPL.AAPL";
2164
2165 let quote = QuoteTick::new(
2167 InstrumentId::from(instrument_id),
2168 Price::from("100.0"),
2169 Price::from("100.0"),
2170 Quantity::from("100.0"),
2171 Quantity::from("100.0"),
2172 UnixNanos::from(100),
2173 UnixNanos::from(100),
2174 );
2175 let metadata = QuoteTick::metadata("e);
2176 let schema = QuoteTick::get_schema(Some(metadata.clone()));
2177 let batch = QuoteTick::encode_batch(&QuoteTick::metadata("e), &[quote]).unwrap();
2178
2179 let temp_dir = TempDir::new().unwrap();
2180 let path = temp_dir.path().join("quotes.feather");
2181 let mut file = FeatherFile::create(&path, &schema, &RotationConfig::NoRotation).unwrap();
2182 file.write_record_batch(&batch).unwrap();
2183 file.seal().unwrap();
2184
2185 assert!(!path.with_extension(FEATHER_PARTIAL_EXTENSION).exists());
2186 let mut reader = StreamReader::try_new(File::open(&path).unwrap(), None).unwrap();
2187
2188 let read_metadata = reader.schema().metadata().clone();
2189 let mut expected_metadata = metadata.clone();
2190 expected_metadata.insert("type_name".to_string(), "QuoteTick".to_string());
2191 assert_eq!(read_metadata, expected_metadata);
2192
2193 let read_batch = reader.next().unwrap().unwrap();
2194 assert_eq!(read_batch.column(0), batch.column(0));
2195
2196 let decoded = QuoteTick::decode_data_batch(&metadata, batch).unwrap();
2197 assert_eq!(decoded[0], Data::from(quote));
2198 }
2199
2200 #[rstest]
2201 fn test_round_trip() {
2202 let temp_dir = TempDir::new_in(".").unwrap();
2204
2205 let clock = WriterClock::Test(Arc::new(AtomicU64::new(0)));
2207
2208 let mut manager = FeatherWriter::new(
2209 temp_dir.path().to_path_buf(),
2210 clock,
2211 RotationConfig::NoRotation,
2212 None,
2213 None, );
2215
2216 let instrument_id = "AAPL.AAPL";
2217
2218 let quote = QuoteTick::new(
2220 InstrumentId::from(instrument_id),
2221 Price::from("100.0"),
2222 Price::from("100.0"),
2223 Quantity::from("100.0"),
2224 Quantity::from("100.0"),
2225 UnixNanos::from(100),
2226 UnixNanos::from(100),
2227 );
2228
2229 let trade = TradeTick::new(
2230 InstrumentId::from(instrument_id),
2231 Price::from("100.0"),
2232 Quantity::from("100.0"),
2233 AggressorSide::Buy,
2234 TradeId::from("1"),
2235 UnixNanos::from(100),
2236 UnixNanos::from(100),
2237 );
2238
2239 manager.write(quote).unwrap();
2240 manager.write(trade).unwrap();
2241
2242 let paths = manager.writers.keys().cloned().collect::<Vec<_>>();
2243 assert_eq!(paths.len(), 2);
2244
2245 manager.close().unwrap();
2246
2247 let mut recovered_quotes = Vec::new();
2248 let mut recovered_trades = Vec::new();
2249
2250 for path in paths {
2251 let path_str = path.path;
2252 for batch in read_feather_batches(&path_str) {
2253 let metadata = batch.schema().metadata().clone();
2254
2255 if path_str.to_str().unwrap().contains("quotes") {
2256 let decoded = QuoteTick::decode_data_batch(&metadata, batch).unwrap();
2257 recovered_quotes.extend(decoded);
2258 } else if path_str.to_str().unwrap().contains("trades") {
2259 let decoded = TradeTick::decode_data_batch(&metadata, batch).unwrap();
2260 recovered_trades.extend(decoded);
2261 }
2262 }
2263 }
2264
2265 assert_eq!(recovered_quotes.len(), 1, "Expected one QuoteTick record");
2267 assert_eq!(recovered_trades.len(), 1, "Expected one TradeTick record");
2268
2269 assert_eq!(recovered_quotes[0], Data::from(quote));
2271 assert_eq!(recovered_trades[0], Data::from(trade));
2272 }
2273
2274 #[rstest]
2275 fn test_write_data_enum() {
2276 let temp_dir = TempDir::new().unwrap();
2277 let clock = WriterClock::Test(Arc::new(AtomicU64::new(0)));
2278
2279 let mut writer = FeatherWriter::new(
2280 temp_dir.path().to_path_buf(),
2281 clock,
2282 RotationConfig::NoRotation,
2283 None,
2284 None,
2285 );
2286
2287 let quote = QuoteTick::new(
2288 InstrumentId::from("AUD/USD.SIM"),
2289 Price::from("1.0"),
2290 Price::from("1.0"),
2291 Quantity::from("1000"),
2292 Quantity::from("1000"),
2293 UnixNanos::from(1000),
2294 UnixNanos::from(1000),
2295 );
2296
2297 writer.write_data(Data::Quote(quote)).unwrap();
2299 writer.flush().unwrap();
2300
2301 assert!(!writer.writers.is_empty() || temp_dir.path().read_dir().unwrap().count() > 0);
2303 }
2304
2305 #[rstest]
2306 fn test_write_data_all_types() {
2307 let temp_dir = TempDir::new().unwrap();
2308 let clock = WriterClock::Test(Arc::new(AtomicU64::new(0)));
2309
2310 let mut writer = FeatherWriter::new(
2311 temp_dir.path().to_path_buf(),
2312 clock,
2313 RotationConfig::NoRotation,
2314 None,
2315 None,
2316 );
2317
2318 let instrument_id = InstrumentId::from("AUD/USD.SIM");
2319
2320 let quote = QuoteTick::new(
2322 instrument_id,
2323 Price::from("1.0"),
2324 Price::from("1.0"),
2325 Quantity::from("1000"),
2326 Quantity::from("1000"),
2327 UnixNanos::from(1000),
2328 UnixNanos::from(1000),
2329 );
2330 writer.write_data(Data::Quote(quote)).unwrap();
2331
2332 let trade = TradeTick::new(
2333 instrument_id,
2334 Price::from("1.0"),
2335 Quantity::from("1000"),
2336 AggressorSide::Buy,
2337 TradeId::from("1"),
2338 UnixNanos::from(2000),
2339 UnixNanos::from(2000),
2340 );
2341 writer.write_data(Data::Trade(trade)).unwrap();
2342
2343 let delta = OrderBookDelta::clear(
2344 instrument_id,
2345 0,
2346 UnixNanos::from(3000),
2347 UnixNanos::from(3000),
2348 );
2349 writer.write_data(Data::BookDelta(delta)).unwrap();
2350
2351 writer.flush().unwrap();
2352 }
2353
2354 #[rstest]
2355 fn test_auto_flush() {
2356 let temp_dir = TempDir::new().unwrap();
2357 let shared_time = Arc::new(AtomicU64::new(0));
2358 let clock = WriterClock::Test(Arc::clone(&shared_time));
2359
2360 let mut writer = FeatherWriter::new(
2361 temp_dir.path().to_path_buf(),
2362 clock,
2363 RotationConfig::NoRotation,
2364 None,
2365 Some(100), );
2367
2368 let quote = QuoteTick::new(
2369 InstrumentId::from("AUD/USD.SIM"),
2370 Price::from("1.0"),
2371 Price::from("1.0"),
2372 Quantity::from("1000"),
2373 Quantity::from("1000"),
2374 UnixNanos::from(1000),
2375 UnixNanos::from(1000),
2376 );
2377
2378 writer.write(quote).unwrap();
2380 assert_eq!(writer.writers.len(), 1);
2381 assert_eq!(writer.last_flush_ns, UnixNanos::from(0));
2382
2383 shared_time.store(200_000_000, Ordering::Relaxed);
2385
2386 let quote2 = QuoteTick::new(
2388 InstrumentId::from("AUD/USD.SIM"),
2389 Price::from("1.1"),
2390 Price::from("1.1"),
2391 Quantity::from("1000"),
2392 Quantity::from("1000"),
2393 UnixNanos::from(2000),
2394 UnixNanos::from(2000),
2395 );
2396 writer.write(quote2).unwrap();
2397
2398 let partial = feather_files(temp_dir.path(), FEATHER_PARTIAL_EXTENSION);
2401 let flushed_len = fs::metadata(&partial[0]).unwrap().len();
2402 let open_writers = writer.writers.len();
2403 let last_flush_ns = writer.last_flush_ns;
2404 let sealed_before_close = feather_files(temp_dir.path(), FEATHER_EXTENSION);
2405 writer.close().unwrap();
2406
2407 let sealed = partial[0].with_extension("");
2408 let sealed_rows = read_feather_batches(&sealed)
2409 .iter()
2410 .map(RecordBatch::num_rows)
2411 .sum::<usize>();
2412 assert_eq!(open_writers, 1);
2413 assert_eq!(last_flush_ns, UnixNanos::from(200_000_000));
2414 assert_eq!(partial.len(), 1);
2415 assert!(sealed_before_close.is_empty());
2416 assert_eq!(fs::metadata(&sealed).unwrap().len(), flushed_len + 8);
2417 assert_eq!(sealed_rows, 2);
2418 }
2419
2420 #[rstest]
2421 fn flushes_append_to_one_file_per_type_until_close() {
2422 let temp_dir = TempDir::new().unwrap();
2423 let shared_time = Arc::new(AtomicU64::new(0));
2424
2425 let mut writer = FeatherWriter::new(
2426 temp_dir.path().to_path_buf(),
2427 WriterClock::Test(Arc::clone(&shared_time)),
2428 RotationConfig::NoRotation,
2429 None,
2430 Some(1_000),
2431 );
2432
2433 let quotes = (1..=10)
2434 .map(|minute| {
2435 QuoteTick::new(
2436 InstrumentId::from("AUD/USD.SIM"),
2437 Price::from("1.0"),
2438 Price::from("1.1"),
2439 Quantity::from("1000"),
2440 Quantity::from("1000"),
2441 UnixNanos::from(minute * 60_000_000_000),
2442 UnixNanos::from(minute * 60_000_000_000),
2443 )
2444 })
2445 .collect::<Vec<_>>();
2446
2447 for quote in "es {
2449 shared_time.store(quote.ts_init.as_u64(), Ordering::Relaxed);
2450 writer.write(*quote).unwrap();
2451 }
2452
2453 let partial_before_close = feather_files(temp_dir.path(), FEATHER_PARTIAL_EXTENSION);
2454 let sealed_before_close = feather_files(temp_dir.path(), FEATHER_EXTENSION);
2455 writer.close().unwrap();
2456 let sealed = feather_files(temp_dir.path(), FEATHER_EXTENSION);
2457
2458 let recovered = read_feather_batches(&sealed[0])
2459 .into_iter()
2460 .flat_map(|batch| {
2461 let metadata = batch.schema().metadata().clone();
2462 QuoteTick::decode_data_batch(&metadata, batch).unwrap()
2463 })
2464 .collect::<Vec<_>>();
2465
2466 assert_eq!(partial_before_close.len(), 1);
2467 assert!(sealed_before_close.is_empty());
2468 assert_eq!(sealed.len(), 1);
2469 assert!(feather_files(temp_dir.path(), FEATHER_PARTIAL_EXTENSION).is_empty());
2470 assert_eq!(
2471 recovered,
2472 quotes.into_iter().map(Data::from).collect::<Vec<_>>()
2473 );
2474 }
2475
2476 #[rstest]
2477 #[case::below_row_cap(3, vec![3])]
2478 #[case::above_row_cap(
2479 DEFAULT_DATA_BATCH_CHUNK_SIZE + 1,
2480 vec![DEFAULT_DATA_BATCH_CHUNK_SIZE, 1]
2481 )]
2482 fn writes_between_flushes_append_one_batch_per_row_cap(
2483 #[case] count: usize,
2484 #[case] expected_batch_rows: Vec<usize>,
2485 ) {
2486 let temp_dir = TempDir::new().unwrap();
2487
2488 let mut writer = FeatherWriter::new(
2489 temp_dir.path().to_path_buf(),
2490 WriterClock::Test(Arc::new(AtomicU64::new(0))),
2491 RotationConfig::NoRotation,
2492 None,
2493 Some(0),
2494 );
2495 let instrument_ids = [
2496 InstrumentId::from("AUD/USD.SIM"),
2497 InstrumentId::from("EUR/USD.SIM"),
2498 ];
2499
2500 for index in 0..count {
2501 writer
2502 .write(QuoteTick::new(
2503 instrument_ids[index % 2],
2504 Price::from("1.0"),
2505 Price::from("1.1"),
2506 Quantity::from("1000"),
2507 Quantity::from("1000"),
2508 UnixNanos::from(index as u64),
2509 UnixNanos::from(index as u64),
2510 ))
2511 .unwrap();
2512 }
2513
2514 writer.close().unwrap();
2515 let sealed = feather_files(temp_dir.path(), FEATHER_EXTENSION);
2516 let batch_rows = StreamReader::try_new(File::open(&sealed[0]).unwrap(), None)
2517 .unwrap()
2518 .map(|batch| batch.unwrap().num_rows())
2519 .collect::<Vec<_>>();
2520
2521 assert_eq!(sealed.len(), 1);
2522 assert_eq!(batch_rows, expected_batch_rows);
2523 }
2524
2525 #[rstest]
2526 fn drop_seals_open_files() {
2527 let temp_dir = TempDir::new().unwrap();
2528
2529 let mut writer = FeatherWriter::new(
2530 temp_dir.path().to_path_buf(),
2531 WriterClock::Test(Arc::new(AtomicU64::new(0))),
2532 RotationConfig::NoRotation,
2533 None,
2534 None,
2535 );
2536 writer
2537 .write(QuoteTick::new(
2538 InstrumentId::from("AUD/USD.SIM"),
2539 Price::from("1.0"),
2540 Price::from("1.1"),
2541 Quantity::from("1000"),
2542 Quantity::from("1000"),
2543 UnixNanos::from(1_000),
2544 UnixNanos::from(1_000),
2545 ))
2546 .unwrap();
2547
2548 drop(writer);
2549
2550 assert_eq!(feather_files(temp_dir.path(), FEATHER_EXTENSION).len(), 1);
2551 assert!(feather_files(temp_dir.path(), FEATHER_PARTIAL_EXTENSION).is_empty());
2552 }
2553
2554 #[rstest]
2555 fn reserved_paths_skip_files_left_in_the_directory() {
2556 let temp_dir = TempDir::new().unwrap();
2557
2558 let mut writer = FeatherWriter::new(
2559 temp_dir.path().to_path_buf(),
2560 WriterClock::Test(Arc::new(AtomicU64::new(5))),
2561 RotationConfig::NoRotation,
2562 None,
2563 None,
2564 );
2565 let sealed = temp_dir.path().join("trades").join("trades_5.feather");
2566 let partial = temp_dir
2567 .path()
2568 .join("trades")
2569 .join("trades_5-1.feather.partial");
2570 fs::create_dir_all(sealed.parent().unwrap()).unwrap();
2571 fs::write(&sealed, b"sealed").unwrap();
2572 fs::write(&partial, b"partial").unwrap();
2573
2574 let path = writer.reserve_writer_path(NautilusDataType::TradeTick.into());
2575
2576 assert_eq!(
2577 path.path,
2578 temp_dir.path().join("trades").join("trades_5-2.feather")
2579 );
2580 assert_eq!(fs::read(&sealed).unwrap(), b"sealed");
2581 assert_eq!(fs::read(&partial).unwrap(), b"partial");
2582 }
2583
2584 #[rstest]
2585 #[case::same_time(vec![5; 32])]
2586 #[case::clock_rewinds(vec![5, 3, 5])]
2587 #[case::clock_advances_then_rewinds(vec![5, 7, 5, 7])]
2588 fn path_reservations_are_bounded_after_files_are_deleted(#[case] timestamps: Vec<u64>) {
2589 let temp_dir = TempDir::new().unwrap();
2590 let time = Arc::new(AtomicU64::new(0));
2591 let mut writer = FeatherWriter::new(
2592 temp_dir.path().to_path_buf(),
2593 WriterClock::Test(Arc::clone(&time)),
2594 RotationConfig::NoRotation,
2595 None,
2596 None,
2597 );
2598 let mut paths = HashSet::new();
2599
2600 for timestamp in ×tamps {
2601 time.store(*timestamp, Ordering::Relaxed);
2602 writer.write(quote_at(1)).unwrap();
2603 writer.close().unwrap();
2604 let path = feather_files(temp_dir.path(), FEATHER_EXTENSION).remove(0);
2605 assert!(paths.insert(path.clone()));
2606 fs::remove_file(path).unwrap();
2607 }
2608
2609 assert_eq!(paths.len(), timestamps.len());
2610 assert_eq!(writer.path_sequences.len(), 1);
2611 }
2612
2613 #[rstest]
2614 fn test_close() {
2615 let temp_dir = TempDir::new().unwrap();
2616 let clock = WriterClock::Test(Arc::new(AtomicU64::new(0)));
2617
2618 let mut writer = FeatherWriter::new(
2619 temp_dir.path().to_path_buf(),
2620 clock,
2621 RotationConfig::NoRotation,
2622 None,
2623 None,
2624 );
2625
2626 let quote = QuoteTick::new(
2627 InstrumentId::from("AUD/USD.SIM"),
2628 Price::from("1.0"),
2629 Price::from("1.0"),
2630 Quantity::from("1000"),
2631 Quantity::from("1000"),
2632 UnixNanos::from(1000),
2633 UnixNanos::from(1000),
2634 );
2635
2636 writer.write(quote).unwrap();
2637 assert!(!writer.writers.is_empty());
2638
2639 writer.close().unwrap();
2640 assert!(writer.writers.is_empty());
2641 }
2642
2643 #[rstest]
2644 fn test_write_data_orderbook_deltas() {
2645 let temp_dir = TempDir::new().unwrap();
2646 let clock = WriterClock::Test(Arc::new(AtomicU64::new(0)));
2647
2648 let mut writer = FeatherWriter::new(
2649 temp_dir.path().to_path_buf(),
2650 clock,
2651 RotationConfig::NoRotation,
2652 None,
2653 None,
2654 );
2655
2656 let instrument_id = InstrumentId::from("AUD/USD.SIM");
2657 let delta1 = OrderBookDelta::clear(
2658 instrument_id,
2659 0,
2660 UnixNanos::from(1000),
2661 UnixNanos::from(1000),
2662 );
2663 let delta2 = OrderBookDelta::clear(
2664 instrument_id,
2665 0,
2666 UnixNanos::from(2000),
2667 UnixNanos::from(2000),
2668 );
2669
2670 let deltas = OrderBookDeltas::new(instrument_id, vec![delta1, delta2]);
2671 writer
2673 .write_data(Data::BookDeltas(Box::new(deltas)))
2674 .unwrap();
2675 writer.flush().unwrap();
2676 }
2677
2678 #[rstest]
2679 fn feather_writer_is_send() {
2680 fn assert_send<T: Send>() {}
2681 assert_send::<FeatherWriter>();
2682 assert_send::<WriterClock>();
2683 }
2684
2685 #[rstest]
2686 fn writer_clock_test_source_reads_shared_atomic() {
2687 let shared = Arc::new(AtomicU64::new(7));
2688 let clock = WriterClock::Test(Arc::clone(&shared));
2689 assert_eq!(clock.timestamp_ns(), UnixNanos::from(7));
2690
2691 shared.store(42, Ordering::Relaxed);
2692 assert_eq!(clock.timestamp_ns(), UnixNanos::from(42));
2693 }
2694
2695 #[rstest]
2696 fn writer_clock_from_shared_clock_wires_test_clock() {
2697 let test_clock = Rc::new(RefCell::new(VirtualClock::new()));
2698 test_clock
2699 .borrow_mut()
2700 .advance_time(UnixNanos::from(42), true);
2701 let clock: Rc<RefCell<dyn Clock>> = test_clock.clone();
2702
2703 let (writer_clock, shared) = WriterClock::from_shared_clock(&clock);
2704 let shared = shared.expect("non-live clocks must return a shared atomic");
2705
2706 assert_eq!(writer_clock.timestamp_ns(), UnixNanos::from(42));
2708
2709 test_clock
2712 .borrow_mut()
2713 .advance_time(UnixNanos::from(99), true);
2714 shared.store(clock.borrow().timestamp_ns().as_u64(), Ordering::Relaxed);
2715 assert_eq!(writer_clock.timestamp_ns(), UnixNanos::from(99));
2716 }
2717
2718 #[rstest]
2719 fn writer_clock_from_shared_clock_live_clock_is_live() {
2720 let clock: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(LiveClock::new(None)));
2721
2722 let (writer_clock, shared) = WriterClock::from_shared_clock(&clock);
2723
2724 assert!(matches!(writer_clock, WriterClock::Live));
2725 assert!(shared.is_none());
2726 }
2727
2728 #[rstest]
2729 #[cfg(unix)]
2730 fn failed_seal_of_a_removed_file_reports_the_error_and_publishes_nothing() {
2731 let temp_dir = TempDir::new().unwrap();
2732 let (sealed_tx, sealed_rx) = std::sync::mpsc::channel();
2733
2734 let mut writer = FeatherWriter::new(
2735 temp_dir.path().to_path_buf(),
2736 WriterClock::Test(Arc::new(AtomicU64::new(0))),
2737 RotationConfig::NoRotation,
2738 None,
2739 None,
2740 )
2741 .with_sealed_sender(sealed_tx);
2742 writer
2743 .write(QuoteTick::new(
2744 InstrumentId::from("AUD/USD.SIM"),
2745 Price::from("1.0"),
2746 Price::from("1.1"),
2747 Quantity::from("1000"),
2748 Quantity::from("1000"),
2749 UnixNanos::from(1_000),
2750 UnixNanos::from(1_000),
2751 ))
2752 .unwrap();
2753
2754 let partial = feather_files(temp_dir.path(), FEATHER_PARTIAL_EXTENSION);
2756 fs::remove_file(&partial[0]).unwrap();
2757
2758 let error = writer.close().unwrap_err();
2759
2760 assert_eq!(error.to_string(), "No such file or directory (os error 2)");
2761 assert!(writer.is_closed());
2762 assert!(feather_files(temp_dir.path(), FEATHER_EXTENSION).is_empty());
2763 assert_eq!(
2764 sealed_rx.try_iter().collect::<Vec<_>>(),
2765 Vec::<PathBuf>::new()
2766 );
2767 }
2768
2769 #[rstest]
2770 #[cfg(target_os = "linux")]
2771 fn failed_seal_recovers_the_batches_that_reached_the_file() {
2772 let temp_dir = TempDir::new().unwrap();
2773 let (sealed_tx, sealed_rx) = std::sync::mpsc::channel();
2774
2775 let mut writer = FeatherWriter::new(
2776 temp_dir.path().to_path_buf(),
2777 WriterClock::Test(Arc::new(AtomicU64::new(0))),
2778 RotationConfig::NoRotation,
2779 None,
2780 Some(0),
2781 )
2782 .with_sealed_sender(sealed_tx);
2783
2784 writer.write(quote_at(1)).unwrap();
2785 writer.flush().unwrap();
2786 writer.write(quote_at(2)).unwrap();
2787
2788 let (path, file) = writer.writers.iter_mut().next().unwrap();
2790 let sealed = path.path.clone();
2791 let full = OpenOptions::new().write(true).open("/dev/full").unwrap();
2792 file.writer =
2793 StreamWriter::try_new(CountingWriter::new(BufWriter::new(full)), &file.schema).unwrap();
2794
2795 let error = writer.close().unwrap_err();
2796
2797 assert_eq!(
2798 error.to_string(),
2799 "Io error: No space left on device (os error 28)"
2800 );
2801 assert!(writer.is_closed());
2802 assert!(feather_files(temp_dir.path(), FEATHER_PARTIAL_EXTENSION).is_empty());
2803 assert_eq!(read_quotes(&sealed), quotes_at(&[1]));
2804 assert_eq!(sealed_rx.try_iter().collect::<Vec<_>>(), vec![sealed]);
2805 }
2806
2807 #[rstest]
2808 #[case::nothing_flushed(false, vec![3])]
2809 #[case::batch_flushed(true, vec![1, 3])]
2810 #[cfg(target_os = "linux")]
2811 fn failed_flush_recovers_the_abandoned_file_and_next_write_opens_a_new_one(
2812 #[case] flushed: bool,
2813 #[case] expected: Vec<u64>,
2814 ) {
2815 let temp_dir = TempDir::new().unwrap();
2816 let (sealed_tx, sealed_rx) = std::sync::mpsc::channel();
2817
2818 let mut writer = FeatherWriter::new(
2819 temp_dir.path().to_path_buf(),
2820 WriterClock::Test(Arc::new(AtomicU64::new(0))),
2821 RotationConfig::NoRotation,
2822 None,
2823 Some(0),
2824 )
2825 .with_sealed_sender(sealed_tx);
2826
2827 writer.write(quote_at(1)).unwrap();
2828
2829 if flushed {
2830 writer.flush().unwrap();
2831 }
2832
2833 writer.write(quote_at(2)).unwrap();
2834
2835 let (abandoned, file) = writer.writers.iter_mut().next().unwrap();
2837 let abandoned = abandoned.clone();
2838 let full = OpenOptions::new().write(true).open("/dev/full").unwrap();
2839 file.writer =
2840 StreamWriter::try_new(CountingWriter::new(BufWriter::new(full)), &file.schema).unwrap();
2841
2842 let error = writer.flush().unwrap_err();
2843 writer.write(quote_at(3)).unwrap();
2844 writer.close().unwrap();
2845
2846 let mut recovered = feather_files(temp_dir.path(), FEATHER_EXTENSION)
2847 .iter()
2848 .flat_map(|path| read_quotes(path))
2849 .collect::<Vec<_>>();
2850 recovered.sort_by_key(HasTsInit::ts_init);
2851
2852 assert_eq!(
2855 error.to_string(),
2856 "Io error: No space left on device (os error 28)"
2857 );
2858 assert_eq!(abandoned.path.exists(), flushed);
2859 assert!(feather_files(temp_dir.path(), FEATHER_PARTIAL_EXTENSION).is_empty());
2860 assert_eq!(recovered, quotes_at(&expected));
2861 let mut notified = sealed_rx.try_iter().collect::<Vec<_>>();
2862 notified.sort();
2863 assert_eq!(notified, feather_files(temp_dir.path(), FEATHER_EXTENSION));
2864 }
2865
2866 #[rstest]
2867 #[case::end_of_stream_marker(0, &[], vec![1, 2, 3], "")]
2868 #[case::no_end_of_stream_marker(8, &[], vec![1, 2, 3], "")]
2869 #[case::stray_bytes(8, &[0xFF, 0xFF], vec![1, 2, 3], "")]
2870 #[case::truncated_last_batch(9, &[], vec![1, 2], TORN_TAIL)]
2871 fn recovery_seals_complete_batches_and_drops_the_rest(
2872 #[case] cut: usize,
2873 #[case] stray: &[u8],
2874 #[case] expected: Vec<u64>,
2875 #[case] cause: &str,
2876 ) {
2877 let temp_dir = TempDir::new().unwrap();
2878 let directory = temp_dir.path().join("recover");
2879 let partial = write_partial_file(&directory, &[1, 2, 3]);
2880 let mut staged = fs::read(&partial).unwrap();
2881 staged.truncate(staged.len() - cut);
2882 staged.extend_from_slice(stray);
2883 fs::write(&partial, &staged).unwrap();
2884
2885 let reference = fs::read(write_partial_file(
2887 &temp_dir.path().join("reference"),
2888 &expected,
2889 ))
2890 .unwrap();
2891 let complete = &reference[..reference.len() - 8];
2892
2893 let warnings = recover_with_warnings(&directory);
2894
2895 let sealed = partial.with_extension("");
2896 let dropped = staged.len() - complete.len();
2897
2898 let tail = if dropped == 0 {
2899 String::new()
2900 } else {
2901 format!(", dropped {dropped} trailing byte(s){cause}")
2902 };
2903
2904 assert!(!partial.exists());
2905 assert_eq!(fs::read(&sealed).unwrap(), complete);
2906 assert_eq!(read_quotes(&sealed), quotes_at(&expected));
2907 assert_eq!(
2908 warnings,
2909 vec![format!(
2910 "Recovered {} record batch(es) into Feather file {}{tail}",
2911 expected.len(),
2912 sealed.display()
2913 )],
2914 );
2915 }
2916
2917 #[rstest]
2918 #[case::schema_only(0, "")]
2919 #[case::truncated_schema(1, TORN_TAIL)]
2920 fn recovery_removes_a_file_without_a_complete_batch(#[case] cut: u64, #[case] cause: &str) {
2921 let temp_dir = TempDir::new().unwrap();
2922 let partial = temp_dir.path().join("quotes_1.feather.partial");
2923 let file = File::create(&partial).unwrap();
2924 StreamWriter::try_new(&file, &QuoteTick::get_schema(None)).unwrap();
2925 file.set_len(file.metadata().unwrap().len() - cut).unwrap();
2926 drop(file);
2927
2928 let warnings = recover_with_warnings(temp_dir.path());
2929
2930 assert_eq!(fs::read_dir(temp_dir.path()).unwrap().count(), 0);
2931 assert_eq!(
2932 warnings,
2933 vec![format!(
2934 "Removed Feather file {} with no complete record batch{cause}",
2935 partial.display()
2936 )],
2937 );
2938 }
2939
2940 #[rstest]
2941 fn recovery_leaves_a_file_with_an_unreadable_tail() {
2942 let temp_dir = TempDir::new().unwrap();
2943 let partial = write_partial_file(temp_dir.path(), &[1, 2]);
2944
2945 let mut staged = fs::read(&partial).unwrap();
2947 staged.truncate(staged.len() - 8);
2948 staged.extend_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0xFF, 0xFF, 0xFF]);
2949 fs::write(&partial, &staged).unwrap();
2950
2951 let warnings = recover_with_warnings(temp_dir.path());
2952
2953 assert_eq!(fs::read(&partial).unwrap(), staged);
2954 assert_eq!(
2955 warnings,
2956 vec![format!(
2957 "Failed to recover Feather file {}: Parser error: Invalid metadata length: -16",
2958 partial.display()
2959 )],
2960 );
2961 }
2962
2963 #[rstest]
2964 #[cfg(unix)]
2965 fn recovery_leaves_partial_file_symlinks_untouched() {
2966 let temp_dir = TempDir::new().unwrap();
2967 let outside = TempDir::new().unwrap();
2968 let partial = write_partial_file(outside.path(), &[1, 2]);
2969 let original = fs::read(&partial).unwrap();
2970 let link = temp_dir.path().join("quotes_0.feather.partial");
2971 std::os::unix::fs::symlink(&partial, &link).unwrap();
2972
2973 recover_partial_feather_files(temp_dir.path());
2974
2975 assert_eq!(fs::read(&partial).unwrap(), original);
2976 assert_eq!(fs::read_link(&link).unwrap(), partial);
2977 assert!(!link.with_extension("").exists());
2978 assert!(!partial.with_extension("").exists());
2979 }
2980
2981 #[rstest]
2982 #[cfg(unix)]
2983 fn recovery_continues_after_a_file_it_cannot_recover() {
2984 let temp_dir = TempDir::new().unwrap();
2985 let run = temp_dir.path().join("run");
2986 let partial = write_partial_file(&run, &[1]);
2987
2988 let unrecoverable = temp_dir.path().join("bad.feather.partial");
2989 std::os::unix::fs::symlink(&run, &unrecoverable).unwrap();
2990
2991 let warnings = recover_with_warnings(temp_dir.path());
2992
2993 let sealed = partial.with_extension("");
2994 assert_eq!(read_quotes(&sealed), quotes_at(&[1]));
2995 assert_eq!(
2996 warnings,
2997 vec![
2998 format!(
2999 "Failed to recover Feather file {}: Partial Feather path must be a regular file: {}",
3000 unrecoverable.display(),
3001 unrecoverable.display(),
3002 ),
3003 format!(
3004 "Recovered 1 record batch(es) into Feather file {}, dropped 8 trailing byte(s)",
3005 sealed.display()
3006 ),
3007 ],
3008 );
3009 }
3010
3011 #[rstest]
3012 fn recovery_leaves_open_and_empty_files() {
3013 let temp_dir = TempDir::new().unwrap();
3014
3015 let mut writer = FeatherWriter::new(
3016 temp_dir.path().join("open"),
3017 WriterClock::Test(Arc::new(AtomicU64::new(0))),
3018 RotationConfig::NoRotation,
3019 None,
3020 Some(0),
3021 );
3022 writer.write(quote_at(1)).unwrap();
3023 writer.flush().unwrap();
3024
3025 let open = feather_files(temp_dir.path(), FEATHER_PARTIAL_EXTENSION).remove(0);
3027 let open_len = fs::metadata(&open).unwrap().len();
3028 let empty = temp_dir.path().join("empty.feather.partial");
3029 File::create(&empty).unwrap();
3030
3031 recover_partial_feather_files(temp_dir.path());
3032
3033 let partial = feather_files(temp_dir.path(), FEATHER_PARTIAL_EXTENSION);
3034 let open_len_after = fs::metadata(&open).unwrap().len();
3035 writer.close().unwrap();
3036
3037 assert_eq!(partial, vec![empty, open.clone()]);
3038 assert_eq!(open_len_after, open_len);
3039 assert_eq!(read_quotes(&open.with_extension("")), quotes_at(&[1]));
3040 }
3041
3042 #[rstest]
3043 fn size_rotation_in_mixed_identifier_batch_seals_the_shared_file_once() {
3044 let temp_dir = TempDir::new().unwrap();
3045
3046 let mut writer = FeatherWriter::new(
3047 temp_dir.path().to_path_buf(),
3048 WriterClock::Test(Arc::new(AtomicU64::new(0))),
3049 RotationConfig::Size { max_size: 1 },
3050 None,
3051 None,
3052 );
3053
3054 let quotes = ["AUD/USD.SIM", "EUR/USD.SIM"]
3055 .map(|instrument_id| {
3056 QuoteTick::new(
3057 InstrumentId::from(instrument_id),
3058 Price::from("1.0"),
3059 Price::from("1.1"),
3060 Quantity::from("1000"),
3061 Quantity::from("1000"),
3062 UnixNanos::from(1),
3063 UnixNanos::from(1),
3064 )
3065 })
3066 .to_vec();
3067
3068 writer.write_batch(quotes).unwrap();
3070
3071 let sealed = feather_files(temp_dir.path(), FEATHER_EXTENSION);
3072 let batches = read_feather_batches(&sealed[0]);
3073
3074 assert!(writer.writers.is_empty());
3075 assert_eq!(sealed.len(), 1);
3076 assert!(feather_files(temp_dir.path(), FEATHER_PARTIAL_EXTENSION).is_empty());
3077 assert_eq!(
3078 identifiers_from_record_batches(&batches).unwrap(),
3079 vec!["AUD/USD.SIM".to_string(), "EUR/USD.SIM".to_string()],
3080 );
3081 }
3082
3083 #[rstest]
3084 fn size_rotation_seals_each_full_file_and_opens_the_next_lazily() {
3085 let temp_dir = TempDir::new().unwrap();
3086 let shared_clock = Arc::new(AtomicU64::new(0));
3087
3088 let mut writer = FeatherWriter::new(
3089 temp_dir.path().to_path_buf(),
3090 WriterClock::Test(Arc::clone(&shared_clock)),
3091 RotationConfig::Size { max_size: 1 },
3092 None,
3093 Some(1),
3094 );
3095
3096 for ts in [1_000, 2_000] {
3097 shared_clock.store(ts * 1_000_000, Ordering::Relaxed);
3098 writer
3099 .write(QuoteTick::new(
3100 InstrumentId::from("AUD/USD.SIM"),
3101 Price::from("1.0"),
3102 Price::from("1.0"),
3103 Quantity::from("1000"),
3104 Quantity::from("1000"),
3105 UnixNanos::from(ts),
3106 UnixNanos::from(ts),
3107 ))
3108 .unwrap();
3109 }
3110
3111 let rows = feather_files(temp_dir.path(), FEATHER_EXTENSION)
3112 .iter()
3113 .map(|path| {
3114 read_feather_batches(path)
3115 .iter()
3116 .map(RecordBatch::num_rows)
3117 .sum::<usize>()
3118 })
3119 .collect::<Vec<_>>();
3120
3121 assert!(writer.writers.is_empty());
3122 assert_eq!(rows, vec![1, 1]);
3123 assert!(feather_files(temp_dir.path(), FEATHER_PARTIAL_EXTENSION).is_empty());
3124 }
3125
3126 #[rstest]
3127 #[case(
3128 "FeatherMissingTimestamp",
3129 Schema::empty(),
3130 "registered without an Arrow schema containing ts_init"
3131 )]
3132 #[case(
3133 "FeatherLegacyTimestamp",
3134 Schema::new(vec![Field::new("ts_init", DataType::UInt64, false)]),
3135 "registered with ts_init as UInt64",
3136 )]
3137 fn test_custom_writer_path_rejects_unqueryable_schema(
3138 #[case] type_name: &str,
3139 #[case] schema: Schema,
3140 #[case] expected_error: &str,
3141 ) {
3142 nautilus_model::data::registry::ensure_arrow_registered(
3143 type_name,
3144 Arc::new(schema),
3145 Box::new(|_| unreachable!("writer creation does not encode data")),
3146 Box::new(|_, _| unreachable!("writer creation does not decode data")),
3147 )
3148 .unwrap();
3149
3150 let temp_dir = TempDir::new().unwrap();
3151
3152 let mut writer = FeatherWriter::new(
3153 temp_dir.path().to_path_buf(),
3154 WriterClock::Test(Arc::new(AtomicU64::new(0))),
3155 RotationConfig::NoRotation,
3156 None,
3157 None,
3158 );
3159
3160 for _ in 0..2 {
3161 let error = writer.get_writer_path_custom(type_name).unwrap_err();
3162
3163 assert!(error.to_string().contains(expected_error));
3164 assert!(writer.writers.is_empty());
3165 assert!(writer.path_sequences.is_empty());
3166 }
3167 }
3168
3169 #[rstest]
3170 #[cfg(feature = "python")]
3171 fn test_write_custom_data_round_trip() {
3172 use std::sync::Arc;
3173
3174 use nautilus_model::{
3175 data::{CustomData, Data, DataType},
3176 identifiers::InstrumentId,
3177 };
3178 use nautilus_serialization::{
3179 arrow::custom::CustomDataDecoder, ensure_custom_data_registered,
3180 };
3181
3182 use crate::test_data::RustTestCustomData;
3183
3184 ensure_custom_data_registered::<RustTestCustomData>();
3185
3186 let temp_dir = TempDir::new().unwrap();
3187 let clock = WriterClock::Test(Arc::new(AtomicU64::new(0)));
3188
3189 let mut writer = FeatherWriter::new(
3190 temp_dir.path().to_path_buf(),
3191 clock,
3192 RotationConfig::NoRotation,
3193 None,
3194 None,
3195 );
3196
3197 let instrument_id = InstrumentId::from("RUST.TEST");
3198 let data_type = DataType::new("RustTestCustomData", None, Some(instrument_id.to_string()));
3199
3200 let original = RustTestCustomData {
3201 instrument_id,
3202 value: 1.23,
3203 flag: true,
3204 ts_event: UnixNanos::from(1000),
3205 ts_init: UnixNanos::from(1000),
3206 };
3207
3208 let custom = CustomData::new(Arc::new(original.clone()), data_type);
3209
3210 writer
3211 .write_data(Data::Custom(custom))
3212 .expect("write_data CustomData");
3213 writer.close().expect("close");
3214
3215 let prefix = temp_dir
3216 .path()
3217 .join("data")
3218 .join("custom")
3219 .join("RustTestCustomData");
3220 let files = feather_files(&prefix, FEATHER_EXTENSION);
3221 let batch = read_feather_batches(&files[0]).remove(0);
3222 let metadata = batch.schema().metadata().clone();
3223 let decoded =
3224 CustomDataDecoder::decode_data_batch(&metadata, batch).expect("decode_data_batch");
3225 assert_eq!(decoded.len(), 1);
3226
3227 if let Data::Custom(decoded_custom) = &decoded[0] {
3228 assert_eq!(decoded_custom.data_type.type_name(), "RustTestCustomData");
3229 let rust: &RustTestCustomData = decoded_custom
3230 .data
3231 .as_any()
3232 .downcast_ref::<RustTestCustomData>()
3233 .expect("RustTestCustomData");
3234 assert_eq!(rust, &original);
3235 } else {
3236 panic!("Expected Data::Custom");
3237 }
3238 }
3239
3240 #[rstest]
3241 #[cfg(feature = "python")]
3242 fn test_write_custom_data_reuses_writer_until_rotation() {
3243 use nautilus_model::data::{CustomData, DataType};
3244 use nautilus_serialization::ensure_custom_data_registered;
3245
3246 use crate::test_data::RustTestCustomData;
3247
3248 ensure_custom_data_registered::<RustTestCustomData>();
3249 let temp_dir = TempDir::new().unwrap();
3250
3251 let mut writer = FeatherWriter::new(
3252 temp_dir.path().to_path_buf(),
3253 WriterClock::Test(Arc::new(AtomicU64::new(0))),
3254 RotationConfig::NoRotation,
3255 None,
3256 None,
3257 );
3258 let instrument_id = InstrumentId::from("RUST.TEST");
3259 let data_type = DataType::new("RustTestCustomData", None, Some(instrument_id.to_string()));
3260
3261 for (ts, value) in [(1_000, 1_000.0), (2_000, 2_000.0)] {
3262 writer
3263 .write_data(Data::Custom(CustomData::new(
3264 Arc::new(RustTestCustomData {
3265 instrument_id,
3266 value,
3267 flag: true,
3268 ts_event: UnixNanos::from(ts),
3269 ts_init: UnixNanos::from(ts),
3270 }),
3271 data_type.clone(),
3272 )))
3273 .unwrap();
3274 }
3275
3276 assert_eq!(writer.get_current_file_info().len(), 1);
3277 writer.close().unwrap();
3278
3279 let prefix = temp_dir
3280 .path()
3281 .join("data")
3282 .join("custom")
3283 .join("RustTestCustomData");
3284 let files = feather_files(&prefix, FEATHER_EXTENSION);
3285 assert_eq!(files.len(), 1);
3286 let rows = read_feather_batches(&files[0])
3287 .iter()
3288 .map(RecordBatch::num_rows)
3289 .sum::<usize>();
3290 assert_eq!(rows, 2);
3291 }
3292}