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