Skip to main content

nautilus_persistence/
config.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//! Persistence configuration shared by live and backtest runtimes.
17
18use std::fmt::Display;
19
20use nautilus_common::config::{ConfigError, ConfigErrorCollector, ConfigResult};
21use nautilus_core::{
22    DurationNanos, Params, UnixNanos, datetime::get_timezone, string::secret::SecretString,
23};
24use nautilus_model::{
25    data::{NautilusDataType, NautilusRecordType},
26    instruments::NautilusInstrumentType,
27};
28use serde::{Deserialize, Serialize};
29use strum::{Display, EnumIter, EnumString, FromRepr};
30
31use crate::{
32    catalog::factory::{CatalogConnectConfig, PARQUET_CATALOG_FACTORY_NAME},
33    common::{backend_name::backend_type, paths::local_writer_directory},
34    writer::factory::WriterBackendType,
35};
36
37backend_type!(
38    /// Catalog backend used to satisfy runtime data catalog requests.
39    ///
40    /// Serializes as its name, so an external backend round-trips as the plain factory name that
41    /// registered it, matching [`WriterBackendType`].
42    CatalogBackendType {
43        Parquet => PARQUET_CATALOG_FACTORY_NAME,
44    }
45);
46
47/// Compression codec for the data files a catalog writes.
48///
49/// Displays and serializes as the lowercase codec name, such as `zstd`; `FromStr` ignores ASCII
50/// case. `lz4` writes Parquet `LZ4_RAW` and also parses from `lz4_raw`. LZO has no variant
51/// because the Parquet writer cannot produce LZO files.
52#[derive(
53    Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Display, EnumIter, EnumString,
54)]
55#[serde(rename_all = "snake_case")]
56#[strum(ascii_case_insensitive)]
57#[strum(serialize_all = "snake_case")]
58pub enum CatalogCompression {
59    Uncompressed,
60    Snappy,
61    Gzip,
62    Brotli,
63    #[serde(alias = "lz4_raw")]
64    #[strum(to_string = "lz4", serialize = "lz4_raw")]
65    Lz4,
66    Zstd,
67}
68
69/// Configuration for catalog connection and storage settings.
70#[cfg_attr(
71    feature = "python",
72    expect(
73        clippy::unsafe_derive_deserialize,
74        reason = "config deserializes plain fields; unsafe methods come from generated PyO3 integration"
75    )
76)]
77#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, bon::Builder)]
78#[builder(finish_fn(name = build_inner, vis = ""))]
79#[serde(deny_unknown_fields)]
80#[cfg_attr(
81    feature = "python",
82    pyo3::pyclass(module = "nautilus_trader.persistence", from_py_object, eq)
83)]
84#[cfg_attr(
85    feature = "python",
86    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.persistence")
87)]
88pub struct DataCatalogConfig {
89    /// The path to the data catalog.
90    path: String,
91    /// The catalog registration name.
92    name: Option<String>,
93    /// The fsspec file system protocol for the data catalog.
94    #[serde(default = "default_fs_protocol")]
95    #[builder(default = default_fs_protocol())]
96    fs_protocol: String,
97    /// The catalog backend implementation to use.
98    #[serde(default)]
99    #[builder(default)]
100    catalog_backend: CatalogBackendType,
101    /// The number of rows per batch the catalog reads and writes; omit for the backend default.
102    batch_size: Option<usize>,
103    /// The compression codec of written data files; omit for the backend default.
104    compression: Option<CatalogCompression>,
105    /// The maximum number of rows per written row group; omit for the backend default.
106    max_row_group_size: Option<usize>,
107    /// Backend-specific catalog parameters.
108    params: Option<Params>,
109    #[serde(default)]
110    fs_rust_storage_options: Option<ahash::AHashMap<String, SecretString>>,
111    /// Whether the catalog rejects response write-back.
112    #[serde(default)]
113    #[builder(default)]
114    read_only: bool,
115}
116
117impl<S: data_catalog_config_builder::IsComplete> DataCatalogConfigBuilder<S> {
118    /// Validates and builds the [`DataCatalogConfig`].
119    ///
120    /// # Errors
121    ///
122    /// Returns an error if any field fails validation (see [`DataCatalogConfig::validate`]).
123    pub fn build(self) -> ConfigResult<DataCatalogConfig> {
124        let config = self.build_inner();
125        config.validate()?;
126        Ok(config)
127    }
128}
129
130impl DataCatalogConfig {
131    /// Creates a new [`DataCatalogConfig`] instance.
132    #[must_use]
133    pub fn new(
134        path: String,
135        fs_protocol: Option<String>,
136        catalog_backend: Option<CatalogBackendType>,
137    ) -> Self {
138        Self {
139            path,
140            name: None,
141            fs_protocol: fs_protocol.unwrap_or_else(default_fs_protocol),
142            catalog_backend: catalog_backend.unwrap_or_default(),
143            batch_size: None,
144            compression: None,
145            max_row_group_size: None,
146            params: None,
147            fs_rust_storage_options: None,
148            read_only: false,
149        }
150    }
151
152    /// Validates the catalog configuration, collecting every field violation.
153    ///
154    /// # Errors
155    ///
156    /// Returns an error if `batch_size` or `max_row_group_size` is zero.
157    pub fn validate(&self) -> ConfigResult<()> {
158        validate_catalog_counts(self.batch_size, self.max_row_group_size)
159    }
160
161    /// Sets native object-store connection options.
162    #[must_use]
163    pub fn with_storage_options(
164        mut self,
165        options: Option<ahash::AHashMap<String, SecretString>>,
166    ) -> Self {
167        self.fs_rust_storage_options = options;
168        self
169    }
170
171    /// Returns native object-store options.
172    #[must_use]
173    pub fn fs_rust_storage_options(&self) -> Option<&ahash::AHashMap<String, SecretString>> {
174        self.fs_rust_storage_options.as_ref()
175    }
176
177    /// Returns the connection settings catalog and writer factories open this catalog with.
178    #[must_use]
179    pub fn connect_config(&self) -> CatalogConnectConfig {
180        let storage_options = self.fs_rust_storage_options.as_ref().map(|options| {
181            options
182                .iter()
183                .map(|(key, value)| (key.clone(), value.expose_secret().to_string()))
184                .collect()
185        });
186        let mut connect = CatalogConnectConfig::from_path_and_protocol(
187            &self.path,
188            Some(&self.fs_protocol),
189            storage_options,
190        );
191        connect.batch_size = self.batch_size;
192        connect.compression = self.compression;
193        connect.max_row_group_size = self.max_row_group_size;
194        connect.params.clone_from(&self.params);
195        connect
196    }
197
198    /// Returns a copy with catalog registration name set.
199    #[must_use]
200    pub fn with_name(mut self, name: Option<String>) -> Self {
201        self.name = name;
202        self
203    }
204
205    /// Returns a copy with backend-specific catalog parameters set.
206    #[must_use]
207    pub fn with_params(mut self, params: Option<Params>) -> Self {
208        self.params = params;
209        self
210    }
211
212    /// Returns a copy with read-only response write-back behavior set.
213    #[must_use]
214    pub const fn with_read_only(mut self, read_only: bool) -> Self {
215        self.read_only = read_only;
216        self
217    }
218
219    /// Returns the path to the data catalog.
220    #[must_use]
221    pub fn path(&self) -> &str {
222        &self.path
223    }
224
225    /// Returns the catalog registration name.
226    #[must_use]
227    pub fn name(&self) -> Option<&str> {
228        self.name.as_deref()
229    }
230
231    /// Returns whether the catalog rejects response write-back.
232    #[must_use]
233    pub const fn read_only(&self) -> bool {
234        self.read_only
235    }
236
237    /// Returns the fsspec file system protocol for the data catalog.
238    #[must_use]
239    pub fn fs_protocol(&self) -> &str {
240        &self.fs_protocol
241    }
242
243    /// Returns the catalog backend implementation to use.
244    #[must_use]
245    pub const fn catalog_backend(&self) -> &CatalogBackendType {
246        &self.catalog_backend
247    }
248
249    /// Returns the number of rows per batch the catalog reads and writes.
250    #[must_use]
251    pub const fn batch_size(&self) -> Option<usize> {
252        self.batch_size
253    }
254
255    /// Returns the compression codec of written data files.
256    #[must_use]
257    pub const fn compression(&self) -> Option<CatalogCompression> {
258        self.compression
259    }
260
261    /// Returns the maximum number of rows per written row group.
262    #[must_use]
263    pub const fn max_row_group_size(&self) -> Option<usize> {
264        self.max_row_group_size
265    }
266
267    /// Returns backend-specific catalog parameters.
268    #[must_use]
269    pub const fn params(&self) -> Option<&Params> {
270        self.params.as_ref()
271    }
272
273    #[must_use]
274    pub(crate) fn writer_backend(&self) -> WriterBackendType {
275        match &self.catalog_backend {
276            CatalogBackendType::Parquet => WriterBackendType::Parquet,
277            CatalogBackendType::External(name) => WriterBackendType::External(name.clone()),
278        }
279    }
280}
281
282// `ParquetDataCatalog::from_uri` also checks the counts, since deserialized configs skip `validate`
283pub(crate) fn validate_catalog_counts(
284    batch_size: Option<usize>,
285    max_row_group_size: Option<usize>,
286) -> ConfigResult<()> {
287    let mut errors = ConfigErrorCollector::new();
288
289    for (field, count) in [
290        ("batch_size", batch_size),
291        ("max_row_group_size", max_row_group_size),
292    ] {
293        errors.check(
294            count != Some(0),
295            ConfigError::range(
296                field,
297                "must be a positive number of rows; omit the field for the backend default",
298            ),
299        );
300    }
301
302    errors.into_result()
303}
304
305/// Configuration for file rotation in streaming output.
306#[derive(Debug, Clone, Serialize, Deserialize)]
307#[serde(rename_all = "snake_case")]
308pub enum RotationConfig {
309    /// Rotate based on file size.
310    Size {
311        /// Maximum buffer size in bytes before rotation.
312        max_size: u64,
313    },
314    /// Rotate based on a time interval.
315    Interval {
316        /// Interval in nanoseconds.
317        interval_ns: DurationNanos,
318    },
319    /// Rotate based on scheduled dates.
320    ScheduledDates {
321        /// Interval in nanoseconds.
322        interval_ns: DurationNanos,
323        /// Time of day for rotation, in nanoseconds since midnight in `timezone`.
324        schedule_ns: UnixNanos,
325        /// IANA timezone name for the rotation schedule.
326        #[serde(default = "default_rotation_timezone")]
327        timezone: String,
328    },
329    /// No automatic rotation.
330    NoRotation,
331}
332
333impl RotationConfig {
334    /// Returns the rotation policy without its parameters.
335    #[must_use]
336    pub const fn mode(&self) -> RotationMode {
337        match self {
338            Self::Size { .. } => RotationMode::Size,
339            Self::Interval { .. } => RotationMode::Interval,
340            Self::ScheduledDates { .. } => RotationMode::ScheduledDates,
341            Self::NoRotation => RotationMode::NoRotation,
342        }
343    }
344
345    /// Validates the rotation parameters, collecting every field violation.
346    ///
347    /// # Errors
348    ///
349    /// Returns an error if a size or interval is zero or the timezone is unknown.
350    pub fn validate(&self) -> ConfigResult<()> {
351        let mut errors = ConfigErrorCollector::new();
352
353        match self {
354            Self::Size { max_size } => errors.check(
355                *max_size > 0,
356                ConfigError::range(
357                    "rotation_config.max_size",
358                    "must be a positive number of bytes",
359                ),
360            ),
361            Self::Interval { interval_ns } => {
362                errors.check(interval_ns.as_u64() > 0, positive_interval_error());
363            }
364            Self::ScheduledDates {
365                interval_ns,
366                timezone,
367                ..
368            } => {
369                errors.check(interval_ns.as_u64() > 0, positive_interval_error());
370                errors.check(
371                    get_timezone(timezone).is_ok(),
372                    ConfigError::invalid_value(
373                        "rotation_config.timezone",
374                        format!("unknown IANA timezone, was {timezone}"),
375                    ),
376                );
377            }
378            Self::NoRotation => {}
379        }
380
381        errors.into_result()
382    }
383
384    /// Converts the public streaming configuration into the writer's runtime form.
385    ///
386    /// # Errors
387    ///
388    /// Returns an error if a scheduled rotation names an unknown timezone.
389    pub fn to_writer_rotation_config(
390        &self,
391    ) -> anyhow::Result<crate::writer::feather::RotationConfig> {
392        Ok(match self {
393            Self::Size { max_size } => crate::writer::feather::RotationConfig::Size {
394                max_size: *max_size,
395            },
396            Self::Interval { interval_ns } => crate::writer::feather::RotationConfig::Interval {
397                interval_ns: interval_ns.as_u64(),
398            },
399            Self::ScheduledDates {
400                interval_ns,
401                schedule_ns,
402                timezone,
403            } => crate::writer::feather::RotationConfig::ScheduledDates {
404                interval_ns: interval_ns.as_u64(),
405                rotation_time: *schedule_ns,
406                rotation_timezone: get_timezone(timezone)
407                    .map_err(|e| anyhow::anyhow!("Invalid rotation timezone '{timezone}': {e}"))?,
408            },
409            Self::NoRotation => crate::writer::feather::RotationConfig::NoRotation,
410        })
411    }
412}
413
414/// The rotation policy of a streaming writer, without its parameters.
415#[repr(C)]
416#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Display, FromRepr, EnumIter, EnumString)]
417#[strum(ascii_case_insensitive)]
418#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
419#[cfg_attr(
420    feature = "python",
421    pyo3::pyclass(
422        frozen,
423        eq,
424        eq_int,
425        module = "nautilus_trader.persistence",
426        from_py_object,
427        rename_all = "SCREAMING_SNAKE_CASE",
428    )
429)]
430#[cfg_attr(
431    feature = "python",
432    pyo3_stub_gen::derive::gen_stub_pyclass_enum(module = "nautilus_trader.persistence")
433)]
434pub enum RotationMode {
435    Size,
436    Interval,
437    ScheduledDates,
438    NoRotation,
439}
440
441fn positive_interval_error() -> ConfigError {
442    ConfigError::range(
443        "rotation_config.interval_ns",
444        "must be a positive number of nanoseconds",
445    )
446}
447
448pub(crate) const DEFAULT_ROTATION_TIMEZONE: &str = "UTC";
449
450fn default_rotation_timezone() -> String {
451    DEFAULT_ROTATION_TIMEZONE.to_string()
452}
453
454/// Record filter entry for streaming persistence.
455#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
456#[serde(deny_unknown_fields)]
457pub struct StreamingRecordFilterConfig {
458    /// Record family to write.
459    pub record_type: NautilusRecordType,
460    /// Optional identifiers within the record family.
461    pub identifiers: Option<Vec<String>>,
462}
463
464/// Configuration streaming live or backtest runs to a persistence writer.
465///
466/// The writer appends Feather files under the local `writer_path`, in one
467/// `{backtest|sandbox|live}/{instance_id}` directory per run. With a `catalog`, the writer for
468/// that catalog's backend also promotes the files into it; without one, the Feather files are the
469/// only output.
470#[expect(
471    clippy::struct_excessive_bools,
472    reason = "the booleans are independent writer lifecycle and promotion options"
473)]
474#[cfg_attr(
475    feature = "python",
476    expect(
477        clippy::unsafe_derive_deserialize,
478        reason = "config deserializes plain fields; unsafe methods come from generated PyO3 integration"
479    )
480)]
481#[cfg_attr(
482    feature = "python",
483    pyo3::pyclass(frozen, module = "nautilus_trader.persistence", from_py_object)
484)]
485#[cfg_attr(
486    feature = "python",
487    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.persistence")
488)]
489#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
490#[builder(finish_fn(name = build_inner, vis = ""))]
491#[serde(deny_unknown_fields)]
492pub struct StreamingConfig {
493    /// Local directory the writer appends Feather files to.
494    pub writer_path: String,
495    /// Catalog that receives promoted data; omit to keep only the Feather files.
496    pub catalog: Option<DataCatalogConfig>,
497    /// Flush interval in milliseconds.
498    pub flush_interval_ms: u64,
499    /// Whether to replace existing files.
500    pub replace_existing: bool,
501    /// Rotation configuration.
502    pub rotation_config: RotationConfig,
503    /// Interval in milliseconds for promoting sealed files into `catalog`; omit for no interval.
504    pub promotion_interval_ms: Option<u64>,
505    /// Whether closing the writer promotes remaining files into `catalog`.
506    #[serde(default = "default_promote_on_close")]
507    #[builder(default = default_promote_on_close())]
508    pub promote_on_close: bool,
509    /// Whether Feather files are deleted after a successful promotion.
510    #[serde(default)]
511    #[builder(default)]
512    pub delete_feather_after_promotion: bool,
513    /// Whether promotion replaces `ts_init` with `ts_event`.
514    #[serde(default)]
515    #[builder(default)]
516    pub use_ts_event_for_ts_init: bool,
517    /// Optional data families to write.
518    pub data_types: Option<Vec<NautilusDataType>>,
519    /// Optional record families to write.
520    pub record_types: Option<Vec<NautilusRecordType>>,
521    /// Optional instrument families to write.
522    pub instrument_types: Option<Vec<NautilusInstrumentType>>,
523    /// Optional record family and identifier filters.
524    pub record_filters: Option<Vec<StreamingRecordFilterConfig>>,
525    /// Backend-specific writer parameters.
526    pub params: Option<Params>,
527}
528
529impl<S: streaming_config_builder::IsComplete> StreamingConfigBuilder<S> {
530    /// Validates and builds the [`StreamingConfig`].
531    ///
532    /// # Errors
533    ///
534    /// Returns an error if any field fails validation (see [`StreamingConfig::validate`]).
535    pub fn build(self) -> ConfigResult<StreamingConfig> {
536        let config = self.build_inner();
537        config.validate()?;
538        Ok(config)
539    }
540}
541
542impl StreamingConfig {
543    /// Creates new [`StreamingConfig`] instance.
544    #[must_use]
545    pub fn new(
546        writer_path: String,
547        catalog: Option<DataCatalogConfig>,
548        flush_interval_ms: u64,
549        replace_existing: bool,
550        rotation_config: RotationConfig,
551    ) -> Self {
552        Self {
553            writer_path,
554            catalog,
555            flush_interval_ms,
556            replace_existing,
557            rotation_config,
558            promotion_interval_ms: None,
559            promote_on_close: default_promote_on_close(),
560            delete_feather_after_promotion: false,
561            use_ts_event_for_ts_init: false,
562            data_types: None,
563            record_types: None,
564            instrument_types: None,
565            record_filters: None,
566            params: None,
567        }
568    }
569
570    /// Validates the streaming configuration, collecting every field violation.
571    ///
572    /// # Errors
573    ///
574    /// Returns an error if any field fails validation. Multiple validation failures are collected
575    /// into one error.
576    pub fn validate(&self) -> ConfigResult<()> {
577        let mut errors = ConfigErrorCollector::new();
578
579        if self.writer_path.trim().is_empty() {
580            errors.push(ConfigError::empty_field("writer_path"));
581        } else {
582            errors.check(
583                local_writer_directory(&self.writer_path).is_ok(),
584                ConfigError::invalid_value(
585                    "writer_path",
586                    format!(
587                        "must be a local path because streaming appends to local files, was {}",
588                        self.writer_path
589                    ),
590                ),
591            );
592        }
593
594        let flush_interval_ms = self.flush_interval_ms;
595        errors.check(
596            flush_interval_ms > 0,
597            ConfigError::range(
598                "flush_interval_ms",
599                format!("must be a positive number of milliseconds, was {flush_interval_ms}"),
600            ),
601        );
602        errors.collect(self.rotation_config.validate());
603
604        if let Some(promotion_interval_ms) = self.promotion_interval_ms {
605            errors.check(
606                promotion_interval_ms > 0,
607                ConfigError::range(
608                    "promotion_interval_ms",
609                    "must be a positive number of milliseconds; omit the field for no interval",
610                ),
611            );
612        }
613
614        if self.catalog.is_none() {
615            for (field, is_set) in [
616                (
617                    "promotion_interval_ms",
618                    self.promotion_interval_ms.is_some(),
619                ),
620                (
621                    "delete_feather_after_promotion",
622                    self.delete_feather_after_promotion,
623                ),
624                ("use_ts_event_for_ts_init", self.use_ts_event_for_ts_init),
625            ] {
626                errors.check(
627                    !is_set,
628                    ConfigError::dependency(field, "catalog", "promotion needs a catalog"),
629                );
630            }
631        }
632
633        if let Some(data_types) = &self.data_types {
634            errors.check(
635                !data_types.is_empty(),
636                ConfigError::invalid_value(
637                    "data_types",
638                    "must not be an empty list; omit the field for unfiltered streaming",
639                ),
640            );
641        }
642
643        if let Some(record_types) = &self.record_types {
644            errors.check(
645                !record_types.is_empty(),
646                ConfigError::invalid_value(
647                    "record_types",
648                    "must not be an empty list; omit the field for unfiltered streaming",
649                ),
650            );
651        }
652
653        if let Some(instrument_types) = &self.instrument_types {
654            errors.check(
655                !instrument_types.is_empty(),
656                ConfigError::invalid_value(
657                    "instrument_types",
658                    "must not be an empty list; omit the field for unfiltered streaming",
659                ),
660            );
661        }
662
663        if let Some(record_filters) = &self.record_filters {
664            errors.check(
665                !record_filters.is_empty(),
666                ConfigError::invalid_value(
667                    "record_filters",
668                    "must not be an empty list; omit the field for unfiltered streaming",
669                ),
670            );
671        }
672
673        errors.into_result()
674    }
675
676    /// Returns the writer backend: `Feather` without a catalog, otherwise the catalog's backend.
677    #[must_use]
678    pub fn writer_backend(&self) -> WriterBackendType {
679        self.catalog.as_ref().map_or(
680            WriterBackendType::Feather,
681            DataCatalogConfig::writer_backend,
682        )
683    }
684}
685
686const fn default_promote_on_close() -> bool {
687    true
688}
689
690pub(crate) fn default_fs_protocol() -> String {
691    "file".to_string()
692}
693
694#[cfg(test)]
695mod tests {
696    use nautilus_core::string::secret::REDACTED;
697    use rstest::rstest;
698    use serde_json::json;
699
700    use super::*;
701
702    #[rstest]
703    fn catalog_backend_type_preserves_external_factory_case() {
704        assert_eq!(
705            "CaseSensitiveExternal"
706                .parse::<CatalogBackendType>()
707                .unwrap(),
708            CatalogBackendType::External("CaseSensitiveExternal".to_string()),
709        );
710    }
711
712    #[rstest]
713    fn catalog_backend_type_accepts_parquet_without_changing_default() {
714        assert_eq!(
715            "parquet".parse::<CatalogBackendType>().unwrap(),
716            CatalogBackendType::Parquet
717        );
718        assert_eq!(CatalogBackendType::Parquet.to_string(), "Parquet");
719        assert_eq!(CatalogBackendType::default(), CatalogBackendType::Parquet);
720    }
721
722    #[rstest]
723    fn catalog_backend_type_rejects_empty_name() {
724        assert!("".parse::<CatalogBackendType>().is_err());
725    }
726
727    #[rstest]
728    fn catalog_backend_type_display_roundtrips_built_ins() {
729        assert_eq!(CatalogBackendType::Parquet.to_string(), "Parquet");
730        assert_eq!(CatalogBackendType::Parquet.to_string(), "Parquet");
731        assert_eq!(
732            "Parquet".parse::<CatalogBackendType>().unwrap(),
733            CatalogBackendType::Parquet,
734        );
735        assert_eq!(
736            "Parquet".parse::<CatalogBackendType>().unwrap(),
737            CatalogBackendType::Parquet,
738        );
739    }
740
741    #[rstest]
742    fn data_catalog_config_preserves_backend_params() {
743        let params = Params::new([("snapshot_id".to_string(), json!(1024))].into());
744        let config = DataCatalogConfig::new(
745            "/data/catalog".to_string(),
746            Some("file".to_string()),
747            Some(CatalogBackendType::Parquet),
748        )
749        .with_params(Some(params));
750
751        assert_eq!(
752            config.params().and_then(|p| p.get_u64("snapshot_id")),
753            Some(1024)
754        );
755    }
756
757    #[rstest]
758    fn data_catalog_config_toml_defaults_backend_fields() {
759        let config: DataCatalogConfig = toml::from_str(
760            r#"
761path = "/data/catalog"
762"#,
763        )
764        .unwrap();
765
766        assert_eq!(config.path(), "/data/catalog");
767        assert_eq!(config.fs_protocol(), "file");
768        assert_eq!(config.catalog_backend(), &CatalogBackendType::Parquet);
769        assert_eq!(config.batch_size(), None);
770        assert_eq!(config.compression(), None);
771        assert_eq!(config.max_row_group_size(), None);
772        assert_eq!(config.params(), None);
773        assert!(!config.read_only());
774    }
775
776    #[rstest]
777    fn data_catalog_config_toml_reads_typed_settings() {
778        let config: DataCatalogConfig = toml::from_str(
779            r#"
780path = "/data/catalog"
781batch_size = 512
782compression = "gzip"
783max_row_group_size = 2048
784"#,
785        )
786        .unwrap();
787
788        assert_eq!(config.batch_size(), Some(512));
789        assert_eq!(config.compression(), Some(CatalogCompression::Gzip));
790        assert_eq!(config.max_row_group_size(), Some(2048));
791    }
792
793    #[rstest]
794    #[case::lzo("lzo")]
795    #[case::unknown("zip")]
796    #[case::numeric_code("3")]
797    fn data_catalog_config_toml_rejects_unsupported_compression(#[case] compression: &str) {
798        let error = toml::from_str::<DataCatalogConfig>(&format!(
799            "path = \"/data/catalog\"\ncompression = \"{compression}\"\n"
800        ))
801        .unwrap_err();
802
803        assert!(
804            error.to_string().contains(&format!(
805                "unknown variant `{compression}`, expected one of `uncompressed`, `snappy`, \
806                 `gzip`, `brotli`, `lz4`, `lz4_raw`, `zstd`"
807            )),
808            "{error}"
809        );
810    }
811
812    #[rstest]
813    #[case::uncompressed("uncompressed", CatalogCompression::Uncompressed)]
814    #[case::snappy("snappy", CatalogCompression::Snappy)]
815    #[case::gzip("gzip", CatalogCompression::Gzip)]
816    #[case::brotli("brotli", CatalogCompression::Brotli)]
817    #[case::lz4("lz4", CatalogCompression::Lz4)]
818    #[case::zstd("zstd", CatalogCompression::Zstd)]
819    fn catalog_compression_round_trips_codec_name(
820        #[case] name: &str,
821        #[case] compression: CatalogCompression,
822    ) {
823        assert_eq!(name.parse::<CatalogCompression>().unwrap(), compression);
824        assert_eq!(
825            name.to_ascii_uppercase()
826                .parse::<CatalogCompression>()
827                .unwrap(),
828            compression
829        );
830        assert_eq!(compression.to_string(), name);
831    }
832
833    #[rstest]
834    fn catalog_compression_accepts_lz4_raw_alias() {
835        let config: DataCatalogConfig = toml::from_str(
836            r#"
837path = "/data/catalog"
838compression = "lz4_raw"
839"#,
840        )
841        .unwrap();
842
843        assert_eq!(
844            "LZ4_RAW".parse::<CatalogCompression>().unwrap(),
845            CatalogCompression::Lz4
846        );
847        assert_eq!(config.compression(), Some(CatalogCompression::Lz4));
848        assert_eq!(
849            serde_json::to_string(&CatalogCompression::Lz4).unwrap(),
850            "\"lz4\""
851        );
852    }
853
854    #[rstest]
855    #[case::lzo("lzo")]
856    #[case::unknown("zip")]
857    fn catalog_compression_rejects_unsupported_codec(#[case] name: &str) {
858        assert_eq!(
859            name.parse::<CatalogCompression>(),
860            Err(strum::ParseError::VariantNotFound)
861        );
862    }
863
864    #[rstest]
865    fn data_catalog_config_builder_sets_typed_settings() {
866        let config = DataCatalogConfig::builder()
867            .path("/data/catalog".to_string())
868            .batch_size(512)
869            .compression(CatalogCompression::Snappy)
870            .max_row_group_size(2048)
871            .build()
872            .unwrap();
873
874        assert_eq!(config.batch_size(), Some(512));
875        assert_eq!(config.compression(), Some(CatalogCompression::Snappy));
876        assert_eq!(config.max_row_group_size(), Some(2048));
877    }
878
879    #[rstest]
880    fn data_catalog_config_builder_rejects_zero_counts() {
881        let error = DataCatalogConfig::builder()
882            .path("/data/catalog".to_string())
883            .batch_size(0)
884            .max_row_group_size(0)
885            .build()
886            .unwrap_err();
887
888        assert_eq!(
889            error,
890            ConfigError::multiple(vec![
891                ConfigError::range(
892                    "batch_size",
893                    "must be a positive number of rows; omit the field for the backend default",
894                ),
895                ConfigError::range(
896                    "max_row_group_size",
897                    "must be a positive number of rows; omit the field for the backend default",
898                ),
899            ])
900        );
901    }
902
903    #[rstest]
904    fn data_catalog_config_reads_read_only_flag() {
905        let config: DataCatalogConfig = toml::from_str(
906            r#"
907path = "/data/catalog"
908read_only = true
909"#,
910        )
911        .unwrap();
912
913        assert!(config.read_only());
914    }
915
916    #[rstest]
917    fn data_catalog_config_connect_config_carries_protocol_options_settings_and_params() {
918        let params = Params::new([("snapshot_id".to_string(), json!(1024))].into());
919        let config = DataCatalogConfig::builder()
920            .path("bucket/catalog".to_string())
921            .fs_protocol("s3".to_string())
922            .batch_size(512)
923            .compression(CatalogCompression::Brotli)
924            .max_row_group_size(2048)
925            .params(params.clone())
926            .fs_rust_storage_options(ahash::AHashMap::from([(
927                "region".to_string(),
928                SecretString::from("eu-west-1"),
929            )]))
930            .build()
931            .unwrap();
932
933        let connect = config.connect_config();
934
935        assert_eq!(connect.uri, "s3://bucket/catalog");
936        assert_eq!(
937            connect.storage_options,
938            Some(ahash::AHashMap::from([(
939                "region".to_string(),
940                "eu-west-1".to_string()
941            )]))
942        );
943        assert_eq!(connect.batch_size, Some(512));
944        assert_eq!(connect.compression, Some(CatalogCompression::Brotli));
945        assert_eq!(connect.max_row_group_size, Some(2048));
946        assert_eq!(connect.params, Some(params));
947    }
948
949    #[rstest]
950    fn data_catalog_config_debug_redacts_storage_option_values() {
951        let config = DataCatalogConfig::builder()
952            .path("bucket/catalog".to_string())
953            .fs_rust_storage_options(ahash::AHashMap::from([(
954                "aws_secret_access_key".to_string(),
955                SecretString::from("catalog-secret-sentinel"),
956            )]))
957            .build()
958            .unwrap();
959
960        let debug_output = format!("{config:?}");
961
962        assert!(!debug_output.contains("catalog-secret-sentinel"));
963        assert!(debug_output.contains(&format!(
964            "fs_rust_storage_options: Some({{\"aws_secret_access_key\": {REDACTED}}})"
965        )));
966    }
967
968    #[rstest]
969    #[case(CatalogBackendType::Parquet, WriterBackendType::Parquet)]
970    #[case(
971        CatalogBackendType::External("DuckLake".to_string()),
972        WriterBackendType::External("DuckLake".to_string())
973    )]
974    fn data_catalog_config_writer_backend_follows_catalog_backend(
975        #[case] catalog_backend: CatalogBackendType,
976        #[case] expected: WriterBackendType,
977    ) {
978        let config =
979            DataCatalogConfig::new("/data/catalog".to_string(), None, Some(catalog_backend));
980
981        assert_eq!(config.writer_backend(), expected);
982    }
983
984    fn streaming_config(catalog: Option<DataCatalogConfig>) -> StreamingConfig {
985        StreamingConfig::new(
986            "/data/stream".to_string(),
987            catalog,
988            1_000,
989            false,
990            RotationConfig::NoRotation,
991        )
992    }
993
994    fn local_catalog() -> DataCatalogConfig {
995        DataCatalogConfig::new("/data/catalog".to_string(), None, None)
996    }
997
998    #[rstest]
999    fn streaming_config_builder_defaults_promotion_options() {
1000        let config = StreamingConfig::builder()
1001            .writer_path("/data/stream".to_string())
1002            .flush_interval_ms(1_000)
1003            .replace_existing(false)
1004            .rotation_config(RotationConfig::NoRotation)
1005            .build()
1006            .unwrap();
1007
1008        assert_eq!(config.writer_path, "/data/stream");
1009        assert!(config.catalog.is_none());
1010        assert_eq!(config.promotion_interval_ms, None);
1011        assert!(config.promote_on_close);
1012        assert!(!config.delete_feather_after_promotion);
1013        assert!(!config.use_ts_event_for_ts_init);
1014        assert_eq!(config.writer_backend(), WriterBackendType::Feather);
1015    }
1016
1017    #[rstest]
1018    fn streaming_config_builder_preserves_promotion_options_and_params() {
1019        let params = Params::new([("batch_size".to_string(), json!(512))].into());
1020        let config = StreamingConfig::builder()
1021            .writer_path("/data/stream".to_string())
1022            .catalog(local_catalog())
1023            .flush_interval_ms(1_000)
1024            .replace_existing(false)
1025            .rotation_config(RotationConfig::NoRotation)
1026            .promotion_interval_ms(5_000)
1027            .promote_on_close(false)
1028            .delete_feather_after_promotion(true)
1029            .use_ts_event_for_ts_init(true)
1030            .params(params)
1031            .build()
1032            .unwrap();
1033
1034        assert_eq!(config.catalog.as_ref().unwrap().path(), "/data/catalog");
1035        assert_eq!(config.promotion_interval_ms, Some(5_000));
1036        assert!(!config.promote_on_close);
1037        assert!(config.delete_feather_after_promotion);
1038        assert!(config.use_ts_event_for_ts_init);
1039        assert_eq!(
1040            config.params.as_ref().and_then(|p| p.get_u64("batch_size")),
1041            Some(512)
1042        );
1043        assert_eq!(config.writer_backend(), WriterBackendType::Parquet);
1044    }
1045
1046    #[rstest]
1047    fn streaming_config_zero_flush_interval_rejected() {
1048        let result = StreamingConfig::builder()
1049            .writer_path("/data/stream".to_string())
1050            .flush_interval_ms(0)
1051            .replace_existing(false)
1052            .rotation_config(RotationConfig::NoRotation)
1053            .build();
1054
1055        assert!(
1056            matches!(result, Err(ConfigError::Range { field, .. }) if field == "flush_interval_ms")
1057        );
1058    }
1059
1060    #[rstest]
1061    fn streaming_config_empty_writer_path_rejected() {
1062        let result = StreamingConfig::builder()
1063            .writer_path(String::new())
1064            .flush_interval_ms(1_000)
1065            .replace_existing(false)
1066            .rotation_config(RotationConfig::NoRotation)
1067            .build();
1068
1069        assert!(matches!(result, Err(ConfigError::EmptyField { field }) if field == "writer_path"));
1070    }
1071
1072    #[rstest]
1073    #[case("s3://bucket/stream")]
1074    #[case("gs://bucket/stream")]
1075    fn streaming_config_remote_writer_path_rejected(#[case] writer_path: &str) {
1076        let mut config = streaming_config(None);
1077        config.writer_path = writer_path.to_string();
1078
1079        assert_eq!(
1080            config.validate().unwrap_err().to_string(),
1081            format!(
1082                "invalid writer_path: must be a local path because streaming appends to local \
1083                 files, was {writer_path}"
1084            ),
1085        );
1086    }
1087
1088    #[rstest]
1089    fn streaming_config_accepts_remote_catalog() {
1090        let config = streaming_config(Some(DataCatalogConfig::new(
1091            "bucket/catalog".to_string(),
1092            Some("s3".to_string()),
1093            None,
1094        )));
1095
1096        config.validate().unwrap();
1097        assert_eq!(
1098            config.catalog.unwrap().connect_config().uri,
1099            "s3://bucket/catalog"
1100        );
1101    }
1102
1103    #[rstest]
1104    fn streaming_config_promotion_options_require_catalog() {
1105        let mut config = streaming_config(None);
1106        config.promotion_interval_ms = Some(1_000);
1107        config.delete_feather_after_promotion = true;
1108        config.use_ts_event_for_ts_init = true;
1109
1110        assert_eq!(
1111            config.validate().unwrap_err().to_string(),
1112            "multiple config validation errors: \
1113             1. promotion_interval_ms requires catalog: promotion needs a catalog; \
1114             2. delete_feather_after_promotion requires catalog: promotion needs a catalog; \
1115             3. use_ts_event_for_ts_init requires catalog: promotion needs a catalog",
1116        );
1117    }
1118
1119    #[rstest]
1120    fn streaming_config_zero_promotion_interval_rejected() {
1121        let mut config = streaming_config(Some(local_catalog()));
1122        config.promotion_interval_ms = Some(0);
1123
1124        assert!(
1125            matches!(config.validate(), Err(ConfigError::Range { field, .. }) if field == "promotion_interval_ms")
1126        );
1127    }
1128
1129    #[rstest]
1130    #[case(RotationConfig::Size { max_size: 0 }, "rotation_config.max_size")]
1131    #[case(
1132        RotationConfig::Interval { interval_ns: DurationNanos::new(0) },
1133        "rotation_config.interval_ns"
1134    )]
1135    #[case(
1136        RotationConfig::ScheduledDates {
1137            interval_ns: DurationNanos::new(0),
1138            schedule_ns: UnixNanos::default(),
1139            timezone: "UTC".to_string(),
1140        },
1141        "rotation_config.interval_ns"
1142    )]
1143    #[case(
1144        RotationConfig::ScheduledDates {
1145            interval_ns: DurationNanos::new(1),
1146            schedule_ns: UnixNanos::default(),
1147            timezone: "Mars/Olympus_Mons".to_string(),
1148        },
1149        "rotation_config.timezone"
1150    )]
1151    fn streaming_config_invalid_rotation_rejected(
1152        #[case] rotation_config: RotationConfig,
1153        #[case] expected_field: &str,
1154    ) {
1155        let mut config = streaming_config(None);
1156        config.rotation_config = rotation_config;
1157
1158        assert!(matches!(
1159            config.validate(),
1160            Err(ConfigError::Range { field, .. } | ConfigError::InvalidValue { field, .. })
1161                if field == expected_field
1162        ));
1163    }
1164
1165    #[rstest]
1166    fn rotation_config_converts_to_writer_form() {
1167        let cases = [
1168            (
1169                RotationConfig::Size { max_size: 17 },
1170                "Size { max_size: 17 }",
1171            ),
1172            (
1173                RotationConfig::Interval {
1174                    interval_ns: DurationNanos::new(23),
1175                },
1176                "Interval { interval_ns: 23 }",
1177            ),
1178            (RotationConfig::NoRotation, "NoRotation"),
1179        ];
1180
1181        for (config, expected) in cases {
1182            assert_eq!(
1183                format!("{:?}", config.to_writer_rotation_config().unwrap()),
1184                expected
1185            );
1186        }
1187    }
1188
1189    #[rstest]
1190    fn rotation_config_scheduled_dates_keeps_timezone() {
1191        let config = RotationConfig::ScheduledDates {
1192            interval_ns: DurationNanos::new(31),
1193            schedule_ns: UnixNanos::from(37),
1194            timezone: "Australia/Sydney".to_string(),
1195        };
1196
1197        let crate::writer::feather::RotationConfig::ScheduledDates {
1198            interval_ns,
1199            rotation_time,
1200            rotation_timezone,
1201        } = config.to_writer_rotation_config().unwrap()
1202        else {
1203            panic!("expected scheduled rotation")
1204        };
1205
1206        assert_eq!(interval_ns, 31);
1207        assert_eq!(rotation_time, UnixNanos::from(37));
1208        assert_eq!(rotation_timezone.iana_name(), Some("Australia/Sydney"));
1209    }
1210
1211    #[rstest]
1212    #[case::size(RotationConfig::Size { max_size: 1 }, RotationMode::Size)]
1213    #[case::interval(
1214        RotationConfig::Interval {
1215            interval_ns: DurationNanos::new(1),
1216        },
1217        RotationMode::Interval,
1218    )]
1219    #[case::scheduled_dates(
1220        RotationConfig::ScheduledDates {
1221            interval_ns: DurationNanos::new(1),
1222            schedule_ns: UnixNanos::from(1),
1223            timezone: DEFAULT_ROTATION_TIMEZONE.to_string(),
1224        },
1225        RotationMode::ScheduledDates,
1226    )]
1227    #[case::no_rotation(RotationConfig::NoRotation, RotationMode::NoRotation)]
1228    fn rotation_config_mode_names_its_policy(
1229        #[case] config: RotationConfig,
1230        #[case] expected: RotationMode,
1231    ) {
1232        assert_eq!(config.mode(), expected);
1233    }
1234
1235    #[rstest]
1236    fn rotation_config_unknown_timezone_fails_conversion() {
1237        let config = RotationConfig::ScheduledDates {
1238            interval_ns: DurationNanos::new(31),
1239            schedule_ns: UnixNanos::from(37),
1240            timezone: "Mars/Olympus_Mons".to_string(),
1241        };
1242
1243        assert!(
1244            config
1245                .to_writer_rotation_config()
1246                .unwrap_err()
1247                .to_string()
1248                .starts_with("Invalid rotation timezone 'Mars/Olympus_Mons'")
1249        );
1250    }
1251
1252    #[rstest]
1253    fn streaming_config_empty_filter_lists_rejected() {
1254        let result = StreamingConfig::builder()
1255            .writer_path("/data/stream".to_string())
1256            .flush_interval_ms(1_000)
1257            .replace_existing(false)
1258            .rotation_config(RotationConfig::NoRotation)
1259            .data_types(vec![])
1260            .record_types(vec![])
1261            .instrument_types(vec![])
1262            .record_filters(vec![])
1263            .build();
1264
1265        match result.unwrap_err() {
1266            ConfigError::Multiple { errors } => {
1267                assert_eq!(errors.len(), 4);
1268                assert!(errors.iter().all(|e| matches!(
1269                    e,
1270                    ConfigError::InvalidValue { field, .. }
1271                        if field == "data_types"
1272                            || field == "record_types"
1273                            || field == "instrument_types"
1274                            || field == "record_filters"
1275                )));
1276            }
1277            error => panic!("Expected multiple config errors, received {error:?}"),
1278        }
1279    }
1280
1281    #[rstest]
1282    fn streaming_config_toml_round_trip() {
1283        let config: StreamingConfig = toml::from_str(
1284            r#"
1285writer_path = "/data/stream"
1286flush_interval_ms = 1000
1287replace_existing = false
1288promotion_interval_ms = 5000
1289
1290[catalog]
1291path = "bucket/catalog"
1292fs_protocol = "s3"
1293
1294[rotation_config.size]
1295max_size = 1048576
1296"#,
1297        )
1298        .unwrap();
1299
1300        assert_eq!(config.writer_path, "/data/stream");
1301        assert_eq!(config.catalog.as_ref().unwrap().path(), "bucket/catalog");
1302        assert_eq!(config.catalog.as_ref().unwrap().fs_protocol(), "s3");
1303        assert_eq!(config.flush_interval_ms, 1000);
1304        assert!(!config.replace_existing);
1305        assert_eq!(config.promotion_interval_ms, Some(5_000));
1306        assert!(config.promote_on_close);
1307        assert!(!config.delete_feather_after_promotion);
1308        assert!(!config.use_ts_event_for_ts_init);
1309        assert_eq!(config.params, None);
1310        assert!(matches!(
1311            config.rotation_config,
1312            RotationConfig::Size {
1313                max_size: 1_048_576
1314            }
1315        ));
1316    }
1317
1318    #[rstest]
1319    fn streaming_config_scheduled_dates_toml_defaults_timezone_to_utc() {
1320        let config: StreamingConfig = toml::from_str(
1321            r#"
1322writer_path = "/data/stream"
1323flush_interval_ms = 1000
1324replace_existing = false
1325
1326[rotation_config.scheduled_dates]
1327interval_ns = 86400000000000
1328schedule_ns = 3600000000000
1329"#,
1330        )
1331        .unwrap();
1332
1333        let RotationConfig::ScheduledDates {
1334            interval_ns,
1335            schedule_ns,
1336            timezone,
1337        } = config.rotation_config
1338        else {
1339            panic!("expected scheduled rotation")
1340        };
1341
1342        assert_eq!(interval_ns.as_u64(), 86_400_000_000_000);
1343        assert_eq!(schedule_ns, UnixNanos::from(3_600_000_000_000));
1344        assert_eq!(timezone, "UTC");
1345    }
1346
1347    #[rstest]
1348    fn streaming_config_with_no_rotation_toml() {
1349        let config: StreamingConfig = toml::from_str(
1350            r#"
1351writer_path = "/data/stream"
1352flush_interval_ms = 500
1353replace_existing = true
1354rotation_config = "no_rotation"
1355"#,
1356        )
1357        .unwrap();
1358
1359        assert!(matches!(config.rotation_config, RotationConfig::NoRotation));
1360        assert!(config.replace_existing);
1361    }
1362}