Skip to main content

nautilus_persistence/backend/
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
16use std::{
17    any::Any,
18    cell::RefCell,
19    collections::{HashMap, HashSet},
20    fmt::Display,
21    rc::Rc,
22    sync::Arc,
23};
24
25use ahash::AHashMap;
26use anyhow::Context;
27use datafusion::arrow::{
28    datatypes::Schema, error::ArrowError, ipc::writer::StreamWriter, record_batch::RecordBatch,
29};
30use futures::StreamExt;
31use jiff::{
32    SignedDuration,
33    civil::Time,
34    tz::{AmbiguousOffset, TimeZone},
35};
36use nautilus_common::{
37    cache::fifo::FifoCache,
38    clock::Clock,
39    msgbus::{
40        self,
41        mstr::MStr,
42        typed_handler::{ShareableMessageHandler, TypedHandler},
43    },
44};
45use nautilus_core::{UUID4, UnixNanos, datetime::NANOSECONDS_IN_SECOND};
46use nautilus_model::{
47    data::{
48        Bar, CatalogPathPrefix, CustomData, CustomDataTrait, Data, FundingRateUpdate,
49        IndexPriceUpdate, InstrumentStatus, MarkPriceUpdate, OptionGreeks, OrderBookDelta,
50        OrderBookDeltas, OrderBookDepth10, QuoteTick, TradeTick, close::InstrumentClose,
51        encode_custom_to_arrow, get_arrow_schema,
52    },
53    events::{
54        AccountState, OrderAccepted, OrderCancelRejected, OrderCanceled, OrderDenied,
55        OrderEmulated, OrderEventAny, OrderExpired, OrderFillVoided, OrderFilled, OrderInitialized,
56        OrderModifyRejected, OrderPendingCancel, OrderPendingUpdate, OrderRejected, OrderReleased,
57        OrderSnapshot, OrderSubmitted, OrderTriggered, OrderUpdated, PositionAdjusted,
58        PositionChanged, PositionClosed, PositionEvent, PositionOpened, PositionSnapshot,
59    },
60    instruments::InstrumentAny,
61    reports::{ExecutionMassStatus, FillReport, OrderStatusReport, PositionStatusReport},
62};
63use nautilus_serialization::arrow::{EncodeToRecordBatch, KEY_INSTRUMENT_ID};
64use object_store::{ObjectStore, ObjectStoreExt, path::Path};
65
66use super::catalog::urisafe_instrument_id;
67use crate::{
68    backend::{
69        catalog::safe_directory_identifier,
70        custom::{augment_batch_with_data_type_column, schema_with_data_type_column},
71    },
72    parquet::{ObjectStoreLocationKind, create_object_store_location_from_path},
73};
74
75#[derive(Debug, Default, PartialEq, PartialOrd, Hash, Eq, Clone)]
76pub struct FileWriterPath {
77    path: Path,
78    type_str: String,
79    instrument_id: Option<String>,
80}
81
82/// A `FeatherBuffer` encodes data via an Arrow `StreamWriter`.
83///
84/// It flushes the internal byte buffer according to rotation policy.
85pub struct FeatherBuffer {
86    /// Arrow `StreamWriter` that writes to an in-memory `Vec<u8>`.
87    writer: StreamWriter<Vec<u8>>,
88    /// Current size in bytes.
89    size: u64,
90    /// TODO: Optional next rotation timestamp.
91    // next_rotation: Option<UnixNanos>,
92    /// Schema of the data being written.
93    schema: Schema,
94    /// Maximum buffer size in bytes.
95    max_buffer_size: u64,
96    /// Rotation config
97    rotation_config: RotationConfig,
98}
99
100impl FeatherBuffer {
101    /// Creates a new [`FeatherBuffer`] using the given path, schema, and maximum buffer size.
102    ///
103    /// # Errors
104    ///
105    /// Returns an error if the Arrow stream writer cannot be created.
106    pub fn new(schema: &Schema, rotation_config: RotationConfig) -> Result<Self, ArrowError> {
107        let writer = StreamWriter::try_new(Vec::new(), schema)?;
108        let mut max_buffer_size = 1_000_000_000_000; // 1 GB
109
110        if let RotationConfig::Size { max_size } = &rotation_config {
111            max_buffer_size = *max_size;
112        }
113
114        Ok(Self {
115            writer,
116            size: 0,
117            // next_rotation: None,
118            max_buffer_size,
119            schema: schema.clone(),
120            rotation_config,
121        })
122    }
123
124    /// Writes the given `RecordBatch` to the internal buffer.
125    ///
126    /// Returns true if it should be rotated according rotation policy
127    ///
128    /// # Errors
129    ///
130    /// Returns an error if Arrow IPC writing fails.
131    pub fn write_record_batch(&mut self, batch: &RecordBatch) -> Result<bool, ArrowError> {
132        self.writer.write(batch)?;
133        self.size += batch.get_array_memory_size() as u64;
134        Ok(self.size >= self.max_buffer_size)
135    }
136
137    /// Consumes the writer and returns the buffer of bytes from the `StreamWriter`
138    ///
139    /// # Errors
140    ///
141    /// Returns an error if the replacement writer cannot be created or the previous
142    /// writer cannot be finalized.
143    pub fn take_buffer(&mut self) -> Result<Vec<u8>, ArrowError> {
144        let mut writer = StreamWriter::try_new(Vec::new(), &self.schema)?;
145        std::mem::swap(&mut self.writer, &mut writer);
146        let buffer = writer.into_inner()?;
147        // TODO: Handle rotation config here
148        self.size = 0;
149        Ok(buffer)
150    }
151
152    /// Should rotate
153    #[must_use]
154    pub const fn should_rotate(&self) -> bool {
155        match &self.rotation_config {
156            RotationConfig::Size { max_size } => self.size >= *max_size,
157            _ => false,
158        }
159    }
160}
161
162/// Configuration for file rotation.
163#[derive(Debug, Clone)]
164pub enum RotationConfig {
165    /// Rotate based on file size.
166    Size {
167        /// Maximum buffer size in bytes before rotation.
168        max_size: u64,
169    },
170    /// Rotate based on a time interval.
171    Interval {
172        /// Interval in nanoseconds.
173        interval_ns: u64,
174    },
175    /// Rotate based on scheduled dates.
176    ScheduledDates {
177        /// Interval in nanoseconds.
178        interval_ns: u64,
179        /// Time of day for rotation (nanoseconds since midnight).
180        rotation_time: UnixNanos,
181        /// Timezone for rotation calculations.
182        rotation_timezone: TimeZone,
183    },
184    /// No automatic rotation.
185    NoRotation,
186}
187
188/// Manages multiple `FeatherBuffers` and handles encoding, rotation, and flushing to the object store.
189///
190/// The `write()` method is the single entry point for clients: they supply a data value (of generic type T)
191/// and the manager encodes it (using T's metadata via `EncodeToRecordBatch`), routes it by `CatalogPathPrefix`,
192/// and writes it to the appropriate `FileWriter`. When a writer's buffer is full or rotation criteria are met,
193/// its contents are flushed to the object store and it is replaced.
194pub struct FeatherWriter {
195    /// Base directory for writing files.
196    base_path: String,
197    /// Object store for persistence.
198    store: Arc<dyn ObjectStore>,
199    /// Clock for timestamps and rotation.
200    clock: Rc<RefCell<dyn Clock>>,
201    /// Rotation configuration.
202    rotation_config: RotationConfig,
203    /// Optional set of type names to include.
204    included_types: Option<HashSet<String>>,
205    /// Set of types that should be split by instrument.
206    per_instrument_types: HashSet<String>,
207    /// Map of active `FeatherBuffers` keyed by their path.
208    writers: HashMap<FileWriterPath, FeatherBuffer>,
209    /// Map of next rotation times keyed by their path.
210    next_rotation_times: HashMap<FileWriterPath, UnixNanos>,
211    /// Runtime handle for async operations.
212    runtime: tokio::runtime::Handle,
213    /// Flush interval in milliseconds (0 = no automatic flushing).
214    flush_interval_ms: u64,
215    /// Last flush timestamp in nanoseconds.
216    last_flush_ns: UnixNanos,
217    /// First write error observed by a message bus handler or flush.
218    pending_write_error: Option<String>,
219    /// Bounded cache of recently seen event IDs for deduplication.
220    seen_event_ids: Box<FifoCache<UUID4, 10_000>>,
221}
222
223/// Message bus subscriptions owned by a [`FeatherWriter`].
224pub struct FeatherWriterSubscriptions {
225    any: ShareableMessageHandler,
226    instruments: TypedHandler<InstrumentAny>,
227    deltas: TypedHandler<OrderBookDeltas>,
228    depths: TypedHandler<OrderBookDepth10>,
229    quotes: TypedHandler<QuoteTick>,
230    trades: TypedHandler<TradeTick>,
231    bars: TypedHandler<Bar>,
232    mark_prices: TypedHandler<MarkPriceUpdate>,
233    index_prices: TypedHandler<IndexPriceUpdate>,
234    funding_rates: TypedHandler<FundingRateUpdate>,
235    option_greeks: TypedHandler<OptionGreeks>,
236    account_states: TypedHandler<AccountState>,
237    order_events: TypedHandler<OrderEventAny>,
238    position_events: TypedHandler<PositionEvent>,
239}
240
241impl FeatherWriter {
242    /// Creates a new [`FeatherWriter`] instance.
243    pub fn new(
244        base_path: String,
245        store: Arc<dyn ObjectStore>,
246        clock: Rc<RefCell<dyn Clock>>,
247        rotation_config: RotationConfig,
248        included_types: Option<HashSet<String>>,
249        per_instrument_types: Option<HashSet<String>>,
250        flush_interval_ms: Option<u64>,
251    ) -> Self {
252        // Get the runtime handle for async operations
253        let runtime = nautilus_common::live::get_runtime().handle().clone();
254        let flush_interval_ms = flush_interval_ms.unwrap_or(1000); // Default 1 second
255        let last_flush_ns = clock.borrow().timestamp_ns();
256
257        Self {
258            base_path,
259            store,
260            clock,
261            rotation_config,
262            included_types,
263            per_instrument_types: per_instrument_types.unwrap_or_default(),
264            writers: HashMap::new(),
265            next_rotation_times: HashMap::new(),
266            runtime,
267            flush_interval_ms,
268            last_flush_ns,
269            pending_write_error: None,
270            seen_event_ids: Box::new(FifoCache::new()),
271        }
272    }
273
274    /// Creates a [`FeatherWriter`] for an object-store URI.
275    ///
276    /// # Errors
277    ///
278    /// Returns an error if the object store cannot be created or existing objects cannot be
279    /// removed when `replace_existing` is enabled.
280    pub fn from_uri(
281        uri: &str,
282        storage_options: Option<AHashMap<String, String>>,
283        clock: Rc<RefCell<dyn Clock>>,
284        rotation_config: RotationConfig,
285        included_types: Option<HashSet<String>>,
286        flush_interval_ms: Option<u64>,
287        replace_existing: bool,
288    ) -> anyhow::Result<Self> {
289        let normalized_uri = crate::parquet::normalize_path_to_uri(uri);
290        if normalized_uri.starts_with("file://") {
291            let path = crate::parquet::file_uri_to_native_path(&normalized_uri);
292            std::fs::create_dir_all(&path)
293                .with_context(|| format!("Failed to create streaming directory '{path}'"))?;
294        }
295        let location = create_object_store_location_from_path(&normalized_uri, storage_options)?;
296        let is_local = matches!(location.kind, ObjectStoreLocationKind::Local);
297        let store = location.object_store;
298        let base_path = location.base_path;
299
300        if replace_existing {
301            let prefix = if base_path.is_empty() {
302                anyhow::ensure!(
303                    is_local,
304                    "replace_existing for remote streaming paths requires a non-empty prefix",
305                );
306                None
307            } else {
308                Some(Path::from(base_path.clone()))
309            };
310            let runtime = nautilus_common::live::get_runtime();
311            runtime.block_on(async {
312                let mut objects = store.list(prefix.as_ref());
313                let mut paths = Vec::new();
314                while let Some(result) = objects.next().await {
315                    paths.push(result?.location);
316                }
317
318                for path in paths {
319                    store.delete(&path).await?;
320                }
321                anyhow::Ok(())
322            })?;
323        }
324
325        Ok(Self::new(
326            base_path,
327            store,
328            clock,
329            rotation_config,
330            included_types,
331            Some(default_per_instrument_types()),
332            flush_interval_ms,
333        ))
334    }
335
336    /// Writes a single data value.
337    /// This is the user entry point. The data is encoded into a `RecordBatch` and written to the appropriate `FileWriter`.
338    /// If the writer's buffer reaches capacity or meets rotation criteria (based on the rotation configuration),
339    /// the `FileWriter` is flushed to the object store and replaced.
340    ///
341    /// # Errors
342    ///
343    /// Returns an error if path selection, Arrow encoding, writer creation, buffering,
344    /// rotation, or flushing fails.
345    pub async fn write<T>(&mut self, data: T) -> Result<(), Box<dyn std::error::Error>>
346    where
347        T: EncodeToRecordBatch + CatalogPathPrefix + 'static,
348    {
349        if !self.should_write::<T>() {
350            return Ok(());
351        }
352
353        let path = self.get_writer_path(&data)?;
354
355        // Create a new FileWriter if one does not exist.
356        if !self.writers.contains_key(&path) {
357            self.create_writer::<T>(path.clone(), &data)?;
358        }
359
360        // Encode the data into a RecordBatch using T's encoding logic.
361        let batch = T::encode_batch(&T::metadata(&data), &[data])?;
362
363        // Write the RecordBatch to the appropriate FileWriter.
364        if let Some(writer) = self.writers.get_mut(&path) {
365            let should_rotate = writer.write_record_batch(&batch)?;
366            if should_rotate || self.check_scheduled_rotation(&path) {
367                self.rotate_writer(&path).await?;
368            }
369        }
370
371        // Check if we should auto-flush based on time interval
372        self.check_flush().await?;
373
374        Ok(())
375    }
376
377    /// Writes a batch of data values as one or more `RecordBatch`es.
378    ///
379    /// Uses `T::chunk_metadata` to derive the file schema metadata. This protects
380    /// types like `OrderBookDelta` from having their file metadata poisoned by a
381    /// leading sentinel row (e.g. `BookAction::Clear`, which carries
382    /// `price_precision=0, size_precision=0`).
383    ///
384    /// Per-instrument types are partitioned by instrument so a mixed-instrument
385    /// batch lands in the correct file for each instrument.
386    ///
387    /// # Errors
388    ///
389    /// Returns an error if path selection, Arrow encoding, writer creation, buffering,
390    /// rotation, or flushing fails.
391    pub async fn write_batch<T>(&mut self, data: Vec<T>) -> Result<(), Box<dyn std::error::Error>>
392    where
393        T: EncodeToRecordBatch + CatalogPathPrefix + 'static,
394    {
395        if data.is_empty() || !self.should_write::<T>() {
396            return Ok(());
397        }
398
399        // Group by logical writer identity (instrument_id for per-instrument types).
400        // Grouping on FileWriterPath would split same-instrument rows across distinct
401        // timestamped paths when the writer does not yet exist under a LiveClock.
402        let type_str = T::path_prefix();
403        let needs_instrument =
404            self.per_instrument_types.contains(type_str) || type_str.starts_with("custom_");
405
406        let mut groups: AHashMap<Option<String>, Vec<T>> = AHashMap::new();
407
408        for item in data {
409            let instrument_id = if needs_instrument {
410                T::metadata(&item).get(KEY_INSTRUMENT_ID).cloned()
411            } else {
412                None
413            };
414            groups.entry(instrument_id).or_default().push(item);
415        }
416
417        for group in groups.into_values() {
418            let path = self.get_writer_path(&group[0])?;
419            let metadata = T::chunk_metadata(&group);
420
421            if !self.writers.contains_key(&path) {
422                self.create_writer_with_metadata::<T>(path.clone(), metadata.clone())?;
423            }
424
425            let batch = T::encode_batch(&metadata, &group)?;
426
427            if let Some(writer) = self.writers.get_mut(&path) {
428                let should_rotate = writer.write_record_batch(&batch)?;
429                if should_rotate || self.check_scheduled_rotation(&path) {
430                    self.rotate_writer(&path).await?;
431                }
432            }
433        }
434
435        self.check_flush().await?;
436
437        Ok(())
438    }
439
440    /// Checks if enough time has passed since last flush and flushes if needed.
441    async fn check_flush(&mut self) -> Result<(), Box<dyn std::error::Error>> {
442        if self.flush_interval_ms == 0 {
443            return Ok(()); // Auto-flush disabled
444        }
445
446        let now_ns = self.clock.borrow().timestamp_ns();
447        let elapsed_ms = (now_ns.as_u64() - self.last_flush_ns.as_u64()) / 1_000_000;
448
449        if elapsed_ms >= self.flush_interval_ms {
450            self.flush().await?;
451            self.last_flush_ns = now_ns;
452        }
453
454        Ok(())
455    }
456
457    fn check_scheduled_rotation(&mut self, path: &FileWriterPath) -> bool {
458        match &self.rotation_config {
459            RotationConfig::Interval { interval_ns } => {
460                let now = self.clock.borrow().timestamp_ns();
461                let next_rotation = self.next_rotation_times.get(path).copied();
462
463                match next_rotation {
464                    None => {
465                        self.next_rotation_times
466                            .insert(path.clone(), now + *interval_ns);
467                        false
468                    }
469                    Some(next) if now >= next => {
470                        self.next_rotation_times
471                            .insert(path.clone(), now + *interval_ns);
472                        true
473                    }
474                    _ => false,
475                }
476            }
477            RotationConfig::ScheduledDates {
478                interval_ns,
479                rotation_time,
480                rotation_timezone,
481            } => {
482                let now = self.clock.borrow().timestamp_ns();
483                let next_rotation = self.next_rotation_times.get(path).copied();
484
485                match next_rotation {
486                    None => {
487                        let next = self.calculate_next_scheduled_rotation(
488                            *rotation_time,
489                            rotation_timezone,
490                            *interval_ns,
491                        );
492                        self.next_rotation_times.insert(path.clone(), next);
493                        false
494                    }
495                    Some(next) if now >= next => {
496                        self.next_rotation_times
497                            .insert(path.clone(), now + *interval_ns);
498                        true
499                    }
500                    _ => false,
501                }
502            }
503            _ => false,
504        }
505    }
506
507    fn calculate_next_scheduled_rotation(
508        &self,
509        rotation_time: UnixNanos,
510        rotation_timezone: &TimeZone,
511        interval_ns: u64,
512    ) -> UnixNanos {
513        let now_utc = self.clock.borrow().utc_now();
514        let now_local = rotation_timezone.to_datetime(now_utc);
515
516        let rotation_time_secs = u32::try_from(*rotation_time / NANOSECONDS_IN_SECOND).unwrap_or(0);
517        let rotation_time_nanos =
518            i32::try_from(*rotation_time % NANOSECONDS_IN_SECOND).unwrap_or(0);
519        let rotation_time = if rotation_time_secs < 86_400 {
520            Time::new(
521                i8::try_from(rotation_time_secs / 3_600).unwrap_or(0),
522                i8::try_from(rotation_time_secs % 3_600 / 60).unwrap_or(0),
523                i8::try_from(rotation_time_secs % 60).unwrap_or(0),
524                rotation_time_nanos,
525            )
526            .unwrap_or(Time::MIN)
527        } else {
528            Time::MIN
529        };
530
531        let local_rotation = now_local.date().to_datetime(rotation_time);
532        let ambiguous = rotation_timezone.to_ambiguous_timestamp(local_rotation);
533        let mut next_rotation = match ambiguous.offset() {
534            AmbiguousOffset::Gap { .. } => now_utc,
535            _ => ambiguous.earlier().unwrap_or(now_utc),
536        };
537
538        if next_rotation <= now_utc {
539            // If the time has already passed today, we would usually add the interval
540            // But let's align exactly with how Python does it:
541            while next_rotation <= now_utc {
542                next_rotation += SignedDuration::from_nanos_i128(i128::from(interval_ns));
543            }
544        }
545
546        UnixNanos::from(u64::try_from(next_rotation.as_nanosecond()).unwrap_or(0))
547    }
548
549    /// Flushes and rotates `FileWriter` associated with `key`.
550    /// TODO: Fix error type to handle arrow error and object store error
551    async fn rotate_writer(
552        &mut self,
553        path: &FileWriterPath,
554    ) -> Result<(), Box<dyn std::error::Error>> {
555        let mut writer = self.writers.remove(path).unwrap();
556        let bytes = writer.take_buffer()?;
557        self.store.put(&path.path, bytes.into()).await?;
558        let new_path = self.regen_writer_path(path);
559        self.writers.insert(new_path, writer);
560        Ok(())
561    }
562
563    /// Creates (and inserts) a new `FileWriter` for type T.
564    fn create_writer<T>(&mut self, path: FileWriterPath, data: &T) -> Result<(), ArrowError>
565    where
566        T: EncodeToRecordBatch + CatalogPathPrefix + 'static,
567    {
568        self.create_writer_with_metadata::<T>(path, T::metadata(data))
569    }
570
571    /// Creates (and inserts) a new `FileWriter` for type T with pre-computed metadata.
572    ///
573    /// Use this variant when the caller has selected metadata from a chunk
574    /// (e.g. via `T::chunk_metadata`) to avoid schema poisoning by sentinel rows.
575    fn create_writer_with_metadata<T>(
576        &mut self,
577        path: FileWriterPath,
578        metadata: HashMap<String, String>,
579    ) -> Result<(), ArrowError>
580    where
581        T: EncodeToRecordBatch + CatalogPathPrefix + 'static,
582    {
583        let schema = if self.per_instrument_types.contains(T::path_prefix()) {
584            T::get_schema(Some(metadata))
585        } else {
586            T::get_schema(None)
587        };
588
589        let writer = FeatherBuffer::new(&schema, self.rotation_config.clone())?;
590        self.writers.insert(path, writer);
591        Ok(())
592    }
593
594    /// Creates (and inserts) a new `FeatherBuffer` for custom data at the given path.
595    fn create_custom_writer(
596        &mut self,
597        path: FileWriterPath,
598        type_name: &str,
599    ) -> Result<(), Box<dyn std::error::Error>> {
600        if self.writers.contains_key(&path) {
601            return Ok(());
602        }
603        let base_schema = get_arrow_schema(type_name).ok_or_else(|| {
604            format!("Custom data type \"{type_name}\" is not registered for Arrow encoding")
605        })?;
606        let schema = schema_with_data_type_column(base_schema.as_ref(), type_name);
607        let writer = FeatherBuffer::new(&schema, self.rotation_config.clone())
608            .map_err(|e| format!("Failed to create feather buffer for custom {type_name}: {e}"))?;
609        self.writers.insert(path, writer);
610        Ok(())
611    }
612
613    /// Encodes a single `CustomData` into a `RecordBatch` with `data_type` column (catalog-compatible).
614    fn encode_custom_to_batch(
615        custom: &CustomData,
616    ) -> Result<RecordBatch, Box<dyn std::error::Error>> {
617        let type_name = custom.data.type_name();
618        let data_type_json = custom
619            .data_type
620            .to_persistence_json()
621            .map_err(|e| format!("Failed to serialize data_type for persistence: {e}"))?;
622        let dt_meta = custom.data_type.metadata_string_map();
623        let items: [Arc<dyn CustomDataTrait>; 1] = [Arc::clone(&custom.data)];
624        let batch = encode_custom_to_arrow(type_name, &items)
625            .map_err(|e| format!("Failed to encode custom data: {e}"))?
626            .ok_or_else(|| {
627                format!("Custom data type \"{type_name}\" is not registered for Arrow")
628            })?;
629        let batch = augment_batch_with_data_type_column(
630            &batch,
631            &data_type_json,
632            type_name,
633            dt_meta.as_ref(),
634        )
635        .map_err(|e| e.to_string())?;
636        Ok(batch)
637    }
638
639    /// Flushes all active `FeatherBuffers` by writing any remaining buffered bytes to the object store.
640    ///
641    /// This is called automatically based on `flush_interval_ms` if configured, but can also
642    /// be called manually by the client.
643    ///
644    /// Note: In Rust, we use in-memory buffers. Flushing writes the current buffer to the
645    /// object store and creates a new buffer for continued writing. This is different from
646    /// Python which just flushes OS buffers.
647    ///
648    /// # Errors
649    ///
650    /// Returns an error if buffer finalization or object store writes fail.
651    pub async fn flush(&mut self) -> Result<(), Box<dyn std::error::Error>> {
652        if let Err(e) = self.flush_active_writers().await {
653            self.record_write_error("streaming output", &e);
654            return Err(e);
655        }
656
657        self.last_flush_ns = self.clock.borrow().timestamp_ns();
658
659        if let Some(error) = &self.pending_write_error {
660            return Err(error.clone().into());
661        }
662        Ok(())
663    }
664
665    async fn flush_active_writers(&mut self) -> Result<(), Box<dyn std::error::Error>> {
666        // Collect paths and their current buffers before flushing
667        let paths_to_flush: Vec<FileWriterPath> = self.writers.keys().cloned().collect();
668
669        // Flush each writer and recreate it
670        for path in paths_to_flush {
671            if let Some(mut writer) = self.writers.remove(&path) {
672                let bytes = writer.take_buffer()?;
673                if !bytes.is_empty() {
674                    // Write to the object store
675                    self.store.put(&path.path, bytes.into()).await?;
676                }
677
678                // Recreate writer with same schema for continued writing
679                // We need the schema and type info - for now, we'll recreate on next write
680                // The writer will be recreated automatically when write() is called again
681            }
682        }
683        Ok(())
684    }
685
686    fn record_write_error(&mut self, type_name: &str, error: impl Display) {
687        let message = format!("Failed to write {type_name}: {error}");
688        log::warn!("{message}");
689        if self.pending_write_error.is_none() {
690            self.pending_write_error = Some(message);
691        }
692    }
693
694    /// Closes all writers by flushing and removing them.
695    ///
696    /// After calling this, no further writes should be performed.
697    ///
698    /// # Errors
699    ///
700    /// Returns an error if flushing buffered data fails.
701    pub async fn close(&mut self) -> Result<(), Box<dyn std::error::Error>> {
702        self.flush().await?;
703        self.writers.clear();
704        Ok(())
705    }
706
707    /// Returns whether the writer has been closed (all writers cleared).
708    #[must_use]
709    pub fn is_closed(&self) -> bool {
710        self.writers.is_empty()
711    }
712
713    /// Returns information about the current files being written.
714    ///
715    /// Each entry maps a writer key (`type_str` and optional `instrument_id`) to
716    /// its current buffer size and file path.
717    #[must_use]
718    pub fn get_current_file_info(&self) -> HashMap<String, (u64, String)> {
719        let mut info = HashMap::new();
720
721        for (path, buffer) in &self.writers {
722            let key = match &path.instrument_id {
723                Some(id) => format!("{}:{}", path.type_str, id),
724                None => path.type_str.clone(),
725            };
726            info.insert(key, (buffer.size, path.path.to_string()));
727        }
728        info
729    }
730
731    /// Returns the next rotation time for a specific writer key, if set.
732    #[must_use]
733    pub fn get_next_rotation_time(
734        &self,
735        type_str: &str,
736        instrument_id: Option<&str>,
737    ) -> Option<UnixNanos> {
738        self.next_rotation_times
739            .iter()
740            .find(|(k, _)| k.type_str == type_str && k.instrument_id.as_deref() == instrument_id)
741            .map(|(_, &v)| v)
742    }
743
744    /// Determines whether type T should be written, based on the inclusion filter.
745    fn should_write<T: CatalogPathPrefix>(&self) -> bool {
746        self.included_types.as_ref().is_none_or(|included| {
747            let path = T::path_prefix();
748            included.contains(path)
749        })
750    }
751
752    /// Returns whether the given event ID has already been seen,
753    /// adding it to the cache if new.
754    pub fn is_duplicate_event_id(&mut self, event_id: &UUID4) -> bool {
755        if self.seen_event_ids.contains(event_id) {
756            return true;
757        }
758
759        self.seen_event_ids.add(*event_id);
760
761        false
762    }
763
764    fn regen_writer_path(&self, path: &FileWriterPath) -> FileWriterPath {
765        let type_str = path.type_str.clone();
766        let instrument_id = path.instrument_id.clone();
767        let timestamp = self.clock.borrow().timestamp_ns();
768        // Note: Path removes prefixing slashes
769        let mut path = Path::from(self.base_path.clone());
770
771        if type_str.starts_with("data/custom/") {
772            // Custom data: data/custom/{type_name}/[{identifier_segments}/]{file_stem}_{ts}.feather
773            let type_name = type_str.strip_prefix("data/custom/").unwrap_or(&type_str);
774            path = path.join("data").join("custom").join(type_name.to_string());
775
776            if let Some(ref id) = instrument_id {
777                let safe = safe_directory_identifier(id);
778                if !safe.is_empty() {
779                    for segment in safe.split('/') {
780                        path = path.join(segment.to_string());
781                    }
782                }
783            }
784            let file_stem = instrument_id.as_deref().unwrap_or(type_name);
785            path = path.join(format!("{file_stem}_{timestamp}.feather"));
786        } else if let Some(ref instrument_id) = instrument_id {
787            path = self.per_instrument_path(&type_str, instrument_id, timestamp);
788        } else {
789            path = path.join(format!("{type_str}_{timestamp}.feather"));
790        }
791
792        FileWriterPath {
793            path,
794            type_str,
795            instrument_id,
796        }
797    }
798
799    /// Builds `FileWriterPath` for custom data using `DataType` identifier as folder partition (catalog layout).
800    fn get_writer_path_custom(&self, type_name: &str, identifier: Option<&str>) -> FileWriterPath {
801        let timestamp = self.clock.borrow().timestamp_ns();
802        let type_str = format!("data/custom/{type_name}");
803        let instrument_id = identifier.map(String::from);
804
805        let mut path = Path::from(self.base_path.clone());
806        path = path.join("data").join("custom").join(type_name.to_string());
807
808        if let Some(id) = &identifier {
809            let safe = safe_directory_identifier(id);
810            if !safe.is_empty() {
811                for segment in safe.split('/') {
812                    path = path.join(segment.to_string());
813                }
814            }
815        }
816        let file_stem = identifier.unwrap_or(type_name);
817        path = path.join(format!("{file_stem}_{timestamp}.feather"));
818
819        FileWriterPath {
820            path,
821            type_str,
822            instrument_id,
823        }
824    }
825
826    /// Generates a key for a `FileWriter` based on type T and optional instrument ID.
827    /// Reuses an existing writer key (same `type_str` and `instrument_id`) if present, so we
828    /// buffer multiple items in the same file until rotation; otherwise creates a new path with current timestamp.
829    fn get_writer_path<T>(&self, data: &T) -> Result<FileWriterPath, Box<dyn std::error::Error>>
830    where
831        T: EncodeToRecordBatch + CatalogPathPrefix,
832    {
833        let type_str = T::path_prefix();
834        let metadata = T::metadata(data);
835
836        let instrument_id = if self.per_instrument_types.contains(type_str)
837            || (type_str.starts_with("custom_") && metadata.contains_key(KEY_INSTRUMENT_ID))
838        {
839            Some(metadata.get(KEY_INSTRUMENT_ID).cloned().ok_or_else(|| {
840                format!("Data {type_str} expected instrument_id metadata for per instrument writer")
841            })?)
842        } else {
843            None
844        };
845
846        // Reuse existing writer for same (type_str, instrument_id) so we buffer in one file until rotation
847        if let Some(existing) = self
848            .writers
849            .keys()
850            .find(|k| k.type_str == type_str && k.instrument_id == instrument_id)
851        {
852            return Ok(existing.clone());
853        }
854
855        let timestamp = self.clock.borrow().timestamp_ns();
856        let path = if let Some(ref instrument_id) = instrument_id {
857            self.per_instrument_path(type_str, instrument_id, timestamp)
858        } else {
859            Path::from(self.base_path.clone()).join(format!("{type_str}_{timestamp}.feather"))
860        };
861
862        Ok(FileWriterPath {
863            path,
864            type_str: type_str.to_string(),
865            instrument_id,
866        })
867    }
868
869    fn per_instrument_path(
870        &self,
871        type_str: &str,
872        instrument_id: &str,
873        timestamp: UnixNanos,
874    ) -> Path {
875        let safe_id = urisafe_instrument_id(instrument_id);
876        let filename = if type_str.contains('/') {
877            format!("{safe_id}_{timestamp}.feather")
878        } else {
879            format!("{type_str}_{timestamp}.feather")
880        };
881        Path::from(self.base_path.clone())
882            .join(type_str)
883            .join(safe_id)
884            .join(filename)
885    }
886
887    /// Writes a Data enum value to the appropriate writer.
888    ///
889    /// This is a convenience method that routes the Data enum to the appropriate
890    /// typed write method.
891    ///
892    /// # Errors
893    ///
894    /// Returns an error if the routed typed or custom data write fails.
895    #[allow(
896        clippy::match_wildcard_for_single_variants,
897        reason = "Data::Defi appears through nautilus-model feature unification"
898    )]
899    pub async fn write_data(&mut self, data: Data) -> Result<(), Box<dyn std::error::Error>> {
900        match data {
901            Data::Quote(quote) => self.write(quote).await,
902            Data::Trade(trade) => self.write(trade).await,
903            Data::Bar(bar) => self.write(bar).await,
904            Data::Delta(delta) => self.write(delta).await,
905            Data::Depth10(depth) => self.write(*depth).await,
906            Data::IndexPrice(price) => self.write(price).await,
907            Data::MarkPrice(price) => self.write(price).await,
908            Data::FundingRate(funding) => self.write(funding).await,
909            Data::OptionGreeks(greeks) => self.write(greeks).await,
910            Data::InstrumentStatus(status) => self.write(status).await,
911            Data::InstrumentClose(close) => self.write(close).await,
912            Data::Custom(custom) => self.write_custom_data(&custom).await,
913            Data::Deltas(deltas) => {
914                // Batch write so chunk_metadata can skip a leading BookAction::Clear sentinel
915                self.write_batch(deltas.deltas.clone()).await
916            }
917            #[cfg(feature = "defi")]
918            Data::Defi(_) => Err("Unsupported Data::Defi variant for feather writes".into()),
919            #[allow(unreachable_patterns)]
920            _ => Err("Unsupported Data variant for feather writes".into()),
921        }
922    }
923
924    /// Writes a single custom data value (catalog layout: `data/custom/{type_name}/[{identifier}/]`).
925    async fn write_custom_data(
926        &mut self,
927        custom: &CustomData,
928    ) -> Result<(), Box<dyn std::error::Error>> {
929        let type_name = custom.data.type_name();
930        let identifier = custom.data_type.identifier().map(String::from);
931
932        if !self.should_write_custom(type_name) {
933            return Ok(());
934        }
935
936        let path = self.get_writer_path_custom(type_name, identifier.as_deref());
937        if !self.writers.contains_key(&path) {
938            self.create_custom_writer(path.clone(), type_name)?;
939        }
940
941        let batch = Self::encode_custom_to_batch(custom)?;
942
943        if let Some(writer) = self.writers.get_mut(&path) {
944            let should_rotate = writer.write_record_batch(&batch)?;
945            if should_rotate || self.check_scheduled_rotation(&path) {
946                self.rotate_writer(&path).await?;
947            }
948        }
949
950        self.check_flush().await?;
951        Ok(())
952    }
953
954    fn should_write_custom(&self, type_name: &str) -> bool {
955        self.included_types.as_ref().is_none_or(|included| {
956            included.contains(type_name)
957                || included.contains("custom")
958                || included.contains(&format!("custom/{type_name}"))
959        })
960    }
961
962    /// Writes an instrument to the appropriate writer.
963    ///
964    /// Instruments are written to feather files and organized by instrument ID.
965    /// This method supports writing instruments that implement `EncodeToRecordBatch` and `CatalogPathPrefix`.
966    ///
967    /// # Errors
968    ///
969    /// Returns an error if the instrument write fails.
970    pub async fn write_instrument(
971        &mut self,
972        instrument: InstrumentAny,
973    ) -> Result<(), Box<dyn std::error::Error>> {
974        self.write(instrument).await
975    }
976
977    /// Subscribes to all messages on the message bus (pattern "*").
978    ///
979    /// This will automatically write all supported data types that are published
980    /// on the message bus to the feather files.
981    ///
982    /// The writer must be wrapped in `Rc<RefCell<>>` to be shareable with the message bus handler.
983    ///
984    /// Note: The handler spawns async tasks to write data, so writes happen asynchronously
985    /// and won't block the message bus.
986    ///
987    /// # Errors
988    ///
989    /// Returns an error if subscription setup fails.
990    pub fn subscribe_to_message_bus(
991        writer: Rc<RefCell<Self>>,
992    ) -> Result<FeatherWriterSubscriptions, Box<dyn std::error::Error>> {
993        Ok(Self::subscribe_to_message_bus_inner(writer, true))
994    }
995
996    /// Subscribes to built-in messages on the message bus (pattern `"*"`).
997    ///
998    /// # Errors
999    ///
1000    /// Returns an error if subscription setup fails.
1001    pub fn subscribe_builtin_to_message_bus(
1002        writer: Rc<RefCell<Self>>,
1003    ) -> Result<FeatherWriterSubscriptions, Box<dyn std::error::Error>> {
1004        Ok(Self::subscribe_to_message_bus_inner(writer, false))
1005    }
1006
1007    #[expect(
1008        clippy::too_many_lines,
1009        reason = "subscription assembly keeps the symmetric handler set and ownership in one place"
1010    )]
1011    fn subscribe_to_message_bus_inner(
1012        writer: Rc<RefCell<Self>>,
1013        include_custom_data: bool,
1014    ) -> FeatherWriterSubscriptions {
1015        let runtime = writer.borrow().runtime.clone();
1016
1017        macro_rules! typed_writer_handler {
1018            ($type:ty, $name:literal) => {{
1019                let writer = Rc::clone(&writer);
1020                let runtime = runtime.clone();
1021                TypedHandler::from(move |value: &$type| {
1022                    let mut writer = writer.borrow_mut();
1023                    if let Err(e) = runtime.block_on(writer.write(value.clone())) {
1024                        writer.record_write_error($name, e);
1025                    }
1026                })
1027            }};
1028        }
1029
1030        let instruments = {
1031            let writer = Rc::clone(&writer);
1032            let runtime = runtime.clone();
1033            TypedHandler::from(move |instrument: &InstrumentAny| {
1034                let mut writer = writer.borrow_mut();
1035                if let Err(e) = runtime.block_on(writer.write_instrument(instrument.clone())) {
1036                    writer.record_write_error("InstrumentAny", e);
1037                }
1038            })
1039        };
1040        let deltas = {
1041            let writer = Rc::clone(&writer);
1042            let runtime = runtime.clone();
1043            TypedHandler::from(move |deltas: &OrderBookDeltas| {
1044                let mut writer = writer.borrow_mut();
1045                if let Err(e) = runtime.block_on(writer.write_batch(deltas.deltas.clone())) {
1046                    writer.record_write_error("OrderBookDeltas", e);
1047                }
1048            })
1049        };
1050        let depths = typed_writer_handler!(OrderBookDepth10, "OrderBookDepth10");
1051        let quotes = typed_writer_handler!(QuoteTick, "QuoteTick");
1052        let trades = typed_writer_handler!(TradeTick, "TradeTick");
1053        let bars = typed_writer_handler!(Bar, "Bar");
1054        let mark_prices = typed_writer_handler!(MarkPriceUpdate, "MarkPriceUpdate");
1055        let index_prices = typed_writer_handler!(IndexPriceUpdate, "IndexPriceUpdate");
1056        let funding_rates = typed_writer_handler!(FundingRateUpdate, "FundingRateUpdate");
1057        let option_greeks = typed_writer_handler!(OptionGreeks, "OptionGreeks");
1058        let account_states = typed_writer_handler!(AccountState, "AccountState");
1059        let order_events = {
1060            let writer = Rc::clone(&writer);
1061            let runtime = runtime.clone();
1062            TypedHandler::from(move |event: &OrderEventAny| {
1063                macro_rules! write_event {
1064                    ($value:expr, $name:literal) => {{
1065                        let mut writer = writer.borrow_mut();
1066                        if let Err(e) = runtime.block_on(writer.write($value.clone())) {
1067                            writer.record_write_error($name, e);
1068                        }
1069                    }};
1070                }
1071
1072                match event {
1073                    OrderEventAny::Initialized(value) => write_event!(value, "OrderInitialized"),
1074                    OrderEventAny::Denied(value) => write_event!(value, "OrderDenied"),
1075                    OrderEventAny::Emulated(value) => write_event!(value, "OrderEmulated"),
1076                    OrderEventAny::Released(value) => write_event!(value, "OrderReleased"),
1077                    OrderEventAny::Submitted(value) => write_event!(value, "OrderSubmitted"),
1078                    OrderEventAny::Accepted(value) => write_event!(value, "OrderAccepted"),
1079                    OrderEventAny::Rejected(value) => write_event!(value, "OrderRejected"),
1080                    OrderEventAny::Canceled(value) => write_event!(value, "OrderCanceled"),
1081                    OrderEventAny::Expired(value) => write_event!(value, "OrderExpired"),
1082                    OrderEventAny::Triggered(value) => write_event!(value, "OrderTriggered"),
1083                    OrderEventAny::PendingUpdate(value) => {
1084                        write_event!(value, "OrderPendingUpdate");
1085                    }
1086                    OrderEventAny::PendingCancel(value) => {
1087                        write_event!(value, "OrderPendingCancel");
1088                    }
1089                    OrderEventAny::ModifyRejected(value) => {
1090                        write_event!(value, "OrderModifyRejected");
1091                    }
1092                    OrderEventAny::CancelRejected(value) => {
1093                        write_event!(value, "OrderCancelRejected");
1094                    }
1095                    OrderEventAny::Updated(value) => write_event!(value, "OrderUpdated"),
1096                    OrderEventAny::Filled(value) => write_event!(value, "OrderFilled"),
1097                    OrderEventAny::FillVoided(value) => write_event!(value, "OrderFillVoided"),
1098                }
1099            })
1100        };
1101        let position_events = {
1102            let writer = Rc::clone(&writer);
1103            let runtime = runtime.clone();
1104            TypedHandler::from(move |event: &PositionEvent| {
1105                macro_rules! write_event {
1106                    ($value:expr, $name:literal) => {{
1107                        let mut writer = writer.borrow_mut();
1108                        if let Err(e) = runtime.block_on(writer.write($value.clone())) {
1109                            writer.record_write_error($name, e);
1110                        }
1111                    }};
1112                }
1113
1114                match event {
1115                    PositionEvent::PositionOpened(value) => write_event!(value, "PositionOpened"),
1116                    PositionEvent::PositionChanged(value) => {
1117                        write_event!(value, "PositionChanged");
1118                    }
1119                    PositionEvent::PositionClosed(value) => write_event!(value, "PositionClosed"),
1120                    PositionEvent::PositionAdjusted(value) => {
1121                        write_event!(value, "PositionAdjusted");
1122                    }
1123                }
1124            })
1125        };
1126
1127        let any = {
1128            ShareableMessageHandler::from_any(move |message: &dyn Any| {
1129                let _guard = runtime.enter();
1130
1131                macro_rules! try_write {
1132                    ($message:expr, $type:ty, $name:literal) => {
1133                        if let Some(value) = $message.downcast_ref::<$type>() {
1134                            let mut writer = writer.borrow_mut();
1135                            if let Err(e) = runtime.block_on(writer.write(value.clone())) {
1136                                writer.record_write_error($name, e);
1137                            }
1138                            return;
1139                        }
1140                    };
1141                }
1142
1143                try_write!(message, QuoteTick, "QuoteTick");
1144                try_write!(message, TradeTick, "TradeTick");
1145                try_write!(message, Bar, "Bar");
1146                try_write!(message, OrderBookDelta, "OrderBookDelta");
1147                try_write!(message, OrderBookDepth10, "OrderBookDepth10");
1148                try_write!(message, IndexPriceUpdate, "IndexPriceUpdate");
1149                try_write!(message, MarkPriceUpdate, "MarkPriceUpdate");
1150                try_write!(message, FundingRateUpdate, "FundingRateUpdate");
1151                try_write!(message, OptionGreeks, "OptionGreeks");
1152                try_write!(message, InstrumentStatus, "InstrumentStatus");
1153                try_write!(message, InstrumentClose, "InstrumentClose");
1154                try_write!(message, AccountState, "AccountState");
1155                try_write!(message, OrderInitialized, "OrderInitialized");
1156                try_write!(message, OrderDenied, "OrderDenied");
1157                try_write!(message, OrderEmulated, "OrderEmulated");
1158                try_write!(message, OrderSubmitted, "OrderSubmitted");
1159                try_write!(message, OrderAccepted, "OrderAccepted");
1160                try_write!(message, OrderRejected, "OrderRejected");
1161                try_write!(message, OrderPendingCancel, "OrderPendingCancel");
1162                try_write!(message, OrderCanceled, "OrderCanceled");
1163                try_write!(message, OrderCancelRejected, "OrderCancelRejected");
1164                try_write!(message, OrderExpired, "OrderExpired");
1165                try_write!(message, OrderTriggered, "OrderTriggered");
1166                try_write!(message, OrderPendingUpdate, "OrderPendingUpdate");
1167                try_write!(message, OrderReleased, "OrderReleased");
1168                try_write!(message, OrderModifyRejected, "OrderModifyRejected");
1169                try_write!(message, OrderUpdated, "OrderUpdated");
1170                try_write!(message, OrderFilled, "OrderFilled");
1171                try_write!(message, OrderFillVoided, "OrderFillVoided");
1172                try_write!(message, PositionOpened, "PositionOpened");
1173                try_write!(message, PositionChanged, "PositionChanged");
1174                try_write!(message, PositionClosed, "PositionClosed");
1175                try_write!(message, PositionAdjusted, "PositionAdjusted");
1176                try_write!(message, OrderSnapshot, "OrderSnapshot");
1177                try_write!(message, PositionSnapshot, "PositionSnapshot");
1178                try_write!(message, OrderStatusReport, "OrderStatusReport");
1179                try_write!(message, FillReport, "FillReport");
1180                try_write!(message, PositionStatusReport, "PositionStatusReport");
1181                try_write!(message, ExecutionMassStatus, "ExecutionMassStatus");
1182
1183                if let Some(deltas) = message.downcast_ref::<OrderBookDeltas>() {
1184                    let mut writer = writer.borrow_mut();
1185                    if let Err(e) = runtime.block_on(writer.write_batch(deltas.deltas.clone())) {
1186                        writer.record_write_error("OrderBookDeltas", e);
1187                    }
1188                } else if include_custom_data
1189                    && let Some(custom) = message.downcast_ref::<CustomData>()
1190                {
1191                    let mut writer = writer.borrow_mut();
1192                    if let Err(e) =
1193                        runtime.block_on(writer.write_data(Data::Custom(custom.clone())))
1194                    {
1195                        writer.record_write_error("CustomData", e);
1196                    }
1197                } else if let Some(instrument) = message.downcast_ref::<InstrumentAny>() {
1198                    let mut writer = writer.borrow_mut();
1199                    if let Err(e) = runtime.block_on(writer.write_instrument(instrument.clone())) {
1200                        writer.record_write_error("InstrumentAny", e);
1201                    }
1202                }
1203            })
1204        };
1205
1206        let pattern = MStr::pattern("*");
1207        msgbus::subscribe_any(pattern, any.clone(), None);
1208        msgbus::subscribe_instruments(pattern, instruments.clone(), None);
1209        msgbus::subscribe_book_deltas(pattern, deltas.clone(), None);
1210        msgbus::subscribe_book_depth10(pattern, depths.clone(), None);
1211        msgbus::subscribe_quotes(pattern, quotes.clone(), None);
1212        msgbus::subscribe_trades(pattern, trades.clone(), None);
1213        msgbus::subscribe_bars(pattern, bars.clone(), None);
1214        msgbus::subscribe_mark_prices(pattern, mark_prices.clone(), None);
1215        msgbus::subscribe_index_prices(pattern, index_prices.clone(), None);
1216        msgbus::subscribe_funding_rates(pattern, funding_rates.clone(), None);
1217        msgbus::subscribe_option_greeks(pattern, option_greeks.clone(), None);
1218        msgbus::subscribe_account_state(pattern, account_states.clone(), None);
1219        msgbus::subscribe_order_events(pattern, order_events.clone(), None);
1220        msgbus::subscribe_position_events(pattern, position_events.clone(), None);
1221
1222        FeatherWriterSubscriptions {
1223            any,
1224            instruments,
1225            deltas,
1226            depths,
1227            quotes,
1228            trades,
1229            bars,
1230            mark_prices,
1231            index_prices,
1232            funding_rates,
1233            option_greeks,
1234            account_states,
1235            order_events,
1236            position_events,
1237        }
1238    }
1239
1240    /// Unsubscribes from the message bus.
1241    pub fn unsubscribe_from_message_bus(subscriptions: &FeatherWriterSubscriptions) {
1242        let pattern = MStr::pattern("*");
1243        msgbus::unsubscribe_any(pattern, &subscriptions.any);
1244        msgbus::unsubscribe_instruments(pattern, &subscriptions.instruments);
1245        msgbus::unsubscribe_book_deltas(pattern, &subscriptions.deltas);
1246        msgbus::unsubscribe_book_depth10(pattern, &subscriptions.depths);
1247        msgbus::unsubscribe_quotes(pattern, &subscriptions.quotes);
1248        msgbus::unsubscribe_trades(pattern, &subscriptions.trades);
1249        msgbus::unsubscribe_bars(pattern, &subscriptions.bars);
1250        msgbus::unsubscribe_mark_prices(pattern, &subscriptions.mark_prices);
1251        msgbus::unsubscribe_index_prices(pattern, &subscriptions.index_prices);
1252        msgbus::unsubscribe_funding_rates(pattern, &subscriptions.funding_rates);
1253        msgbus::unsubscribe_option_greeks(pattern, &subscriptions.option_greeks);
1254        msgbus::unsubscribe_account_state(pattern, &subscriptions.account_states);
1255        msgbus::unsubscribe_order_events(pattern, &subscriptions.order_events);
1256        msgbus::unsubscribe_position_events(pattern, &subscriptions.position_events);
1257    }
1258}
1259
1260pub(crate) fn default_per_instrument_types() -> HashSet<String> {
1261    [
1262        "bars",
1263        "funding_rate_update",
1264        "index_prices",
1265        "mark_prices",
1266        "order_book_deltas",
1267        "order_book_depths",
1268        "option_greeks",
1269        "quotes",
1270        "trades",
1271    ]
1272    .into_iter()
1273    .map(str::to_string)
1274    .collect()
1275}
1276
1277#[cfg(test)]
1278mod tests {
1279    use std::{io::Cursor, sync::Arc};
1280
1281    use datafusion::arrow::ipc::reader::StreamReader;
1282    use nautilus_common::clock::TestClock;
1283    use nautilus_model::{
1284        data::{Data, QuoteTick, TradeTick},
1285        enums::AggressorSide,
1286        identifiers::{InstrumentId, TradeId},
1287        types::{Price, Quantity},
1288    };
1289    use nautilus_serialization::arrow::{
1290        ArrowSchemaProvider, DecodeDataFromRecordBatch, EncodeToRecordBatch,
1291    };
1292    use object_store::{ObjectStore, local::LocalFileSystem};
1293    use rstest::rstest;
1294    use tempfile::TempDir;
1295
1296    use super::*;
1297
1298    #[tokio::test]
1299    async fn test_writer_manager_keys() {
1300        // Create a temporary directory for base path
1301        let temp_dir = TempDir::new().unwrap();
1302        let base_path = temp_dir.path().to_str().unwrap().to_string();
1303
1304        // Create a LocalFileSystem based object store using the temp directory
1305        let local_fs = LocalFileSystem::new_with_prefix(temp_dir.path()).unwrap();
1306        let store: Arc<dyn ObjectStore> = Arc::new(local_fs);
1307
1308        // Create a test clock
1309        let clock: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
1310        let timestamp = clock.borrow().timestamp_ns();
1311
1312        let quote_type_str = QuoteTick::path_prefix();
1313
1314        let mut per_instrument = HashSet::new();
1315        per_instrument.insert(quote_type_str.to_string());
1316
1317        let mut manager = FeatherWriter::new(
1318            base_path.clone(),
1319            store,
1320            clock,
1321            RotationConfig::NoRotation,
1322            None,
1323            Some(per_instrument),
1324            None, // flush_interval_ms
1325        );
1326
1327        let instrument_id = "AAPL.AAPL";
1328        // Write a dummy value
1329        let quote = QuoteTick::new(
1330            InstrumentId::from(instrument_id),
1331            Price::from("100.0"),
1332            Price::from("100.0"),
1333            Quantity::from("100.0"),
1334            Quantity::from("100.0"),
1335            UnixNanos::from(1_000_000_000_000_000_000),
1336            UnixNanos::from(1_000_000_000_000_000_000),
1337        );
1338
1339        let trade = TradeTick::new(
1340            InstrumentId::from(instrument_id),
1341            Price::from("100.0"),
1342            Quantity::from("100.0"),
1343            AggressorSide::Buy,
1344            TradeId::from("1"),
1345            UnixNanos::from(1_000_000_000_000_000_000),
1346            UnixNanos::from(1_000_000_000_000_000_000),
1347        );
1348
1349        manager.write(quote).await.unwrap();
1350        manager.write(trade).await.unwrap();
1351
1352        // Check keys and paths for quotes and trades
1353        let path = manager.get_writer_path(&quote).unwrap();
1354        let safe_id = instrument_id.replace('/', "");
1355        let expected_path = Path::from(format!(
1356            "{base_path}/quotes/{safe_id}/quotes_{timestamp}.feather"
1357        ));
1358        assert_eq!(path.path, expected_path);
1359        assert!(manager.writers.contains_key(&path));
1360        let writer = manager.writers.get(&path).unwrap();
1361        assert!(writer.size > 0);
1362
1363        let path = manager.get_writer_path(&trade).unwrap();
1364        let expected_path = Path::from(format!("{base_path}/trades_{timestamp}.feather"));
1365        assert_eq!(path.path, expected_path);
1366        assert!(manager.writers.contains_key(&path));
1367        let writer = manager.writers.get(&path).unwrap();
1368        assert!(writer.size > 0);
1369    }
1370
1371    #[tokio::test]
1372    async fn test_per_instrument_paths_support_long_instrument_id() {
1373        let temp_dir = TempDir::new().unwrap();
1374        let local_fs = LocalFileSystem::new_with_prefix(temp_dir.path()).unwrap();
1375        let store: Arc<dyn ObjectStore> = Arc::new(local_fs);
1376        let clock: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
1377        let timestamp = clock.borrow().timestamp_ns();
1378        let mut per_instrument = HashSet::new();
1379        per_instrument.insert(QuoteTick::path_prefix().to_string());
1380        let mut manager = FeatherWriter::new(
1381            String::new(),
1382            Arc::clone(&store),
1383            clock,
1384            RotationConfig::NoRotation,
1385            None,
1386            Some(per_instrument),
1387            None,
1388        );
1389        let instrument_id = format!("{}.VENUE", "A".repeat(240));
1390        let quote = QuoteTick::new(
1391            InstrumentId::from(instrument_id.as_str()),
1392            Price::from("100.0"),
1393            Price::from("100.0"),
1394            Quantity::from("100.0"),
1395            Quantity::from("100.0"),
1396            UnixNanos::from(1_000_000_000_000_000_000),
1397            UnixNanos::from(1_000_000_000_000_000_000),
1398        );
1399
1400        manager.write(quote).await.unwrap();
1401        let path = manager.get_writer_path(&quote).unwrap();
1402        let regenerated_path = manager.regen_writer_path(&path);
1403        manager.close().await.unwrap();
1404        let persisted = store.head(&path.path).await.unwrap();
1405
1406        let safe_id = urisafe_instrument_id(&instrument_id);
1407        let expected_path = Path::from(format!("quotes/{safe_id}/quotes_{timestamp}.feather"));
1408        assert_eq!(path.path, expected_path);
1409        assert_eq!(regenerated_path.path, expected_path);
1410        assert_eq!(persisted.location, expected_path);
1411    }
1412
1413    #[rstest]
1414    fn test_per_instrument_path_preserves_nested_type_prefix() {
1415        let store: Arc<dyn ObjectStore> = Arc::new(LocalFileSystem::new());
1416        let clock: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
1417        let manager = FeatherWriter::new(
1418            String::new(),
1419            store,
1420            clock,
1421            RotationConfig::NoRotation,
1422            None,
1423            None,
1424            None,
1425        );
1426
1427        let path = manager.per_instrument_path(
1428            "custom/RustTestCustomData",
1429            "RUST.TEST",
1430            UnixNanos::default(),
1431        );
1432
1433        let expected_path = Path::from("")
1434            .join("custom/RustTestCustomData")
1435            .join("RUST.TEST")
1436            .join("RUST.TEST_0.feather");
1437        assert_eq!(path, expected_path);
1438    }
1439
1440    #[rstest]
1441    fn test_file_writer_round_trip() {
1442        let instrument_id = "AAPL.AAPL";
1443        // Write a dummy value.
1444        let quote = QuoteTick::new(
1445            InstrumentId::from(instrument_id),
1446            Price::from("100.0"),
1447            Price::from("100.0"),
1448            Quantity::from("100.0"),
1449            Quantity::from("100.0"),
1450            UnixNanos::from(100),
1451            UnixNanos::from(100),
1452        );
1453        let metadata = QuoteTick::metadata(&quote);
1454        let schema = QuoteTick::get_schema(Some(metadata.clone()));
1455        let batch = QuoteTick::encode_batch(&QuoteTick::metadata(&quote), &[quote]).unwrap();
1456
1457        let mut writer = FeatherBuffer::new(&schema, RotationConfig::NoRotation).unwrap();
1458        writer.write_record_batch(&batch).unwrap();
1459
1460        let buffer = writer.take_buffer().unwrap();
1461        let mut reader = StreamReader::try_new(Cursor::new(buffer.as_slice()), None).unwrap();
1462
1463        let read_metadata = reader.schema().metadata().clone();
1464        assert_eq!(read_metadata, metadata);
1465
1466        let read_batch = reader.next().unwrap().unwrap();
1467        assert_eq!(read_batch.column(0), batch.column(0));
1468
1469        let decoded = QuoteTick::decode_data_batch(&metadata, batch).unwrap();
1470        assert_eq!(decoded[0], Data::from(quote));
1471    }
1472
1473    #[tokio::test]
1474    async fn test_round_trip() {
1475        // Create a temporary directory for base path
1476        let temp_dir = TempDir::new_in(".").unwrap();
1477        let base_path = temp_dir.path().to_str().unwrap().to_string();
1478
1479        // Create a LocalFileSystem based object store using the temp directory
1480        let local_fs = LocalFileSystem::new_with_prefix(&base_path).unwrap();
1481        let store: Arc<dyn ObjectStore> = Arc::new(local_fs);
1482
1483        // Create a test clock
1484        let clock: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
1485
1486        let quote_type_str = QuoteTick::path_prefix();
1487        let trade_type_str = TradeTick::path_prefix();
1488
1489        let mut per_instrument = HashSet::new();
1490        per_instrument.insert(quote_type_str.to_string());
1491        per_instrument.insert(trade_type_str.to_string());
1492
1493        let mut manager = FeatherWriter::new(
1494            base_path.clone(),
1495            store,
1496            clock,
1497            RotationConfig::NoRotation,
1498            None,
1499            Some(per_instrument),
1500            None, // flush_interval_ms
1501        );
1502
1503        let instrument_id = "AAPL.AAPL";
1504        // Write a dummy value.
1505        let quote = QuoteTick::new(
1506            InstrumentId::from(instrument_id),
1507            Price::from("100.0"),
1508            Price::from("100.0"),
1509            Quantity::from("100.0"),
1510            Quantity::from("100.0"),
1511            UnixNanos::from(100),
1512            UnixNanos::from(100),
1513        );
1514
1515        let trade = TradeTick::new(
1516            InstrumentId::from(instrument_id),
1517            Price::from("100.0"),
1518            Quantity::from("100.0"),
1519            AggressorSide::Buy,
1520            TradeId::from("1"),
1521            UnixNanos::from(100),
1522            UnixNanos::from(100),
1523        );
1524
1525        manager.write(quote).await.unwrap();
1526        manager.write(trade).await.unwrap();
1527
1528        let paths = manager.writers.keys().cloned().collect::<Vec<_>>();
1529        assert_eq!(paths.len(), 2);
1530
1531        // Flush data
1532        manager.flush().await.unwrap();
1533
1534        // Read files from the temporary directory
1535        let mut recovered_quotes = Vec::new();
1536        let mut recovered_trades = Vec::new();
1537        let local_fs = LocalFileSystem::new_with_prefix(&base_path).unwrap();
1538        for path in paths {
1539            let path_str = local_fs.path_to_filesystem(&path.path).unwrap();
1540            let buffer = std::fs::File::open(&path_str).unwrap();
1541            let reader = StreamReader::try_new(buffer, None).unwrap();
1542            let metadata = reader.schema().metadata().clone();
1543            for batch in reader {
1544                let batch = batch.unwrap();
1545                if path_str.to_str().unwrap().contains("quotes") {
1546                    let decoded = QuoteTick::decode_data_batch(&metadata, batch).unwrap();
1547                    recovered_quotes.extend(decoded);
1548                } else if path_str.to_str().unwrap().contains("trades") {
1549                    let decoded = TradeTick::decode_data_batch(&metadata, batch).unwrap();
1550                    recovered_trades.extend(decoded);
1551                }
1552            }
1553        }
1554
1555        // Assert that the recovered data matches the written data
1556        assert_eq!(recovered_quotes.len(), 1, "Expected one QuoteTick record");
1557        assert_eq!(recovered_trades.len(), 1, "Expected one TradeTick record");
1558
1559        // Check key fields to ensure the data round-tripped correctly
1560        assert_eq!(recovered_quotes[0], Data::from(quote));
1561        assert_eq!(recovered_trades[0], Data::from(trade));
1562    }
1563
1564    #[tokio::test]
1565    async fn test_write_data_enum() {
1566        let temp_dir = TempDir::new().unwrap();
1567        let base_path = temp_dir.path().to_str().unwrap().to_string();
1568        let local_fs = LocalFileSystem::new_with_prefix(temp_dir.path()).unwrap();
1569        let store: Arc<dyn ObjectStore> = Arc::new(local_fs);
1570        let clock: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
1571
1572        let mut writer = FeatherWriter::new(
1573            base_path,
1574            store,
1575            clock,
1576            RotationConfig::NoRotation,
1577            None,
1578            None,
1579            None,
1580        );
1581
1582        let quote = QuoteTick::new(
1583            InstrumentId::from("AUD/USD.SIM"),
1584            Price::from("1.0"),
1585            Price::from("1.0"),
1586            Quantity::from("1000"),
1587            Quantity::from("1000"),
1588            UnixNanos::from(1000),
1589            UnixNanos::from(1000),
1590        );
1591
1592        // Test writing via write_data
1593        writer.write_data(Data::Quote(quote)).await.unwrap();
1594        writer.flush().await.unwrap();
1595
1596        // Verify file was created
1597        assert!(!writer.writers.is_empty() || temp_dir.path().read_dir().unwrap().count() > 0);
1598    }
1599
1600    #[tokio::test]
1601    async fn test_write_data_all_types() {
1602        let temp_dir = TempDir::new().unwrap();
1603        let base_path = temp_dir.path().to_str().unwrap().to_string();
1604        let local_fs = LocalFileSystem::new_with_prefix(temp_dir.path()).unwrap();
1605        let store: Arc<dyn ObjectStore> = Arc::new(local_fs);
1606        let clock: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
1607
1608        let mut writer = FeatherWriter::new(
1609            base_path,
1610            store,
1611            clock,
1612            RotationConfig::NoRotation,
1613            None,
1614            None,
1615            None,
1616        );
1617
1618        let instrument_id = InstrumentId::from("AUD/USD.SIM");
1619
1620        // Test all data types
1621        let quote = QuoteTick::new(
1622            instrument_id,
1623            Price::from("1.0"),
1624            Price::from("1.0"),
1625            Quantity::from("1000"),
1626            Quantity::from("1000"),
1627            UnixNanos::from(1000),
1628            UnixNanos::from(1000),
1629        );
1630        writer.write_data(Data::Quote(quote)).await.unwrap();
1631
1632        let trade = TradeTick::new(
1633            instrument_id,
1634            Price::from("1.0"),
1635            Quantity::from("1000"),
1636            AggressorSide::Buy,
1637            TradeId::from("1"),
1638            UnixNanos::from(2000),
1639            UnixNanos::from(2000),
1640        );
1641        writer.write_data(Data::Trade(trade)).await.unwrap();
1642
1643        let delta = OrderBookDelta::clear(
1644            instrument_id,
1645            0,
1646            UnixNanos::from(3000),
1647            UnixNanos::from(3000),
1648        );
1649        writer.write_data(Data::Delta(delta)).await.unwrap();
1650
1651        writer.flush().await.unwrap();
1652    }
1653
1654    #[tokio::test]
1655    async fn test_auto_flush() {
1656        let temp_dir = TempDir::new().unwrap();
1657        let base_path = temp_dir.path().to_str().unwrap().to_string();
1658        let local_fs = LocalFileSystem::new_with_prefix(temp_dir.path()).unwrap();
1659        let store: Arc<dyn ObjectStore> = Arc::new(local_fs);
1660        let clock: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
1661
1662        let mut writer = FeatherWriter::new(
1663            base_path,
1664            store,
1665            clock.clone(),
1666            RotationConfig::NoRotation,
1667            None,
1668            None,
1669            Some(100), // 100ms flush interval
1670        );
1671
1672        let quote = QuoteTick::new(
1673            InstrumentId::from("AUD/USD.SIM"),
1674            Price::from("1.0"),
1675            Price::from("1.0"),
1676            Quantity::from("1000"),
1677            Quantity::from("1000"),
1678            UnixNanos::from(1000),
1679            UnixNanos::from(1000),
1680        );
1681
1682        // Write first quote
1683        writer.write(quote).await.unwrap();
1684
1685        // Note: TestClock doesn't have set_time_ns, so we can't easily test auto-flush
1686        // with time advancement. Instead, we test that check_flush is called during write.
1687        // For a proper test, we'd need a mock clock or use LiveClock with time advancement.
1688
1689        // Write second quote - check_flush will be called but won't flush if time hasn't advanced
1690        let quote2 = QuoteTick::new(
1691            InstrumentId::from("AUD/USD.SIM"),
1692            Price::from("1.1"),
1693            Price::from("1.1"),
1694            Quantity::from("1000"),
1695            Quantity::from("1000"),
1696            UnixNanos::from(2000),
1697            UnixNanos::from(2000),
1698        );
1699        writer.write(quote2).await.unwrap();
1700
1701        // Verify that writes succeeded (check_flush was called, even if it didn't flush)
1702        // The flush_interval_ms is set, so check_flush runs but won't flush without time advancement
1703    }
1704
1705    #[tokio::test]
1706    async fn test_flush_reports_previous_write_failure() {
1707        let temp_dir = TempDir::new().unwrap();
1708        let base_path = temp_dir.path().to_str().unwrap().to_string();
1709        let local_fs = LocalFileSystem::new_with_prefix(temp_dir.path()).unwrap();
1710        let store: Arc<dyn ObjectStore> = Arc::new(local_fs);
1711        let clock: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
1712        let mut writer = FeatherWriter::new(
1713            base_path.clone(),
1714            store,
1715            clock,
1716            RotationConfig::NoRotation,
1717            None,
1718            None,
1719            None,
1720        );
1721        let quote = QuoteTick::new(
1722            InstrumentId::from("AUD/USD.SIM"),
1723            Price::from("1.0"),
1724            Price::from("1.0"),
1725            Quantity::from("1000"),
1726            Quantity::from("1000"),
1727            UnixNanos::from(1_000),
1728            UnixNanos::from(1_000),
1729        );
1730        writer.write(quote).await.unwrap();
1731        std::fs::remove_dir_all(&base_path).unwrap();
1732        std::fs::write(&base_path, b"not a directory").unwrap();
1733
1734        let first_error = writer.flush().await.unwrap_err().to_string();
1735        let second_error = writer.flush().await.unwrap_err().to_string();
1736        std::fs::remove_file(&base_path).unwrap();
1737
1738        assert_eq!(
1739            second_error,
1740            format!("Failed to write streaming output: {first_error}"),
1741        );
1742    }
1743
1744    #[tokio::test]
1745    async fn test_close() {
1746        let temp_dir = TempDir::new().unwrap();
1747        let base_path = temp_dir.path().to_str().unwrap().to_string();
1748        let local_fs = LocalFileSystem::new_with_prefix(temp_dir.path()).unwrap();
1749        let store: Arc<dyn ObjectStore> = Arc::new(local_fs);
1750        let clock: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
1751
1752        let mut writer = FeatherWriter::new(
1753            base_path,
1754            store,
1755            clock,
1756            RotationConfig::NoRotation,
1757            None,
1758            None,
1759            None,
1760        );
1761
1762        let quote = QuoteTick::new(
1763            InstrumentId::from("AUD/USD.SIM"),
1764            Price::from("1.0"),
1765            Price::from("1.0"),
1766            Quantity::from("1000"),
1767            Quantity::from("1000"),
1768            UnixNanos::from(1000),
1769            UnixNanos::from(1000),
1770        );
1771
1772        writer.write(quote).await.unwrap();
1773        assert!(!writer.writers.is_empty());
1774
1775        writer.close().await.unwrap();
1776        assert!(writer.writers.is_empty());
1777    }
1778
1779    // Note: Message bus subscription test is skipped due to async/sync boundary complexity.
1780    // The handler uses block_on which can't be used from within an async runtime.
1781    // This functionality is better tested via Python integration tests where the message bus
1782    // is used in a non-async context or via proper async task spawning.
1783
1784    #[tokio::test]
1785    async fn test_write_data_orderbook_deltas() {
1786        let temp_dir = TempDir::new().unwrap();
1787        let base_path = temp_dir.path().to_str().unwrap().to_string();
1788        let local_fs = LocalFileSystem::new_with_prefix(temp_dir.path()).unwrap();
1789        let store: Arc<dyn ObjectStore> = Arc::new(local_fs);
1790        let clock: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
1791
1792        let mut writer = FeatherWriter::new(
1793            base_path,
1794            store,
1795            clock,
1796            RotationConfig::NoRotation,
1797            None,
1798            None,
1799            None,
1800        );
1801
1802        let instrument_id = InstrumentId::from("AUD/USD.SIM");
1803        let delta1 = OrderBookDelta::clear(
1804            instrument_id,
1805            0,
1806            UnixNanos::from(1000),
1807            UnixNanos::from(1000),
1808        );
1809        let delta2 = OrderBookDelta::clear(
1810            instrument_id,
1811            0,
1812            UnixNanos::from(2000),
1813            UnixNanos::from(2000),
1814        );
1815
1816        let book_deltas = OrderBookDeltas::new(instrument_id, vec![delta1, delta2]);
1817
1818        // Test writing OrderBookDeltas via write_data
1819        writer
1820            .write_data(Data::Deltas(Box::new(book_deltas)))
1821            .await
1822            .unwrap();
1823        writer.flush().await.unwrap();
1824    }
1825
1826    #[tokio::test]
1827    #[cfg(feature = "python")]
1828    async fn test_write_custom_data_round_trip() {
1829        use std::sync::Arc;
1830
1831        use futures::StreamExt;
1832        use nautilus_model::{
1833            data::{CustomData, Data, DataType},
1834            identifiers::InstrumentId,
1835        };
1836        use nautilus_serialization::{
1837            arrow::custom::CustomDataDecoder, ensure_custom_data_registered,
1838        };
1839
1840        use crate::test_data::RustTestCustomData;
1841
1842        ensure_custom_data_registered::<RustTestCustomData>();
1843
1844        let temp_dir = TempDir::new().unwrap();
1845        let base_path = temp_dir.path().to_str().unwrap().to_string();
1846        let local_fs = LocalFileSystem::new_with_prefix(temp_dir.path()).unwrap();
1847        let store: Arc<dyn ObjectStore> = Arc::new(local_fs);
1848        let clock: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
1849
1850        let mut writer = FeatherWriter::new(
1851            base_path.clone(),
1852            store.clone(),
1853            clock,
1854            RotationConfig::NoRotation,
1855            None,
1856            None,
1857            None,
1858        );
1859
1860        let instrument_id = InstrumentId::from("RUST.TEST");
1861        let data_type = DataType::new("RustTestCustomData", None, Some(instrument_id.to_string()));
1862        let original = RustTestCustomData {
1863            instrument_id,
1864            value: 1.23,
1865            flag: true,
1866            ts_event: UnixNanos::from(1000),
1867            ts_init: UnixNanos::from(1000),
1868        };
1869        let custom = CustomData::new(Arc::new(original.clone()), data_type);
1870
1871        writer
1872            .write_data(Data::Custom(custom))
1873            .await
1874            .expect("write_data CustomData");
1875        writer.flush().await.expect("flush");
1876
1877        let prefix = Path::from(format!("{base_path}/data/custom/RustTestCustomData"));
1878        let mut list_stream = store.list(Some(&prefix));
1879        let first = list_stream.next().await.expect("at least one object");
1880        let meta = first.expect("list item");
1881        let bytes = store
1882            .get(&meta.location)
1883            .await
1884            .expect("get")
1885            .bytes()
1886            .await
1887            .expect("bytes");
1888        let mut reader =
1889            StreamReader::try_new(Cursor::new(bytes.as_ref()), None).expect("StreamReader");
1890        let schema = reader.schema();
1891        let metadata: std::collections::HashMap<String, String> = schema
1892            .metadata()
1893            .iter()
1894            .map(|(k, v)| (k.clone(), v.clone()))
1895            .collect();
1896        let batch = reader.next().expect("batch").expect("batch ok");
1897        let decoded =
1898            CustomDataDecoder::decode_data_batch(&metadata, batch).expect("decode_data_batch");
1899        assert_eq!(decoded.len(), 1);
1900        if let Data::Custom(decoded_custom) = &decoded[0] {
1901            assert_eq!(decoded_custom.data_type.type_name(), "RustTestCustomData");
1902            let rust: &RustTestCustomData = decoded_custom
1903                .data
1904                .as_any()
1905                .downcast_ref::<RustTestCustomData>()
1906                .expect("RustTestCustomData");
1907            assert_eq!(rust, &original);
1908        } else {
1909            panic!("Expected Data::Custom");
1910        }
1911    }
1912}