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