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