1use 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 CatalogBackendType {
43 Parquet => PARQUET_CATALOG_FACTORY_NAME,
44 }
45);
46
47#[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#[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 path: String,
91 name: Option<String>,
93 #[serde(default = "default_fs_protocol")]
95 #[builder(default = default_fs_protocol())]
96 fs_protocol: String,
97 #[serde(default)]
99 #[builder(default)]
100 catalog_backend: CatalogBackendType,
101 batch_size: Option<usize>,
103 compression: Option<CatalogCompression>,
105 max_row_group_size: Option<usize>,
107 params: Option<Params>,
109 #[serde(default)]
110 fs_rust_storage_options: Option<ahash::AHashMap<String, SecretString>>,
111 #[serde(default)]
113 #[builder(default)]
114 read_only: bool,
115}
116
117impl<S: data_catalog_config_builder::IsComplete> DataCatalogConfigBuilder<S> {
118 pub fn build(self) -> ConfigResult<DataCatalogConfig> {
124 let config = self.build_inner();
125 config.validate()?;
126 Ok(config)
127 }
128}
129
130impl DataCatalogConfig {
131 #[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 pub fn validate(&self) -> ConfigResult<()> {
158 validate_catalog_counts(self.batch_size, self.max_row_group_size)
159 }
160
161 #[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 #[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 #[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 #[must_use]
200 pub fn with_name(mut self, name: Option<String>) -> Self {
201 self.name = name;
202 self
203 }
204
205 #[must_use]
207 pub fn with_params(mut self, params: Option<Params>) -> Self {
208 self.params = params;
209 self
210 }
211
212 #[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 #[must_use]
221 pub fn path(&self) -> &str {
222 &self.path
223 }
224
225 #[must_use]
227 pub fn name(&self) -> Option<&str> {
228 self.name.as_deref()
229 }
230
231 #[must_use]
233 pub const fn read_only(&self) -> bool {
234 self.read_only
235 }
236
237 #[must_use]
239 pub fn fs_protocol(&self) -> &str {
240 &self.fs_protocol
241 }
242
243 #[must_use]
245 pub const fn catalog_backend(&self) -> &CatalogBackendType {
246 &self.catalog_backend
247 }
248
249 #[must_use]
251 pub const fn batch_size(&self) -> Option<usize> {
252 self.batch_size
253 }
254
255 #[must_use]
257 pub const fn compression(&self) -> Option<CatalogCompression> {
258 self.compression
259 }
260
261 #[must_use]
263 pub const fn max_row_group_size(&self) -> Option<usize> {
264 self.max_row_group_size
265 }
266
267 #[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
282pub(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#[derive(Debug, Clone, Serialize, Deserialize)]
307#[serde(rename_all = "snake_case")]
308pub enum RotationConfig {
309 Size {
311 max_size: u64,
313 },
314 Interval {
316 interval_ns: DurationNanos,
318 },
319 ScheduledDates {
321 interval_ns: DurationNanos,
323 schedule_ns: UnixNanos,
325 #[serde(default = "default_rotation_timezone")]
327 timezone: String,
328 },
329 NoRotation,
331}
332
333impl RotationConfig {
334 #[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 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 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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
456#[serde(deny_unknown_fields)]
457pub struct StreamingRecordFilterConfig {
458 pub record_type: NautilusRecordType,
460 pub identifiers: Option<Vec<String>>,
462}
463
464#[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 pub writer_path: String,
495 pub catalog: Option<DataCatalogConfig>,
497 pub flush_interval_ms: u64,
499 pub replace_existing: bool,
501 pub rotation_config: RotationConfig,
503 pub promotion_interval_ms: Option<u64>,
505 #[serde(default = "default_promote_on_close")]
507 #[builder(default = default_promote_on_close())]
508 pub promote_on_close: bool,
509 #[serde(default)]
511 #[builder(default)]
512 pub delete_feather_after_promotion: bool,
513 #[serde(default)]
515 #[builder(default)]
516 pub use_ts_event_for_ts_init: bool,
517 pub data_types: Option<Vec<NautilusDataType>>,
519 pub record_types: Option<Vec<NautilusRecordType>>,
521 pub instrument_types: Option<Vec<NautilusInstrumentType>>,
523 pub record_filters: Option<Vec<StreamingRecordFilterConfig>>,
525 pub params: Option<Params>,
527}
528
529impl<S: streaming_config_builder::IsComplete> StreamingConfigBuilder<S> {
530 pub fn build(self) -> ConfigResult<StreamingConfig> {
536 let config = self.build_inner();
537 config.validate()?;
538 Ok(config)
539 }
540}
541
542impl StreamingConfig {
543 #[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 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 #[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}