Skip to main content

feldera_types/
config.rs

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