1use std::fmt::Display;
19
20use nautilus_common::config::{ConfigError, ConfigErrorCollector, ConfigResult};
21use nautilus_core::{DurationNanos, Params, UnixNanos};
22use nautilus_model::{
23 data::{NautilusDataType, NautilusRecordType},
24 instruments::NautilusInstrumentType,
25};
26use serde::{Deserialize, Serialize};
27
28use crate::{
29 catalog::factory::PARQUET_CATALOG_FACTORY_NAME, common::backend_name::backend_type,
30 writer::factory::WriterBackendType,
31};
32
33backend_type!(
34 CatalogBackendType {
39 Parquet => PARQUET_CATALOG_FACTORY_NAME,
40 }
41);
42
43#[cfg_attr(
45 feature = "python",
46 expect(
47 clippy::unsafe_derive_deserialize,
48 reason = "config deserializes plain fields; unsafe methods come from generated PyO3 integration"
49 )
50)]
51#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, bon::Builder)]
52#[serde(deny_unknown_fields)]
53#[cfg_attr(
54 feature = "python",
55 pyo3::pyclass(module = "nautilus_trader.persistence", from_py_object, eq)
56)]
57#[cfg_attr(
58 feature = "python",
59 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.persistence")
60)]
61pub struct DataCatalogConfig {
62 path: String,
64 name: Option<String>,
66 #[serde(default = "default_fs_protocol")]
68 #[builder(default = default_fs_protocol())]
69 fs_protocol: String,
70 #[serde(default)]
72 #[builder(default)]
73 catalog_backend: CatalogBackendType,
74 params: Option<Params>,
76 #[serde(default)]
77 fs_rust_storage_options: Option<ahash::AHashMap<String, String>>,
78 #[serde(default)]
80 #[builder(default)]
81 read_only: bool,
82}
83
84impl DataCatalogConfig {
85 #[must_use]
87 pub fn new(
88 path: String,
89 fs_protocol: Option<String>,
90 catalog_backend: Option<CatalogBackendType>,
91 ) -> Self {
92 Self {
93 path,
94 name: None,
95 fs_protocol: fs_protocol.unwrap_or_else(default_fs_protocol),
96 catalog_backend: catalog_backend.unwrap_or_default(),
97 params: None,
98 fs_rust_storage_options: None,
99 read_only: false,
100 }
101 }
102
103 #[must_use]
105 pub fn with_storage_options(
106 mut self,
107 options: Option<ahash::AHashMap<String, String>>,
108 ) -> Self {
109 self.fs_rust_storage_options = options;
110 self
111 }
112
113 #[must_use]
115 pub fn fs_rust_storage_options(&self) -> Option<&ahash::AHashMap<String, String>> {
116 self.fs_rust_storage_options.as_ref()
117 }
118
119 pub fn create_catalog(&self) -> anyhow::Result<crate::catalog::traits::DataCatalog> {
125 let mut connect = crate::catalog::factory::CatalogConnectConfig::from_path_and_protocol(
126 &self.path,
127 Some(&self.fs_protocol),
128 self.fs_rust_storage_options.clone(),
129 );
130 connect.params.clone_from(&self.params);
131 let factories = crate::backend::default_catalog_factories();
132 let name = self.catalog_backend.to_string();
133 factories
134 .get(&name)
135 .ok_or_else(|| anyhow::anyhow!("No catalog factory registered for '{name}'"))?(
136 &connect
137 )
138 }
139
140 #[must_use]
142 pub fn with_name(mut self, name: Option<String>) -> Self {
143 self.name = name;
144 self
145 }
146
147 #[must_use]
149 pub fn with_params(mut self, params: Option<Params>) -> Self {
150 self.params = params;
151 self
152 }
153
154 #[must_use]
156 pub const fn with_read_only(mut self, read_only: bool) -> Self {
157 self.read_only = read_only;
158 self
159 }
160
161 #[must_use]
163 pub fn path(&self) -> &str {
164 &self.path
165 }
166
167 #[must_use]
169 pub fn name(&self) -> Option<&str> {
170 self.name.as_deref()
171 }
172
173 #[must_use]
175 pub const fn read_only(&self) -> bool {
176 self.read_only
177 }
178
179 #[must_use]
181 pub fn fs_protocol(&self) -> &str {
182 &self.fs_protocol
183 }
184
185 #[must_use]
187 pub const fn catalog_backend(&self) -> &CatalogBackendType {
188 &self.catalog_backend
189 }
190
191 #[must_use]
193 pub const fn params(&self) -> Option<&Params> {
194 self.params.as_ref()
195 }
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize)]
200#[serde(rename_all = "snake_case")]
201pub enum RotationConfig {
202 Size {
204 max_size: u64,
206 },
207 Interval {
209 interval_ns: DurationNanos,
211 },
212 ScheduledDates {
214 interval_ns: DurationNanos,
216 schedule_ns: UnixNanos,
218 },
219 NoRotation,
221}
222
223impl RotationConfig {
224 #[must_use]
226 pub fn to_writer_rotation_config(&self) -> crate::writer::feather::RotationConfig {
227 match self {
228 Self::Size { max_size } => crate::writer::feather::RotationConfig::Size {
229 max_size: *max_size,
230 },
231 Self::Interval { interval_ns } => crate::writer::feather::RotationConfig::Interval {
232 interval_ns: interval_ns.as_u64(),
233 },
234 Self::ScheduledDates {
235 interval_ns,
236 schedule_ns,
237 } => crate::writer::feather::RotationConfig::scheduled_utc(
238 interval_ns.as_u64(),
239 *schedule_ns,
240 ),
241 Self::NoRotation => crate::writer::feather::RotationConfig::NoRotation,
242 }
243 }
244}
245
246#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
248#[serde(deny_unknown_fields)]
249pub struct StreamingRecordFilterConfig {
250 pub record_type: NautilusRecordType,
252 pub identifiers: Option<Vec<String>>,
254}
255
256#[cfg_attr(
258 feature = "python",
259 expect(
260 clippy::unsafe_derive_deserialize,
261 reason = "config deserializes plain fields; unsafe methods come from generated PyO3 integration"
262 )
263)]
264#[cfg_attr(
265 feature = "python",
266 pyo3::pyclass(frozen, module = "nautilus_trader.persistence", from_py_object)
267)]
268#[cfg_attr(
269 feature = "python",
270 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.persistence")
271)]
272#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
273#[builder(finish_fn(name = build_inner, vis = ""))]
274#[serde(deny_unknown_fields)]
275pub struct StreamingConfig {
276 pub catalog_path: String,
278 pub fs_protocol: String,
280 pub flush_interval_ms: u64,
282 pub replace_existing: bool,
284 pub rotation_config: RotationConfig,
286 #[serde(default)]
288 #[builder(default)]
289 pub writer_backend: WriterBackendType,
290 pub data_types: Option<Vec<NautilusDataType>>,
292 pub record_types: Option<Vec<NautilusRecordType>>,
294 pub instrument_types: Option<Vec<NautilusInstrumentType>>,
296 pub record_filters: Option<Vec<StreamingRecordFilterConfig>>,
298 pub params: Option<Params>,
300}
301
302impl<S: streaming_config_builder::IsComplete> StreamingConfigBuilder<S> {
303 pub fn build(self) -> ConfigResult<StreamingConfig> {
310 let config = self.build_inner();
311 config.validate()?;
312 Ok(config)
313 }
314}
315
316impl StreamingConfig {
317 #[must_use]
319 pub fn new(
320 catalog_path: String,
321 fs_protocol: String,
322 flush_interval_ms: u64,
323 replace_existing: bool,
324 rotation_config: RotationConfig,
325 ) -> Self {
326 Self {
327 catalog_path,
328 fs_protocol,
329 flush_interval_ms,
330 replace_existing,
331 rotation_config,
332 writer_backend: WriterBackendType::default(),
333 data_types: None,
334 record_types: None,
335 instrument_types: None,
336 record_filters: None,
337 params: None,
338 }
339 }
340
341 pub fn validate(&self) -> ConfigResult<()> {
348 let mut errors = ConfigErrorCollector::new();
349
350 errors.check(
351 !self.catalog_path.trim().is_empty(),
352 ConfigError::empty_field("catalog_path"),
353 );
354 errors.check(
355 !self.fs_protocol.trim().is_empty(),
356 ConfigError::empty_field("fs_protocol"),
357 );
358
359 let flush_interval_ms = self.flush_interval_ms;
360 errors.check(
361 flush_interval_ms > 0,
362 ConfigError::range(
363 "flush_interval_ms",
364 format!("must be a positive number of milliseconds, was {flush_interval_ms}"),
365 ),
366 );
367
368 if let Some(data_types) = &self.data_types {
369 errors.check(
370 !data_types.is_empty(),
371 ConfigError::invalid_value(
372 "data_types",
373 "must not be an empty list; omit the field for unfiltered streaming",
374 ),
375 );
376 }
377
378 if let Some(record_types) = &self.record_types {
379 errors.check(
380 !record_types.is_empty(),
381 ConfigError::invalid_value(
382 "record_types",
383 "must not be an empty list; omit the field for unfiltered streaming",
384 ),
385 );
386 }
387
388 if let Some(instrument_types) = &self.instrument_types {
389 errors.check(
390 !instrument_types.is_empty(),
391 ConfigError::invalid_value(
392 "instrument_types",
393 "must not be an empty list; omit the field for unfiltered streaming",
394 ),
395 );
396 }
397
398 if let Some(record_filters) = &self.record_filters {
399 errors.check(
400 !record_filters.is_empty(),
401 ConfigError::invalid_value(
402 "record_filters",
403 "must not be an empty list; omit the field for unfiltered streaming",
404 ),
405 );
406 }
407
408 errors.into_result()
409 }
410}
411
412pub(crate) fn default_fs_protocol() -> String {
413 "file".to_string()
414}
415
416#[cfg(test)]
417mod tests {
418 use rstest::rstest;
419 use serde_json::json;
420
421 use super::*;
422
423 #[rstest]
424 fn catalog_backend_type_preserves_external_factory_case() {
425 assert_eq!(
426 "CaseSensitiveExternal"
427 .parse::<CatalogBackendType>()
428 .unwrap(),
429 CatalogBackendType::External("CaseSensitiveExternal".to_string()),
430 );
431 }
432
433 #[rstest]
434 fn catalog_backend_type_accepts_parquet_without_changing_default() {
435 assert_eq!(
436 "parquet".parse::<CatalogBackendType>().unwrap(),
437 CatalogBackendType::Parquet
438 );
439 assert_eq!(CatalogBackendType::Parquet.to_string(), "Parquet");
440 assert_eq!(CatalogBackendType::default(), CatalogBackendType::Parquet);
441 }
442
443 #[rstest]
444 fn catalog_backend_type_rejects_empty_name() {
445 assert!("".parse::<CatalogBackendType>().is_err());
446 }
447
448 #[rstest]
449 fn catalog_backend_type_display_roundtrips_built_ins() {
450 assert_eq!(CatalogBackendType::Parquet.to_string(), "Parquet");
451 assert_eq!(CatalogBackendType::Parquet.to_string(), "Parquet");
452 assert_eq!(
453 "Parquet".parse::<CatalogBackendType>().unwrap(),
454 CatalogBackendType::Parquet,
455 );
456 assert_eq!(
457 "Parquet".parse::<CatalogBackendType>().unwrap(),
458 CatalogBackendType::Parquet,
459 );
460 }
461
462 #[rstest]
463 fn data_catalog_config_preserves_backend_params() {
464 let mut params = Params::new();
465 params.insert("batch_size".to_string(), json!(1024));
466 let config = DataCatalogConfig::new(
467 "/data/catalog".to_string(),
468 Some("file".to_string()),
469 Some(CatalogBackendType::Parquet),
470 )
471 .with_params(Some(params));
472
473 assert_eq!(
474 config.params().and_then(|p| p.get_u64("batch_size")),
475 Some(1024)
476 );
477 }
478
479 #[rstest]
480 fn data_catalog_config_toml_defaults_backend_fields() {
481 let config: DataCatalogConfig = toml::from_str(
482 r#"
483path = "/data/catalog"
484"#,
485 )
486 .unwrap();
487
488 assert_eq!(config.path(), "/data/catalog");
489 assert_eq!(config.fs_protocol(), "file");
490 assert_eq!(config.catalog_backend(), &CatalogBackendType::Parquet);
491 assert_eq!(config.params(), None);
492 assert!(!config.read_only());
493 }
494
495 #[rstest]
496 fn data_catalog_config_reads_read_only_flag() {
497 let config: DataCatalogConfig = toml::from_str(
498 r#"
499path = "/data/catalog"
500read_only = true
501"#,
502 )
503 .unwrap();
504
505 assert!(config.read_only());
506 }
507
508 #[rstest]
509 fn streaming_config_builder_valid() {
510 let config = StreamingConfig::builder()
511 .catalog_path("/data/catalog".to_string())
512 .fs_protocol("file".to_string())
513 .flush_interval_ms(1_000)
514 .replace_existing(false)
515 .rotation_config(RotationConfig::NoRotation)
516 .build();
517
518 assert!(config.is_ok());
519 }
520
521 #[rstest]
522 fn streaming_config_builder_preserves_backend_params() {
523 let mut params = Params::new();
524 params.insert("promote_on_close".to_string(), json!(true));
525 let config = StreamingConfig::builder()
526 .catalog_path("/data/catalog".to_string())
527 .fs_protocol("file".to_string())
528 .flush_interval_ms(1_000)
529 .replace_existing(false)
530 .rotation_config(RotationConfig::NoRotation)
531 .params(params)
532 .build()
533 .unwrap();
534
535 assert_eq!(
536 config
537 .params
538 .as_ref()
539 .and_then(|p| p.get_bool("promote_on_close")),
540 Some(true)
541 );
542 }
543
544 #[rstest]
545 fn streaming_config_zero_flush_interval_rejected() {
546 let result = StreamingConfig::builder()
547 .catalog_path("/data/catalog".to_string())
548 .fs_protocol("file".to_string())
549 .flush_interval_ms(0)
550 .replace_existing(false)
551 .rotation_config(RotationConfig::NoRotation)
552 .build();
553
554 assert!(
555 matches!(result, Err(ConfigError::Range { field, .. }) if field == "flush_interval_ms")
556 );
557 }
558
559 #[rstest]
560 fn streaming_config_empty_catalog_path_rejected() {
561 let result = StreamingConfig::builder()
562 .catalog_path(String::new())
563 .fs_protocol("file".to_string())
564 .flush_interval_ms(1_000)
565 .replace_existing(false)
566 .rotation_config(RotationConfig::NoRotation)
567 .build();
568
569 assert!(
570 matches!(result, Err(ConfigError::EmptyField { field }) if field == "catalog_path")
571 );
572 }
573
574 #[rstest]
575 fn streaming_config_empty_filter_lists_rejected() {
576 let result = StreamingConfig::builder()
577 .catalog_path("/data/catalog".to_string())
578 .fs_protocol("file".to_string())
579 .flush_interval_ms(1_000)
580 .replace_existing(false)
581 .rotation_config(RotationConfig::NoRotation)
582 .data_types(vec![])
583 .record_types(vec![])
584 .instrument_types(vec![])
585 .record_filters(vec![])
586 .build();
587
588 match result.unwrap_err() {
589 ConfigError::Multiple { errors } => {
590 assert_eq!(errors.len(), 4);
591 assert!(errors.iter().all(|e| matches!(
592 e,
593 ConfigError::InvalidValue { field, .. }
594 if field == "data_types"
595 || field == "record_types"
596 || field == "instrument_types"
597 || field == "record_filters"
598 )));
599 }
600 error => panic!("Expected multiple config errors, received {error:?}"),
601 }
602 }
603
604 #[rstest]
605 fn streaming_config_toml_round_trip() {
606 let config: StreamingConfig = toml::from_str(
607 r#"
608catalog_path = "/data/catalog"
609fs_protocol = "file"
610flush_interval_ms = 1000
611replace_existing = false
612
613[rotation_config.size]
614max_size = 1048576
615"#,
616 )
617 .unwrap();
618
619 assert_eq!(config.catalog_path, "/data/catalog");
620 assert_eq!(config.fs_protocol, "file");
621 assert_eq!(config.flush_interval_ms, 1000);
622 assert!(!config.replace_existing);
623 assert_eq!(config.params, None);
624 assert!(matches!(
625 config.rotation_config,
626 RotationConfig::Size {
627 max_size: 1_048_576
628 }
629 ));
630 }
631
632 #[rstest]
633 fn streaming_config_with_no_rotation_toml() {
634 let config: StreamingConfig = toml::from_str(
635 r#"
636catalog_path = "/data/catalog"
637fs_protocol = "file"
638flush_interval_ms = 500
639replace_existing = true
640rotation_config = "no_rotation"
641"#,
642 )
643 .unwrap();
644
645 assert!(matches!(config.rotation_config, RotationConfig::NoRotation));
646 assert!(config.replace_existing);
647 }
648}