Skip to main content

spate_s3/
config.rs

1//! S3 source configuration: typed fields plus a validated raw
2//! `object_store` option passthrough.
3//!
4//! Deliberately absent: any coordination-backend configuration. The
5//! source takes a fully built coordinator at assembly time
6//! ([`S3Source::with_coordinator`](crate::S3Source::with_coordinator)) —
7//! which backend to use, and its tuning, is the deployer's wiring, not
8//! this connector's.
9//!
10//! What is here splits in two. `url`, `compression`, `split_target_bytes`
11//! and `refresh_listing` shape the **work itself**: they feed the job
12//! fingerprint and must be byte-equal across every instance of a
13//! coordinated job. `prefetch_bytes`, `chunk_bytes` and `store` are how
14//! *this* instance reads that work, and may differ per replica.
15
16use bytesize::ByteSize;
17use serde::{Deserialize, Serialize};
18use spate_core::config::{ComponentConfig, ConfigError};
19use std::collections::BTreeMap;
20use url::Url;
21
22/// `object_store` option keys the framework owns. Setting them through the
23/// passthrough is rejected at load time with an explanation.
24///
25/// `object_store` accepts several spellings for the same underlying option
26/// (its config keys parse with and without the `aws_` prefix and with
27/// historic aliases), and lowercases keys before parsing — so the check
28/// here is case-insensitive over every spelling. Because the store is
29/// built from the `url` field, a passthrough bucket key could never take
30/// effect (the URL's bucket wins at build time); rejecting it keeps the
31/// config honest instead of silently ignoring it.
32const DENYLIST: &[(&str, &str)] = &[
33    ("bucket", "owned by the typed `url` field"),
34    ("bucket_name", "owned by the typed `url` field"),
35    ("aws_bucket", "owned by the typed `url` field"),
36    ("aws_bucket_name", "owned by the typed `url` field"),
37];
38
39fn default_prefetch_bytes() -> ByteSize {
40    ByteSize::mib(8)
41}
42
43fn default_chunk_bytes() -> ByteSize {
44    ByteSize::kib(512)
45}
46
47fn default_split_target_bytes() -> ByteSize {
48    ByteSize::mib(64)
49}
50
51/// Floor for `split_target_bytes`: below ~1 MiB the per-object open-cost
52/// floor collapses and packing degenerates into thousands of one-object
53/// splits, taxing the coordination store for no read-parallelism gain.
54const SPLIT_TARGET_FLOOR: u64 = 1024 * 1024;
55
56/// Debug view of a raw option map: keys are configuration, values may be
57/// credentials (`aws_secret_access_key`, session tokens) — never print
58/// them.
59struct Redacted<'a>(&'a BTreeMap<String, String>);
60
61impl std::fmt::Debug for Redacted<'_> {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        f.debug_map()
64            .entries(self.0.keys().map(|k| (k, "<redacted>")))
65            .finish()
66    }
67}
68
69/// Compression codec of the objects under the prefix.
70#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq, Serialize)]
71#[serde(rename_all = "kebab-case")]
72#[non_exhaustive]
73pub enum Compression {
74    /// Decide per object by key extension: `.gz`/`.gzip` → gzip,
75    /// `.zst`/`.zstd` → zstd, anything else → uncompressed.
76    #[default]
77    Auto,
78    /// Every object is uncompressed.
79    None,
80    /// Every object is gzip (multi-member streams are fully read).
81    Gzip,
82    /// Every object is zstd (multi-frame streams are fully read).
83    Zstd,
84}
85
86/// Configuration of an `S3Source`, deserialized from the pipeline's opaque
87/// `source: { s3: ... }` section.
88#[derive(Clone, Deserialize, PartialEq)]
89#[serde(deny_unknown_fields)]
90#[non_exhaustive]
91pub struct S3SourceConfig {
92    /// Bucket and prefix to backfill, e.g. `s3://my-bucket/exports/2026/`.
93    /// Every object under the prefix is read; the planned key set is
94    /// pinned per split (see the crate docs). `file://` URLs work for
95    /// infrastructure-free runs and tests.
96    pub url: String,
97    /// Compression codec of the objects.
98    #[serde(default)]
99    pub compression: Compression,
100    /// Target size of one split — the unit of work distribution, and the
101    /// unit the leader balances. Objects are packed toward it in listing
102    /// order with a
103    /// per-object cost floor of a sixteenth of the target, so a split
104    /// holds at most ~16 objects; an object at or above the target gets a
105    /// split of its own. Part of the job fingerprint: every instance of a
106    /// coordinated job must agree on it. Default 64 MiB.
107    #[serde(default = "default_split_target_bytes")]
108    pub split_target_bytes: ByteSize,
109    /// Re-list the prefix on the coordinator's replan interval, planning
110    /// newly arrived objects as additional splits (an **open** plan: the
111    /// job never self-terminates). Default `false`: one listing, a
112    /// bounded backfill that completes.
113    #[serde(default)]
114    pub refresh_listing: bool,
115    /// Per-lane read-ahead budget: the size of each lane's in-memory read
116    /// window. A lane fetches one window at a time as a bounded ranged GET and
117    /// drains it before fetching the next, so peak per-lane *read-ahead* is
118    /// ~one window and total source read-ahead is
119    /// `in-flight splits × prefetch_bytes` (the decoded records a lane holds
120    /// while framing are a separate, smaller budget). An object at or below
121    /// this size is read in a single GET.
122    ///
123    /// Keep this modest. A large window is read as one long-held GET — which
124    /// works against the connection release this source relies on — and a
125    /// single window must complete inside the client request timeout (the
126    /// `store` `timeout` option, 30s by default), so an oversized window on a
127    /// slow link degrades into repeated whole-window retries. The 8 MiB default
128    /// suits typical objects.
129    #[serde(default = "default_prefetch_bytes")]
130    pub prefetch_bytes: ByteSize,
131    /// Target size of a single buffered chunk.
132    #[serde(default = "default_chunk_bytes")]
133    pub chunk_bytes: ByteSize,
134    /// Raw `object_store` options, applied when building the store from
135    /// `url` (credentials, region, endpoint, timeouts, ...). Bucket keys
136    /// are rejected — the bucket comes from `url`.
137    #[serde(default)]
138    pub store: BTreeMap<String, String>,
139}
140
141// Hand-written: the `store` map carries credentials; `{:?}` must never
142// print them.
143impl std::fmt::Debug for S3SourceConfig {
144    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
145        f.debug_struct("S3SourceConfig")
146            .field("url", &self.url)
147            .field("compression", &self.compression)
148            .field("split_target_bytes", &self.split_target_bytes)
149            .field("refresh_listing", &self.refresh_listing)
150            .field("prefetch_bytes", &self.prefetch_bytes)
151            .field("chunk_bytes", &self.chunk_bytes)
152            .field("store", &Redacted(&self.store))
153            .finish()
154    }
155}
156
157impl S3SourceConfig {
158    /// Deserialize and validate from the pipeline's opaque component
159    /// section.
160    pub fn from_component_config(section: &ComponentConfig) -> Result<Self, ConfigError> {
161        let cfg: S3SourceConfig = section.deserialize_into()?;
162        cfg.validate()?;
163        Ok(cfg)
164    }
165
166    /// Cross-field validation, including the passthrough denylist.
167    pub fn validate(&self) -> Result<(), ConfigError> {
168        parse_url("source.s3.url", &self.url)?;
169        if self.chunk_bytes.as_u64() == 0 {
170            return Err(ConfigError::Validation(
171                "source.s3.chunk_bytes must not be zero".into(),
172            ));
173        }
174        if self.prefetch_bytes.as_u64() < self.chunk_bytes.as_u64() {
175            return Err(ConfigError::Validation(format!(
176                "source.s3.prefetch_bytes ({}) must be at least chunk_bytes ({})",
177                self.prefetch_bytes, self.chunk_bytes
178            )));
179        }
180        if self.split_target_bytes.as_u64() < SPLIT_TARGET_FLOOR {
181            return Err(ConfigError::Validation(format!(
182                "source.s3.split_target_bytes ({}) must be at least 1MiB",
183                self.split_target_bytes
184            )));
185        }
186        // Case-insensitive: object_store lowercases option keys before
187        // parsing, so `Bucket:` would otherwise slip past the check.
188        for key in self.store.keys() {
189            let lowered = key.to_ascii_lowercase();
190            if let Some((_, why)) = DENYLIST.iter().find(|(denied, _)| *denied == lowered) {
191                return Err(ConfigError::Validation(format!(
192                    "source.s3.store.\"{key}\" cannot be overridden: {why}"
193                )));
194            }
195        }
196        Ok(())
197    }
198}
199
200/// Parse and reject URLs the source cannot honor.
201fn parse_url(field: &str, value: &str) -> Result<Url, ConfigError> {
202    if value.trim().is_empty() {
203        return Err(ConfigError::Validation(format!(
204            "{field} must not be empty"
205        )));
206    }
207    let url = Url::parse(value)
208        .map_err(|e| ConfigError::Validation(format!("{field} is not a valid URL: {e}")))?;
209    // `memory://` builds a fresh empty store on every parse, so the source
210    // and any test setup would each see a different store.
211    if url.scheme() == "memory" {
212        return Err(ConfigError::Validation(format!(
213            "{field}: memory:// creates a new empty store per component and cannot be \
214             shared; use file:// for infrastructure-free runs"
215        )));
216    }
217    Ok(url)
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    fn section(body: &str) -> ComponentConfig {
225        let yaml = format!("s3:\n{body}");
226        let value: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap();
227        ComponentConfig::new("s3", value["s3"].clone())
228    }
229
230    fn minimal() -> String {
231        "  url: s3://bucket/exports/\n".to_string()
232    }
233
234    #[test]
235    fn minimal_config_gets_documented_defaults() {
236        let cfg = S3SourceConfig::from_component_config(&section(&minimal())).unwrap();
237        assert_eq!(cfg.compression, Compression::Auto);
238        assert_eq!(cfg.prefetch_bytes, ByteSize::mib(8));
239        assert_eq!(cfg.chunk_bytes, ByteSize::kib(512));
240        assert_eq!(cfg.split_target_bytes, ByteSize::mib(64));
241        assert!(!cfg.refresh_listing);
242        assert!(cfg.store.is_empty());
243    }
244
245    #[test]
246    fn planner_knobs_parse_and_floor() {
247        let body = format!(
248            "{}  split_target_bytes: 32MB\n  refresh_listing: true\n",
249            minimal()
250        );
251        let cfg = S3SourceConfig::from_component_config(&section(&body)).unwrap();
252        assert_eq!(cfg.split_target_bytes, ByteSize::mb(32));
253        assert!(cfg.refresh_listing);
254
255        let low = format!("{}  split_target_bytes: 64KB\n", minimal());
256        let err = S3SourceConfig::from_component_config(&section(&low)).unwrap_err();
257        assert!(err.to_string().contains("split_target_bytes"), "{err}");
258    }
259
260    #[test]
261    fn removed_knobs_are_rejected() {
262        // `lanes`, `checkpoint:`, and a source-level `coordination:` all
263        // died with the manifest checkpoint and the assembly-only
264        // coordinator wiring; deny_unknown_fields turns a stale config
265        // into a load error.
266        for extra in [
267            "  lanes: 4\n",
268            "  checkpoint:\n    url: s3://bucket/_etl/b.json\n",
269            "  coordination:\n    nats:\n      servers: [\"nats://n:4222\"]\n",
270        ] {
271            let body = format!("{}{extra}", minimal());
272            assert!(
273                S3SourceConfig::from_component_config(&section(&body)).is_err(),
274                "must reject removed knob: {extra}"
275            );
276        }
277    }
278
279    #[test]
280    fn debug_never_prints_store_values() {
281        let body = format!(
282            "{}  store:\n    aws_secret_access_key: hunter2\n",
283            minimal()
284        );
285        let cfg = S3SourceConfig::from_component_config(&section(&body)).unwrap();
286        let printed = format!("{cfg:?}");
287        assert!(!printed.contains("hunter2"), "{printed}");
288        assert!(
289            printed.contains("aws_secret_access_key") && printed.contains("<redacted>"),
290            "keys stay visible for debugging: {printed}"
291        );
292    }
293
294    #[test]
295    fn bucket_aliases_are_rejected_in_the_passthrough() {
296        // object_store lowercases option keys before parsing, so the
297        // denylist must catch every case spelling too.
298        for key in [
299            "bucket",
300            "bucket_name",
301            "aws_bucket",
302            "aws_bucket_name",
303            "Bucket",
304            "AWS_BUCKET_NAME",
305        ] {
306            let body = format!("{}  store:\n    {key}: other\n", minimal());
307            let err = S3SourceConfig::from_component_config(&section(&body)).unwrap_err();
308            assert!(err.to_string().contains(key), "error names the key: {err}");
309        }
310    }
311
312    #[test]
313    fn memory_scheme_is_rejected_with_guidance() {
314        let err = S3SourceConfig::from_component_config(&section("  url: memory:///data/\n"))
315            .unwrap_err();
316        assert!(err.to_string().contains("file://"), "actionable: {err}");
317    }
318
319    #[test]
320    fn bad_buffer_sizes_are_rejected() {
321        for extra in [
322            "  chunk_bytes: 0\n",
323            "  prefetch_bytes: 4KiB\n  chunk_bytes: 1MiB\n",
324        ] {
325            let body = format!("{}{extra}", minimal());
326            assert!(
327                S3SourceConfig::from_component_config(&section(&body)).is_err(),
328                "must reject: {extra}"
329            );
330        }
331    }
332
333    #[test]
334    fn unknown_fields_and_unknown_variants_are_rejected() {
335        for extra in ["  prefix: also-here\n", "  compression: xz\n"] {
336            let body = format!("{}{extra}", minimal());
337            assert!(
338                S3SourceConfig::from_component_config(&section(&body)).is_err(),
339                "must reject: {extra}"
340            );
341        }
342    }
343
344    #[test]
345    fn file_urls_are_accepted() {
346        S3SourceConfig::from_component_config(&section("  url: file:///tmp/objects/\n")).unwrap();
347    }
348}