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