Skip to main content

feldera_types/
config.rs

1//! Controller configuration.
2//!
3//! This module defines the controller configuration structure.  The leaves of
4//! this structure are individual transport-specific and data-format-specific
5//! endpoint configs.  We represent these configs as opaque JSON values, so
6//! that the entire configuration tree can be deserialized from a JSON file.
7
8use crate::postprocess::PostprocessorConfig;
9use crate::preprocess::PreprocessorConfig;
10use crate::secret_resolver::default_secrets_directory;
11use crate::transport::adhoc::AdHocInputConfig;
12use crate::transport::clock::{ClockConfig, ClockTimezoneOffset};
13use crate::transport::datagen::DatagenInputConfig;
14use crate::transport::delta_table::{DeltaTableReaderConfig, DeltaTableWriterConfig};
15use crate::transport::dynamodb::DynamoDBWriterConfig;
16use crate::transport::file::{FileInputConfig, FileOutputConfig};
17use crate::transport::http::{HttpInputConfig, HttpOutputConfig};
18use crate::transport::iceberg::IcebergReaderConfig;
19use crate::transport::kafka::{KafkaInputConfig, KafkaOutputConfig};
20use crate::transport::nats::NatsInputConfig;
21use crate::transport::nexmark::NexmarkInputConfig;
22use crate::transport::postgres::{
23    PostgresCdcReaderConfig, PostgresReaderConfig, PostgresWriterConfig,
24};
25use crate::transport::pubsub::PubSubInputConfig;
26use crate::transport::redis::RedisOutputConfig;
27use crate::transport::s3::S3InputConfig;
28use crate::transport::url::UrlInputConfig;
29use core::fmt;
30use feldera_ir::{MirNode, MirNodeId};
31use serde::de::{self, MapAccess, Visitor};
32use serde::{Deserialize, Deserializer, Serialize};
33use serde_json::Value as JsonValue;
34use std::collections::HashMap;
35use std::fmt::Display;
36use std::path::Path;
37use std::str::FromStr;
38use std::time::Duration;
39use std::{borrow::Cow, cmp::max, collections::BTreeMap};
40use utoipa::ToSchema;
41use utoipa::openapi::{ObjectBuilder, OneOfBuilder, Ref, RefOr, Schema, SchemaType};
42
43pub mod dev_tweaks;
44pub use dev_tweaks::DevTweaks;
45
46const DEFAULT_MAX_PARALLEL_CONNECTOR_INIT: u64 = 10;
47
48/// Default maximum number of updates to be kept in the output buffer.
49const DEFAULT_MAX_OUTPUT_BUFFER_SIZE_RECORDS: usize = 10_000_000;
50
51/// Default value of `ConnectorConfig::max_queued_records`.
52pub const fn default_max_queued_records() -> u64 {
53    1_000_000
54}
55
56pub const DEFAULT_MAX_WORKER_BATCH_SIZE: u64 = 10_000;
57
58pub const DEFAULT_CLOCK_RESOLUTION_USECS: u64 = 1_000_000;
59
60/// Program information included in the pipeline configuration.
61#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq, Eq)]
62pub struct ProgramIr {
63    /// The MIR of the program.
64    pub mir: HashMap<MirNodeId, MirNode>,
65    /// Program schema.
66    pub program_schema: serde_json::Value,
67    /// The circuit IR (the compiler's JIT `allOperators` form) that the Gen-2
68    /// engine lowers onto the runtime to build the circuit. `None` for
69    /// Rust-compiled pipelines, whose circuit is statically linked into the binary.
70    #[serde(default)]
71    pub circuit_ir: Option<serde_json::Value>,
72}
73
74/// Identity of a pipeline, used to record and check ownership of an S3
75/// checkpoint bucket.
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77pub struct PipelineIdentity {
78    /// System-generated name of the pipeline (format `pipeline-<uuid>`).
79    pub name: String,
80    /// Name given to the pipeline by the tenant.
81    pub given_name: Option<String>,
82}
83
84impl Display for PipelineIdentity {
85    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86        match &self.given_name {
87            Some(given_name) => write!(f, "{given_name} (id: {})", self.name),
88            None => write!(f, "{}", self.name),
89        }
90    }
91}
92
93/// Pipeline deployment configuration.
94/// It represents configuration entries directly provided by the user
95/// (e.g., runtime configuration) and entries derived from the schema
96/// of the compiled program (e.g., connectors). Storage configuration,
97/// if applicable, is set by the runner.
98#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq)]
99pub struct PipelineConfig {
100    /// Global controller configuration.
101    #[serde(flatten)]
102    #[schema(inline)]
103    pub global: RuntimeConfig,
104
105    /// Configuration for multihost pipelines.
106    ///
107    /// The presence of this field indicates that the pipeline is running in
108    /// multihost mode.  In the pod with ordinal 0, this triggers starting the
109    /// coordinator process.  In all pods, this tells the pipeline process to
110    /// await a connection from the coordinator instead of initializing the
111    /// pipeline immediately.
112    pub multihost: Option<MultihostConfig>,
113
114    /// Unique system-generated name of the pipeline (format: `pipeline-<uuid>`).
115    /// It is unique across all tenants and cannot be changed.
116    ///
117    /// The `<uuid>` is also used in the naming of various resources that back the pipeline,
118    /// and as such this name is useful to find/identify corresponding resources.
119    pub name: Option<String>,
120
121    /// Name given by the tenant to the pipeline. It is only unique within the same tenant, and can
122    /// be changed by the tenant when the pipeline is stopped.
123    ///
124    /// Given a specific tenant, it can be used to find/identify a specific pipeline of theirs.
125    pub given_name: Option<String>,
126
127    /// Configuration for persistent storage
128    ///
129    /// If `global.storage` is `Some(_)`, this field must be set to some
130    /// [`StorageConfig`].  If `global.storage` is `None``, the pipeline ignores
131    /// this field.
132    #[serde(default)]
133    pub storage_config: Option<StorageConfig>,
134
135    /// Directory containing values of secrets.
136    ///
137    /// If this is not set, a default directory is used.
138    pub secrets_dir: Option<String>,
139
140    /// Input endpoint configuration.
141    #[serde(default)]
142    pub inputs: BTreeMap<Cow<'static, str>, InputEndpointConfig>,
143
144    /// Output endpoint configuration.
145    #[serde(default)]
146    pub outputs: BTreeMap<Cow<'static, str>, OutputEndpointConfig>,
147
148    /// Program information.
149    #[serde(default)]
150    pub program_ir: Option<ProgramIr>,
151}
152
153impl PipelineConfig {
154    pub fn max_parallel_connector_init(&self) -> u64 {
155        max(
156            self.global
157                .max_parallel_connector_init
158                .unwrap_or(DEFAULT_MAX_PARALLEL_CONNECTOR_INIT),
159            1,
160        )
161    }
162
163    /// Extract the compiler-generated subset of the configuration (connectors
164    /// and program IR) used to compute pipeline diffs.
165    pub fn program_info_subset(&self) -> PipelineConfigProgramInfo {
166        PipelineConfigProgramInfo {
167            inputs: self.inputs.clone(),
168            outputs: self.outputs.clone(),
169            program_ir: self.program_ir.clone(),
170        }
171    }
172
173    pub fn with_storage(self, storage: Option<(StorageConfig, StorageOptions)>) -> Self {
174        let (storage_config, storage_options) = storage.unzip();
175        Self {
176            global: RuntimeConfig {
177                storage: storage_options,
178                ..self.global
179            },
180            storage_config,
181            ..self
182        }
183    }
184
185    pub fn storage(&self) -> Option<(&StorageConfig, &StorageOptions)> {
186        let storage_options = self.global.storage.as_ref();
187        let storage_config = self.storage_config.as_ref();
188        storage_config.zip(storage_options)
189    }
190
191    /// Returns this pipeline's [`PipelineIdentity`], used to identify the
192    /// pipeline that owns an S3 checkpoint bucket.
193    ///
194    /// Returns `None` when the pipeline has no system-generated [`name`](Self::name).
195    pub fn pipeline_identity(&self) -> Option<PipelineIdentity> {
196        self.name.as_ref().map(|name| PipelineIdentity {
197            name: name.clone(),
198            given_name: self.given_name.clone(),
199        })
200    }
201
202    /// Returns `self.secrets_dir`, or the default secrets directory if it isn't
203    /// set.
204    pub fn secrets_dir(&self) -> &Path {
205        match &self.secrets_dir {
206            Some(dir) => Path::new(dir.as_str()),
207            None => default_secrets_directory(),
208        }
209    }
210
211    /// Abbreviated config that can be printed in the log on pipeline startup.
212    pub fn display_summary(&self) -> String {
213        // TODO: we may want to further abbreviate connector config.
214        let summary = serde_json::json!({
215            "name": self.name,
216            "given_name": self.given_name,
217            "global": self.global,
218            "storage_config": self.storage_config,
219            "secrets_dir": self.secrets_dir,
220            "inputs": self.inputs,
221            "outputs": self.outputs
222        });
223
224        serde_json::to_string_pretty(&summary).unwrap_or_else(|_| "{}".to_string())
225    }
226}
227
228/// A subset of fields in `PipelineConfig` that are generated by the compiler.
229/// These fields are shipped to the pipeline by the compilation server along with
230/// the program binary.
231// Note: An alternative would be to embed these fields in the program binary itself
232// as static strings. This would work well for program IR, but it would require recompiling
233// the program anytime a connector config changes, whereas today connector changes
234// do not require recompilation.
235#[derive(Default, Deserialize, Serialize, Eq, PartialEq, Debug, Clone)]
236pub struct PipelineConfigProgramInfo {
237    /// Input endpoint configuration.
238    pub inputs: BTreeMap<Cow<'static, str>, InputEndpointConfig>,
239
240    /// Output endpoint configuration.
241    #[serde(default)]
242    pub outputs: BTreeMap<Cow<'static, str>, OutputEndpointConfig>,
243
244    /// Program information.
245    #[serde(default)]
246    pub program_ir: Option<ProgramIr>,
247}
248
249/// Configuration for a multihost Feldera pipeline.
250///
251/// This configuration is primarily for the coordinator.
252#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ToSchema)]
253pub struct MultihostConfig {
254    /// Number of hosts to launch.
255    ///
256    /// For the configuration to be truly multihost, this should be at least 2.
257    /// A value of 1 still runs the multihost coordinator but it only
258    /// coordinates a single host.
259    pub hosts: usize,
260}
261
262impl Default for MultihostConfig {
263    fn default() -> Self {
264        Self { hosts: 1 }
265    }
266}
267
268/// Configuration for persistent storage in a [`PipelineConfig`].
269#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ToSchema)]
270pub struct StorageConfig {
271    /// A directory to keep pipeline state, as a path on the filesystem of the
272    /// machine or container where the pipeline will run.
273    ///
274    /// When storage is enabled, this directory stores the data for
275    /// [StorageBackendConfig::Default].
276    ///
277    /// When fault tolerance is enabled, this directory stores checkpoints and
278    /// the log.
279    pub path: String,
280
281    /// How to cache access to storage in this pipeline.
282    #[serde(default)]
283    pub cache: StorageCacheConfig,
284}
285
286impl StorageConfig {
287    pub fn path(&self) -> &Path {
288        Path::new(&self.path)
289    }
290}
291
292/// How to cache access to storage within a Feldera pipeline.
293#[derive(Copy, Clone, Default, Deserialize, Serialize, Debug, PartialEq, Eq, ToSchema)]
294#[serde(rename_all = "snake_case")]
295pub enum StorageCacheConfig {
296    /// Use the operating system's page cache as the primary storage cache.
297    ///
298    /// This is the default because it currently performs better than
299    /// `FelderaCache`.
300    #[default]
301    PageCache,
302
303    /// Use Feldera's internal cache implementation.
304    ///
305    /// This is under development. It will become the default when its
306    /// performance exceeds that of `PageCache`.
307    FelderaCache,
308}
309
310impl StorageCacheConfig {
311    #[cfg(unix)]
312    pub fn to_custom_open_flags(&self) -> i32 {
313        match self {
314            StorageCacheConfig::PageCache => (),
315            StorageCacheConfig::FelderaCache => {
316                #[cfg(target_os = "linux")]
317                return libc::O_DIRECT;
318            }
319        }
320        0
321    }
322}
323
324/// Storage configuration for a pipeline.
325#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ToSchema)]
326#[serde(default)]
327pub struct StorageOptions {
328    /// How to connect to the underlying storage.
329    pub backend: StorageBackendConfig,
330
331    /// For a batch of data maintained as part of a persistent index during a
332    /// pipeline run, the minimum estimated number of bytes to write it to
333    /// storage.
334    ///
335    /// This is provided for debugging and fine-tuning and should ordinarily be
336    /// left unset.
337    ///
338    /// A value of 0 will write even empty batches to storage, and nonzero
339    /// values provide a threshold.  `usize::MAX` would effectively disable
340    /// storage for such batches.  The default is 10,048,576 (10 MiB).
341    pub min_storage_bytes: Option<usize>,
342
343    /// For a batch of data passed through the pipeline during a single step,
344    /// the minimum estimated number of bytes to write it to storage.
345    ///
346    /// This is provided for debugging and fine-tuning and should ordinarily be
347    /// left unset.  A value of 0 writes every batch that holds anything to
348    /// storage; an empty batch stays in memory, since it has nothing to spill
349    /// and a layer file is not free.  Nonzero values provide a threshold.
350    /// `usize::MAX`, the default, effectively disables storage for such
351    /// batches.  If it is set to another value, it should ordinarily be
352    /// greater than or equal to `min_storage_bytes`.
353    pub min_step_storage_bytes: Option<usize>,
354
355    /// The form of compression to use in data batches.
356    ///
357    /// Compression has a CPU cost but it can take better advantage of limited
358    /// NVMe and network bandwidth, which means that it can increase overall
359    /// performance.
360    pub compression: StorageCompression,
361
362    /// Compression level, for compression algorithms that have one.
363    ///
364    /// Only `StorageCompression::Zstd` uses this. Higher levels compress
365    /// harder and more slowly; decompression speed is largely unaffected, so
366    /// the tradeoff is write throughput against stored size. Zstd accepts
367    /// roughly 1 to 22, and a value outside the range its build supports is
368    /// clamped into it.
369    ///
370    /// This is provided for fine-tuning and should ordinarily be left unset,
371    /// which selects the algorithm's own default.
372    pub compression_level: Option<i32>,
373
374    /// The maximum size of the in-memory storage cache, in MiB.
375    ///
376    /// If set, the specified cache size is spread across all the foreground and
377    /// background threads. If unset, each foreground or background thread cache
378    /// is limited to 256 MiB.
379    pub cache_mib: Option<usize>,
380
381    /// False-positive rate for Bloom filters on batches on storage, as a
382    /// fraction f, where 0 < f < 1.
383    ///
384    /// The false-positive rate trades off between the amount of memory used by
385    /// Bloom filters and how frequently storage needs to be searched for keys
386    /// that are not actually present.  Typical false-positive rates and their
387    /// corresponding memory costs are:
388    ///
389    /// - 0.1: 4.8 bits per key
390    /// - 0.01: 9.6 bits per key
391    /// - 0.001: 14.4 bits per key
392    /// - 0.0001: 19.2 bits per key (default)
393    ///
394    /// Values outside the valid range, such as 0.0, disable Bloom filters.
395    #[serde(skip_serializing_if = "Option::is_none")]
396    #[serde(default, deserialize_with = "crate::serde_via_value::deserialize")]
397    pub bloom_false_positive_rate: Option<f64>,
398}
399
400// `f64` does not implement `Eq`, but `bloom_false_positive_rate` is always
401// finite, so `PartialEq` is an equivalence relation here.
402impl Eq for StorageOptions {}
403
404/// Backend storage configuration.
405#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, ToSchema)]
406#[serde(tag = "name", content = "config", rename_all = "snake_case")]
407pub enum StorageBackendConfig {
408    /// Use the default storage configuration.
409    ///
410    /// This currently uses the local file system.
411    #[default]
412    Default,
413
414    /// Use the local file system.
415    ///
416    /// This uses ordinary system file operations.
417    File(Box<FileBackendConfig>),
418
419    /// Object storage.
420    Object(ObjectStorageConfig),
421}
422
423impl Display for StorageBackendConfig {
424    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
425        match self {
426            StorageBackendConfig::Default => write!(f, "default"),
427            StorageBackendConfig::File(_) => write!(f, "file"),
428            StorageBackendConfig::Object(_) => write!(f, "object"),
429        }
430    }
431}
432
433/// Storage compression algorithm.
434#[derive(Debug, Copy, Clone, Default, Eq, PartialEq, Serialize, Deserialize, ToSchema)]
435#[serde(rename_all = "snake_case")]
436pub enum StorageCompression {
437    /// Use Feldera's default compression algorithm.
438    ///
439    /// The default may change as Feldera's performance is tuned and new
440    /// algorithms are introduced.
441    #[default]
442    Default,
443
444    /// Do not compress.
445    None,
446
447    /// Use [Snappy](https://en.wikipedia.org/wiki/Snappy_(compression)) compression.
448    Snappy,
449
450    /// Use [LZ4](https://en.wikipedia.org/wiki/LZ4_(compression_algorithm)) compression.
451    ///
452    /// Compresses better than Snappy and decompresses several times faster, at
453    /// a similar compression rate.
454    Lz4,
455
456    /// Use [Zstandard](https://en.wikipedia.org/wiki/Zstd) compression.
457    ///
458    /// Roughly halves LZ4's output at a comparable decompression rate, for
459    /// slower compression. `StorageOptions::compression_level` selects the
460    /// speed/size tradeoff.
461    Zstd,
462}
463
464#[derive(Debug, Clone, Eq, PartialEq)]
465pub enum StartFromCheckpoint {
466    Latest,
467    Uuid(uuid::Uuid),
468}
469
470impl ToSchema<'_> for StartFromCheckpoint {
471    fn schema() -> (
472        &'static str,
473        utoipa::openapi::RefOr<utoipa::openapi::schema::Schema>,
474    ) {
475        (
476            "StartFromCheckpoint",
477            utoipa::openapi::RefOr::T(Schema::OneOf(
478                OneOfBuilder::new()
479                    .item(
480                        ObjectBuilder::new()
481                            .schema_type(SchemaType::String)
482                            .enum_values(Some(["latest"].into_iter()))
483                            .build(),
484                    )
485                    .item(
486                        ObjectBuilder::new()
487                            .schema_type(SchemaType::String)
488                            .format(Some(utoipa::openapi::SchemaFormat::KnownFormat(
489                                utoipa::openapi::KnownFormat::Uuid,
490                            )))
491                            .build(),
492                    )
493                    .nullable(true)
494                    .build(),
495            )),
496        )
497    }
498}
499
500impl<'de> Deserialize<'de> for StartFromCheckpoint {
501    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
502    where
503        D: Deserializer<'de>,
504    {
505        struct StartFromCheckpointVisitor;
506
507        impl<'de> Visitor<'de> for StartFromCheckpointVisitor {
508            type Value = StartFromCheckpoint;
509
510            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
511                formatter.write_str("a UUID string or the string \"latest\"")
512            }
513
514            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
515            where
516                E: de::Error,
517            {
518                if value == "latest" {
519                    Ok(StartFromCheckpoint::Latest)
520                } else {
521                    uuid::Uuid::parse_str(value)
522                        .map(StartFromCheckpoint::Uuid)
523                        .map_err(|_| E::invalid_value(serde::de::Unexpected::Str(value), &self))
524                }
525            }
526        }
527
528        deserializer.deserialize_str(StartFromCheckpointVisitor)
529    }
530}
531
532impl Serialize for StartFromCheckpoint {
533    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
534    where
535        S: serde::Serializer,
536    {
537        match self {
538            StartFromCheckpoint::Latest => serializer.serialize_str("latest"),
539            StartFromCheckpoint::Uuid(uuid) => serializer.serialize_str(&uuid.to_string()),
540        }
541    }
542}
543
544#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, ToSchema)]
545pub struct SyncConfig {
546    /// The endpoint URL for the storage service.
547    ///
548    /// This is typically required for custom or local S3-compatible storage providers like MinIO.
549    /// Example: `http://localhost:9000`
550    ///
551    /// For a `gs://` bucket this is the Google Cloud Storage JSON API base URL;
552    /// a bare host gets `/storage/v1/` appended. Leave empty for the public service.
553    ///
554    /// Relevant rclone config key: [`endpoint`](https://rclone.org/s3/#s3-endpoint)
555    pub endpoint: Option<String>,
556
557    /// The name of the storage bucket.
558    ///
559    /// This may include a path to a folder inside the bucket (e.g., `my-bucket/data`).
560    ///
561    /// Prefix it with `gs://` to sync to Google Cloud Storage through its native
562    /// API instead of S3. `provider`, `access_key`, and `secret_key` are then
563    /// ignored; rclone authenticates with Application Default Credentials, for
564    /// example a GKE Workload Identity.
565    pub bucket: String,
566
567    /// The region that this bucket is in.
568    ///
569    /// Leave empty for Minio or the default region (`us-east-1` for AWS).
570    pub region: Option<String>,
571
572    /// The name of the cloud storage provider (e.g., `"AWS"`, `"Minio"`).
573    ///
574    /// Used for provider-specific behavior in rclone.
575    /// If omitted, defaults to `"Other"`.
576    ///
577    /// See [rclone S3 provider documentation](https://rclone.org/s3/#s3-provider)
578    pub provider: Option<String>,
579
580    /// The access key used to authenticate with the storage provider.
581    ///
582    /// If not provided, rclone will fall back to environment-based credentials, such as
583    /// `RCLONE_S3_ACCESS_KEY_ID`. In Kubernetes environments using IRSA (IAM Roles for Service Accounts),
584    /// this can be left empty to allow automatic authentication via the pod's service account.
585    pub access_key: Option<String>,
586
587    /// The secret key used together with the access key for authentication.
588    ///
589    /// If not provided, rclone will fall back to environment-based credentials, such as
590    /// `RCLONE_S3_SECRET_ACCESS_KEY`. In Kubernetes environments using IRSA (IAM Roles for Service Accounts),
591    /// this can be left empty to allow automatic authentication via the pod's service account.
592    pub secret_key: Option<String>,
593
594    /// When set, the pipeline will try fetch the specified checkpoint from the
595    /// object store.
596    ///
597    /// If `fail_if_no_checkpoint` is `true`, the pipeline will fail to initialize.
598    pub start_from_checkpoint: Option<StartFromCheckpoint>,
599
600    /// When true, the pipeline will fail to initialize if fetching the
601    /// specified checkpoint fails (missing, download error).
602    /// When false, the pipeline will start from scratch instead.
603    ///
604    /// False by default.
605    #[schema(default = std::primitive::bool::default)]
606    #[serde(default)]
607    pub fail_if_no_checkpoint: bool,
608
609    /// The number of file transfers to run in parallel.
610    /// Default: 20
611    pub transfers: Option<u8>,
612
613    /// The number of checkers to run in parallel.
614    /// Default: 20
615    pub checkers: Option<u8>,
616
617    /// Set to skip post copy check of checksums, and only check the file sizes.
618    /// This can significantly improve the throughput.
619    /// Defualt: false
620    pub ignore_checksum: Option<bool>,
621
622    /// Number of streams to use for multi-thread downloads.
623    /// Default: 10
624    pub multi_thread_streams: Option<u8>,
625
626    /// Use multi-thread download for files above this size.
627    /// Format: `[size][Suffix]` (Example: 1G, 500M)
628    /// Supported suffixes: k|M|G|T
629    /// Default: 100M
630    pub multi_thread_cutoff: Option<String>,
631
632    /// When true, checkpoint downloads use the maximum resources available on
633    /// the host: `transfers` and `checkers` are scaled to the number of CPUs,
634    /// and the download buffer is allowed to grow up to most of the available
635    /// memory. This maximizes download throughput at the cost of higher CPU and
636    /// memory usage during a pull.
637    ///
638    /// When false, downloads use the values configured via `transfers`,
639    /// `checkers`, and the rclone defaults instead.
640    ///
641    /// Default: true
642    #[schema(default = default_optimize_download_resources)]
643    #[serde(default = "default_optimize_download_resources")]
644    pub optimize_download_resources: bool,
645
646    /// The number of chunks of the same file that are uploaded for multipart uploads.
647    /// Default: 10
648    pub upload_concurrency: Option<u8>,
649
650    /// **Deprecated.** Use `initial=standby` when starting the pipeline instead.
651    #[deprecated(note = "Use `initial=standby` when starting the pipeline instead.")]
652    #[schema(default = std::primitive::bool::default)]
653    #[serde(default)]
654    pub standby: bool,
655
656    /// The interval (in seconds) between each attempt to fetch the latest
657    /// checkpoint from object store while in standby mode.
658    ///
659    /// Applies only when `start_from_checkpoint` is set to `latest`.
660    ///
661    /// Default: 10 seconds
662    #[schema(default = default_pull_interval)]
663    #[serde(default = "default_pull_interval")]
664    pub pull_interval: u64,
665
666    /// The interval (in seconds) between each push of checkpoints to object store.
667    ///
668    /// Default: disabled (no periodic push).
669    #[serde(default)]
670    pub push_interval: Option<u64>,
671
672    /// Extra flags to pass to `rclone`.
673    ///
674    /// WARNING: Supplying incorrect or conflicting flags can break `rclone`.
675    /// Use with caution.
676    ///
677    /// Refer to the docs to see the supported flags:
678    /// - [Global flags](https://rclone.org/flags/)
679    /// - [S3 specific flags](https://rclone.org/s3/)
680    pub flags: Option<Vec<String>>,
681
682    /// The minimum number of checkpoints to retain in object store.
683    /// No checkpoints will be deleted if the total count is below this threshold.
684    ///
685    /// Default: 10
686    #[schema(default = default_retention_min_count)]
687    #[serde(default = "default_retention_min_count")]
688    pub retention_min_count: u32,
689
690    /// The minimum age (in days) a checkpoint must reach before it becomes
691    /// eligible for deletion. All younger checkpoints will be preserved.
692    ///
693    /// Default: 30
694    #[schema(default = default_retention_min_age)]
695    #[serde(default = "default_retention_min_age")]
696    pub retention_min_age: u32,
697
698    /// A read-only bucket used as a fallback checkpoint source.
699    ///
700    /// When the pipeline has no local checkpoint and `bucket` contains no
701    /// checkpoint either, it will attempt to fetch the checkpoint from this
702    /// location instead.  All connection settings (`endpoint`, `region`,
703    /// `provider`, `access_key`, `secret_key`) are shared with `bucket`. The
704    /// scheme is not: a `gs://` `bucket` needs a `gs://` `read_bucket` too,
705    /// because the prefix alone selects the object store.
706    ///
707    /// The pipeline **never writes** to `read_bucket`.
708    ///
709    /// Must point to a different location than `bucket`.
710    #[serde(default)]
711    pub read_bucket: Option<String>,
712}
713
714fn default_pull_interval() -> u64 {
715    10
716}
717
718fn default_retention_min_count() -> u32 {
719    10
720}
721
722fn default_retention_min_age() -> u32 {
723    30
724}
725
726fn default_optimize_download_resources() -> bool {
727    true
728}
729
730impl SyncConfig {
731    pub fn validate(&self) -> Result<(), String> {
732        #[allow(deprecated)]
733        if self.standby {
734            return Err(
735                "The `standby` config field has been deprecated. Use `initial=standby` when starting the pipeline instead.".to_owned()
736            );
737        }
738
739        if let Some(ref rb) = self.read_bucket {
740            if rb == &self.bucket {
741                return Err(
742                    "invalid sync config: `read_bucket` and `bucket` must point to different locations"
743                        .to_owned(),
744                );
745            }
746            // The `gs://` prefix selects the object store, so a mismatch would
747            // send the fallback pull to the wrong service.
748            if is_gcs_bucket(rb) != is_gcs_bucket(&self.bucket) {
749                return Err(format!(
750                    "invalid sync config: `bucket` ('{}') and `read_bucket` ('{}') must use the same object store; prefix both with `gs://` for Google Cloud Storage or neither for S3",
751                    self.bucket, rb
752                ));
753            }
754        }
755
756        Ok(())
757    }
758}
759
760/// Whether a sync `bucket` names Google Cloud Storage rather than S3.
761fn is_gcs_bucket(bucket: &str) -> bool {
762    bucket.starts_with("gs://")
763}
764
765/// Configuration for supplying a custom pipeline StatefulSet template via a Kubernetes ConfigMap.
766///
767/// Operators can provide a custom StatefulSet YAML that the Kubernetes runner will use when
768/// creating pipeline StatefulSets for a pipeline. The custom template must be stored as the
769/// value of a key in a ConfigMap in the same namespace as the pipeline; set `name` to the
770/// ConfigMap name and `key` to the entry that contains the template.
771///
772/// Recommendations and requirements:
773/// - **Start from the default template and modify it as needed.** The default template is present
774///   in ConfigMap named as `<release-name>-pipeline-template`, with key `pipelineTemplate` in the release
775///   namespace and should be used as a reference.
776/// - The template must contain a valid Kubernetes `StatefulSet` manifest in YAML form. The
777///   runner substitutes variables in the template before parsing; therefore the final YAML
778///   must be syntactically valid.
779/// - The runner performs simple string substitution for the following placeholders. Please ensure these
780///   placeholders are placed at appropriate location for their semantics:
781///   - `{id}`: pipeline Kubernetes name (used for object names and labels)
782///   - `{namespace}`: Kubernetes namespace where the pipeline runs
783///   - `{pipeline_executor_image}`: container image used to run the pipeline executor
784///   - `{binary_ref}`: program binary reference passed as an argument
785///   - `{program_info_ref}`: program info reference passed as an argument
786///   - `{pipeline_storage_path}`: mount path for persistent pipeline storage
787///   - `{storage_class_name}`: storage class name to use for PVCs (if applicable)
788///   - `{deployment_id}`: UUID identifying the deployment instance
789///   - `{deployment_initial}`: initial desired runtime status (e.g., `provisioning`)
790///   - `{bootstrap_policy}`: bootstrap policy value when applicable
791#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
792pub struct PipelineTemplateConfig {
793    /// Name of the ConfigMap containing the pipeline template.
794    pub name: String,
795    /// Key in the ConfigMap containing the pipeline template.
796    ///
797    /// If not set, defaults to `pipelineTemplate`.
798    #[schema(default = default_pipeline_template_key)]
799    #[serde(default = "default_pipeline_template_key")]
800    pub key: String,
801}
802
803fn default_pipeline_template_key() -> String {
804    "pipelineTemplate".to_string()
805}
806
807#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, ToSchema)]
808pub struct ObjectStorageConfig {
809    /// URL.
810    ///
811    /// The following URL schemes are supported:
812    ///
813    /// * S3:
814    ///   - `s3://<bucket>/<path>`
815    ///   - `s3a://<bucket>/<path>`
816    ///   - `https://s3.<region>.amazonaws.com/<bucket>`
817    ///   - `https://<bucket>.s3.<region>.amazonaws.com`
818    ///   - `https://ACCOUNT_ID.r2.cloudflarestorage.com/bucket`
819    /// * Google Cloud Storage:
820    ///   - `gs://<bucket>/<path>`
821    /// * Microsoft Azure Blob Storage:
822    ///   - `abfs[s]://<container>/<path>` (according to [fsspec](https://github.com/fsspec/adlfs))
823    ///   - `abfs[s]://<file_system>@<account_name>.dfs.core.windows.net/<path>`
824    ///   - `abfs[s]://<file_system>@<account_name>.dfs.fabric.microsoft.com/<path>`
825    ///   - `az://<container>/<path>` (according to [fsspec](https://github.com/fsspec/adlfs))
826    ///   - `adl://<container>/<path>` (according to [fsspec](https://github.com/fsspec/adlfs))
827    ///   - `azure://<container>/<path>` (custom)
828    ///   - `https://<account>.dfs.core.windows.net`
829    ///   - `https://<account>.blob.core.windows.net`
830    ///   - `https://<account>.blob.core.windows.net/<container>`
831    ///   - `https://<account>.dfs.fabric.microsoft.com`
832    ///   - `https://<account>.dfs.fabric.microsoft.com/<container>`
833    ///   - `https://<account>.blob.fabric.microsoft.com`
834    ///   - `https://<account>.blob.fabric.microsoft.com/<container>`
835    ///
836    /// Settings derived from the URL will override other settings.
837    pub url: String,
838
839    /// Additional options as key-value pairs.
840    ///
841    /// The following keys are supported:
842    ///
843    /// * S3:
844    ///   - `access_key_id`: AWS Access Key.
845    ///   - `secret_access_key`: AWS Secret Access Key.
846    ///   - `region`: Region.
847    ///   - `default_region`: Default region.
848    ///   - `endpoint`: Custom endpoint for communicating with S3,
849    ///     e.g. `https://localhost:4566` for testing against a localstack
850    ///     instance.
851    ///   - `token`: Token to use for requests (passed to underlying provider).
852    ///   - [Other keys](https://docs.rs/object_store/latest/object_store/aws/enum.AmazonS3ConfigKey.html#variants).
853    /// * Google Cloud Storage:
854    ///   - `service_account`: Path to the service account file.
855    ///   - `service_account_key`: The serialized service account key.
856    ///   - `google_application_credentials`: Application credentials path.
857    ///   - [Other keys](https://docs.rs/object_store/latest/object_store/gcp/enum.GoogleConfigKey.html).
858    /// * Microsoft Azure Blob Storage:
859    ///   - `access_key`: Azure Access Key.
860    ///   - `container_name`: Azure Container Name.
861    ///   - `account`: Azure Account.
862    ///   - `bearer_token_authorization`: Static bearer token for authorizing requests.
863    ///   - `client_id`: Client ID for use in client secret or Kubernetes federated credential flow.
864    ///   - `client_secret`: Client secret for use in client secret flow.
865    ///   - `tenant_id`: Tenant ID for use in client secret or Kubernetes federated credential flow.
866    ///   - `endpoint`: Override the endpoint for communicating with blob storage.
867    ///   - [Other keys](https://docs.rs/object_store/latest/object_store/azure/enum.AzureConfigKey.html#variants).
868    ///
869    /// Options set through the URL take precedence over those set with these
870    /// options.
871    #[serde(flatten)]
872    pub other_options: BTreeMap<String, String>,
873}
874
875/// Configuration for local file system access.
876#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, ToSchema)]
877#[serde(default)]
878pub struct FileBackendConfig {
879    /// Whether to use background threads for file I/O.
880    ///
881    /// Background threads should improve performance, but they can reduce
882    /// performance if too few cores are available. This is provided for
883    /// debugging and fine-tuning and should ordinarily be left unset.
884    pub async_threads: Option<bool>,
885
886    /// Per-I/O operation sleep duration, in milliseconds.
887    ///
888    /// This is for simulating slow storage devices.  Do not use this in
889    /// production.
890    pub ioop_delay: Option<u64>,
891
892    /// Configuration to synchronize checkpoints to object store.
893    pub sync: Option<SyncConfig>,
894
895    /// How to make files durable when committing a checkpoint.
896    ///
897    /// This is provided for debugging and fine-tuning and should ordinarily be
898    /// left unset.
899    pub sync_mode: Option<StorageSyncMode>,
900}
901
902/// How a pipeline makes its files durable when it commits a checkpoint.
903///
904/// A checkpoint can hold tens of thousands of files, so committing them one at
905/// a time is the dominant cost of making one. Both alternatives below fix that,
906/// but they suit different deployments.
907#[derive(Debug, Copy, Clone, Default, Eq, PartialEq, Serialize, Deserialize, ToSchema)]
908#[serde(rename_all = "snake_case")]
909pub enum StorageSyncMode {
910    /// Use `syncfs` when storage has a filesystem to itself, and fsync files in
911    /// parallel otherwise.
912    #[default]
913    Auto,
914
915    /// Always `syncfs`.
916    ///
917    /// One syscall makes the whole filesystem durable, which is the cheapest
918    /// option by far when storage is a dedicated volume. On a filesystem shared
919    /// with anything else, it also writes back that other data.
920    ///
921    /// A pipeline falls back to `per_file` where `syncfs` cannot report a failed
922    /// writeback, rather than honoring this setting: off Linux, and before Linux
923    /// 5.8, where `syncfs` returned success and discarded the error.
924    Syncfs,
925
926    /// Always fsync each file, spread across several threads.
927    ///
928    /// Slower than `syncfs` on a dedicated volume, but it touches only this
929    /// pipeline's files.
930    PerFile,
931}
932
933/// Global pipeline configuration settings. This is the publicly
934/// exposed type for users to configure pipelines.
935#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
936#[serde(default)]
937pub struct RuntimeConfig {
938    /// Number of DBSP worker threads.
939    ///
940    /// Each DBSP "foreground" worker thread is paired with a "background"
941    /// thread for LSM merging, making the total number of threads twice the
942    /// specified number.
943    ///
944    /// The typical sweet spot for the number of workers is between 4 and 16.
945    /// Each worker increases overall memory consumption for data structures
946    /// used during a step.
947    pub workers: u16,
948
949    /// The maximum amount of memory, in Megabytes, that the pipeline is allowed to use
950    /// on each host.
951    ///
952    /// Setting this property activates memory pressure monitoring and backpressure
953    /// mechanisms. The pipeline will track the amount of remaining memory and
954    /// report the memory pressure level via the `memory_pressure` metric.
955    ///
956    /// As the memory pressure increases, the system will apply increasing backpressure
957    /// to push state cached in memory to storage, preventing the pipeline from running
958    /// out of memory at the cost of some performance degradation.
959    ///
960    /// It is strongly recommended to set this property to prevent the pipeline from
961    /// running out of memory. The setting should not exceed the memory limit of the pipeline
962    /// instance.
963    ///
964    /// When `max_rss_mb` is not specified but `resources.memory_mb_max` is set, the
965    /// latter is used as the effective memory cap for the pipeline.
966    ///
967    /// See [documentation on the pipeline's memory usage](https://docs.feldera.com/operations/memory)
968    /// for more details.
969    pub max_rss_mb: Option<u64>,
970
971    /// DataFusion memory pool size, in MB, shared by the ad-hoc query
972    /// engine and the Delta Lake / Iceberg connectors.
973    ///
974    /// Carved out of `max_rss_mb` (falling back to
975    /// `resources.memory_mb_max`); the remainder goes to the DBSP circuit,
976    /// so the two do not double-book RAM.
977    ///
978    /// Unset: defaults to 5% of the effective budget, capped at 2 GB.
979    /// Pipelines that don't run heavy ad-hoc / Delta / Iceberg workloads
980    /// can leave this unset.
981    ///
982    /// Set this explicitly for ad-hoc queries, or Delta / Iceberg scans,
983    /// over data too large for the default share. A pool that cannot hold
984    /// the query surfaces as `ResourcesExhausted` on that query alone; the
985    /// pipeline keeps running.
986    ///
987    /// No pool limit applied if no overall budget is configured.
988    ///
989    /// See [documentation on the pipeline's memory usage](https://docs.feldera.com/operations/memory)
990    /// for more details.
991    pub datafusion_memory_mb: Option<u64>,
992
993    /// Number of DBSP hosts.
994    ///
995    /// The worker threads are evenly divided among the hosts.  For single-host
996    /// deployments, this should be 1 (the default).
997    ///
998    /// Multihost pipelines are an enterprise-only preview feature.
999    pub hosts: usize,
1000
1001    /// Storage configuration.
1002    ///
1003    /// - If this is `None`, the default, the pipeline's state is kept in
1004    ///   in-memory data-structures.  This is useful if the pipeline's state
1005    ///   will fit in memory and if the pipeline is ephemeral and does not need
1006    ///   to be recovered after a restart. The pipeline will most likely run
1007    ///   faster since it does not need to access storage.
1008    ///
1009    /// - If set, the pipeline's state is kept on storage.  This allows the
1010    ///   pipeline to work with state that will not fit into memory. It also
1011    ///   allows the state to be checkpointed and recovered across restarts.
1012    #[serde(deserialize_with = "deserialize_storage_options")]
1013    pub storage: Option<StorageOptions>,
1014
1015    /// Fault tolerance configuration.
1016    #[serde(deserialize_with = "deserialize_fault_tolerance")]
1017    pub fault_tolerance: FtConfig,
1018
1019    /// Enable CPU profiler.
1020    ///
1021    /// The default value is `true`.
1022    pub cpu_profiler: bool,
1023
1024    /// Enable pipeline tracing.
1025    pub tracing: bool,
1026
1027    /// Jaeger tracing endpoint to send tracing information to.
1028    pub tracing_endpoint_jaeger: String,
1029
1030    /// Minimal input batch size.
1031    ///
1032    /// The controller delays pushing input records to the circuit until at
1033    /// least `min_batch_size_records` records have been received (total
1034    /// across all endpoints) or `max_buffering_delay_usecs` microseconds
1035    /// have passed since at least one input records has been buffered.
1036    /// Defaults to 0.
1037    pub min_batch_size_records: u64,
1038
1039    /// Maximal delay in microseconds to wait for `min_batch_size_records` to
1040    /// get buffered by the controller, defaults to 0.
1041    pub max_buffering_delay_usecs: u64,
1042
1043    /// Resource reservations and limits. This is enforced
1044    /// only in Feldera Cloud.
1045    pub resources: ResourceConfig,
1046
1047    /// Real-time clock resolution in microseconds.
1048    ///
1049    /// This parameter controls the execution of queries that use the `NOW()` function.  The output of such
1050    /// queries depends on the real-time clock and can change over time without any external
1051    /// inputs.  If the query uses `NOW()`, the pipeline will update the clock value and trigger incremental
1052    /// recomputation at most each `clock_resolution_usecs` microseconds.  If the query does not use
1053    /// `NOW()`, then clock value updates are suppressed and the pipeline ignores this setting.
1054    ///
1055    /// It is set to 1 second (1,000,000 microseconds) by default.
1056    pub clock_resolution_usecs: Option<u64>,
1057
1058    /// Fixed timezone offset for the SQL `NOW()` clock.
1059    ///
1060    /// An ISO-8601 UTC offset, for example `"+05:30"` or `"-08:00"`, that the
1061    /// clock connector adds to every `NOW()` value it emits, so `NOW()`
1062    /// returns local time in that fixed timezone instead of UTC.
1063    ///
1064    /// The offset is baked into the pipeline's checkpointed state and cannot
1065    /// be changed when the pipeline resumes from a checkpoint: the value from
1066    /// the checkpoint stays in effect, and a differing new value is ignored
1067    /// with a warning in the pipeline log.
1068    #[schema(value_type = Option<String>, example = "+05:30")]
1069    pub clock_timezone_offset: Option<ClockTimezoneOffset>,
1070
1071    /// Optionally, a list of CPU numbers for CPUs to which the pipeline may pin
1072    /// its worker threads.  Specify at least twice as many CPU numbers as
1073    /// workers.  CPUs are generally numbered starting from 0.  The pipeline
1074    /// might not be able to honor CPU pinning requests.
1075    ///
1076    /// CPU pinning can make pipelines run faster and perform more consistently,
1077    /// as long as different pipelines running on the same machine are pinned to
1078    /// different CPUs.
1079    pub pin_cpus: Vec<usize>,
1080
1081    /// Timeout in seconds for the `Provisioning` phase of the pipeline.
1082    /// Setting this value will override the default of the runner.
1083    pub provisioning_timeout_secs: Option<u64>,
1084
1085    /// The maximum number of connectors initialized in parallel during pipeline
1086    /// startup.
1087    ///
1088    /// At startup, the pipeline must initialize all of its input and output connectors.
1089    /// Depending on the number and types of connectors, this can take a long time.
1090    /// To accelerate the process, multiple connectors are initialized concurrently.
1091    /// This option controls the maximum number of connectors that can be initialized
1092    /// in parallel.
1093    ///
1094    /// The default is 10.
1095    pub max_parallel_connector_init: Option<u64>,
1096
1097    /// Specification of additional (sidecar) containers.
1098    pub init_containers: Option<serde_json::Value>,
1099
1100    /// Deprecated: setting this true or false does not have an effect anymore.
1101    pub checkpoint_during_suspend: bool,
1102
1103    /// Sets the number of available runtime threads for the http server.
1104    ///
1105    /// In most cases, this does not need to be set explicitly and
1106    /// the default is sufficient. Can be increased in case the
1107    /// pipeline HTTP API operations are a bottleneck.
1108    ///
1109    /// If not specified, the default is set to `workers`.
1110    pub http_workers: Option<u64>,
1111
1112    /// Sets the number of available runtime threads for async IO tasks.
1113    ///
1114    /// This affects some networking and file I/O operations
1115    /// especially adapters and ad-hoc queries.
1116    ///
1117    /// In most cases, this does not need to be set explicitly and
1118    /// the default is sufficient. Can be increased in case
1119    /// ingress, egress or ad-hoc queries are a bottleneck.
1120    ///
1121    /// If not specified, the default is set to `workers`.
1122    pub io_workers: Option<u64>,
1123
1124    /// Environment variables for the pipeline process.
1125    ///
1126    /// These are key-value pairs injected into the pipeline process environment.
1127    /// Some variable names are reserved by the platform and cannot be overridden
1128    /// (for example `RUST_LOG`, and variables in the `FELDERA_`,
1129    /// `KUBERNETES_`, and `TOKIO_` namespaces).
1130    #[serde(default)]
1131    pub env: BTreeMap<String, String>,
1132
1133    /// Optional settings for tweaking Feldera internals.
1134    pub dev_tweaks: DevTweaks,
1135
1136    /// Log filtering directives.
1137    ///
1138    /// If set to a valid [tracing-subscriber] filter, this controls the log
1139    /// messages emitted by the pipeline process.  Otherwise, or if the filter
1140    /// has invalid syntax, messages at "info" severity and higher are written
1141    /// to the log and all others are discarded.
1142    ///
1143    /// [tracing-subscriber]: https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html#directives
1144    pub logging: Option<String>,
1145
1146    /// ConfigMap containing a custom pipeline template (Enterprise only).
1147    ///
1148    /// This feature is only available in Feldera Enterprise. If set, the Kubernetes runner
1149    /// will read the template from the specified ConfigMap and use it instead of the default
1150    /// StatefulSet template for the configured pipeline.
1151    ///
1152    /// check [`PipelineTemplateConfig`] documentation for details.
1153    pub pipeline_template_configmap: Option<PipelineTemplateConfig>,
1154}
1155
1156/// Accepts "true" and "false" and converts them to the new format.
1157fn deserialize_storage_options<'de, D>(deserializer: D) -> Result<Option<StorageOptions>, D::Error>
1158where
1159    D: Deserializer<'de>,
1160{
1161    struct BoolOrStruct;
1162
1163    impl<'de> Visitor<'de> for BoolOrStruct {
1164        type Value = Option<StorageOptions>;
1165
1166        fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
1167            formatter.write_str("boolean or StorageOptions")
1168        }
1169
1170        fn visit_bool<E>(self, v: bool) -> Result<Self::Value, E>
1171        where
1172            E: de::Error,
1173        {
1174            match v {
1175                false => Ok(None),
1176                true => Ok(Some(StorageOptions::default())),
1177            }
1178        }
1179
1180        fn visit_unit<E>(self) -> Result<Self::Value, E>
1181        where
1182            E: de::Error,
1183        {
1184            Ok(None)
1185        }
1186
1187        fn visit_none<E>(self) -> Result<Self::Value, E>
1188        where
1189            E: de::Error,
1190        {
1191            Ok(None)
1192        }
1193
1194        fn visit_map<M>(self, map: M) -> Result<Option<StorageOptions>, M::Error>
1195        where
1196            M: MapAccess<'de>,
1197        {
1198            Deserialize::deserialize(de::value::MapAccessDeserializer::new(map)).map(Some)
1199        }
1200    }
1201
1202    deserializer.deserialize_any(BoolOrStruct)
1203}
1204
1205/// Accepts very old 'initial_state' and 'latest_checkpoint' as enabling fault
1206/// tolerance.
1207///
1208/// Accepts `null` as disabling fault tolerance.
1209///
1210/// Otherwise, deserializes [FtConfig] in the way that one might otherwise
1211/// expect.
1212fn deserialize_fault_tolerance<'de, D>(deserializer: D) -> Result<FtConfig, D::Error>
1213where
1214    D: Deserializer<'de>,
1215{
1216    struct StringOrStruct;
1217
1218    impl<'de> Visitor<'de> for StringOrStruct {
1219        type Value = FtConfig;
1220
1221        fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
1222            formatter.write_str("none or FtConfig or 'initial_state' or 'latest_checkpoint'")
1223        }
1224
1225        fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
1226        where
1227            E: de::Error,
1228        {
1229            match v {
1230                "initial_state" | "latest_checkpoint" => Ok(FtConfig {
1231                    model: Some(FtModel::default()),
1232                    ..FtConfig::default()
1233                }),
1234                _ => Err(de::Error::invalid_value(de::Unexpected::Str(v), &self)),
1235            }
1236        }
1237
1238        fn visit_unit<E>(self) -> Result<Self::Value, E>
1239        where
1240            E: de::Error,
1241        {
1242            Ok(FtConfig::default())
1243        }
1244
1245        fn visit_none<E>(self) -> Result<Self::Value, E>
1246        where
1247            E: de::Error,
1248        {
1249            Ok(FtConfig::default())
1250        }
1251
1252        fn visit_map<M>(self, map: M) -> Result<FtConfig, M::Error>
1253        where
1254            M: MapAccess<'de>,
1255        {
1256            Deserialize::deserialize(de::value::MapAccessDeserializer::new(map))
1257        }
1258    }
1259
1260    deserializer.deserialize_any(StringOrStruct)
1261}
1262
1263impl Default for RuntimeConfig {
1264    fn default() -> Self {
1265        Self {
1266            workers: 8,
1267            max_rss_mb: None,
1268            datafusion_memory_mb: None,
1269            hosts: 1,
1270            storage: Some(StorageOptions::default()),
1271            fault_tolerance: FtConfig::default(),
1272            cpu_profiler: true,
1273            tracing: {
1274                // We discovered that the jaeger crate can use up gigabytes of RAM, so it's not harmless
1275                // to keep it on by default.
1276                false
1277            },
1278            tracing_endpoint_jaeger: "127.0.0.1:6831".to_string(),
1279            min_batch_size_records: 0,
1280            max_buffering_delay_usecs: 0,
1281            resources: ResourceConfig::default(),
1282            clock_resolution_usecs: { Some(DEFAULT_CLOCK_RESOLUTION_USECS) },
1283            clock_timezone_offset: None,
1284            pin_cpus: Vec::new(),
1285            provisioning_timeout_secs: None,
1286            max_parallel_connector_init: None,
1287            init_containers: None,
1288            checkpoint_during_suspend: true,
1289            io_workers: None,
1290            http_workers: None,
1291            env: BTreeMap::default(),
1292            dev_tweaks: DevTweaks::default(),
1293            logging: None,
1294            pipeline_template_configmap: None,
1295        }
1296    }
1297}
1298
1299/// Upper bound on the default DataFusion pool size, in MB.
1300///
1301/// Spill-to-disk handles overflow; reserving more starves the circuit.
1302pub const DEFAULT_DATAFUSION_MEMORY_MB_CEILING: u64 = 2048;
1303
1304/// Default DataFusion pool size as a percentage of the pipeline's
1305/// effective memory budget. The remainder is left for the DBSP circuit.
1306pub const DEFAULT_DATAFUSION_MEMORY_PERCENT: u64 = 5;
1307
1308impl RuntimeConfig {
1309    /// Pipeline's effective memory budget in MB: `max_rss_mb`, falling back
1310    /// to `resources.memory_mb_max` (the k8s pod limit).
1311    pub fn effective_memory_mb(&self) -> Option<u64> {
1312        self.max_rss_mb.or(self.resources.memory_mb_max)
1313    }
1314
1315    /// Resolved DataFusion pool size in MB: explicit `datafusion_memory_mb`
1316    /// if set, else 5% of the effective budget capped at
1317    /// `DEFAULT_DATAFUSION_MEMORY_MB_CEILING`. `None` if no budget is
1318    /// configured.
1319    pub fn resolved_datafusion_memory_mb(&self) -> Option<u64> {
1320        if let Some(explicit) = self.datafusion_memory_mb {
1321            return Some(explicit);
1322        }
1323        let effective = self.effective_memory_mb()?;
1324        let fraction = effective * DEFAULT_DATAFUSION_MEMORY_PERCENT / 100;
1325        Some(fraction.min(DEFAULT_DATAFUSION_MEMORY_MB_CEILING))
1326    }
1327}
1328
1329/// Fault-tolerance configuration.
1330///
1331/// The default [FtConfig] (via [FtConfig::default]) disables fault tolerance,
1332/// which is the configuration that one gets if [RuntimeConfig] omits fault
1333/// tolerance configuration.
1334///
1335/// The default value for [FtConfig::model] enables fault tolerance, as
1336/// `Some(FtModel::default())`.  This is the configuration that one gets if
1337/// [RuntimeConfig] includes a fault tolerance configuration but does not
1338/// specify a particular model.
1339#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize, Deserialize, ToSchema)]
1340#[serde(rename_all = "snake_case")]
1341pub struct FtConfig {
1342    /// Fault tolerance model to use.
1343    #[serde(with = "none_as_string")]
1344    #[serde(default = "default_model")]
1345    #[schema(
1346        schema_with = none_as_string_schema::<FtModel>,
1347    )]
1348    pub model: Option<FtModel>,
1349
1350    /// Interval between automatic checkpoints, in seconds.
1351    ///
1352    /// The default is 60 seconds.  Values less than 1 or greater than 3600 will
1353    /// be forced into that range.
1354    #[serde(default = "default_checkpoint_interval_secs")]
1355    pub checkpoint_interval_secs: Option<u64>,
1356}
1357
1358fn default_model() -> Option<FtModel> {
1359    Some(FtModel::default())
1360}
1361
1362pub fn default_checkpoint_interval_secs() -> Option<u64> {
1363    Some(60)
1364}
1365
1366impl Default for FtConfig {
1367    fn default() -> Self {
1368        Self {
1369            model: None,
1370            checkpoint_interval_secs: default_checkpoint_interval_secs(),
1371        }
1372    }
1373}
1374
1375#[cfg(test)]
1376mod test {
1377    use super::deserialize_fault_tolerance;
1378    use crate::config::{
1379        ConnectorConfig, DEFAULT_DATAFUSION_MEMORY_MB_CEILING, FtConfig, FtModel, PipelineConfig,
1380        ResourceConfig, RuntimeConfig, StorageOptions, TransportConfig,
1381    };
1382    use serde::{Deserialize, Serialize};
1383    use serde_json::json;
1384
1385    /// A configuration written by an older version of Feldera carries no
1386    /// `soft_delete` field, and a connector that does not use soft deletes
1387    /// serializes exactly as it did before the field existed.  This keeps
1388    /// configurations readable and writable across versions.
1389    #[test]
1390    fn soft_delete_is_backward_compatible() {
1391        let config: ConnectorConfig = serde_json::from_value(json!({
1392            "transport": {"name": "empty_input"}
1393        }))
1394        .unwrap();
1395        assert!(!config.soft_delete);
1396
1397        let serialized = serde_json::to_value(&config).unwrap();
1398        assert_eq!(serialized.get("soft_delete"), None);
1399
1400        let config: ConnectorConfig = serde_json::from_value(json!({
1401            "transport": {"name": "empty_input"},
1402            "soft_delete": true
1403        }))
1404        .unwrap();
1405        assert!(config.soft_delete);
1406        assert_eq!(
1407            serde_json::to_value(&config).unwrap().get("soft_delete"),
1408            Some(&json!(true))
1409        );
1410    }
1411
1412    /// Soft deletes change how input is interpreted, so flipping the flag must
1413    /// count as a connector change even where flow-control settings do not.
1414    #[test]
1415    fn soft_delete_invalidates_checkpointed_connector_state() {
1416        let config = ConnectorConfig::new(TransportConfig::EmptyInput, None);
1417        let mut soft_delete = config.clone();
1418        soft_delete.soft_delete = true;
1419
1420        assert!(config.equal_for_input_checkpoint_replay(&config));
1421        assert!(!config.equal_for_input_checkpoint_replay(&soft_delete));
1422    }
1423
1424    fn config_with_name(name: Option<&str>) -> PipelineConfig {
1425        PipelineConfig {
1426            global: RuntimeConfig::default(),
1427            multihost: None,
1428            name: name.map(str::to_string),
1429            given_name: None,
1430            storage_config: None,
1431            secrets_dir: None,
1432            inputs: Default::default(),
1433            outputs: Default::default(),
1434            program_ir: None,
1435        }
1436    }
1437
1438    #[test]
1439    fn pipeline_identity_uses_system_and_given_names() {
1440        let mut config = config_with_name(Some("pipeline-018f6f57-4e15-7438-91af-3fd21ff2a8d3"));
1441        config.given_name = Some("my-pipeline".to_string());
1442
1443        let metadata = config.pipeline_identity().unwrap();
1444        assert_eq!(
1445            metadata.name,
1446            "pipeline-018f6f57-4e15-7438-91af-3fd21ff2a8d3"
1447        );
1448        assert_eq!(metadata.given_name.as_deref(), Some("my-pipeline"));
1449    }
1450
1451    #[test]
1452    fn pipeline_identity_is_none_without_system_name() {
1453        assert_eq!(config_with_name(None).pipeline_identity(), None);
1454    }
1455
1456    #[test]
1457    fn resolved_datafusion_memory_explicit_passes_through() {
1458        let config = RuntimeConfig {
1459            max_rss_mb: Some(8_000),
1460            datafusion_memory_mb: Some(1_500),
1461            ..Default::default()
1462        };
1463        assert_eq!(config.resolved_datafusion_memory_mb(), Some(1_500));
1464    }
1465
1466    #[test]
1467    fn resolved_datafusion_memory_unconfigured_returns_none() {
1468        let config = RuntimeConfig::default();
1469        assert!(config.max_rss_mb.is_none());
1470        assert!(config.resources.memory_mb_max.is_none());
1471        assert_eq!(config.resolved_datafusion_memory_mb(), None);
1472    }
1473
1474    #[test]
1475    fn resolved_datafusion_memory_small_budget_scales_down() {
1476        // Small pipelines must provision cleanly; the default just shrinks.
1477        let config = RuntimeConfig {
1478            max_rss_mb: Some(256),
1479            ..Default::default()
1480        };
1481        assert_eq!(config.resolved_datafusion_memory_mb(), Some(12));
1482
1483        let config = RuntimeConfig {
1484            max_rss_mb: Some(512),
1485            ..Default::default()
1486        };
1487        assert_eq!(config.resolved_datafusion_memory_mb(), Some(25));
1488    }
1489
1490    #[test]
1491    fn resolved_datafusion_memory_clamps_to_ceiling_for_large_budgets() {
1492        // 5% of 64 GB = 3.2 GB, above the 2 GB ceiling.
1493        let config = RuntimeConfig {
1494            max_rss_mb: Some(64_000),
1495            ..Default::default()
1496        };
1497        assert_eq!(
1498            config.resolved_datafusion_memory_mb(),
1499            Some(DEFAULT_DATAFUSION_MEMORY_MB_CEILING),
1500        );
1501    }
1502
1503    #[test]
1504    fn resolved_datafusion_memory_midrange_uses_five_percent() {
1505        // 5% of 16 GB = 800 MB, inside the clamp range.
1506        let config = RuntimeConfig {
1507            max_rss_mb: Some(16_000),
1508            ..Default::default()
1509        };
1510        assert_eq!(config.resolved_datafusion_memory_mb(), Some(800));
1511    }
1512
1513    #[test]
1514    fn resolved_datafusion_memory_falls_back_to_resources() {
1515        // No max_rss_mb, but resources.memory_mb_max is set.
1516        let config = RuntimeConfig {
1517            max_rss_mb: None,
1518            resources: ResourceConfig {
1519                memory_mb_max: Some(16_000),
1520                ..Default::default()
1521            },
1522            ..Default::default()
1523        };
1524        assert_eq!(config.resolved_datafusion_memory_mb(), Some(800));
1525    }
1526
1527    #[test]
1528    fn ft_config() {
1529        #[derive(Serialize, Deserialize, Default, PartialEq, Eq, Debug)]
1530        #[serde(default)]
1531        struct Wrapper {
1532            #[serde(deserialize_with = "deserialize_fault_tolerance")]
1533            config: FtConfig,
1534        }
1535
1536        // Omitting FtConfig, or specifying null, or specifying model "none", disables fault tolerance.
1537        for s in [
1538            "{}",
1539            r#"{"config": null}"#,
1540            r#"{"config": {"model": "none"}}"#,
1541        ] {
1542            let config: Wrapper = serde_json::from_str(s).unwrap();
1543            assert_eq!(
1544                config,
1545                Wrapper {
1546                    config: FtConfig {
1547                        model: None,
1548                        checkpoint_interval_secs: Some(60)
1549                    }
1550                }
1551            );
1552        }
1553
1554        // Serializing disabled FT produces explicit "none" form.
1555        let s = serde_json::to_string(&Wrapper {
1556            config: FtConfig::default(),
1557        })
1558        .unwrap();
1559        assert!(s.contains("\"none\""));
1560
1561        // `{}` for FtConfig, or `{...}` with `model` omitted, enables fault
1562        // tolerance.
1563        for s in [r#"{"config": {}}"#, r#"{"checkpoint_interval_secs": 60}"#] {
1564            assert_eq!(
1565                serde_json::from_str::<FtConfig>(s).unwrap(),
1566                FtConfig {
1567                    model: Some(FtModel::default()),
1568                    checkpoint_interval_secs: Some(60)
1569                }
1570            );
1571        }
1572
1573        // `"checkpoint_interval_secs": null` disables periodic checkpointing.
1574        assert_eq!(
1575            serde_json::from_str::<FtConfig>(r#"{"checkpoint_interval_secs": null}"#).unwrap(),
1576            FtConfig {
1577                model: Some(FtModel::default()),
1578                checkpoint_interval_secs: None
1579            }
1580        );
1581    }
1582
1583    /// Regression test: `Option<f64>` fields inside `StorageOptions` must
1584    /// survive a JSON-string round-trip through `PipelineConfig`, which uses
1585    /// `#[serde(flatten)]` on `RuntimeConfig`.
1586    #[test]
1587    fn storage_options_f64_roundtrip_through_pipeline_config() {
1588        let pc = PipelineConfig {
1589            global: RuntimeConfig {
1590                storage: Some(StorageOptions {
1591                    bloom_false_positive_rate: Some(0.01),
1592                    ..Default::default()
1593                }),
1594                ..Default::default()
1595            },
1596            multihost: None,
1597            name: Some("test-pipeline".into()),
1598            given_name: None,
1599            storage_config: None,
1600            secrets_dir: None,
1601            inputs: Default::default(),
1602            outputs: Default::default(),
1603            program_ir: None,
1604        };
1605
1606        // JSON string round-trip (the path the pipeline process takes).
1607        let json = serde_json::to_string_pretty(&pc).unwrap();
1608        let pc2: PipelineConfig = serde_json::from_str(&json).expect(
1609            "JSON string round-trip of PipelineConfig with f64 StorageOptions must succeed",
1610        );
1611        assert_eq!(
1612            pc2.global.storage.unwrap().bloom_false_positive_rate,
1613            Some(0.01)
1614        );
1615
1616        // serde_json::Value round-trip (the path the pipeline manager takes).
1617        let value = serde_json::to_value(&pc).unwrap();
1618        let pc3: PipelineConfig = serde_json::from_value(value)
1619            .expect("Value round-trip of PipelineConfig with f64 StorageOptions must succeed");
1620        assert_eq!(
1621            pc3.global.storage.unwrap().bloom_false_positive_rate,
1622            Some(0.01)
1623        );
1624    }
1625}
1626
1627impl FtConfig {
1628    pub fn is_enabled(&self) -> bool {
1629        self.model.is_some()
1630    }
1631
1632    /// Returns the checkpoint interval, if fault tolerance is enabled, and
1633    /// otherwise `None`.
1634    pub fn checkpoint_interval(&self) -> Option<Duration> {
1635        if self.is_enabled() {
1636            self.checkpoint_interval_secs
1637                .map(|interval| Duration::from_secs(interval.clamp(1, 3600)))
1638        } else {
1639            None
1640        }
1641    }
1642}
1643
1644/// Serde implementation for de/serializing a string into `Option<T>` where
1645/// `"none"` indicates `None` and any other string indicates `Some`.
1646///
1647/// This could be extended to handle non-strings by adding more forwarding
1648/// `visit_*` methods to the Visitor implementation.  I don't see a way to write
1649/// them automatically.
1650mod none_as_string {
1651    use std::marker::PhantomData;
1652
1653    use serde::de::{Deserialize, Deserializer, IntoDeserializer, Visitor};
1654    use serde::ser::{Serialize, Serializer};
1655
1656    pub(super) fn serialize<S, T>(value: &Option<T>, serializer: S) -> Result<S::Ok, S::Error>
1657    where
1658        S: Serializer,
1659        T: Serialize,
1660    {
1661        match value.as_ref() {
1662            Some(value) => value.serialize(serializer),
1663            None => "none".serialize(serializer),
1664        }
1665    }
1666
1667    struct NoneAsString<T>(PhantomData<fn() -> T>);
1668
1669    impl<'de, T> Visitor<'de> for NoneAsString<T>
1670    where
1671        T: Deserialize<'de>,
1672    {
1673        type Value = Option<T>;
1674
1675        fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
1676            formatter.write_str("string")
1677        }
1678
1679        fn visit_none<E>(self) -> Result<Self::Value, E>
1680        where
1681            E: serde::de::Error,
1682        {
1683            Ok(None)
1684        }
1685
1686        fn visit_str<E>(self, value: &str) -> Result<Option<T>, E>
1687        where
1688            E: serde::de::Error,
1689        {
1690            if &value.to_ascii_lowercase() == "none" {
1691                Ok(None)
1692            } else {
1693                Ok(Some(T::deserialize(value.into_deserializer())?))
1694            }
1695        }
1696    }
1697
1698    pub(super) fn deserialize<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
1699    where
1700        D: Deserializer<'de>,
1701        T: Deserialize<'de>,
1702    {
1703        deserializer.deserialize_str(NoneAsString(PhantomData))
1704    }
1705}
1706
1707/// Generates an OpenAPI schema for an `Option<T>` field serialized with `none_as_string`.
1708/// The schema is a `oneOf` with a reference to `T`'s schema and a `"none"` string enum.
1709fn none_as_string_schema<'a, T: ToSchema<'a> + Default + Serialize>() -> Schema {
1710    Schema::OneOf(
1711        OneOfBuilder::new()
1712            .item(RefOr::Ref(Ref::new(format!(
1713                "#/components/schemas/{}",
1714                T::schema().0
1715            ))))
1716            .item(
1717                ObjectBuilder::new()
1718                    .schema_type(SchemaType::String)
1719                    .enum_values(Some(vec!["none"])),
1720            )
1721            .default(Some(
1722                serde_json::to_value(T::default()).expect("Failed to serialize default value"),
1723            ))
1724            .build(),
1725    )
1726}
1727
1728/// Fault tolerance model.
1729///
1730/// The ordering is significant: we consider [Self::ExactlyOnce] to be a "higher
1731/// level" of fault tolerance than [Self::AtLeastOnce].
1732#[derive(
1733    Debug, Copy, Clone, Default, Eq, PartialEq, PartialOrd, Ord, Serialize, Deserialize, ToSchema,
1734)]
1735#[serde(rename_all = "snake_case")]
1736pub enum FtModel {
1737    /// Each record is output at least once.  Crashes may duplicate output, but
1738    /// no input or output is dropped.
1739    AtLeastOnce,
1740
1741    /// Each record is output exactly once.  Crashes do not drop or duplicate
1742    /// input or output.
1743    #[default]
1744    ExactlyOnce,
1745}
1746
1747impl FtModel {
1748    pub fn option_as_str(value: Option<FtModel>) -> &'static str {
1749        value.map_or("no", |model| model.as_str())
1750    }
1751
1752    pub fn as_str(&self) -> &'static str {
1753        match self {
1754            FtModel::AtLeastOnce => "at_least_once",
1755            FtModel::ExactlyOnce => "exactly_once",
1756        }
1757    }
1758}
1759
1760pub struct FtModelUnknown;
1761
1762impl FromStr for FtModel {
1763    type Err = FtModelUnknown;
1764
1765    fn from_str(s: &str) -> Result<Self, Self::Err> {
1766        match s.to_ascii_lowercase().as_str() {
1767            "exactly_once" => Ok(Self::ExactlyOnce),
1768            "at_least_once" => Ok(Self::AtLeastOnce),
1769            _ => Err(FtModelUnknown),
1770        }
1771    }
1772}
1773
1774/// Describes an input connector configuration
1775#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ToSchema)]
1776pub struct InputEndpointConfig {
1777    /// The name of the input stream of the circuit that this endpoint is
1778    /// connected to.
1779    pub stream: Cow<'static, str>,
1780
1781    /// Connector configuration.
1782    #[serde(flatten)]
1783    pub connector_config: ConnectorConfig,
1784}
1785
1786impl InputEndpointConfig {
1787    pub fn new(stream: impl Into<Cow<'static, str>>, connector_config: ConnectorConfig) -> Self {
1788        Self {
1789            stream: stream.into(),
1790            connector_config,
1791        }
1792    }
1793}
1794
1795/// Deserialize the `start_after` property of a connector configuration.
1796/// It requires a non-standard deserialization because we want to accept
1797/// either a string or an array of strings.
1798fn deserialize_start_after<'de, D>(deserializer: D) -> Result<Option<Vec<String>>, D::Error>
1799where
1800    D: Deserializer<'de>,
1801{
1802    let value = Option::<JsonValue>::deserialize(deserializer)?;
1803    match value {
1804        Some(JsonValue::String(s)) => Ok(Some(vec![s])),
1805        Some(JsonValue::Array(arr)) => {
1806            let vec = arr
1807                .into_iter()
1808                .map(|item| {
1809                    item.as_str()
1810                        .map(|s| s.to_string())
1811                        .ok_or_else(|| serde::de::Error::custom("invalid 'start_after' property: expected a string, an array of strings, or null"))
1812                })
1813                .collect::<Result<Vec<String>, _>>()?;
1814            Ok(Some(vec))
1815        }
1816        Some(JsonValue::Null) | None => Ok(None),
1817        _ => Err(serde::de::Error::custom(
1818            "invalid 'start_after' property: expected a string, an array of strings, or null",
1819        )),
1820    }
1821}
1822
1823#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ToSchema)]
1824pub struct ConnectorConfig {
1825    /// Send a full snapshot of a materialized view when the connector first
1826    /// starts. Valid for output connectors only.
1827    ///
1828    /// When `true`, the pipeline emits the current contents of the view as the
1829    /// initial batch the first time the connector runs. The view must be
1830    /// materialized (declared with `CREATE MATERIALIZED VIEW`).
1831    ///
1832    /// The snapshot is sent exactly once per connector lifetime: it does not
1833    /// fire again when the pipeline resumes from a checkpoint. Modifying the
1834    /// connector configuration or invoking the reset API triggers a fresh
1835    /// snapshot when the connector supports reset (e.g., Delta Lake in
1836    /// `truncate` mode and Postgres).
1837    #[serde(default)]
1838    pub send_snapshot: bool,
1839
1840    /// Ingest deletions as insertions, recording the original polarity in the
1841    /// `is_delete` metadata attribute. Valid for input connectors only.
1842    ///
1843    /// When `true`, a delete received by the connector is pushed to the table
1844    /// as an insertion of the same record, and the connector attaches the
1845    /// `is_delete` metadata attribute set to `true` to it. Insertions carry no
1846    /// `is_delete` attribute, so a column declared as
1847    /// `DEFAULT CAST(CONNECTOR_METADATA()['is_delete'] AS BOOLEAN)` is `NULL`
1848    /// for them. The table then contains the entire history of the input
1849    /// stream instead of tracking its current contents.
1850    ///
1851    /// Only tables without a primary key support this mode, since deletions in
1852    /// a table with a primary key delete a key rather than a record.
1853    ///
1854    /// Versions of Feldera that predate this option ignore it and apply
1855    /// deletions as regular deletions.
1856    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1857    pub soft_delete: bool,
1858
1859    /// Transport endpoint configuration.
1860    pub transport: TransportConfig,
1861
1862    /// Optional preprocessor configuration
1863    #[serde(skip_serializing_if = "Option::is_none")]
1864    pub preprocessor: Option<Vec<PreprocessorConfig>>,
1865
1866    /// Parser configuration.
1867    pub format: Option<FormatConfig>,
1868
1869    /// Optional postprocessor configuration
1870    #[serde(skip_serializing_if = "Option::is_none")]
1871    pub postprocessor: Option<Vec<PostprocessorConfig>>,
1872
1873    /// Name of the index that the connector is attached to.
1874    ///
1875    /// This property is valid for output connectors only.  It is used with data
1876    /// transports and formats that expect output updates in the form of key/value
1877    /// pairs, where the key typically represents a unique id associated with the
1878    /// table or view.
1879    ///
1880    /// To support such output formats, an output connector can be attached to an
1881    /// index created using the SQL CREATE INDEX statement.  An index of a table
1882    /// or view contains the same updates as the table or view itself, indexed by
1883    /// one or more key columns.
1884    ///
1885    /// See individual connector documentation for details on how they work
1886    /// with indexes.
1887    pub index: Option<String>,
1888
1889    /// Output buffer configuration.
1890    #[serde(flatten)]
1891    pub output_buffer_config: OutputBufferConfig,
1892
1893    /// Maximum number of records from this connector to process in a single batch.
1894    ///
1895    /// When set, this caps how many records are taken from the connector’s input
1896    /// buffer and pushed through the circuit at once.
1897    ///
1898    /// This is typically configured lower than `max_queued_records` to allow the
1899    /// connector time to restart and refill its buffer while a batch is being
1900    /// processed.
1901    ///
1902    /// Not all input adapters honor this limit.
1903    ///
1904    /// If this is not set, the batch size is derived from `max_worker_batch_size`.
1905    #[serde(skip_serializing_if = "Option::is_none")]
1906    pub max_batch_size: Option<u64>,
1907
1908    /// Maximum number of records processed per batch, per worker thread.
1909    ///
1910    /// When `max_batch_size` is not set, this setting is used to cap
1911    /// the number of records that can be taken from the connector’s input
1912    /// buffer and pushed through the circuit at once.  The effective batch size is computed as:
1913    /// `max_worker_batch_size × workers`.
1914    ///
1915    /// This provides an alternative to `max_batch_size` that automatically adjusts batch
1916    /// size as the number of worker threads changes to maintain constant amount of
1917    /// work per worker per batch.
1918    ///
1919    /// Defaults to 10,000 records per worker.
1920    #[serde(skip_serializing_if = "Option::is_none")]
1921    pub max_worker_batch_size: Option<u64>,
1922
1923    /// Backpressure threshold, in records.
1924    ///
1925    /// Maximal number of records queued by the endpoint before the endpoint
1926    /// is paused by the backpressure mechanism.
1927    ///
1928    /// For input endpoints, this setting bounds the number of records that have
1929    /// been received from the input transport but haven't yet been consumed by
1930    /// the circuit, since the circuit is still busy processing previous inputs.
1931    ///
1932    /// For output endpoints, this setting bounds the number of records that have
1933    /// been produced by the circuit but not yet sent via the output transport endpoint
1934    /// nor stored in the output buffer (see `enable_output_buffer`).
1935    ///
1936    /// Note that this is not a hard bound: there can be a small delay between
1937    /// the backpressure mechanism is triggered and the endpoint is paused, during
1938    /// which more data may be queued.
1939    ///
1940    /// The default is 1 million.
1941    #[serde(default = "default_max_queued_records")]
1942    pub max_queued_records: u64,
1943
1944    /// Backpressure threshold, in bytes.
1945    ///
1946    /// Maximal number of bytes queued by the endpoint before the endpoint
1947    /// is paused by the backpressure mechanism.
1948    ///
1949    /// For input endpoints, this setting bounds the number of bytes that have
1950    /// been received from the input transport but haven't yet been consumed by
1951    /// the circuit since the circuit, since the circuit is still busy processing
1952    /// previous inputs.
1953    ///
1954    /// This setting is not yet implemented for output endpoints.
1955    ///
1956    /// Note that this is not a hard bound: there can be a small delay between
1957    /// the backpressure mechanism is triggered and the endpoint is paused, during
1958    /// which more data may be queued.
1959    ///
1960    /// When this is unspecified, it defaults to `1000 * max_queued_records`.
1961    #[serde(skip_serializing_if = "Option::is_none")]
1962    pub max_queued_bytes: Option<u64>,
1963
1964    /// Create connector in paused state.
1965    ///
1966    /// A paused input connector does not fetch data from its source. A paused
1967    /// output connector discards the output it receives instead of sending it
1968    /// to its sink.
1969    ///
1970    /// A connector is started at runtime with the `start` action of the
1971    /// connector API and paused again with `pause`.
1972    ///
1973    /// The default is `false`.
1974    #[serde(default)]
1975    pub paused: bool,
1976
1977    /// Arbitrary user-defined text labels associated with the connector.
1978    ///
1979    /// These labels can be used in conjunction with the `start_after` property
1980    /// to control the start order of connectors.
1981    #[serde(default)]
1982    pub labels: Vec<String>,
1983
1984    /// Start the connector after all connectors with specified labels.
1985    ///
1986    /// This property is used to control the start order of connectors.
1987    /// The connector will not start until all connectors with the specified
1988    /// labels have finished processing all inputs.
1989    #[serde(deserialize_with = "deserialize_start_after")]
1990    #[serde(default)]
1991    pub start_after: Option<Vec<String>>,
1992}
1993
1994impl ConnectorConfig {
1995    pub fn new(transport: TransportConfig, format: Option<FormatConfig>) -> Self {
1996        Self {
1997            send_snapshot: false,
1998            soft_delete: false,
1999            transport,
2000            preprocessor: None,
2001            format,
2002            postprocessor: None,
2003            index: None,
2004            output_buffer_config: Default::default(),
2005            max_batch_size: None,
2006            max_worker_batch_size: None,
2007            max_queued_records: default_max_queued_records(),
2008            max_queued_bytes: None,
2009            paused: false,
2010            labels: Vec::new(),
2011            start_after: None,
2012        }
2013    }
2014
2015    pub fn with_max_batch_size(mut self, max_batch_size: Option<u64>) -> Self {
2016        self.max_batch_size = max_batch_size;
2017        self
2018    }
2019
2020    pub fn with_max_queued_records(mut self, max_queued_records: u64) -> Self {
2021        self.max_queued_records = max_queued_records;
2022        self
2023    }
2024
2025    /// Compare two configs modulo the `paused` field.
2026    ///
2027    /// Used to compare checkpointed and current connector configs.
2028    pub fn equal_modulo_paused(&self, other: &Self) -> bool {
2029        let mut a = self.clone();
2030        let mut b = other.clone();
2031        a.paused = false;
2032        b.paused = false;
2033        a == b
2034    }
2035
2036    /// Compare two input connector configs modulo fields that only affect
2037    /// runtime flow control and do not invalidate checkpointed connector state.
2038    pub fn equal_for_input_checkpoint_replay(&self, other: &Self) -> bool {
2039        let mut a = self.clone();
2040        let mut b = other.clone();
2041        a.normalize_for_input_checkpoint_replay();
2042        b.normalize_for_input_checkpoint_replay();
2043        a == b
2044    }
2045
2046    fn normalize_for_input_checkpoint_replay(&mut self) {
2047        self.paused = false;
2048        self.max_batch_size = None;
2049        self.max_worker_batch_size = None;
2050        self.max_queued_records = default_max_queued_records();
2051        self.max_queued_bytes = None;
2052    }
2053
2054    /// Adopt input connector settings that are safe to change while replaying
2055    /// checkpointed connector state.
2056    pub fn apply_input_checkpoint_replay_config_from(&mut self, other: &Self) {
2057        self.max_batch_size = other.max_batch_size;
2058        self.max_worker_batch_size = other.max_worker_batch_size;
2059        self.max_queued_records = other.max_queued_records;
2060        self.max_queued_bytes = other.max_queued_bytes;
2061    }
2062
2063    /// Returns `max_queued_records` or, if it is not set, the default.
2064    pub fn max_queued_records(&self) -> u64 {
2065        self.max_queued_records
2066    }
2067
2068    /// Returns `max_queued_bytes` or, if it is not set, the default based on
2069    /// `max_queued_records`.
2070    pub fn max_queued_bytes(&self) -> u64 {
2071        self.max_queued_bytes
2072            .unwrap_or_else(|| self.max_queued_records().saturating_mul(1000))
2073    }
2074}
2075
2076#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ToSchema)]
2077#[serde(default)]
2078pub struct OutputBufferConfig {
2079    /// Enable output buffering.
2080    ///
2081    /// The output buffering mechanism allows decoupling the rate at which the pipeline
2082    /// pushes changes to the output transport from the rate of input changes.
2083    ///
2084    /// By default, output updates produced by the pipeline are pushed directly to
2085    /// the output transport. Some destinations may prefer to receive updates in fewer
2086    /// bigger batches. For instance, when writing Parquet files, producing
2087    /// one bigger file every few minutes is usually better than creating
2088    /// small files every few milliseconds.
2089    ///
2090    /// To achieve such input/output decoupling, users can enable output buffering by
2091    /// setting the `enable_output_buffer` flag to `true`.  When buffering is enabled, output
2092    /// updates produced by the pipeline are consolidated in an internal buffer and are
2093    /// pushed to the output transport when one of several conditions is satisfied:
2094    ///
2095    /// * data has been accumulated in the buffer for more than `max_output_buffer_time_millis`
2096    ///   milliseconds.
2097    /// * buffer size exceeds `max_output_buffer_size_records` records.
2098    ///
2099    /// This flag is `false` by default.
2100    // TODO: on-demand output triggered via the API.
2101    pub enable_output_buffer: bool,
2102
2103    /// Maximum time in milliseconds data is kept in the output buffer.
2104    ///
2105    /// By default, data is kept in the buffer indefinitely until one of
2106    /// the other output conditions is satisfied.  When this option is
2107    /// set the buffer will be flushed at most every
2108    /// `max_output_buffer_time_millis` milliseconds.
2109    ///
2110    /// NOTE: this configuration option requires the `enable_output_buffer` flag
2111    /// to be set.
2112    pub max_output_buffer_time_millis: usize,
2113
2114    /// Maximum number of updates to be kept in the output buffer.
2115    ///
2116    /// This parameter bounds the maximal size of the buffer.
2117    /// Note that the size of the buffer is not always equal to the
2118    /// total number of updates output by the pipeline. Updates to the
2119    /// same record can overwrite or cancel previous updates.
2120    ///
2121    /// The default is 10,000,000.
2122    ///
2123    /// NOTE: this configuration option requires the `enable_output_buffer` flag
2124    /// to be set.
2125    pub max_output_buffer_size_records: usize,
2126}
2127
2128impl Default for OutputBufferConfig {
2129    fn default() -> Self {
2130        Self {
2131            enable_output_buffer: false,
2132            max_output_buffer_size_records: DEFAULT_MAX_OUTPUT_BUFFER_SIZE_RECORDS,
2133            max_output_buffer_time_millis: usize::MAX,
2134        }
2135    }
2136}
2137
2138/// Describes an output connector configuration
2139#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ToSchema)]
2140pub struct OutputEndpointConfig {
2141    /// The name of the output stream of the circuit that this endpoint is
2142    /// connected to.
2143    pub stream: Cow<'static, str>,
2144
2145    /// Connector configuration.
2146    #[serde(flatten)]
2147    pub connector_config: ConnectorConfig,
2148}
2149
2150impl OutputEndpointConfig {
2151    pub fn new(stream: impl Into<Cow<'static, str>>, connector_config: ConnectorConfig) -> Self {
2152        Self {
2153            stream: stream.into(),
2154            connector_config,
2155        }
2156    }
2157}
2158
2159/// Transport-specific endpoint configuration passed to
2160/// `crate::OutputTransport::new_endpoint`
2161/// and `crate::InputTransport::new_endpoint`.
2162#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ToSchema)]
2163#[serde(tag = "name", content = "config", rename_all = "snake_case")]
2164pub enum TransportConfig {
2165    FileInput(FileInputConfig),
2166    FileOutput(FileOutputConfig),
2167    NatsInput(NatsInputConfig),
2168    KafkaInput(KafkaInputConfig),
2169    KafkaOutput(KafkaOutputConfig),
2170    PubSubInput(PubSubInputConfig),
2171    UrlInput(UrlInputConfig),
2172    S3Input(S3InputConfig),
2173    DeltaTableInput(DeltaTableReaderConfig),
2174    DeltaTableOutput(DeltaTableWriterConfig),
2175    // Snake case would rename "DynamoDBOutput" to `dynamo_db_output`.
2176    // However, DynamoDB is a single word, so override the tag to `dynamodb_output`.
2177    #[serde(rename = "dynamodb_output")]
2178    DynamoDBOutput(DynamoDBWriterConfig),
2179    RedisOutput(RedisOutputConfig),
2180    // Prevent rust from complaining about large size difference between enum variants.
2181    IcebergInput(Box<IcebergReaderConfig>),
2182    PostgresInput(PostgresReaderConfig),
2183    PostgresCdcInput(PostgresCdcReaderConfig),
2184    PostgresOutput(PostgresWriterConfig),
2185    Datagen(DatagenInputConfig),
2186    Nexmark(NexmarkInputConfig),
2187    /// Direct HTTP input: cannot be instantiated through API
2188    HttpInput(HttpInputConfig),
2189    /// Direct HTTP output: cannot be instantiated through API
2190    HttpOutput(HttpOutputConfig),
2191    /// Ad hoc input: cannot be instantiated through API
2192    AdHocInput(AdHocInputConfig),
2193    ClockInput(ClockConfig),
2194    /// Output connector that discards all data.
2195    NullOutput,
2196    /// Input connector that produces no data.
2197    EmptyInput,
2198}
2199
2200impl TransportConfig {
2201    pub fn name(&self) -> String {
2202        match self {
2203            TransportConfig::FileInput(_) => "file_input".to_string(),
2204            TransportConfig::FileOutput(_) => "file_output".to_string(),
2205            TransportConfig::NatsInput(_) => "nats_input".to_string(),
2206            TransportConfig::KafkaInput(_) => "kafka_input".to_string(),
2207            TransportConfig::KafkaOutput(_) => "kafka_output".to_string(),
2208            TransportConfig::PubSubInput(_) => "pub_sub_input".to_string(),
2209            TransportConfig::UrlInput(_) => "url_input".to_string(),
2210            TransportConfig::S3Input(_) => "s3_input".to_string(),
2211            TransportConfig::DeltaTableInput(_) => "delta_table_input".to_string(),
2212            TransportConfig::DeltaTableOutput(_) => "delta_table_output".to_string(),
2213            TransportConfig::DynamoDBOutput(_) => "dynamodb_output".to_string(),
2214            TransportConfig::IcebergInput(_) => "iceberg_input".to_string(),
2215            TransportConfig::PostgresInput(_) => "postgres_input".to_string(),
2216            TransportConfig::PostgresCdcInput(_) => "postgres_cdc_input".to_string(),
2217            TransportConfig::PostgresOutput(_) => "postgres_output".to_string(),
2218            TransportConfig::Datagen(_) => "datagen".to_string(),
2219            TransportConfig::Nexmark(_) => "nexmark".to_string(),
2220            TransportConfig::HttpInput(_) => "http_input".to_string(),
2221            TransportConfig::HttpOutput(_) => "http_output".to_string(),
2222            TransportConfig::AdHocInput(_) => "adhoc_input".to_string(),
2223            TransportConfig::RedisOutput(_) => "redis_output".to_string(),
2224            TransportConfig::ClockInput(_) => "clock".to_string(),
2225            TransportConfig::NullOutput => "null_output".to_string(),
2226            TransportConfig::EmptyInput => "empty_input".to_string(),
2227        }
2228    }
2229
2230    /// Returns true if the connector is transient, i.e., is created and destroyed
2231    /// at runtime on demand, rather than being configured as part of the pipeline.
2232    pub fn is_transient(&self) -> bool {
2233        matches!(
2234            self,
2235            TransportConfig::AdHocInput(_)
2236                | TransportConfig::HttpInput(_)
2237                | TransportConfig::HttpOutput(_)
2238                | TransportConfig::ClockInput(_)
2239        )
2240    }
2241
2242    pub fn is_http_input(&self) -> bool {
2243        matches!(self, TransportConfig::HttpInput(_))
2244    }
2245}
2246
2247/// Data format specification used to parse raw data received from the
2248/// endpoint or to encode data sent to the endpoint.
2249#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize, ToSchema)]
2250pub struct FormatConfig {
2251    /// Format name, e.g., "csv", "json", "bincode", etc.
2252    pub name: Cow<'static, str>,
2253
2254    /// Format-specific parser or encoder configuration.
2255    #[serde(default)]
2256    #[schema(value_type = Object)]
2257    pub config: JsonValue,
2258}
2259
2260#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, ToSchema)]
2261#[serde(default)]
2262pub struct ResourceConfig {
2263    /// The minimum number of CPU cores to reserve
2264    /// for an instance of this pipeline
2265    #[serde(deserialize_with = "crate::serde_via_value::deserialize")]
2266    pub cpu_cores_min: Option<f64>,
2267
2268    /// The maximum number of CPU cores to reserve
2269    /// for an instance of this pipeline
2270    #[serde(deserialize_with = "crate::serde_via_value::deserialize")]
2271    pub cpu_cores_max: Option<f64>,
2272
2273    /// The minimum memory in Megabytes to reserve
2274    /// for an instance of this pipeline
2275    pub memory_mb_min: Option<u64>,
2276
2277    /// The maximum memory in Megabytes to reserve
2278    /// for an instance of this pipeline
2279    pub memory_mb_max: Option<u64>,
2280
2281    /// The initial storage in Megabytes to reserve for an instance of this
2282    /// pipeline. Set together with `storage_mb_max` to enable storage
2283    /// autoscaling.
2284    #[serde(skip_serializing_if = "Option::is_none")]
2285    pub storage_mb_min: Option<u64>,
2286
2287    /// The maximum storage in Megabytes for an instance of this pipeline.
2288    ///
2289    /// Without `storage_mb_min`, this much storage is reserved up front.
2290    ///
2291    /// With `storage_mb_min`, storage starts there and autoscaling expands it
2292    /// up to this limit.
2293    pub storage_mb_max: Option<u64>,
2294
2295    /// Autoscaling policy for an instance of this pipeline.
2296    #[serde(skip_serializing_if = "Option::is_none")]
2297    pub autoscaling: Option<AutoscalingConfig>,
2298
2299    /// Storage class to use for an instance of this pipeline.
2300    /// The class determines storage performance such as IOPS and throughput.
2301    pub storage_class: Option<String>,
2302
2303    /// Kubernetes service account name to use for an instance of this pipeline.
2304    /// The account determines permissions and access controls.
2305    pub service_account_name: Option<String>,
2306
2307    /// Kubernetes namespace to use for an instance of this pipeline.
2308    /// The namespace determines the scope of names for resources created
2309    /// for the pipeline.
2310    /// If not set, the pipeline will be deployed in the same namespace
2311    /// as the control-plane.
2312    // The type of this field should not be backward incompatibly changed, and its location in the
2313    // runtime configuration JSON (`runtime_config.resources.namespace`) should not be changed.
2314    pub namespace: Option<String>,
2315}
2316
2317/// Autoscaling policy for an instance of a pipeline.
2318#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, ToSchema)]
2319#[serde(default)]
2320pub struct AutoscalingConfig {
2321    /// Storage autoscaling policy.
2322    #[serde(skip_serializing_if = "Option::is_none")]
2323    pub storage: Option<StorageAutoscalingConfig>,
2324}
2325
2326/// Storage autoscaling policy: expand storage from `storage_mb_min` toward
2327/// `storage_mb_max` as it fills up.
2328#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, ToSchema)]
2329#[serde(default)]
2330pub struct StorageAutoscalingConfig {
2331    /// Usage fraction that triggers expansion. Defaults to 0.8.
2332    #[serde(
2333        deserialize_with = "crate::serde_via_value::deserialize",
2334        skip_serializing_if = "Option::is_none"
2335    )]
2336    pub scale_threshold: Option<f64>,
2337
2338    /// Expansion multiplier. Defaults to 2.0.
2339    #[serde(
2340        deserialize_with = "crate::serde_via_value::deserialize",
2341        skip_serializing_if = "Option::is_none"
2342    )]
2343    pub scale_factor: Option<f64>,
2344}
2345
2346#[cfg(test)]
2347mod resource_config_tests {
2348    use super::{AutoscalingConfig, ResourceConfig, StorageAutoscalingConfig};
2349    use serde_json::json;
2350
2351    #[test]
2352    fn storage_autoscaling_round_trips_through_json() {
2353        let input = json!({
2354            "storage_mb_min": 1000,
2355            "storage_mb_max": 8000,
2356            "autoscaling": { "storage": { "scale_threshold": 0.8, "scale_factor": 2.0 } }
2357        });
2358        let config: ResourceConfig = serde_json::from_value(input).unwrap();
2359        assert_eq!(config.storage_mb_min, Some(1000));
2360        assert_eq!(config.storage_mb_max, Some(8000));
2361        assert_eq!(
2362            config.autoscaling,
2363            Some(AutoscalingConfig {
2364                storage: Some(StorageAutoscalingConfig {
2365                    scale_threshold: Some(0.8),
2366                    scale_factor: Some(2.0),
2367                }),
2368            })
2369        );
2370        let output = serde_json::to_value(&config).unwrap();
2371        assert_eq!(output["storage_mb_min"], json!(1000));
2372        assert_eq!(
2373            output["autoscaling"]["storage"]["scale_threshold"],
2374            json!(0.8)
2375        );
2376        assert_eq!(output["autoscaling"]["storage"]["scale_factor"], json!(2.0));
2377    }
2378
2379    #[test]
2380    fn autoscaling_fields_default_to_none() {
2381        let config: ResourceConfig = serde_json::from_value(json!({})).unwrap();
2382        assert_eq!(config.storage_mb_min, None);
2383        assert_eq!(config.autoscaling, None);
2384
2385        // Unset autoscaling fields stay out of the serialized config so they
2386        // do not clutter configs of users who never touch them.
2387        let output = serde_json::to_value(&config).unwrap();
2388        assert!(output.get("storage_mb_min").is_none());
2389        assert!(output.get("autoscaling").is_none());
2390        assert!(output.get("storage_mb_max").is_some());
2391
2392        // A partially set policy serializes only the fields the user set.
2393        let input = json!({ "autoscaling": { "storage": { "scale_threshold": 0.9 } } });
2394        let config: ResourceConfig = serde_json::from_value(input.clone()).unwrap();
2395        let output = serde_json::to_value(&config).unwrap();
2396        assert_eq!(output["autoscaling"], input["autoscaling"]);
2397
2398        // A policy object with no storage section is accepted as-is.
2399        let config: ResourceConfig = serde_json::from_value(json!({ "autoscaling": {} })).unwrap();
2400        assert_eq!(config.autoscaling, Some(AutoscalingConfig::default()));
2401    }
2402
2403    #[test]
2404    fn autoscaling_rejects_non_numeric_values() {
2405        let input = json!({ "autoscaling": { "storage": { "scale_threshold": "high" } } });
2406        assert!(serde_json::from_value::<ResourceConfig>(input).is_err());
2407        let input = json!({ "storage_mb_min": -1 });
2408        assert!(serde_json::from_value::<ResourceConfig>(input).is_err());
2409    }
2410}
2411
2412#[cfg(test)]
2413mod sync_config_tests {
2414    use super::SyncConfig;
2415
2416    fn config(bucket: &str, read_bucket: Option<&str>) -> SyncConfig {
2417        SyncConfig {
2418            bucket: bucket.to_owned(),
2419            read_bucket: read_bucket.map(str::to_owned),
2420            ..Default::default()
2421        }
2422    }
2423
2424    #[test]
2425    fn read_bucket_must_differ_from_bucket() {
2426        let err = config("ckpts/a", Some("ckpts/a")).validate().unwrap_err();
2427        assert!(err.contains("different locations"), "{err}");
2428    }
2429
2430    #[test]
2431    fn read_bucket_must_use_the_same_object_store() {
2432        let err = config("gs://ckpts/a", Some("ckpts/b"))
2433            .validate()
2434            .unwrap_err();
2435        assert!(err.contains("same object store"), "{err}");
2436        let err = config("ckpts/a", Some("gs://ckpts/b"))
2437            .validate()
2438            .unwrap_err();
2439        assert!(err.contains("same object store"), "{err}");
2440    }
2441
2442    #[test]
2443    fn matching_object_stores_are_accepted() {
2444        config("gs://ckpts/a", Some("gs://ckpts/b"))
2445            .validate()
2446            .unwrap();
2447        config("ckpts/a", Some("s3://ckpts/b")).validate().unwrap();
2448        config("gs://ckpts/a", None).validate().unwrap();
2449    }
2450}