Skip to main content

nautilus_persistence/writer/
feather.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16#![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
129/// Arrow type of the staged metadata columns; rows of one flush share a few distinct values.
130pub(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/// An open file and the catalog type it stages; each instrument class stages its own file.
145#[derive(Debug, Clone, PartialEq, Eq, Hash)]
146pub struct FileWriterPath {
147    path: PathBuf,
148    data_type: CatalogDataType,
149}
150
151/// Minimal `Send` time source for streaming writers.
152///
153/// `Live` reads the wall clock directly; `Test` reads a shared atomic that the
154/// owner of the source clock (e.g. a PyO3 wrapper holding a `VirtualClock`)
155/// refreshes before forwarding writer calls.
156#[derive(Clone, Debug)]
157pub enum WriterClock {
158    /// Wall-clock time via [`nanos_since_unix_epoch`].
159    Live,
160    /// Externally driven time stored in a shared atomic (nanoseconds since the UNIX epoch).
161    Test(Arc<AtomicU64>),
162}
163
164impl WriterClock {
165    /// Returns the current timestamp in nanoseconds since the UNIX epoch.
166    #[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    /// Builds a writer clock from a shared `dyn Clock`.
175    ///
176    /// Returns [`WriterClock::Live`] for a [`LiveClock`]. For any other clock
177    /// (e.g. `VirtualClock`) it returns a [`WriterClock::Test`] source seeded with
178    /// the clock's current time, plus the shared atomic the caller must refresh
179    /// from the source clock before each forwarded writer call.
180    #[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
293/// Canonical schema-level metadata JSON: keys serialized in sorted order.
294///
295/// The staged metadata id hashes this exact string so readers can verify the schema metadata
296/// independently of its storage location.
297pub(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
312/// File extension of a sealed Feather stream file.
313pub(crate) const FEATHER_EXTENSION: &str = "feather";
314
315/// File extension of a Feather stream file that is still being appended to.
316pub(crate) const FEATHER_PARTIAL_EXTENSION: &str = "feather.partial";
317
318/// An open local Feather file that appends Arrow IPC stream batches.
319///
320/// Batches go to `{name}.feather.partial`, which is renamed to `{name}.feather` when the file is
321/// sealed, so readers listing `.feather` files only see complete streams. The file stays
322/// exclusively locked until it is sealed, so [`recover_partial_feather_files`] can tell an open
323/// file from one its writer abandoned. Written rows are
324/// buffered and appended as one record batch on flush, on seal, when the buffer reaches
325/// [`DEFAULT_DATA_BATCH_CHUNK_SIZE`] rows, or when size rotation may be due, because each IPC
326/// batch carries a message header that outweighs a single row.
327struct 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        // Recovery can briefly lock a new, empty file before its writer does, so wait it out
355        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    // Returns true when size rotation is due
382    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        // The in-memory size only estimates the encoded size, so write before the exact check
402        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        // Renaming before the file closes keeps it locked until it is sealed
442        fs::rename(&self.partial_path, &self.path)?;
443        Ok(())
444    }
445}
446
447/// Counts the bytes written through the inner writer.
448struct 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/// Rotations and flushes collected by the synchronous encode path and run once it completes.
472#[derive(Debug, Default)]
473struct PendingIo {
474    rotate_paths: Vec<FileWriterPath>,
475    flush_due: bool,
476}
477
478/// Configuration for file rotation.
479#[derive(Debug, Clone)]
480pub enum RotationConfig {
481    /// Rotate based on file size.
482    Size {
483        /// Maximum buffer size in bytes before rotation.
484        max_size: u64,
485    },
486    /// Rotate based on a time interval.
487    Interval {
488        /// Interval in nanoseconds.
489        interval_ns: u64,
490    },
491    /// Rotate based on scheduled dates.
492    ScheduledDates {
493        /// Interval in nanoseconds.
494        interval_ns: u64,
495        /// Time of day for rotation (nanoseconds since midnight).
496        rotation_time: UnixNanos,
497        /// Timezone for rotation calculations.
498        rotation_timezone: TimeZone,
499    },
500    /// No automatic rotation.
501    NoRotation,
502}
503
504/// Streams encoded data into one local Feather file per data or record type, and per class for
505/// instruments.
506///
507/// Rows of every identifier share their type's file. Each row carries its catalog `identifier`
508/// and the Arrow metadata it was encoded with, such as its instrument's precision, so readers
509/// restore per-row metadata the file schema cannot hold.
510///
511/// The [`Self::write`] method accepts a data value of generic type `T`, encodes it using `T`'s
512/// metadata via `EncodeToRecordBatch`, routes it by `CatalogFamily`, and appends it to that key's
513/// open file.
514///
515/// # File Lifecycle
516///
517/// Open files are tracked by their final `.feather` paths, while writes go to `.feather.partial`
518/// files. Sealing completes the stream and renames the partial file to its final path. A file is
519/// sealed and replaced only on rotation, [`Self::seal`], or [`Self::close`].
520///
521/// # Flushing
522///
523/// [`Self::flush`] pushes buffered bytes to disk without starting a new file. `flush_interval_ms`
524/// controls automatic flushing in milliseconds; `0` disables automatic flushing.
525pub 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    /// Creates a new [`FeatherWriter`] instance.
542    #[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); // Default 1 second
551        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    /// Sets typed record-family filter for subsequent writes.
570    #[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    /// Writes a single data value.
582    ///
583    /// This is the user entry point. The data is encoded into a `RecordBatch` and appended to the
584    /// open file for its type, or for its class when the data is an instrument.
585    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        // Create a new FileWriter if one does not exist.
600        if !self.writers.contains_key(&path) {
601            self.create_writer::<T>(path.clone(), &data)?;
602        }
603
604        // Encode the data into a RecordBatch using T's encoding logic.
605        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        // Write the RecordBatch to the appropriate FileWriter.
612        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    /// Writes a batch of data values as one or more `RecordBatch`es.
621    ///
622    /// Uses `T::chunk_metadata` to derive the file schema metadata. This protects
623    /// types like `OrderBookDelta` from having their file metadata poisoned by a
624    /// leading sentinel row (e.g. `BookAction::Clear`, which carries
625    /// `price_precision=0, size_precision=0`).
626    ///
627    /// Rows are grouped by identifier so each group encodes with its own metadata before it is
628    /// appended to the type's file.
629    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    // Instrument classes have different fields, so each class stages its own file
688    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    /// Returns whether the auto-flush interval has elapsed since the last flush.
729    fn flush_is_due(&self) -> bool {
730        if self.flush_interval_ms == 0 {
731            return false; // Auto-flush disabled
732        }
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            // If the time has already passed today, we would usually add the interval
842            // But let's align exactly with how Python does it:
843            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    // Seals the open file at `path`; the next write for its key opens a new file. A failed seal
852    // leaves the stream unfinished, so the file is recovered to its complete batches instead.
853    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    // A failed append can leave a partial IPC message, so the file is recovered to its complete
869    // batches rather than sealed as written; the next write for its key opens a new file.
870    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    /// Creates (and inserts) a new `FileWriter` for type T.
898    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    /// Creates (and inserts) a new `FileWriter` for type T with pre-computed metadata.
910    ///
911    /// Use this variant when the caller has selected metadata from a chunk
912    /// (e.g. via `T::chunk_metadata`) to avoid schema poisoning by sentinel rows.
913    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    /// Creates (and inserts) a new `FeatherBuffer` for custom data at the given path.
931    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    /// Encodes a single `CustomData` into a `RecordBatch` with `data_type` column (catalog-compatible).
956    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    /// Flushes buffered bytes of every open file to disk.
1019    ///
1020    /// This is called automatically based on `flush_interval_ms` if configured, but can also
1021    /// be called manually by the client. Files stay open, so flushing never starts a new file.
1022    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    /// Seals every open file so its complete stream is visible as a `.feather` file.
1044    ///
1045    /// Writes after sealing open new files, so this is the boundary promotion uses.
1046    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    /// Seals all open files and reports any write error recorded since the last flush.
1064    ///
1065    /// Writes after closing open new files.
1066    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    /// Returns whether the writer has been closed (all writers cleared).
1077    #[must_use]
1078    pub fn is_closed(&self) -> bool {
1079        self.writers.is_empty()
1080    }
1081
1082    /// Returns the current file size and path of each open file, keyed by the type it stages.
1083    #[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    /// Returns the next rotation time of the file staging `data_type`, if set.
1097    ///
1098    /// Each instrument class stages its own file, so pass [`CatalogDataType::Instrument`].
1099    #[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    /// Determines whether a family can be written before row-level identifier checks.
1108    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            // Files left by an earlier writer in the same directory keep their names
1141            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    /// Returns the open file of a custom type, or reserves `data/custom/{type_name}/` for it.
1197    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    /// Returns the open file staging `data_type`, or reserves a new one.
1217    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    /// Writes a Data enum value to the appropriate writer.
1226    ///
1227    /// This is a convenience method that routes the Data enum to the appropriate
1228    /// typed write method.
1229    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                // Batch write so chunk_metadata can skip a leading BookAction::Clear sentinel
1246                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    /// Writes mixed data as typed Arrow batches.
1260    ///
1261    /// # Panics
1262    ///
1263    /// Panics if the built-in data batch dispatch becomes non-exhaustive.
1264    #[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    /// Writes supported message bus value.
1285    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    /// Writes a single custom data value (catalog layout: `data/custom/{type_name}[/{identifier}]`).
1426    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    /// Writes an instrument to the appropriate writer.
1471    ///
1472    /// Each instrument class stages its own file, and every row carries its instrument ID.
1473    pub fn write_instrument(
1474        &mut self,
1475        instrument: InstrumentAny,
1476    ) -> Result<(), Box<dyn std::error::Error>> {
1477        self.write(instrument)
1478    }
1479
1480    /// Subscribes to all messages on the message bus (pattern "*").
1481    ///
1482    /// This will automatically write all supported data types that are published
1483    /// on the message bus to the feather files.
1484    ///
1485    /// The writer must be wrapped in `Rc<RefCell<>>` to be shareable with the message bus handler.
1486    ///
1487    /// Note: Writes are synchronous local file appends.
1488    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    /// Unsubscribes from message bus.
1499    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
1511/// Drop contract: seals open files so their streams stay readable and promotable.
1512///
1513/// Errors are logged, never raised. Call [`FeatherWriter::close`] for deterministic sealing.
1514impl 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
1566/// Seals every abandoned `.feather.partial` file under `directory`.
1567///
1568/// A writer locks each file until it seals it, so a file that recovery can lock was left by a
1569/// writer that crashed, was killed, or released it after a failed write or seal. Recovery keeps the
1570/// complete record batches, drops any bytes after them with a warning, and renames the file to
1571/// `.feather`. It removes a file with no complete record batch and leaves an empty one, which a new
1572/// writer may not have locked yet. A read error other than a write cut short leaves the file for a
1573/// later pass. Failures are logged per file, so callers still read the sealed files.
1574pub(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
1605// Recovers a file its writer released after a failure, so its complete batches stay promotable
1606fn 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    // A writer renames its file before unlocking it, and a new file stays empty until its writer
1643    // locks it, so recovery leaves a missing or empty file alone
1644    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    // Only a write cut short ends the stream inside a message; any other read error could hide
1670    // complete batches after it, so the file waits for a later pass
1671    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    // Arrow C++ rejects trailing bytes that form no message, and EOF at a message boundary ends a
1692    // valid stream, so the file ends at its last complete batch
1693    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    // Reads a type's file and splits it into batches that each carry their rows' metadata
1760    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    // Seals one flushed batch per quote, then reopens the stream as a partial file whose writer
1798    // exited before sealing it
1799    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    // The logger is process-global, so only warnings naming `directory` belong to the caller
1826    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(), &quote);
1887        FeatherWriter::unsubscribe_from_message_bus(&handler);
1888        publish_quote(MStr::topic("data.quotes.AUD/USD.SIM").unwrap(), &quote);
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(&quote).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        // Create a test time source
1999        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, // flush_interval_ms
2008        );
2009
2010        let instrument_id = "AAPL.AAPL";
2011
2012        // Write a dummy value
2013        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        // Check keys and paths for quotes and trades
2037        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, // 2026-01-01 09:00:00 UTC
2128        ));
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); // 10:30 UTC
2154        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        // Write a dummy value.
2166        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(&quote);
2176        let schema = QuoteTick::get_schema(Some(metadata.clone()));
2177        let batch = QuoteTick::encode_batch(&QuoteTick::metadata(&quote), &[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        // Create a temporary directory for base path
2203        let temp_dir = TempDir::new_in(".").unwrap();
2204
2205        // Create a test time source
2206        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, // flush_interval_ms
2214        );
2215
2216        let instrument_id = "AAPL.AAPL";
2217
2218        // Write a dummy value.
2219        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 that the recovered data matches the written data
2266        assert_eq!(recovered_quotes.len(), 1, "Expected one QuoteTick record");
2267        assert_eq!(recovered_trades.len(), 1, "Expected one TradeTick record");
2268
2269        // Check key fields to ensure the data round-tripped correctly
2270        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        // Test writing via write_data
2298        writer.write_data(Data::Quote(quote)).unwrap();
2299        writer.flush().unwrap();
2300
2301        // Verify file was created
2302        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        // Test all data types
2321        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), // 100ms flush interval
2366        );
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        // Write first quote; interval not elapsed, so nothing is flushed yet
2379        writer.write(quote).unwrap();
2380        assert_eq!(writer.writers.len(), 1);
2381        assert_eq!(writer.last_flush_ns, UnixNanos::from(0));
2382
2383        // Advance the shared time source past the 100ms flush interval
2384        shared_time.store(200_000_000, Ordering::Relaxed);
2385
2386        // Second write hits the flush boundary: both quotes reach the same open file
2387        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        // Windows locks block reads through other handles, so the open file is measured instead:
2399        // if closing adds only the 8-byte end-of-stream marker, both quotes were already on disk
2400        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        // Each write crosses a flush interval, as a 1-minute bar does in a backtest
2448        for quote in &quotes {
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 &timestamps {
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        // Test writing OrderBookDeltas via write_data
2672        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        // Seeded with the source clock's current time
2707        assert_eq!(writer_clock.timestamp_ns(), UnixNanos::from(42));
2708
2709        // Bridge refresh: advancing the source clock and storing into the atomic
2710        // is what the PyO3 wrapper does before each forwarded call
2711        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        // Removing the open partial file makes the seal's rename fail after the stream finishes
2755        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        // Route the open stream to a device that reports a full disk, so sealing it fails
2789        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        // Route the open stream to a device that reports a full disk on every write
2836        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        // Quote 2 was buffered when the flush failed, and the full device also fails the writer's
2853        // drop-time flush, so it reaches no file
2854        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        // The expected batches alone form this prefix, which ends before the end-of-stream marker
2886        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        // A continuation marker followed by a negative metadata length is corrupt, not cut short
2946        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        // Windows locks block reads through other handles, so the open file is compared by length
3026        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        // Both identifier groups fill the shared quotes file, so the batch queues its seal twice
3069        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}