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 a catalog available to request-time historical data loading.
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 mut params = Params::new();
744        params.insert("snapshot_id".to_string(), json!(1024));
745        let config = DataCatalogConfig::new(
746            "/data/catalog".to_string(),
747            Some("file".to_string()),
748            Some(CatalogBackendType::Parquet),
749        )
750        .with_params(Some(params));
751
752        assert_eq!(
753            config.params().and_then(|p| p.get_u64("snapshot_id")),
754            Some(1024)
755        );
756    }
757
758    #[rstest]
759    fn data_catalog_config_toml_defaults_backend_fields() {
760        let config: DataCatalogConfig = toml::from_str(
761            r#"
762path = "/data/catalog"
763"#,
764        )
765        .unwrap();
766
767        assert_eq!(config.path(), "/data/catalog");
768        assert_eq!(config.fs_protocol(), "file");
769        assert_eq!(config.catalog_backend(), &CatalogBackendType::Parquet);
770        assert_eq!(config.batch_size(), None);
771        assert_eq!(config.compression(), None);
772        assert_eq!(config.max_row_group_size(), None);
773        assert_eq!(config.params(), None);
774        assert!(!config.read_only());
775    }
776
777    #[rstest]
778    fn data_catalog_config_toml_reads_typed_settings() {
779        let config: DataCatalogConfig = toml::from_str(
780            r#"
781path = "/data/catalog"
782batch_size = 512
783compression = "gzip"
784max_row_group_size = 2048
785"#,
786        )
787        .unwrap();
788
789        assert_eq!(config.batch_size(), Some(512));
790        assert_eq!(config.compression(), Some(CatalogCompression::Gzip));
791        assert_eq!(config.max_row_group_size(), Some(2048));
792    }
793
794    #[rstest]
795    #[case::lzo("lzo")]
796    #[case::unknown("zip")]
797    #[case::numeric_code("3")]
798    fn data_catalog_config_toml_rejects_unsupported_compression(#[case] compression: &str) {
799        let error = toml::from_str::<DataCatalogConfig>(&format!(
800            "path = \"/data/catalog\"\ncompression = \"{compression}\"\n"
801        ))
802        .unwrap_err();
803
804        assert!(
805            error.to_string().contains(&format!(
806                "unknown variant `{compression}`, expected one of `uncompressed`, `snappy`, \
807                 `gzip`, `brotli`, `lz4`, `lz4_raw`, `zstd`"
808            )),
809            "{error}"
810        );
811    }
812
813    #[rstest]
814    #[case::uncompressed("uncompressed", CatalogCompression::Uncompressed)]
815    #[case::snappy("snappy", CatalogCompression::Snappy)]
816    #[case::gzip("gzip", CatalogCompression::Gzip)]
817    #[case::brotli("brotli", CatalogCompression::Brotli)]
818    #[case::lz4("lz4", CatalogCompression::Lz4)]
819    #[case::zstd("zstd", CatalogCompression::Zstd)]
820    fn catalog_compression_round_trips_codec_name(
821        #[case] name: &str,
822        #[case] compression: CatalogCompression,
823    ) {
824        assert_eq!(name.parse::<CatalogCompression>().unwrap(), compression);
825        assert_eq!(
826            name.to_ascii_uppercase()
827                .parse::<CatalogCompression>()
828                .unwrap(),
829            compression
830        );
831        assert_eq!(compression.to_string(), name);
832    }
833
834    #[rstest]
835    fn catalog_compression_accepts_lz4_raw_alias() {
836        let config: DataCatalogConfig = toml::from_str(
837            r#"
838path = "/data/catalog"
839compression = "lz4_raw"
840"#,
841        )
842        .unwrap();
843
844        assert_eq!(
845            "LZ4_RAW".parse::<CatalogCompression>().unwrap(),
846            CatalogCompression::Lz4
847        );
848        assert_eq!(config.compression(), Some(CatalogCompression::Lz4));
849        assert_eq!(
850            serde_json::to_string(&CatalogCompression::Lz4).unwrap(),
851            "\"lz4\""
852        );
853    }
854
855    #[rstest]
856    #[case::lzo("lzo")]
857    #[case::unknown("zip")]
858    fn catalog_compression_rejects_unsupported_codec(#[case] name: &str) {
859        assert_eq!(
860            name.parse::<CatalogCompression>(),
861            Err(strum::ParseError::VariantNotFound)
862        );
863    }
864
865    #[rstest]
866    fn data_catalog_config_builder_sets_typed_settings() {
867        let config = DataCatalogConfig::builder()
868            .path("/data/catalog".to_string())
869            .batch_size(512)
870            .compression(CatalogCompression::Snappy)
871            .max_row_group_size(2048)
872            .build()
873            .unwrap();
874
875        assert_eq!(config.batch_size(), Some(512));
876        assert_eq!(config.compression(), Some(CatalogCompression::Snappy));
877        assert_eq!(config.max_row_group_size(), Some(2048));
878    }
879
880    #[rstest]
881    fn data_catalog_config_builder_rejects_zero_counts() {
882        let error = DataCatalogConfig::builder()
883            .path("/data/catalog".to_string())
884            .batch_size(0)
885            .max_row_group_size(0)
886            .build()
887            .unwrap_err();
888
889        assert_eq!(
890            error,
891            ConfigError::multiple(vec![
892                ConfigError::range(
893                    "batch_size",
894                    "must be a positive number of rows; omit the field for the backend default",
895                ),
896                ConfigError::range(
897                    "max_row_group_size",
898                    "must be a positive number of rows; omit the field for the backend default",
899                ),
900            ])
901        );
902    }
903
904    #[rstest]
905    fn data_catalog_config_reads_read_only_flag() {
906        let config: DataCatalogConfig = toml::from_str(
907            r#"
908path = "/data/catalog"
909read_only = true
910"#,
911        )
912        .unwrap();
913
914        assert!(config.read_only());
915    }
916
917    #[rstest]
918    fn data_catalog_config_connect_config_carries_protocol_options_settings_and_params() {
919        let mut params = Params::new();
920        params.insert("snapshot_id".to_string(), json!(1024));
921        let config = DataCatalogConfig::builder()
922            .path("bucket/catalog".to_string())
923            .fs_protocol("s3".to_string())
924            .batch_size(512)
925            .compression(CatalogCompression::Brotli)
926            .max_row_group_size(2048)
927            .params(params.clone())
928            .fs_rust_storage_options(ahash::AHashMap::from([(
929                "region".to_string(),
930                SecretString::from("eu-west-1"),
931            )]))
932            .build()
933            .unwrap();
934
935        let connect = config.connect_config();
936
937        assert_eq!(connect.uri, "s3://bucket/catalog");
938        assert_eq!(
939            connect.storage_options,
940            Some(ahash::AHashMap::from([(
941                "region".to_string(),
942                "eu-west-1".to_string()
943            )]))
944        );
945        assert_eq!(connect.batch_size, Some(512));
946        assert_eq!(connect.compression, Some(CatalogCompression::Brotli));
947        assert_eq!(connect.max_row_group_size, Some(2048));
948        assert_eq!(connect.params, Some(params));
949    }
950
951    #[rstest]
952    fn data_catalog_config_debug_redacts_storage_option_values() {
953        let config = DataCatalogConfig::builder()
954            .path("bucket/catalog".to_string())
955            .fs_rust_storage_options(ahash::AHashMap::from([(
956                "aws_secret_access_key".to_string(),
957                SecretString::from("catalog-secret-sentinel"),
958            )]))
959            .build()
960            .unwrap();
961
962        let debug_output = format!("{config:?}");
963
964        assert!(!debug_output.contains("catalog-secret-sentinel"));
965        assert!(debug_output.contains(&format!(
966            "fs_rust_storage_options: Some({{\"aws_secret_access_key\": {REDACTED}}})"
967        )));
968    }
969
970    #[rstest]
971    #[case(CatalogBackendType::Parquet, WriterBackendType::Parquet)]
972    #[case(
973        CatalogBackendType::External("DuckLake".to_string()),
974        WriterBackendType::External("DuckLake".to_string())
975    )]
976    fn data_catalog_config_writer_backend_follows_catalog_backend(
977        #[case] catalog_backend: CatalogBackendType,
978        #[case] expected: WriterBackendType,
979    ) {
980        let config =
981            DataCatalogConfig::new("/data/catalog".to_string(), None, Some(catalog_backend));
982
983        assert_eq!(config.writer_backend(), expected);
984    }
985
986    fn streaming_config(catalog: Option<DataCatalogConfig>) -> StreamingConfig {
987        StreamingConfig::new(
988            "/data/stream".to_string(),
989            catalog,
990            1_000,
991            false,
992            RotationConfig::NoRotation,
993        )
994    }
995
996    fn local_catalog() -> DataCatalogConfig {
997        DataCatalogConfig::new("/data/catalog".to_string(), None, None)
998    }
999
1000    #[rstest]
1001    fn streaming_config_builder_defaults_promotion_options() {
1002        let config = StreamingConfig::builder()
1003            .writer_path("/data/stream".to_string())
1004            .flush_interval_ms(1_000)
1005            .replace_existing(false)
1006            .rotation_config(RotationConfig::NoRotation)
1007            .build()
1008            .unwrap();
1009
1010        assert_eq!(config.writer_path, "/data/stream");
1011        assert!(config.catalog.is_none());
1012        assert_eq!(config.promotion_interval_ms, None);
1013        assert!(config.promote_on_close);
1014        assert!(!config.delete_feather_after_promotion);
1015        assert!(!config.use_ts_event_for_ts_init);
1016        assert_eq!(config.writer_backend(), WriterBackendType::Feather);
1017    }
1018
1019    #[rstest]
1020    fn streaming_config_builder_preserves_promotion_options_and_params() {
1021        let mut params = Params::new();
1022        params.insert("batch_size".to_string(), json!(512));
1023        let config = StreamingConfig::builder()
1024            .writer_path("/data/stream".to_string())
1025            .catalog(local_catalog())
1026            .flush_interval_ms(1_000)
1027            .replace_existing(false)
1028            .rotation_config(RotationConfig::NoRotation)
1029            .promotion_interval_ms(5_000)
1030            .promote_on_close(false)
1031            .delete_feather_after_promotion(true)
1032            .use_ts_event_for_ts_init(true)
1033            .params(params)
1034            .build()
1035            .unwrap();
1036
1037        assert_eq!(config.catalog.as_ref().unwrap().path(), "/data/catalog");
1038        assert_eq!(config.promotion_interval_ms, Some(5_000));
1039        assert!(!config.promote_on_close);
1040        assert!(config.delete_feather_after_promotion);
1041        assert!(config.use_ts_event_for_ts_init);
1042        assert_eq!(
1043            config.params.as_ref().and_then(|p| p.get_u64("batch_size")),
1044            Some(512)
1045        );
1046        assert_eq!(config.writer_backend(), WriterBackendType::Parquet);
1047    }
1048
1049    #[rstest]
1050    fn streaming_config_zero_flush_interval_rejected() {
1051        let result = StreamingConfig::builder()
1052            .writer_path("/data/stream".to_string())
1053            .flush_interval_ms(0)
1054            .replace_existing(false)
1055            .rotation_config(RotationConfig::NoRotation)
1056            .build();
1057
1058        assert!(
1059            matches!(result, Err(ConfigError::Range { field, .. }) if field == "flush_interval_ms")
1060        );
1061    }
1062
1063    #[rstest]
1064    fn streaming_config_empty_writer_path_rejected() {
1065        let result = StreamingConfig::builder()
1066            .writer_path(String::new())
1067            .flush_interval_ms(1_000)
1068            .replace_existing(false)
1069            .rotation_config(RotationConfig::NoRotation)
1070            .build();
1071
1072        assert!(matches!(result, Err(ConfigError::EmptyField { field }) if field == "writer_path"));
1073    }
1074
1075    #[rstest]
1076    #[case("s3://bucket/stream")]
1077    #[case("gs://bucket/stream")]
1078    fn streaming_config_remote_writer_path_rejected(#[case] writer_path: &str) {
1079        let mut config = streaming_config(None);
1080        config.writer_path = writer_path.to_string();
1081
1082        assert_eq!(
1083            config.validate().unwrap_err().to_string(),
1084            format!(
1085                "invalid writer_path: must be a local path because streaming appends to local \
1086                 files, was {writer_path}"
1087            ),
1088        );
1089    }
1090
1091    #[rstest]
1092    fn streaming_config_accepts_remote_catalog() {
1093        let config = streaming_config(Some(DataCatalogConfig::new(
1094            "bucket/catalog".to_string(),
1095            Some("s3".to_string()),
1096            None,
1097        )));
1098
1099        config.validate().unwrap();
1100        assert_eq!(
1101            config.catalog.unwrap().connect_config().uri,
1102            "s3://bucket/catalog"
1103        );
1104    }
1105
1106    #[rstest]
1107    fn streaming_config_promotion_options_require_catalog() {
1108        let mut config = streaming_config(None);
1109        config.promotion_interval_ms = Some(1_000);
1110        config.delete_feather_after_promotion = true;
1111        config.use_ts_event_for_ts_init = true;
1112
1113        assert_eq!(
1114            config.validate().unwrap_err().to_string(),
1115            "multiple config validation errors: \
1116             1. promotion_interval_ms requires catalog: promotion needs a catalog; \
1117             2. delete_feather_after_promotion requires catalog: promotion needs a catalog; \
1118             3. use_ts_event_for_ts_init requires catalog: promotion needs a catalog",
1119        );
1120    }
1121
1122    #[rstest]
1123    fn streaming_config_zero_promotion_interval_rejected() {
1124        let mut config = streaming_config(Some(local_catalog()));
1125        config.promotion_interval_ms = Some(0);
1126
1127        assert!(
1128            matches!(config.validate(), Err(ConfigError::Range { field, .. }) if field == "promotion_interval_ms")
1129        );
1130    }
1131
1132    #[rstest]
1133    #[case(RotationConfig::Size { max_size: 0 }, "rotation_config.max_size")]
1134    #[case(
1135        RotationConfig::Interval { interval_ns: DurationNanos::new(0) },
1136        "rotation_config.interval_ns"
1137    )]
1138    #[case(
1139        RotationConfig::ScheduledDates {
1140            interval_ns: DurationNanos::new(0),
1141            schedule_ns: UnixNanos::default(),
1142            timezone: "UTC".to_string(),
1143        },
1144        "rotation_config.interval_ns"
1145    )]
1146    #[case(
1147        RotationConfig::ScheduledDates {
1148            interval_ns: DurationNanos::new(1),
1149            schedule_ns: UnixNanos::default(),
1150            timezone: "Mars/Olympus_Mons".to_string(),
1151        },
1152        "rotation_config.timezone"
1153    )]
1154    fn streaming_config_invalid_rotation_rejected(
1155        #[case] rotation_config: RotationConfig,
1156        #[case] expected_field: &str,
1157    ) {
1158        let mut config = streaming_config(None);
1159        config.rotation_config = rotation_config;
1160
1161        assert!(matches!(
1162            config.validate(),
1163            Err(ConfigError::Range { field, .. } | ConfigError::InvalidValue { field, .. })
1164                if field == expected_field
1165        ));
1166    }
1167
1168    #[rstest]
1169    fn rotation_config_converts_to_writer_form() {
1170        let cases = [
1171            (
1172                RotationConfig::Size { max_size: 17 },
1173                "Size { max_size: 17 }",
1174            ),
1175            (
1176                RotationConfig::Interval {
1177                    interval_ns: DurationNanos::new(23),
1178                },
1179                "Interval { interval_ns: 23 }",
1180            ),
1181            (RotationConfig::NoRotation, "NoRotation"),
1182        ];
1183
1184        for (config, expected) in cases {
1185            assert_eq!(
1186                format!("{:?}", config.to_writer_rotation_config().unwrap()),
1187                expected
1188            );
1189        }
1190    }
1191
1192    #[rstest]
1193    fn rotation_config_scheduled_dates_keeps_timezone() {
1194        let config = RotationConfig::ScheduledDates {
1195            interval_ns: DurationNanos::new(31),
1196            schedule_ns: UnixNanos::from(37),
1197            timezone: "Australia/Sydney".to_string(),
1198        };
1199
1200        let crate::writer::feather::RotationConfig::ScheduledDates {
1201            interval_ns,
1202            rotation_time,
1203            rotation_timezone,
1204        } = config.to_writer_rotation_config().unwrap()
1205        else {
1206            panic!("expected scheduled rotation")
1207        };
1208
1209        assert_eq!(interval_ns, 31);
1210        assert_eq!(rotation_time, UnixNanos::from(37));
1211        assert_eq!(rotation_timezone.iana_name(), Some("Australia/Sydney"));
1212    }
1213
1214    #[rstest]
1215    #[case::size(RotationConfig::Size { max_size: 1 }, RotationMode::Size)]
1216    #[case::interval(
1217        RotationConfig::Interval {
1218            interval_ns: DurationNanos::new(1),
1219        },
1220        RotationMode::Interval,
1221    )]
1222    #[case::scheduled_dates(
1223        RotationConfig::ScheduledDates {
1224            interval_ns: DurationNanos::new(1),
1225            schedule_ns: UnixNanos::from(1),
1226            timezone: DEFAULT_ROTATION_TIMEZONE.to_string(),
1227        },
1228        RotationMode::ScheduledDates,
1229    )]
1230    #[case::no_rotation(RotationConfig::NoRotation, RotationMode::NoRotation)]
1231    fn rotation_config_mode_names_its_policy(
1232        #[case] config: RotationConfig,
1233        #[case] expected: RotationMode,
1234    ) {
1235        assert_eq!(config.mode(), expected);
1236    }
1237
1238    #[rstest]
1239    fn rotation_config_unknown_timezone_fails_conversion() {
1240        let config = RotationConfig::ScheduledDates {
1241            interval_ns: DurationNanos::new(31),
1242            schedule_ns: UnixNanos::from(37),
1243            timezone: "Mars/Olympus_Mons".to_string(),
1244        };
1245
1246        assert!(
1247            config
1248                .to_writer_rotation_config()
1249                .unwrap_err()
1250                .to_string()
1251                .starts_with("Invalid rotation timezone 'Mars/Olympus_Mons'")
1252        );
1253    }
1254
1255    #[rstest]
1256    fn streaming_config_empty_filter_lists_rejected() {
1257        let result = StreamingConfig::builder()
1258            .writer_path("/data/stream".to_string())
1259            .flush_interval_ms(1_000)
1260            .replace_existing(false)
1261            .rotation_config(RotationConfig::NoRotation)
1262            .data_types(vec![])
1263            .record_types(vec![])
1264            .instrument_types(vec![])
1265            .record_filters(vec![])
1266            .build();
1267
1268        match result.unwrap_err() {
1269            ConfigError::Multiple { errors } => {
1270                assert_eq!(errors.len(), 4);
1271                assert!(errors.iter().all(|e| matches!(
1272                    e,
1273                    ConfigError::InvalidValue { field, .. }
1274                        if field == "data_types"
1275                            || field == "record_types"
1276                            || field == "instrument_types"
1277                            || field == "record_filters"
1278                )));
1279            }
1280            error => panic!("Expected multiple config errors, received {error:?}"),
1281        }
1282    }
1283
1284    #[rstest]
1285    fn streaming_config_toml_round_trip() {
1286        let config: StreamingConfig = toml::from_str(
1287            r#"
1288writer_path = "/data/stream"
1289flush_interval_ms = 1000
1290replace_existing = false
1291promotion_interval_ms = 5000
1292
1293[catalog]
1294path = "bucket/catalog"
1295fs_protocol = "s3"
1296
1297[rotation_config.size]
1298max_size = 1048576
1299"#,
1300        )
1301        .unwrap();
1302
1303        assert_eq!(config.writer_path, "/data/stream");
1304        assert_eq!(config.catalog.as_ref().unwrap().path(), "bucket/catalog");
1305        assert_eq!(config.catalog.as_ref().unwrap().fs_protocol(), "s3");
1306        assert_eq!(config.flush_interval_ms, 1000);
1307        assert!(!config.replace_existing);
1308        assert_eq!(config.promotion_interval_ms, Some(5_000));
1309        assert!(config.promote_on_close);
1310        assert!(!config.delete_feather_after_promotion);
1311        assert!(!config.use_ts_event_for_ts_init);
1312        assert_eq!(config.params, None);
1313        assert!(matches!(
1314            config.rotation_config,
1315            RotationConfig::Size {
1316                max_size: 1_048_576
1317            }
1318        ));
1319    }
1320
1321    #[rstest]
1322    fn streaming_config_scheduled_dates_toml_defaults_timezone_to_utc() {
1323        let config: StreamingConfig = toml::from_str(
1324            r#"
1325writer_path = "/data/stream"
1326flush_interval_ms = 1000
1327replace_existing = false
1328
1329[rotation_config.scheduled_dates]
1330interval_ns = 86400000000000
1331schedule_ns = 3600000000000
1332"#,
1333        )
1334        .unwrap();
1335
1336        let RotationConfig::ScheduledDates {
1337            interval_ns,
1338            schedule_ns,
1339            timezone,
1340        } = config.rotation_config
1341        else {
1342            panic!("expected scheduled rotation")
1343        };
1344
1345        assert_eq!(interval_ns.as_u64(), 86_400_000_000_000);
1346        assert_eq!(schedule_ns, UnixNanos::from(3_600_000_000_000));
1347        assert_eq!(timezone, "UTC");
1348    }
1349
1350    #[rstest]
1351    fn streaming_config_with_no_rotation_toml() {
1352        let config: StreamingConfig = toml::from_str(
1353            r#"
1354writer_path = "/data/stream"
1355flush_interval_ms = 500
1356replace_existing = true
1357rotation_config = "no_rotation"
1358"#,
1359        )
1360        .unwrap();
1361
1362        assert!(matches!(config.rotation_config, RotationConfig::NoRotation));
1363        assert!(config.replace_existing);
1364    }
1365}