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 file = match OpenOptions::new().read(true).write(true).open(partial_path) {
1617 Ok(file) => file,
1618 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
1619 Err(e) => return Err(e.into()),
1620 };
1621
1622 match file.try_lock() {
1623 Ok(()) => {}
1624 Err(TryLockError::WouldBlock) => return Ok(()),
1625 Err(TryLockError::Error(e)) => return Err(e.into()),
1626 }
1627
1628 let len = file.metadata()?.len();
1629
1630 if !partial_path.try_exists()? || len == 0 {
1633 return Ok(());
1634 }
1635
1636 let mut batches = 0;
1637 let mut complete_len = 0;
1638 let mut tail_error = None;
1639
1640 match StreamReader::try_new(BufReader::new(&file), None) {
1641 Ok(mut reader) => loop {
1642 match reader.next() {
1643 Some(Ok(_)) => {
1644 batches += 1;
1645 complete_len = reader.get_mut().stream_position()?;
1646 }
1647 Some(Err(e)) => {
1648 tail_error = Some(e);
1649 break;
1650 }
1651 None => break,
1652 }
1653 },
1654 Err(e) => tail_error = Some(e),
1655 }
1656
1657 if let Some(e) = tail_error.take_if(|e| {
1660 !matches!(e, ArrowError::IoError(_, source) if source.kind() == io::ErrorKind::UnexpectedEof)
1661 }) {
1662 return Err(e.into());
1663 }
1664
1665 let cause = tail_error.map_or_else(String::new, |e| format!(": {e}"));
1666
1667 if batches == 0 {
1668 fs::remove_file(partial_path)?;
1669
1670 log::warn!(
1671 "Removed Feather file {} with no complete record batch{cause}",
1672 partial_path.display()
1673 );
1674 return Ok(());
1675 }
1676
1677 let dropped = len.saturating_sub(complete_len);
1678
1679 if dropped > 0 {
1682 file.set_len(complete_len)?;
1683 }
1684
1685 file.sync_all()?;
1686
1687 let sealed_path = partial_path.with_extension("");
1688 fs::rename(partial_path, &sealed_path)?;
1689
1690 let tail = if dropped == 0 {
1691 String::new()
1692 } else {
1693 format!(", dropped {dropped} trailing byte(s){cause}")
1694 };
1695
1696 log::warn!(
1697 "Recovered {batches} record batch(es) into Feather file {}{tail}",
1698 sealed_path.display()
1699 );
1700 Ok(())
1701}
1702
1703#[cfg(test)]
1704mod tests {
1705 use std::sync::{Arc, Mutex, atomic::Ordering};
1706
1707 use nautilus_common::{clock::VirtualClock, live::LiveClock};
1708 #[cfg(target_os = "linux")]
1709 use nautilus_model::data::HasTsInit;
1710 use nautilus_model::{
1711 data::{Data, NautilusRecordType, QuoteTick, TradeTick},
1712 enums::AggressorSide,
1713 identifiers::{InstrumentId, TradeId},
1714 types::{ERROR_PRICE, Price, Quantity},
1715 };
1716 use nautilus_serialization::arrow::{
1717 ArrowSchemaProvider, DecodeDataFromRecordBatch, EncodeToRecordBatch,
1718 };
1719 use rstest::rstest;
1720 use tempfile::TempDir;
1721
1722 use super::*;
1723 use crate::{
1724 common::datafusion::identifiers_from_record_batches,
1725 writer::materializer::restore_staged_record_batches,
1726 };
1727
1728 fn feather_files(directory: &Path, extension: &str) -> Vec<PathBuf> {
1729 let mut files = Vec::new();
1730 let mut stack = vec![directory.to_path_buf()];
1731
1732 while let Some(path) = stack.pop() {
1733 for entry in fs::read_dir(&path).unwrap().flatten() {
1734 let path = entry.path();
1735 if path.is_dir() {
1736 stack.push(path);
1737 } else if path.to_string_lossy().ends_with(&format!(".{extension}")) {
1738 files.push(path);
1739 }
1740 }
1741 }
1742
1743 files.sort();
1744 files
1745 }
1746
1747 fn read_feather_batches(path: &Path) -> Vec<RecordBatch> {
1749 StreamReader::try_new(File::open(path).unwrap(), None)
1750 .unwrap()
1751 .map(Result::unwrap)
1752 .flat_map(|batch| restore_staged_record_batches(batch).unwrap())
1753 .collect()
1754 }
1755
1756 fn read_quotes(path: &Path) -> Vec<Data> {
1757 read_feather_batches(path)
1758 .into_iter()
1759 .flat_map(|batch| {
1760 let metadata = batch.schema().metadata().clone();
1761 QuoteTick::decode_data_batch(&metadata, batch).unwrap()
1762 })
1763 .collect()
1764 }
1765
1766 fn quote_at(ts: u64) -> QuoteTick {
1767 QuoteTick::new(
1768 InstrumentId::from("AUD/USD.SIM"),
1769 Price::from("1.0"),
1770 Price::from("1.1"),
1771 Quantity::from("1000"),
1772 Quantity::from("1000"),
1773 UnixNanos::from(ts),
1774 UnixNanos::from(ts),
1775 )
1776 }
1777
1778 fn quotes_at(timestamps: &[u64]) -> Vec<Data> {
1779 timestamps
1780 .iter()
1781 .map(|&ts| Data::from(quote_at(ts)))
1782 .collect()
1783 }
1784
1785 fn write_partial_file(directory: &Path, timestamps: &[u64]) -> PathBuf {
1788 let mut writer = FeatherWriter::new(
1789 directory.to_path_buf(),
1790 WriterClock::Test(Arc::new(AtomicU64::new(0))),
1791 RotationConfig::NoRotation,
1792 None,
1793 Some(0),
1794 );
1795
1796 for &ts in timestamps {
1797 writer.write(quote_at(ts)).unwrap();
1798 writer.flush().unwrap();
1799 }
1800
1801 writer.close().unwrap();
1802
1803 let sealed = feather_files(directory, FEATHER_EXTENSION).remove(0);
1804 let partial = sealed.with_extension(FEATHER_PARTIAL_EXTENSION);
1805 fs::rename(&sealed, &partial).unwrap();
1806 partial
1807 }
1808
1809 const TORN_TAIL: &str = ": Io error: failed to fill whole buffer";
1810
1811 static RECOVERY_LOGS: RecoveryLogCapture = RecoveryLogCapture(Mutex::new(Vec::new()));
1812
1813 fn recover_with_warnings(directory: &Path) -> Vec<String> {
1815 let _ = log::set_logger(&RECOVERY_LOGS);
1816 log::set_max_level(log::LevelFilter::Warn);
1817
1818 recover_partial_feather_files(directory);
1819
1820 let directory = directory.display().to_string();
1821 RECOVERY_LOGS
1822 .0
1823 .lock()
1824 .unwrap()
1825 .iter()
1826 .filter(|(level, message)| *level == log::Level::Warn && message.contains(&directory))
1827 .map(|(_, message)| message.clone())
1828 .collect()
1829 }
1830
1831 struct RecoveryLogCapture(Mutex<Vec<(log::Level, String)>>);
1832
1833 impl log::Log for RecoveryLogCapture {
1834 fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
1835 metadata.target() == "nautilus_persistence::writer::feather"
1836 }
1837
1838 fn log(&self, record: &log::Record<'_>) {
1839 if self.enabled(record.metadata()) {
1840 self.0
1841 .lock()
1842 .unwrap()
1843 .push((record.level(), record.args().to_string()));
1844 }
1845 }
1846
1847 fn flush(&self) {}
1848 }
1849
1850 #[rstest]
1851 fn test_subscription_receives_typed_quotes_and_unsubscribes() {
1852 use nautilus_common::msgbus::{MStr, publish_quote};
1853
1854 let temp_dir = TempDir::new().unwrap();
1855
1856 let writer = Rc::new(RefCell::new(FeatherWriter::new(
1857 temp_dir.path().to_path_buf(),
1858 WriterClock::Test(Arc::new(AtomicU64::new(0))),
1859 RotationConfig::NoRotation,
1860 None,
1861 Some(0),
1862 )));
1863
1864 let quote = QuoteTick::new(
1865 InstrumentId::from("AUD/USD.SIM"),
1866 Price::from("1.0"),
1867 Price::from("1.1"),
1868 Quantity::from("2"),
1869 Quantity::from("3"),
1870 4.into(),
1871 5.into(),
1872 );
1873 let handler = FeatherWriter::subscribe_to_message_bus(writer.clone()).unwrap();
1874 publish_quote(MStr::topic("data.quotes.AUD/USD.SIM").unwrap(), "e);
1875 FeatherWriter::unsubscribe_from_message_bus(&handler);
1876 publish_quote(MStr::topic("data.quotes.AUD/USD.SIM").unwrap(), "e);
1877 writer.borrow_mut().close().unwrap();
1878
1879 let rows = feather_files(temp_dir.path(), FEATHER_EXTENSION)
1880 .iter()
1881 .flat_map(|path| read_feather_batches(path))
1882 .map(|batch| batch.num_rows())
1883 .sum::<usize>();
1884 assert_eq!(rows, 1);
1885 }
1886
1887 #[rstest]
1888 fn test_subscription_records_greeks_once_and_unsubscribes() {
1889 use nautilus_common::msgbus::{MStr, publish_any, publish_greeks};
1890 use nautilus_model::data::greeks::GreeksData;
1891 use nautilus_serialization::arrow::DecodeTypedFromRecordBatch;
1892 let temp_dir = TempDir::new().unwrap();
1893
1894 let writer = Rc::new(RefCell::new(FeatherWriter::new(
1895 temp_dir.path().to_path_buf(),
1896 WriterClock::Test(Arc::new(AtomicU64::new(0))),
1897 RotationConfig::NoRotation,
1898 None,
1899 Some(0),
1900 )));
1901 let greeks = GreeksData::from_delta("AUD/USD.SIM".parse().unwrap(), 1.25, 8.0, 11.into());
1902 let portfolio = greeks.to_portfolio_greeks();
1903 let greeks_topic = MStr::topic("data.GreeksData.instrument_id=AUD/USD.SIM").unwrap();
1904 let portfolio_topic = MStr::topic("data.PortfolioGreeks").unwrap();
1905 let handler = FeatherWriter::subscribe_to_message_bus(writer.clone()).unwrap();
1906 publish_greeks(greeks_topic, &greeks);
1907 publish_any(portfolio_topic, &portfolio);
1908 FeatherWriter::unsubscribe_from_message_bus(&handler);
1909 publish_greeks(greeks_topic, &greeks);
1910 publish_any(portfolio_topic, &portfolio);
1911 writer.borrow_mut().close().unwrap();
1912 let mut counts = HashMap::new();
1913
1914 for batch in feather_files(temp_dir.path(), FEATHER_EXTENSION)
1915 .iter()
1916 .flat_map(|path| read_feather_batches(path))
1917 {
1918 let schema = batch.schema();
1919 let name = schema.metadata()["type_name"].clone();
1920 *counts.entry(name.clone()).or_insert(0) += batch.num_rows();
1921 match name.as_str() {
1922 "GreeksData" => {
1923 let values = GreeksData::decode_typed_batch(schema.metadata(), batch).unwrap();
1924 assert_eq!(values[0].instrument_id, greeks.instrument_id);
1925 assert_eq!(values[0].greeks, greeks.greeks);
1926 assert_eq!(values[0].ts_init, greeks.ts_init);
1927 }
1928 "PortfolioGreeks" => {
1929 let values =
1930 PortfolioGreeks::decode_typed_batch(schema.metadata(), batch).unwrap();
1931 assert_eq!(values[0].greeks, portfolio.greeks);
1932 assert_eq!(values[0].ts_init, portfolio.ts_init);
1933 }
1934 _ => panic!("unexpected record type {name}"),
1935 }
1936 }
1937
1938 assert_eq!(
1939 counts,
1940 HashMap::from([
1941 ("GreeksData".to_string(), 1),
1942 ("PortfolioGreeks".to_string(), 1),
1943 ])
1944 );
1945 }
1946
1947 #[rstest]
1948 #[case(false)]
1949 #[case(true)]
1950 fn test_message_write_error_reaches_flush_or_close(#[case] close: bool) {
1951 let temp_dir = TempDir::new().unwrap();
1952
1953 let mut writer = FeatherWriter::new(
1954 temp_dir.path().to_path_buf(),
1955 WriterClock::Test(Arc::new(AtomicU64::new(0))),
1956 RotationConfig::NoRotation,
1957 None,
1958 Some(0),
1959 );
1960
1961 let quote = QuoteTick::new(
1962 InstrumentId::from("AUD/USD.SIM"),
1963 ERROR_PRICE,
1964 ERROR_PRICE,
1965 Quantity::from("1"),
1966 Quantity::from("2"),
1967 3.into(),
1968 4.into(),
1969 );
1970 let write_error = writer.write_any_message("e).unwrap_err().to_string();
1971
1972 let error = if close {
1973 StreamingSink::close(&mut writer)
1974 } else {
1975 StreamingSink::flush(&mut writer)
1976 }
1977 .unwrap_err();
1978
1979 assert_eq!(error.to_string(), write_error);
1980 }
1981
1982 #[rstest]
1983 fn test_writer_manager_keys() {
1984 let temp_dir = TempDir::new().unwrap();
1985
1986 let clock = WriterClock::Test(Arc::new(AtomicU64::new(0)));
1988 let timestamp = clock.timestamp_ns();
1989
1990 let mut manager = FeatherWriter::new(
1991 temp_dir.path().to_path_buf(),
1992 clock,
1993 RotationConfig::NoRotation,
1994 None,
1995 None, );
1997
1998 let instrument_id = "AAPL.AAPL";
1999
2000 let quote = QuoteTick::new(
2002 InstrumentId::from(instrument_id),
2003 Price::from("100.0"),
2004 Price::from("100.0"),
2005 Quantity::from("100.0"),
2006 Quantity::from("100.0"),
2007 UnixNanos::from(1_000_000_000_000_000_000),
2008 UnixNanos::from(1_000_000_000_000_000_000),
2009 );
2010
2011 let trade = TradeTick::new(
2012 InstrumentId::from(instrument_id),
2013 Price::from("100.0"),
2014 Quantity::from("100.0"),
2015 AggressorSide::Buy,
2016 TradeId::from("1"),
2017 UnixNanos::from(1_000_000_000_000_000_000),
2018 UnixNanos::from(1_000_000_000_000_000_000),
2019 );
2020
2021 manager.write(quote).unwrap();
2022 manager.write(trade).unwrap();
2023
2024 let path = manager.get_writer_path(QuoteTick::catalog_family());
2026 let expected_path = temp_dir
2027 .path()
2028 .join("quotes")
2029 .join(format!("quotes_{timestamp}.feather"));
2030 assert_eq!(path.path, expected_path);
2031 assert!(manager.writers.contains_key(&path));
2032 let writer = manager.writers.get(&path).unwrap();
2033 assert!(writer.size() > 0);
2034
2035 let path = manager.get_writer_path(TradeTick::catalog_family());
2036 let expected_path = temp_dir
2037 .path()
2038 .join("trades")
2039 .join(format!("trades_{timestamp}.feather"));
2040 assert_eq!(path.path, expected_path);
2041 assert!(manager.writers.contains_key(&path));
2042 let writer = manager.writers.get(&path).unwrap();
2043 assert!(writer.size() > 0);
2044 }
2045
2046 #[rstest]
2047 #[case::quotes(NautilusDataType::QuoteTick.into(), "quotes/quotes_0.feather")]
2048 #[case::record(NautilusRecordType::OrderFilled.into(), "order_filled/order_filled_0.feather")]
2049 #[case::instrument_class(
2050 CatalogDataType::Instrument(NautilusInstrumentType::CurrencyPair),
2051 "instruments/currency_pair/instruments_0.feather"
2052 )]
2053 #[case::custom(
2054 NautilusDataType::Custom { type_name: "Signal".to_string() }.into(),
2055 "data/custom/Signal/Signal_0.feather"
2056 )]
2057 fn writer_paths_hold_one_file_per_type(
2058 #[case] data_type: CatalogDataType,
2059 #[case] expected: &str,
2060 ) {
2061 let temp_dir = TempDir::new().unwrap();
2062
2063 let manager = FeatherWriter::new(
2064 temp_dir.path().to_path_buf(),
2065 WriterClock::Test(Arc::new(AtomicU64::new(0))),
2066 RotationConfig::NoRotation,
2067 None,
2068 None,
2069 );
2070
2071 let path = manager.build_writer_path(&data_type, UnixNanos::default(), 0);
2072
2073 assert_eq!(path, temp_dir.path().join(expected));
2074 }
2075
2076 #[rstest]
2077 fn existing_feather_writer_implements_streaming_data_sink() {
2078 let temp_dir = TempDir::new().unwrap();
2079 let clock = WriterClock::Test(Arc::new(AtomicU64::new(0)));
2080
2081 let mut writer = FeatherWriter::new(
2082 temp_dir.path().to_path_buf(),
2083 clock,
2084 RotationConfig::NoRotation,
2085 None,
2086 None,
2087 );
2088
2089 let quote = QuoteTick::new(
2090 InstrumentId::from("AUD/USD.SIM"),
2091 Price::from("1.0"),
2092 Price::from("1.1"),
2093 Quantity::from("1000"),
2094 Quantity::from("1000"),
2095 UnixNanos::from(1_000),
2096 UnixNanos::from(1_000),
2097 );
2098
2099 StreamingSink::write_data(&mut writer, Data::Quote(quote)).unwrap();
2100 StreamingSink::flush(&mut writer).unwrap();
2101 StreamingSink::close(&mut writer).unwrap();
2102
2103 let files = feather_files(&temp_dir.path().join("quotes"), FEATHER_EXTENSION);
2104 assert_eq!(files.len(), 1);
2105 let identifiers =
2106 identifiers_from_record_batches(&read_feather_batches(&files[0])).unwrap();
2107 assert_eq!(identifiers, vec!["AUD/USD.SIM".to_string()]);
2108 }
2109
2110 #[rstest]
2111 fn scheduled_rotation_keeps_time_of_day_anchor_after_late_rotation() {
2112 let temp_dir = TempDir::new().unwrap();
2113
2114 let now = Arc::new(AtomicU64::new(
2115 1_767_258_000_000_000_000, ));
2117
2118 let path = FileWriterPath {
2119 path: PathBuf::from("quotes.feather"),
2120 data_type: NautilusDataType::QuoteTick.into(),
2121 };
2122
2123 let mut writer = FeatherWriter::new(
2124 temp_dir.path().to_path_buf(),
2125 WriterClock::Test(Arc::clone(&now)),
2126 RotationConfig::ScheduledDates {
2127 interval_ns: 86_400_000_000_000,
2128 rotation_time: UnixNanos::from(36_000_000_000_000u64),
2129 rotation_timezone: jiff::tz::TimeZone::UTC,
2130 },
2131 None,
2132 None,
2133 );
2134
2135 assert!(!writer.check_scheduled_rotation(&path));
2136 assert_eq!(
2137 writer.next_rotation_times[&path],
2138 UnixNanos::from(1_767_261_600_000_000_000u64),
2139 );
2140
2141 now.store(1_767_263_400_000_000_000, Ordering::Relaxed); assert!(writer.check_scheduled_rotation(&path));
2143 assert_eq!(
2144 writer.next_rotation_times[&path],
2145 UnixNanos::from(1_767_348_000_000_000_000u64),
2146 );
2147 }
2148
2149 #[rstest]
2150 fn test_file_writer_round_trip() {
2151 let instrument_id = "AAPL.AAPL";
2152
2153 let quote = QuoteTick::new(
2155 InstrumentId::from(instrument_id),
2156 Price::from("100.0"),
2157 Price::from("100.0"),
2158 Quantity::from("100.0"),
2159 Quantity::from("100.0"),
2160 UnixNanos::from(100),
2161 UnixNanos::from(100),
2162 );
2163 let metadata = QuoteTick::metadata("e);
2164 let schema = QuoteTick::get_schema(Some(metadata.clone()));
2165 let batch = QuoteTick::encode_batch(&QuoteTick::metadata("e), &[quote]).unwrap();
2166
2167 let temp_dir = TempDir::new().unwrap();
2168 let path = temp_dir.path().join("quotes.feather");
2169 let mut file = FeatherFile::create(&path, &schema, &RotationConfig::NoRotation).unwrap();
2170 file.write_record_batch(&batch).unwrap();
2171 file.seal().unwrap();
2172
2173 assert!(!path.with_extension(FEATHER_PARTIAL_EXTENSION).exists());
2174 let mut reader = StreamReader::try_new(File::open(&path).unwrap(), None).unwrap();
2175
2176 let read_metadata = reader.schema().metadata().clone();
2177 let mut expected_metadata = metadata.clone();
2178 expected_metadata.insert("type_name".to_string(), "QuoteTick".to_string());
2179 assert_eq!(read_metadata, expected_metadata);
2180
2181 let read_batch = reader.next().unwrap().unwrap();
2182 assert_eq!(read_batch.column(0), batch.column(0));
2183
2184 let decoded = QuoteTick::decode_data_batch(&metadata, batch).unwrap();
2185 assert_eq!(decoded[0], Data::from(quote));
2186 }
2187
2188 #[rstest]
2189 fn test_round_trip() {
2190 let temp_dir = TempDir::new_in(".").unwrap();
2192
2193 let clock = WriterClock::Test(Arc::new(AtomicU64::new(0)));
2195
2196 let mut manager = FeatherWriter::new(
2197 temp_dir.path().to_path_buf(),
2198 clock,
2199 RotationConfig::NoRotation,
2200 None,
2201 None, );
2203
2204 let instrument_id = "AAPL.AAPL";
2205
2206 let quote = QuoteTick::new(
2208 InstrumentId::from(instrument_id),
2209 Price::from("100.0"),
2210 Price::from("100.0"),
2211 Quantity::from("100.0"),
2212 Quantity::from("100.0"),
2213 UnixNanos::from(100),
2214 UnixNanos::from(100),
2215 );
2216
2217 let trade = TradeTick::new(
2218 InstrumentId::from(instrument_id),
2219 Price::from("100.0"),
2220 Quantity::from("100.0"),
2221 AggressorSide::Buy,
2222 TradeId::from("1"),
2223 UnixNanos::from(100),
2224 UnixNanos::from(100),
2225 );
2226
2227 manager.write(quote).unwrap();
2228 manager.write(trade).unwrap();
2229
2230 let paths = manager.writers.keys().cloned().collect::<Vec<_>>();
2231 assert_eq!(paths.len(), 2);
2232
2233 manager.close().unwrap();
2234
2235 let mut recovered_quotes = Vec::new();
2236 let mut recovered_trades = Vec::new();
2237
2238 for path in paths {
2239 let path_str = path.path;
2240 for batch in read_feather_batches(&path_str) {
2241 let metadata = batch.schema().metadata().clone();
2242
2243 if path_str.to_str().unwrap().contains("quotes") {
2244 let decoded = QuoteTick::decode_data_batch(&metadata, batch).unwrap();
2245 recovered_quotes.extend(decoded);
2246 } else if path_str.to_str().unwrap().contains("trades") {
2247 let decoded = TradeTick::decode_data_batch(&metadata, batch).unwrap();
2248 recovered_trades.extend(decoded);
2249 }
2250 }
2251 }
2252
2253 assert_eq!(recovered_quotes.len(), 1, "Expected one QuoteTick record");
2255 assert_eq!(recovered_trades.len(), 1, "Expected one TradeTick record");
2256
2257 assert_eq!(recovered_quotes[0], Data::from(quote));
2259 assert_eq!(recovered_trades[0], Data::from(trade));
2260 }
2261
2262 #[rstest]
2263 fn test_write_data_enum() {
2264 let temp_dir = TempDir::new().unwrap();
2265 let clock = WriterClock::Test(Arc::new(AtomicU64::new(0)));
2266
2267 let mut writer = FeatherWriter::new(
2268 temp_dir.path().to_path_buf(),
2269 clock,
2270 RotationConfig::NoRotation,
2271 None,
2272 None,
2273 );
2274
2275 let quote = QuoteTick::new(
2276 InstrumentId::from("AUD/USD.SIM"),
2277 Price::from("1.0"),
2278 Price::from("1.0"),
2279 Quantity::from("1000"),
2280 Quantity::from("1000"),
2281 UnixNanos::from(1000),
2282 UnixNanos::from(1000),
2283 );
2284
2285 writer.write_data(Data::Quote(quote)).unwrap();
2287 writer.flush().unwrap();
2288
2289 assert!(!writer.writers.is_empty() || temp_dir.path().read_dir().unwrap().count() > 0);
2291 }
2292
2293 #[rstest]
2294 fn test_write_data_all_types() {
2295 let temp_dir = TempDir::new().unwrap();
2296 let clock = WriterClock::Test(Arc::new(AtomicU64::new(0)));
2297
2298 let mut writer = FeatherWriter::new(
2299 temp_dir.path().to_path_buf(),
2300 clock,
2301 RotationConfig::NoRotation,
2302 None,
2303 None,
2304 );
2305
2306 let instrument_id = InstrumentId::from("AUD/USD.SIM");
2307
2308 let quote = QuoteTick::new(
2310 instrument_id,
2311 Price::from("1.0"),
2312 Price::from("1.0"),
2313 Quantity::from("1000"),
2314 Quantity::from("1000"),
2315 UnixNanos::from(1000),
2316 UnixNanos::from(1000),
2317 );
2318 writer.write_data(Data::Quote(quote)).unwrap();
2319
2320 let trade = TradeTick::new(
2321 instrument_id,
2322 Price::from("1.0"),
2323 Quantity::from("1000"),
2324 AggressorSide::Buy,
2325 TradeId::from("1"),
2326 UnixNanos::from(2000),
2327 UnixNanos::from(2000),
2328 );
2329 writer.write_data(Data::Trade(trade)).unwrap();
2330
2331 let delta = OrderBookDelta::clear(
2332 instrument_id,
2333 0,
2334 UnixNanos::from(3000),
2335 UnixNanos::from(3000),
2336 );
2337 writer.write_data(Data::BookDelta(delta)).unwrap();
2338
2339 writer.flush().unwrap();
2340 }
2341
2342 #[rstest]
2343 fn test_auto_flush() {
2344 let temp_dir = TempDir::new().unwrap();
2345 let shared_time = Arc::new(AtomicU64::new(0));
2346 let clock = WriterClock::Test(Arc::clone(&shared_time));
2347
2348 let mut writer = FeatherWriter::new(
2349 temp_dir.path().to_path_buf(),
2350 clock,
2351 RotationConfig::NoRotation,
2352 None,
2353 Some(100), );
2355
2356 let quote = QuoteTick::new(
2357 InstrumentId::from("AUD/USD.SIM"),
2358 Price::from("1.0"),
2359 Price::from("1.0"),
2360 Quantity::from("1000"),
2361 Quantity::from("1000"),
2362 UnixNanos::from(1000),
2363 UnixNanos::from(1000),
2364 );
2365
2366 writer.write(quote).unwrap();
2368 assert_eq!(writer.writers.len(), 1);
2369 assert_eq!(writer.last_flush_ns, UnixNanos::from(0));
2370
2371 shared_time.store(200_000_000, Ordering::Relaxed);
2373
2374 let quote2 = QuoteTick::new(
2376 InstrumentId::from("AUD/USD.SIM"),
2377 Price::from("1.1"),
2378 Price::from("1.1"),
2379 Quantity::from("1000"),
2380 Quantity::from("1000"),
2381 UnixNanos::from(2000),
2382 UnixNanos::from(2000),
2383 );
2384 writer.write(quote2).unwrap();
2385
2386 let partial = feather_files(temp_dir.path(), FEATHER_PARTIAL_EXTENSION);
2389 let flushed_len = fs::metadata(&partial[0]).unwrap().len();
2390 let open_writers = writer.writers.len();
2391 let last_flush_ns = writer.last_flush_ns;
2392 let sealed_before_close = feather_files(temp_dir.path(), FEATHER_EXTENSION);
2393 writer.close().unwrap();
2394
2395 let sealed = partial[0].with_extension("");
2396 let sealed_rows = read_feather_batches(&sealed)
2397 .iter()
2398 .map(RecordBatch::num_rows)
2399 .sum::<usize>();
2400 assert_eq!(open_writers, 1);
2401 assert_eq!(last_flush_ns, UnixNanos::from(200_000_000));
2402 assert_eq!(partial.len(), 1);
2403 assert!(sealed_before_close.is_empty());
2404 assert_eq!(fs::metadata(&sealed).unwrap().len(), flushed_len + 8);
2405 assert_eq!(sealed_rows, 2);
2406 }
2407
2408 #[rstest]
2409 fn flushes_append_to_one_file_per_type_until_close() {
2410 let temp_dir = TempDir::new().unwrap();
2411 let shared_time = Arc::new(AtomicU64::new(0));
2412
2413 let mut writer = FeatherWriter::new(
2414 temp_dir.path().to_path_buf(),
2415 WriterClock::Test(Arc::clone(&shared_time)),
2416 RotationConfig::NoRotation,
2417 None,
2418 Some(1_000),
2419 );
2420
2421 let quotes = (1..=10)
2422 .map(|minute| {
2423 QuoteTick::new(
2424 InstrumentId::from("AUD/USD.SIM"),
2425 Price::from("1.0"),
2426 Price::from("1.1"),
2427 Quantity::from("1000"),
2428 Quantity::from("1000"),
2429 UnixNanos::from(minute * 60_000_000_000),
2430 UnixNanos::from(minute * 60_000_000_000),
2431 )
2432 })
2433 .collect::<Vec<_>>();
2434
2435 for quote in "es {
2437 shared_time.store(quote.ts_init.as_u64(), Ordering::Relaxed);
2438 writer.write(*quote).unwrap();
2439 }
2440
2441 let partial_before_close = feather_files(temp_dir.path(), FEATHER_PARTIAL_EXTENSION);
2442 let sealed_before_close = feather_files(temp_dir.path(), FEATHER_EXTENSION);
2443 writer.close().unwrap();
2444 let sealed = feather_files(temp_dir.path(), FEATHER_EXTENSION);
2445
2446 let recovered = read_feather_batches(&sealed[0])
2447 .into_iter()
2448 .flat_map(|batch| {
2449 let metadata = batch.schema().metadata().clone();
2450 QuoteTick::decode_data_batch(&metadata, batch).unwrap()
2451 })
2452 .collect::<Vec<_>>();
2453
2454 assert_eq!(partial_before_close.len(), 1);
2455 assert!(sealed_before_close.is_empty());
2456 assert_eq!(sealed.len(), 1);
2457 assert!(feather_files(temp_dir.path(), FEATHER_PARTIAL_EXTENSION).is_empty());
2458 assert_eq!(
2459 recovered,
2460 quotes.into_iter().map(Data::from).collect::<Vec<_>>()
2461 );
2462 }
2463
2464 #[rstest]
2465 #[case::below_row_cap(3, vec![3])]
2466 #[case::above_row_cap(
2467 DEFAULT_DATA_BATCH_CHUNK_SIZE + 1,
2468 vec![DEFAULT_DATA_BATCH_CHUNK_SIZE, 1]
2469 )]
2470 fn writes_between_flushes_append_one_batch_per_row_cap(
2471 #[case] count: usize,
2472 #[case] expected_batch_rows: Vec<usize>,
2473 ) {
2474 let temp_dir = TempDir::new().unwrap();
2475
2476 let mut writer = FeatherWriter::new(
2477 temp_dir.path().to_path_buf(),
2478 WriterClock::Test(Arc::new(AtomicU64::new(0))),
2479 RotationConfig::NoRotation,
2480 None,
2481 Some(0),
2482 );
2483 let instrument_ids = [
2484 InstrumentId::from("AUD/USD.SIM"),
2485 InstrumentId::from("EUR/USD.SIM"),
2486 ];
2487
2488 for index in 0..count {
2489 writer
2490 .write(QuoteTick::new(
2491 instrument_ids[index % 2],
2492 Price::from("1.0"),
2493 Price::from("1.1"),
2494 Quantity::from("1000"),
2495 Quantity::from("1000"),
2496 UnixNanos::from(index as u64),
2497 UnixNanos::from(index as u64),
2498 ))
2499 .unwrap();
2500 }
2501
2502 writer.close().unwrap();
2503 let sealed = feather_files(temp_dir.path(), FEATHER_EXTENSION);
2504 let batch_rows = StreamReader::try_new(File::open(&sealed[0]).unwrap(), None)
2505 .unwrap()
2506 .map(|batch| batch.unwrap().num_rows())
2507 .collect::<Vec<_>>();
2508
2509 assert_eq!(sealed.len(), 1);
2510 assert_eq!(batch_rows, expected_batch_rows);
2511 }
2512
2513 #[rstest]
2514 fn drop_seals_open_files() {
2515 let temp_dir = TempDir::new().unwrap();
2516
2517 let mut writer = FeatherWriter::new(
2518 temp_dir.path().to_path_buf(),
2519 WriterClock::Test(Arc::new(AtomicU64::new(0))),
2520 RotationConfig::NoRotation,
2521 None,
2522 None,
2523 );
2524 writer
2525 .write(QuoteTick::new(
2526 InstrumentId::from("AUD/USD.SIM"),
2527 Price::from("1.0"),
2528 Price::from("1.1"),
2529 Quantity::from("1000"),
2530 Quantity::from("1000"),
2531 UnixNanos::from(1_000),
2532 UnixNanos::from(1_000),
2533 ))
2534 .unwrap();
2535
2536 drop(writer);
2537
2538 assert_eq!(feather_files(temp_dir.path(), FEATHER_EXTENSION).len(), 1);
2539 assert!(feather_files(temp_dir.path(), FEATHER_PARTIAL_EXTENSION).is_empty());
2540 }
2541
2542 #[rstest]
2543 fn reserved_paths_skip_files_left_in_the_directory() {
2544 let temp_dir = TempDir::new().unwrap();
2545
2546 let mut writer = FeatherWriter::new(
2547 temp_dir.path().to_path_buf(),
2548 WriterClock::Test(Arc::new(AtomicU64::new(5))),
2549 RotationConfig::NoRotation,
2550 None,
2551 None,
2552 );
2553 let sealed = temp_dir.path().join("trades").join("trades_5.feather");
2554 let partial = temp_dir
2555 .path()
2556 .join("trades")
2557 .join("trades_5-1.feather.partial");
2558 fs::create_dir_all(sealed.parent().unwrap()).unwrap();
2559 fs::write(&sealed, b"sealed").unwrap();
2560 fs::write(&partial, b"partial").unwrap();
2561
2562 let path = writer.reserve_writer_path(NautilusDataType::TradeTick.into());
2563
2564 assert_eq!(
2565 path.path,
2566 temp_dir.path().join("trades").join("trades_5-2.feather")
2567 );
2568 assert_eq!(fs::read(&sealed).unwrap(), b"sealed");
2569 assert_eq!(fs::read(&partial).unwrap(), b"partial");
2570 }
2571
2572 #[rstest]
2573 #[case::same_time(vec![5; 32])]
2574 #[case::clock_rewinds(vec![5, 3, 5])]
2575 #[case::clock_advances_then_rewinds(vec![5, 7, 5, 7])]
2576 fn path_reservations_are_bounded_after_files_are_deleted(#[case] timestamps: Vec<u64>) {
2577 let temp_dir = TempDir::new().unwrap();
2578 let time = Arc::new(AtomicU64::new(0));
2579 let mut writer = FeatherWriter::new(
2580 temp_dir.path().to_path_buf(),
2581 WriterClock::Test(Arc::clone(&time)),
2582 RotationConfig::NoRotation,
2583 None,
2584 None,
2585 );
2586 let mut paths = HashSet::new();
2587
2588 for timestamp in ×tamps {
2589 time.store(*timestamp, Ordering::Relaxed);
2590 writer.write(quote_at(1)).unwrap();
2591 writer.close().unwrap();
2592 let path = feather_files(temp_dir.path(), FEATHER_EXTENSION).remove(0);
2593 assert!(paths.insert(path.clone()));
2594 fs::remove_file(path).unwrap();
2595 }
2596
2597 assert_eq!(paths.len(), timestamps.len());
2598 assert_eq!(writer.path_sequences.len(), 1);
2599 }
2600
2601 #[rstest]
2602 fn test_close() {
2603 let temp_dir = TempDir::new().unwrap();
2604 let clock = WriterClock::Test(Arc::new(AtomicU64::new(0)));
2605
2606 let mut writer = FeatherWriter::new(
2607 temp_dir.path().to_path_buf(),
2608 clock,
2609 RotationConfig::NoRotation,
2610 None,
2611 None,
2612 );
2613
2614 let quote = QuoteTick::new(
2615 InstrumentId::from("AUD/USD.SIM"),
2616 Price::from("1.0"),
2617 Price::from("1.0"),
2618 Quantity::from("1000"),
2619 Quantity::from("1000"),
2620 UnixNanos::from(1000),
2621 UnixNanos::from(1000),
2622 );
2623
2624 writer.write(quote).unwrap();
2625 assert!(!writer.writers.is_empty());
2626
2627 writer.close().unwrap();
2628 assert!(writer.writers.is_empty());
2629 }
2630
2631 #[rstest]
2632 fn test_write_data_orderbook_deltas() {
2633 let temp_dir = TempDir::new().unwrap();
2634 let clock = WriterClock::Test(Arc::new(AtomicU64::new(0)));
2635
2636 let mut writer = FeatherWriter::new(
2637 temp_dir.path().to_path_buf(),
2638 clock,
2639 RotationConfig::NoRotation,
2640 None,
2641 None,
2642 );
2643
2644 let instrument_id = InstrumentId::from("AUD/USD.SIM");
2645 let delta1 = OrderBookDelta::clear(
2646 instrument_id,
2647 0,
2648 UnixNanos::from(1000),
2649 UnixNanos::from(1000),
2650 );
2651 let delta2 = OrderBookDelta::clear(
2652 instrument_id,
2653 0,
2654 UnixNanos::from(2000),
2655 UnixNanos::from(2000),
2656 );
2657
2658 let deltas = OrderBookDeltas::new(instrument_id, vec![delta1, delta2]);
2659 writer
2661 .write_data(Data::BookDeltas(Box::new(deltas)))
2662 .unwrap();
2663 writer.flush().unwrap();
2664 }
2665
2666 #[rstest]
2667 fn feather_writer_is_send() {
2668 fn assert_send<T: Send>() {}
2669 assert_send::<FeatherWriter>();
2670 assert_send::<WriterClock>();
2671 }
2672
2673 #[rstest]
2674 fn writer_clock_test_source_reads_shared_atomic() {
2675 let shared = Arc::new(AtomicU64::new(7));
2676 let clock = WriterClock::Test(Arc::clone(&shared));
2677 assert_eq!(clock.timestamp_ns(), UnixNanos::from(7));
2678
2679 shared.store(42, Ordering::Relaxed);
2680 assert_eq!(clock.timestamp_ns(), UnixNanos::from(42));
2681 }
2682
2683 #[rstest]
2684 fn writer_clock_from_shared_clock_wires_test_clock() {
2685 let test_clock = Rc::new(RefCell::new(VirtualClock::new()));
2686 test_clock
2687 .borrow_mut()
2688 .advance_time(UnixNanos::from(42), true);
2689 let clock: Rc<RefCell<dyn Clock>> = test_clock.clone();
2690
2691 let (writer_clock, shared) = WriterClock::from_shared_clock(&clock);
2692 let shared = shared.expect("non-live clocks must return a shared atomic");
2693
2694 assert_eq!(writer_clock.timestamp_ns(), UnixNanos::from(42));
2696
2697 test_clock
2700 .borrow_mut()
2701 .advance_time(UnixNanos::from(99), true);
2702 shared.store(clock.borrow().timestamp_ns().as_u64(), Ordering::Relaxed);
2703 assert_eq!(writer_clock.timestamp_ns(), UnixNanos::from(99));
2704 }
2705
2706 #[rstest]
2707 fn writer_clock_from_shared_clock_live_clock_is_live() {
2708 let clock: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(LiveClock::new(None)));
2709
2710 let (writer_clock, shared) = WriterClock::from_shared_clock(&clock);
2711
2712 assert!(matches!(writer_clock, WriterClock::Live));
2713 assert!(shared.is_none());
2714 }
2715
2716 #[rstest]
2717 #[cfg(unix)]
2718 fn failed_seal_of_a_removed_file_reports_the_error_and_publishes_nothing() {
2719 let temp_dir = TempDir::new().unwrap();
2720 let (sealed_tx, sealed_rx) = std::sync::mpsc::channel();
2721
2722 let mut writer = FeatherWriter::new(
2723 temp_dir.path().to_path_buf(),
2724 WriterClock::Test(Arc::new(AtomicU64::new(0))),
2725 RotationConfig::NoRotation,
2726 None,
2727 None,
2728 )
2729 .with_sealed_sender(sealed_tx);
2730 writer
2731 .write(QuoteTick::new(
2732 InstrumentId::from("AUD/USD.SIM"),
2733 Price::from("1.0"),
2734 Price::from("1.1"),
2735 Quantity::from("1000"),
2736 Quantity::from("1000"),
2737 UnixNanos::from(1_000),
2738 UnixNanos::from(1_000),
2739 ))
2740 .unwrap();
2741
2742 let partial = feather_files(temp_dir.path(), FEATHER_PARTIAL_EXTENSION);
2744 fs::remove_file(&partial[0]).unwrap();
2745
2746 let error = writer.close().unwrap_err();
2747
2748 assert_eq!(error.to_string(), "No such file or directory (os error 2)");
2749 assert!(writer.is_closed());
2750 assert!(feather_files(temp_dir.path(), FEATHER_EXTENSION).is_empty());
2751 assert_eq!(
2752 sealed_rx.try_iter().collect::<Vec<_>>(),
2753 Vec::<PathBuf>::new()
2754 );
2755 }
2756
2757 #[rstest]
2758 #[cfg(target_os = "linux")]
2759 fn failed_seal_recovers_the_batches_that_reached_the_file() {
2760 let temp_dir = TempDir::new().unwrap();
2761 let (sealed_tx, sealed_rx) = std::sync::mpsc::channel();
2762
2763 let mut writer = FeatherWriter::new(
2764 temp_dir.path().to_path_buf(),
2765 WriterClock::Test(Arc::new(AtomicU64::new(0))),
2766 RotationConfig::NoRotation,
2767 None,
2768 Some(0),
2769 )
2770 .with_sealed_sender(sealed_tx);
2771
2772 writer.write(quote_at(1)).unwrap();
2773 writer.flush().unwrap();
2774 writer.write(quote_at(2)).unwrap();
2775
2776 let (path, file) = writer.writers.iter_mut().next().unwrap();
2778 let sealed = path.path.clone();
2779 let full = OpenOptions::new().write(true).open("/dev/full").unwrap();
2780 file.writer =
2781 StreamWriter::try_new(CountingWriter::new(BufWriter::new(full)), &file.schema).unwrap();
2782
2783 let error = writer.close().unwrap_err();
2784
2785 assert_eq!(
2786 error.to_string(),
2787 "Io error: No space left on device (os error 28)"
2788 );
2789 assert!(writer.is_closed());
2790 assert!(feather_files(temp_dir.path(), FEATHER_PARTIAL_EXTENSION).is_empty());
2791 assert_eq!(read_quotes(&sealed), quotes_at(&[1]));
2792 assert_eq!(sealed_rx.try_iter().collect::<Vec<_>>(), vec![sealed]);
2793 }
2794
2795 #[rstest]
2796 #[case::nothing_flushed(false, vec![3])]
2797 #[case::batch_flushed(true, vec![1, 3])]
2798 #[cfg(target_os = "linux")]
2799 fn failed_flush_recovers_the_abandoned_file_and_next_write_opens_a_new_one(
2800 #[case] flushed: bool,
2801 #[case] expected: Vec<u64>,
2802 ) {
2803 let temp_dir = TempDir::new().unwrap();
2804 let (sealed_tx, sealed_rx) = std::sync::mpsc::channel();
2805
2806 let mut writer = FeatherWriter::new(
2807 temp_dir.path().to_path_buf(),
2808 WriterClock::Test(Arc::new(AtomicU64::new(0))),
2809 RotationConfig::NoRotation,
2810 None,
2811 Some(0),
2812 )
2813 .with_sealed_sender(sealed_tx);
2814
2815 writer.write(quote_at(1)).unwrap();
2816
2817 if flushed {
2818 writer.flush().unwrap();
2819 }
2820
2821 writer.write(quote_at(2)).unwrap();
2822
2823 let (abandoned, file) = writer.writers.iter_mut().next().unwrap();
2825 let abandoned = abandoned.clone();
2826 let full = OpenOptions::new().write(true).open("/dev/full").unwrap();
2827 file.writer =
2828 StreamWriter::try_new(CountingWriter::new(BufWriter::new(full)), &file.schema).unwrap();
2829
2830 let error = writer.flush().unwrap_err();
2831 writer.write(quote_at(3)).unwrap();
2832 writer.close().unwrap();
2833
2834 let mut recovered = feather_files(temp_dir.path(), FEATHER_EXTENSION)
2835 .iter()
2836 .flat_map(|path| read_quotes(path))
2837 .collect::<Vec<_>>();
2838 recovered.sort_by_key(HasTsInit::ts_init);
2839
2840 assert_eq!(
2843 error.to_string(),
2844 "Io error: No space left on device (os error 28)"
2845 );
2846 assert_eq!(abandoned.path.exists(), flushed);
2847 assert!(feather_files(temp_dir.path(), FEATHER_PARTIAL_EXTENSION).is_empty());
2848 assert_eq!(recovered, quotes_at(&expected));
2849 let mut notified = sealed_rx.try_iter().collect::<Vec<_>>();
2850 notified.sort();
2851 assert_eq!(notified, feather_files(temp_dir.path(), FEATHER_EXTENSION));
2852 }
2853
2854 #[rstest]
2855 #[case::end_of_stream_marker(0, &[], vec![1, 2, 3], "")]
2856 #[case::no_end_of_stream_marker(8, &[], vec![1, 2, 3], "")]
2857 #[case::stray_bytes(8, &[0xFF, 0xFF], vec![1, 2, 3], "")]
2858 #[case::truncated_last_batch(9, &[], vec![1, 2], TORN_TAIL)]
2859 fn recovery_seals_complete_batches_and_drops_the_rest(
2860 #[case] cut: usize,
2861 #[case] stray: &[u8],
2862 #[case] expected: Vec<u64>,
2863 #[case] cause: &str,
2864 ) {
2865 let temp_dir = TempDir::new().unwrap();
2866 let directory = temp_dir.path().join("recover");
2867 let partial = write_partial_file(&directory, &[1, 2, 3]);
2868 let mut staged = fs::read(&partial).unwrap();
2869 staged.truncate(staged.len() - cut);
2870 staged.extend_from_slice(stray);
2871 fs::write(&partial, &staged).unwrap();
2872
2873 let reference = fs::read(write_partial_file(
2875 &temp_dir.path().join("reference"),
2876 &expected,
2877 ))
2878 .unwrap();
2879 let complete = &reference[..reference.len() - 8];
2880
2881 let warnings = recover_with_warnings(&directory);
2882
2883 let sealed = partial.with_extension("");
2884 let dropped = staged.len() - complete.len();
2885
2886 let tail = if dropped == 0 {
2887 String::new()
2888 } else {
2889 format!(", dropped {dropped} trailing byte(s){cause}")
2890 };
2891
2892 assert!(!partial.exists());
2893 assert_eq!(fs::read(&sealed).unwrap(), complete);
2894 assert_eq!(read_quotes(&sealed), quotes_at(&expected));
2895 assert_eq!(
2896 warnings,
2897 vec![format!(
2898 "Recovered {} record batch(es) into Feather file {}{tail}",
2899 expected.len(),
2900 sealed.display()
2901 )],
2902 );
2903 }
2904
2905 #[rstest]
2906 #[case::schema_only(0, "")]
2907 #[case::truncated_schema(1, TORN_TAIL)]
2908 fn recovery_removes_a_file_without_a_complete_batch(#[case] cut: u64, #[case] cause: &str) {
2909 let temp_dir = TempDir::new().unwrap();
2910 let partial = temp_dir.path().join("quotes_1.feather.partial");
2911 let file = File::create(&partial).unwrap();
2912 StreamWriter::try_new(&file, &QuoteTick::get_schema(None)).unwrap();
2913 file.set_len(file.metadata().unwrap().len() - cut).unwrap();
2914 drop(file);
2915
2916 let warnings = recover_with_warnings(temp_dir.path());
2917
2918 assert_eq!(fs::read_dir(temp_dir.path()).unwrap().count(), 0);
2919 assert_eq!(
2920 warnings,
2921 vec![format!(
2922 "Removed Feather file {} with no complete record batch{cause}",
2923 partial.display()
2924 )],
2925 );
2926 }
2927
2928 #[rstest]
2929 fn recovery_leaves_a_file_with_an_unreadable_tail() {
2930 let temp_dir = TempDir::new().unwrap();
2931 let partial = write_partial_file(temp_dir.path(), &[1, 2]);
2932
2933 let mut staged = fs::read(&partial).unwrap();
2935 staged.truncate(staged.len() - 8);
2936 staged.extend_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0xFF, 0xFF, 0xFF]);
2937 fs::write(&partial, &staged).unwrap();
2938
2939 let warnings = recover_with_warnings(temp_dir.path());
2940
2941 assert_eq!(fs::read(&partial).unwrap(), staged);
2942 assert_eq!(
2943 warnings,
2944 vec![format!(
2945 "Failed to recover Feather file {}: Parser error: Invalid metadata length: -16",
2946 partial.display()
2947 )],
2948 );
2949 }
2950
2951 #[rstest]
2952 #[cfg(unix)]
2953 fn recovery_continues_after_a_file_it_cannot_recover() {
2954 let temp_dir = TempDir::new().unwrap();
2955 let run = temp_dir.path().join("run");
2956 let partial = write_partial_file(&run, &[1]);
2957
2958 let unrecoverable = temp_dir.path().join("bad.feather.partial");
2961 std::os::unix::fs::symlink(&run, &unrecoverable).unwrap();
2962
2963 let warnings = recover_with_warnings(temp_dir.path());
2964
2965 let sealed = partial.with_extension("");
2966 assert_eq!(read_quotes(&sealed), quotes_at(&[1]));
2967 assert_eq!(
2968 warnings,
2969 vec![
2970 format!(
2971 "Failed to recover Feather file {}: Is a directory (os error 21)",
2972 unrecoverable.display()
2973 ),
2974 format!(
2975 "Recovered 1 record batch(es) into Feather file {}, dropped 8 trailing byte(s)",
2976 sealed.display()
2977 ),
2978 ],
2979 );
2980 }
2981
2982 #[rstest]
2983 fn recovery_leaves_open_and_empty_files() {
2984 let temp_dir = TempDir::new().unwrap();
2985
2986 let mut writer = FeatherWriter::new(
2987 temp_dir.path().join("open"),
2988 WriterClock::Test(Arc::new(AtomicU64::new(0))),
2989 RotationConfig::NoRotation,
2990 None,
2991 Some(0),
2992 );
2993 writer.write(quote_at(1)).unwrap();
2994 writer.flush().unwrap();
2995
2996 let open = feather_files(temp_dir.path(), FEATHER_PARTIAL_EXTENSION).remove(0);
2998 let open_len = fs::metadata(&open).unwrap().len();
2999 let empty = temp_dir.path().join("empty.feather.partial");
3000 File::create(&empty).unwrap();
3001
3002 recover_partial_feather_files(temp_dir.path());
3003
3004 let partial = feather_files(temp_dir.path(), FEATHER_PARTIAL_EXTENSION);
3005 let open_len_after = fs::metadata(&open).unwrap().len();
3006 writer.close().unwrap();
3007
3008 assert_eq!(partial, vec![empty, open.clone()]);
3009 assert_eq!(open_len_after, open_len);
3010 assert_eq!(read_quotes(&open.with_extension("")), quotes_at(&[1]));
3011 }
3012
3013 #[rstest]
3014 fn size_rotation_in_mixed_identifier_batch_seals_the_shared_file_once() {
3015 let temp_dir = TempDir::new().unwrap();
3016
3017 let mut writer = FeatherWriter::new(
3018 temp_dir.path().to_path_buf(),
3019 WriterClock::Test(Arc::new(AtomicU64::new(0))),
3020 RotationConfig::Size { max_size: 1 },
3021 None,
3022 None,
3023 );
3024
3025 let quotes = ["AUD/USD.SIM", "EUR/USD.SIM"]
3026 .map(|instrument_id| {
3027 QuoteTick::new(
3028 InstrumentId::from(instrument_id),
3029 Price::from("1.0"),
3030 Price::from("1.1"),
3031 Quantity::from("1000"),
3032 Quantity::from("1000"),
3033 UnixNanos::from(1),
3034 UnixNanos::from(1),
3035 )
3036 })
3037 .to_vec();
3038
3039 writer.write_batch(quotes).unwrap();
3041
3042 let sealed = feather_files(temp_dir.path(), FEATHER_EXTENSION);
3043 let batches = read_feather_batches(&sealed[0]);
3044
3045 assert!(writer.writers.is_empty());
3046 assert_eq!(sealed.len(), 1);
3047 assert!(feather_files(temp_dir.path(), FEATHER_PARTIAL_EXTENSION).is_empty());
3048 assert_eq!(
3049 identifiers_from_record_batches(&batches).unwrap(),
3050 vec!["AUD/USD.SIM".to_string(), "EUR/USD.SIM".to_string()],
3051 );
3052 }
3053
3054 #[rstest]
3055 fn size_rotation_seals_each_full_file_and_opens_the_next_lazily() {
3056 let temp_dir = TempDir::new().unwrap();
3057 let shared_clock = Arc::new(AtomicU64::new(0));
3058
3059 let mut writer = FeatherWriter::new(
3060 temp_dir.path().to_path_buf(),
3061 WriterClock::Test(Arc::clone(&shared_clock)),
3062 RotationConfig::Size { max_size: 1 },
3063 None,
3064 Some(1),
3065 );
3066
3067 for ts in [1_000, 2_000] {
3068 shared_clock.store(ts * 1_000_000, Ordering::Relaxed);
3069 writer
3070 .write(QuoteTick::new(
3071 InstrumentId::from("AUD/USD.SIM"),
3072 Price::from("1.0"),
3073 Price::from("1.0"),
3074 Quantity::from("1000"),
3075 Quantity::from("1000"),
3076 UnixNanos::from(ts),
3077 UnixNanos::from(ts),
3078 ))
3079 .unwrap();
3080 }
3081
3082 let rows = feather_files(temp_dir.path(), FEATHER_EXTENSION)
3083 .iter()
3084 .map(|path| {
3085 read_feather_batches(path)
3086 .iter()
3087 .map(RecordBatch::num_rows)
3088 .sum::<usize>()
3089 })
3090 .collect::<Vec<_>>();
3091
3092 assert!(writer.writers.is_empty());
3093 assert_eq!(rows, vec![1, 1]);
3094 assert!(feather_files(temp_dir.path(), FEATHER_PARTIAL_EXTENSION).is_empty());
3095 }
3096
3097 #[rstest]
3098 #[case(
3099 "FeatherMissingTimestamp",
3100 Schema::empty(),
3101 "registered without an Arrow schema containing ts_init"
3102 )]
3103 #[case(
3104 "FeatherLegacyTimestamp",
3105 Schema::new(vec![Field::new("ts_init", DataType::UInt64, false)]),
3106 "registered with ts_init as UInt64",
3107 )]
3108 fn test_custom_writer_path_rejects_unqueryable_schema(
3109 #[case] type_name: &str,
3110 #[case] schema: Schema,
3111 #[case] expected_error: &str,
3112 ) {
3113 nautilus_model::data::registry::ensure_arrow_registered(
3114 type_name,
3115 Arc::new(schema),
3116 Box::new(|_| unreachable!("writer creation does not encode data")),
3117 Box::new(|_, _| unreachable!("writer creation does not decode data")),
3118 )
3119 .unwrap();
3120
3121 let temp_dir = TempDir::new().unwrap();
3122
3123 let mut writer = FeatherWriter::new(
3124 temp_dir.path().to_path_buf(),
3125 WriterClock::Test(Arc::new(AtomicU64::new(0))),
3126 RotationConfig::NoRotation,
3127 None,
3128 None,
3129 );
3130
3131 for _ in 0..2 {
3132 let error = writer.get_writer_path_custom(type_name).unwrap_err();
3133
3134 assert!(error.to_string().contains(expected_error));
3135 assert!(writer.writers.is_empty());
3136 assert!(writer.path_sequences.is_empty());
3137 }
3138 }
3139
3140 #[rstest]
3141 #[cfg(feature = "python")]
3142 fn test_write_custom_data_round_trip() {
3143 use std::sync::Arc;
3144
3145 use nautilus_model::{
3146 data::{CustomData, Data, DataType},
3147 identifiers::InstrumentId,
3148 };
3149 use nautilus_serialization::{
3150 arrow::custom::CustomDataDecoder, ensure_custom_data_registered,
3151 };
3152
3153 use crate::test_data::RustTestCustomData;
3154
3155 ensure_custom_data_registered::<RustTestCustomData>();
3156
3157 let temp_dir = TempDir::new().unwrap();
3158 let clock = WriterClock::Test(Arc::new(AtomicU64::new(0)));
3159
3160 let mut writer = FeatherWriter::new(
3161 temp_dir.path().to_path_buf(),
3162 clock,
3163 RotationConfig::NoRotation,
3164 None,
3165 None,
3166 );
3167
3168 let instrument_id = InstrumentId::from("RUST.TEST");
3169 let data_type = DataType::new("RustTestCustomData", None, Some(instrument_id.to_string()));
3170
3171 let original = RustTestCustomData {
3172 instrument_id,
3173 value: 1.23,
3174 flag: true,
3175 ts_event: UnixNanos::from(1000),
3176 ts_init: UnixNanos::from(1000),
3177 };
3178
3179 let custom = CustomData::new(Arc::new(original.clone()), data_type);
3180
3181 writer
3182 .write_data(Data::Custom(custom))
3183 .expect("write_data CustomData");
3184 writer.close().expect("close");
3185
3186 let prefix = temp_dir
3187 .path()
3188 .join("data")
3189 .join("custom")
3190 .join("RustTestCustomData");
3191 let files = feather_files(&prefix, FEATHER_EXTENSION);
3192 let batch = read_feather_batches(&files[0]).remove(0);
3193 let metadata = batch.schema().metadata().clone();
3194 let decoded =
3195 CustomDataDecoder::decode_data_batch(&metadata, batch).expect("decode_data_batch");
3196 assert_eq!(decoded.len(), 1);
3197
3198 if let Data::Custom(decoded_custom) = &decoded[0] {
3199 assert_eq!(decoded_custom.data_type.type_name(), "RustTestCustomData");
3200 let rust: &RustTestCustomData = decoded_custom
3201 .data
3202 .as_any()
3203 .downcast_ref::<RustTestCustomData>()
3204 .expect("RustTestCustomData");
3205 assert_eq!(rust, &original);
3206 } else {
3207 panic!("Expected Data::Custom");
3208 }
3209 }
3210
3211 #[rstest]
3212 #[cfg(feature = "python")]
3213 fn test_write_custom_data_reuses_writer_until_rotation() {
3214 use nautilus_model::data::{CustomData, DataType};
3215 use nautilus_serialization::ensure_custom_data_registered;
3216
3217 use crate::test_data::RustTestCustomData;
3218
3219 ensure_custom_data_registered::<RustTestCustomData>();
3220 let temp_dir = TempDir::new().unwrap();
3221
3222 let mut writer = FeatherWriter::new(
3223 temp_dir.path().to_path_buf(),
3224 WriterClock::Test(Arc::new(AtomicU64::new(0))),
3225 RotationConfig::NoRotation,
3226 None,
3227 None,
3228 );
3229 let instrument_id = InstrumentId::from("RUST.TEST");
3230 let data_type = DataType::new("RustTestCustomData", None, Some(instrument_id.to_string()));
3231
3232 for (ts, value) in [(1_000, 1_000.0), (2_000, 2_000.0)] {
3233 writer
3234 .write_data(Data::Custom(CustomData::new(
3235 Arc::new(RustTestCustomData {
3236 instrument_id,
3237 value,
3238 flag: true,
3239 ts_event: UnixNanos::from(ts),
3240 ts_init: UnixNanos::from(ts),
3241 }),
3242 data_type.clone(),
3243 )))
3244 .unwrap();
3245 }
3246
3247 assert_eq!(writer.get_current_file_info().len(), 1);
3248 writer.close().unwrap();
3249
3250 let prefix = temp_dir
3251 .path()
3252 .join("data")
3253 .join("custom")
3254 .join("RustTestCustomData");
3255 let files = feather_files(&prefix, FEATHER_EXTENSION);
3256 assert_eq!(files.len(), 1);
3257 let rows = read_feather_batches(&files[0])
3258 .iter()
3259 .map(RecordBatch::num_rows)
3260 .sum::<usize>();
3261 assert_eq!(rows, 2);
3262 }
3263}