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