Skip to main content

spate_s3/
source.rs

1//! The control plane: [`S3Source`] implements the framework's
2//! [`Source`] trait by delegating the entire assignment choreography to
3//! the coordination layer's `CoordinationDriver`.
4//!
5//! Lifecycle: `open` builds the data store, takes the coordinator the
6//! deployer assembled ([`S3Source::with_coordinator`]; a solo in-process
7//! one is built when none was injected), and stashes the planner; the
8//! first `poll_events` joins the job via `driver.start` (leader-only
9//! listing + packing happen in the planner), and every later call drains
10//! poison reports into `driver.fail` and then delegates to
11//! `driver.poll_events` — split gains materialize lanes through the
12//! sibling [`SplitCtx`], losses retire them, `AllComplete` drains the
13//! pipeline. `commit` routes acked watermarks into per-split fenced
14//! progress commits; the coordination store is the source's **only**
15//! progress store.
16//!
17//! No coordination backend appears in this crate's public API or in its
18//! cargo features: which store a deployment uses (NATS today, others
19//! tomorrow) is assembly wiring, kept out of every connector on purpose.
20//! The one backend it does link is `spate-coordination`'s in-process
21//! `MemoryStore`, unconditionally, as the solo fallback below.
22
23use crate::config::S3SourceConfig;
24use crate::lane::S3Lane;
25use crate::metrics::S3Metrics;
26use crate::planner::{S3Planner, job_fingerprint};
27use crate::split_ctx::SplitCtx;
28use object_store::aws::{AmazonS3Builder, AmazonS3ConfigKey};
29use object_store::{ClientConfigKey, ObjectStore, ObjectStoreScheme};
30use spate_coordination::store::memory::MemoryStore;
31use spate_coordination::{CoordinationConfig, StoreCoordinator};
32use spate_core::config::{ComponentConfig, ConfigError};
33use spate_core::coordination::driver::CoordinationDriver;
34use spate_core::coordination::{PlanFinality, SplitCoordinator};
35use spate_core::error::{ErrorClass, SourceError};
36use spate_core::framing::{FramingContract, RecordFramer};
37use spate_core::metrics::CoordinationMetrics;
38use spate_core::record::PartitionId;
39use spate_core::source::{LaneId, Source, SourceCtx, SourceEvent};
40use std::sync::Arc;
41use std::time::Duration;
42use url::Url;
43
44/// First backoff step for fetcher-internal GET retries.
45const RETRY_BASE: Duration = Duration::from_millis(200);
46
47/// Where the source is in its lifecycle.
48enum State {
49    /// Constructed, not yet opened.
50    Created,
51    /// Opened: driver and lane-assembly context live side by side (they
52    /// borrow disjointly); the planner is handed to the driver on the
53    /// first `poll_events`. Boxed: the open state dwarfs `Created` and
54    /// lives once per source.
55    Open(Box<OpenState>),
56}
57
58/// The contents of [`State::Open`].
59struct OpenState {
60    driver: CoordinationDriver,
61    ctx: SplitCtx,
62    planner: Option<S3Planner>,
63}
64
65/// Coordinated object-storage backfill source. See the crate docs for the
66/// delivery model and the split identity/drift contract.
67pub struct S3Source {
68    config: S3SourceConfig,
69    handle: tokio::runtime::Handle,
70    /// The per-object record framer, supplied by the chosen format via
71    /// [`with_framer`](S3Source::with_framer). Required before the pipeline
72    /// opens the source — `spate-s3` is a transport and owns no framing itself.
73    framer: Option<crate::framer::FramerFactory>,
74    /// A coordinator injected via [`with_coordinator`](S3Source::with_coordinator),
75    /// overriding the config-driven store construction.
76    coordinator: Option<Box<dyn SplitCoordinator>>,
77    /// A data store injected via the `testing`-gated `with_store` seam.
78    store: Option<Arc<dyn ObjectStore>>,
79    state: State,
80}
81
82impl std::fmt::Debug for S3Source {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        f.debug_struct("S3Source")
85            .field("url", &self.config.url)
86            .field("opened", &matches!(self.state, State::Open { .. }))
87            .finish()
88    }
89}
90
91impl S3Source {
92    /// A source over `config`, doing its network I/O on `io` — pass the
93    /// pipeline's I/O runtime handle
94    /// ([`Pipeline::io_handle`](spate_core::pipeline::Pipeline::io_handle)).
95    ///
96    /// The handle **must belong to a multi-thread runtime** that outlives
97    /// the pipeline (the pipeline's I/O runtime satisfies both): the
98    /// source, its lanes, and the coordinator briefly block pipeline
99    /// threads on it, which a current-thread runtime cannot drive.
100    #[must_use]
101    pub fn new(config: S3SourceConfig, io: tokio::runtime::Handle) -> S3Source {
102        S3Source {
103            config,
104            handle: io,
105            framer: None,
106            coordinator: None,
107            store: None,
108            state: State::Created,
109        }
110    }
111
112    /// Build from the pipeline's opaque `source: { s3: ... }` section.
113    pub fn from_component_config(
114        section: &ComponentConfig,
115        io: tokio::runtime::Handle,
116    ) -> Result<S3Source, ConfigError> {
117        Ok(S3Source::new(
118            S3SourceConfig::from_component_config(section)?,
119            io,
120        ))
121    }
122
123    /// Set the record framer that cuts each object's byte stream into records.
124    /// `spate-s3` is a transport and owns no framing, so this is **required**:
125    /// supply the framer for the objects' format — e.g. `spate-json`'s
126    /// `NdjsonFramer` for NDJSON — before the pipeline opens the source.
127    ///
128    /// `factory` builds a fresh
129    /// [`RecordFramer`](spate_core::framing::RecordFramer) per object (framers are
130    /// per-object stateful and each lane frames its own split). A framed source
131    /// always emits one record per payload, so its
132    /// [`FramingContract`] is [`PerRecord`](FramingContract::PerRecord) and the
133    /// paired deserializer decodes a single unit.
134    #[must_use]
135    pub fn with_framer<F>(mut self, factory: F) -> S3Source
136    where
137        F: Fn() -> Box<dyn RecordFramer> + Send + Sync + 'static,
138    {
139        self.framer = Some(Arc::new(factory));
140        self
141    }
142
143    /// Hand the source its coordinator — **the** multi-instance seam.
144    /// Build any [`SplitCoordinator`] at assembly time (e.g.
145    /// `StoreCoordinator` over the NATS store from `spate-coordination`,
146    /// with your own tuning) and inject it here; run more replicas of the
147    /// same pipeline against the same backend and they share the
148    /// backfill. Must be called before the pipeline opens the source.
149    ///
150    /// Without it the source runs **solo** over an in-process store:
151    /// correct and self-terminating, but progress is ephemeral — a
152    /// restart replays the whole prefix (a startup WARN says so).
153    #[must_use]
154    pub fn with_coordinator(mut self, coordinator: Box<dyn SplitCoordinator>) -> S3Source {
155        self.coordinator = Some(coordinator);
156        self
157    }
158
159    /// Test seam: replace the data store built from `config.url` (the URL
160    /// still supplies the listing prefix). Lets tests wrap the store with
161    /// failure- or call-counting decorators.
162    ///
163    /// Behind the off-by-default `testing` feature because it exposes an
164    /// `object_store` type, which this crate's public API otherwise avoids
165    /// entirely; no stability promise attaches to it.
166    #[cfg(feature = "testing")]
167    #[doc(hidden)]
168    #[must_use]
169    pub fn with_store(mut self, store: Arc<dyn ObjectStore>) -> S3Source {
170        self.store = Some(store);
171        self
172    }
173}
174
175impl Source for S3Source {
176    type Lane = S3Lane;
177
178    fn component_type(&self) -> &str {
179        "s3"
180    }
181
182    fn framing_contract(&self) -> FramingContract {
183        // A framed source always emits one record per payload. (The framer is
184        // required; a missing one is caught at `open`.)
185        FramingContract::PerRecord
186    }
187
188    fn open(&mut self, ctx: SourceCtx) -> Result<(), SourceError> {
189        if !matches!(self.state, State::Created) {
190            return Err(SourceError::Client {
191                class: ErrorClass::Fatal,
192                reason: "source opened twice".into(),
193            });
194        }
195        let Some(make_framer) = self.framer.clone() else {
196            return Err(SourceError::Client {
197                class: ErrorClass::Fatal,
198                reason: "S3Source has no record framer; supply one with `with_framer(...)` \
199                         (e.g. spate-json's NdjsonFramer) before running the pipeline"
200                    .into(),
201            });
202        };
203        // Defense in depth for hand-constructed configs; cheap.
204        self.config.validate().map_err(|e| SourceError::Client {
205            class: ErrorClass::Fatal,
206            reason: e.to_string(),
207        })?;
208
209        let url = parse(&self.config.url)?;
210        let (store, prefix) = match self.store.take() {
211            // Injected store: the URL still names the listing prefix.
212            Some(store) => {
213                let (_, path) =
214                    ObjectStoreScheme::parse(&url).map_err(|e| SourceError::Client {
215                        class: ErrorClass::Fatal,
216                        reason: format!("parsing {}: {e}", self.config.url),
217                    })?;
218                (store, path)
219            }
220            None => {
221                let (store, path) =
222                    build_store(&url, &self.config.store).map_err(|e| SourceError::Client {
223                        class: ErrorClass::Fatal,
224                        reason: format!("building the object store for {}: {e}", self.config.url),
225                    })?;
226                (Arc::from(store), path)
227            }
228        };
229
230        let metrics = ctx.meter.as_ref().map(S3Metrics::new);
231        let coordinator = match self.coordinator.take() {
232            Some(injected) => injected,
233            None => {
234                // Coordination metrics are opt-in and constructor-injected
235                // (there is no later hook); the solo coordinator shares
236                // the source's labels.
237                let coord_metrics = ctx
238                    .meter
239                    .as_ref()
240                    .map(|m| CoordinationMetrics::new(m.labels()));
241                solo_coordinator(self.handle.clone(), coord_metrics)?
242            }
243        };
244
245        let finality = if self.config.refresh_listing {
246            PlanFinality::Open
247        } else {
248            PlanFinality::Final
249        };
250        let planner = S3Planner::new(
251            Arc::clone(&store),
252            Some(prefix),
253            self.handle.clone(),
254            self.config.split_target_bytes.as_u64(),
255            finality,
256            job_fingerprint(
257                &self.config.url,
258                self.config.compression,
259                self.config.split_target_bytes.as_u64(),
260                self.config.refresh_listing,
261            ),
262            metrics.clone(),
263        );
264        let split_ctx = SplitCtx::new(
265            store,
266            self.handle.clone(),
267            ctx.issuer,
268            make_framer,
269            self.config.compression,
270            self.config.chunk_bytes.as_u64() as usize,
271            self.config.prefetch_bytes.as_u64() as usize,
272            RETRY_BASE,
273            metrics,
274        );
275        self.state = State::Open(Box::new(OpenState {
276            driver: CoordinationDriver::new(coordinator),
277            ctx: split_ctx,
278            planner: Some(planner),
279        }));
280        Ok(())
281    }
282
283    fn poll_events(&mut self, timeout: Duration) -> Result<SourceEvent<S3Lane>, SourceError> {
284        let State::Open(open) = &mut self.state else {
285            return Err(SourceError::Client {
286                class: ErrorClass::Fatal,
287                reason: "poll_events before open".into(),
288            });
289        };
290        let OpenState {
291            driver,
292            ctx,
293            planner,
294        } = open.as_mut();
295        if let Some(planner) = planner.take() {
296            // Join the job; the empty ready signal — splits arrive as
297            // later assignments while claims race.
298            return driver.start(Box::new(planner));
299        }
300        // Object-level failures first: hand each poisoned split back (a
301        // delivery attempt is consumed; quarantine happens at the cap).
302        for report in ctx.drain_poison() {
303            tracing::warn!(
304                split = %report.split,
305                reason = report.kind.reason_label(),
306                detail = %report.reason,
307                "object-level failure; handing the split back to the coordinator"
308            );
309            driver.fail(ctx, &report.split, &report.reason)?;
310        }
311        driver.poll_events(ctx, timeout)
312    }
313
314    fn commit(&mut self, watermarks: &[(PartitionId, i64)]) -> Result<(), SourceError> {
315        let State::Open(open) = &mut self.state else {
316            debug_assert!(watermarks.is_empty(), "watermarks before open");
317            return Ok(());
318        };
319        let OpenState { driver, ctx, .. } = open.as_mut();
320        driver.commit(ctx, watermarks)
321    }
322
323    // `flush_commits` keeps the trait's no-op default: an Ok fenced commit
324    // is already store-durable, and a Retryable one is driver-cached and
325    // recommitted on the next tick.
326
327    fn pause(&mut self, lanes: &[LaneId]) -> Result<(), SourceError> {
328        if let State::Open(open) = &self.state {
329            open.ctx.set_paused(lanes, true);
330        }
331        Ok(())
332    }
333
334    fn resume(&mut self, lanes: &[LaneId]) -> Result<(), SourceError> {
335        if let State::Open(open) = &self.state {
336            open.ctx.set_paused(lanes, false);
337        }
338        Ok(())
339    }
340}
341
342impl Drop for S3Source {
343    fn drop(&mut self) {
344        if let State::Open(open) = &mut self.state {
345            // Graceful hand-back: peers claim the splits without waiting
346            // out the lease. Fetchers are detached and exit as their lanes
347            // drop.
348            open.driver.release();
349        }
350    }
351}
352
353/// The fallback when no coordinator was injected: a solo, in-process
354/// coordinator with default tuning. Correct and self-terminating, but its
355/// store dies with the process — hence the WARN.
356fn solo_coordinator(
357    handle: tokio::runtime::Handle,
358    metrics: Option<CoordinationMetrics>,
359) -> Result<Box<dyn SplitCoordinator>, SourceError> {
360    tracing::warn!(
361        "no coordinator injected: running solo over an in-process store; progress is \
362         EPHEMERAL and a restart replays the entire prefix (at-least-once, safe but \
363         wasteful) — inject one via with_coordinator (e.g. a StoreCoordinator over a \
364         durable backend) for durable resume and multi-instance sharing"
365    );
366    let tuning = CoordinationConfig::default();
367    let store = MemoryStore::new(tuning.lease_duration);
368    let coordinator =
369        StoreCoordinator::new(store, tuning, handle, metrics).map_err(|e| SourceError::Client {
370            class: e.class(),
371            reason: format!("building the solo coordinator: {e}"),
372        })?;
373    Ok(Box::new(coordinator))
374}
375
376fn parse(url: &str) -> Result<Url, SourceError> {
377    Url::parse(url).map_err(|e| SourceError::Client {
378        class: ErrorClass::Fatal,
379        reason: format!("invalid URL {url}: {e}"),
380    })
381}
382
383fn opts(map: &std::collections::BTreeMap<String, String>) -> Vec<(String, String)> {
384    map.iter().map(|(k, v)| (k.clone(), v.clone())).collect()
385}
386
387/// Build the object store for `url`, applying the raw `store` passthrough
388/// `options`.
389///
390/// For S3, credentials and region are seeded from the standard AWS
391/// environment via [`AmazonS3Builder::from_env`] — the same mechanism the AWS
392/// SDK uses — so EKS Pod Identity, IRSA (web identity), ECS task roles, IMDS,
393/// and `AWS_REGION`/`AWS_DEFAULT_REGION` all resolve without configuration.
394/// The explicit `options` are overlaid on top and win over the environment;
395/// the bucket comes from the URL. `object_store::parse_url_opts` (which every
396/// other scheme still uses) deliberately does *not* read the environment, so
397/// the S3 scheme is special-cased here.
398///
399/// We must not simply pass `std::env::vars()` through `parse_url_opts`:
400/// `AmazonS3ConfigKey` parses *unprefixed* keys (`region`, `token`,
401/// `endpoint`, ...), so an unrelated environment variable could hijack the
402/// client. `from_env` guards on the `AWS_` prefix; we rely on that.
403fn build_store(
404    url: &Url,
405    options: &std::collections::BTreeMap<String, String>,
406) -> Result<(Box<dyn ObjectStore>, object_store::path::Path), object_store::Error> {
407    // `ObjectStoreScheme::parse` returns the scheme and the in-store `Path` —
408    // the very call `parse_url_opts` makes, so `path` is the exact listing
409    // prefix it would return and every planned descriptor that depends on it
410    // stays valid.
411    let (scheme, path) = ObjectStoreScheme::parse(url)?;
412    // Every non-S3 scheme (notably `file://` for infrastructure-free runs and
413    // tests) keeps the stock, scheme-generic behaviour.
414    if !matches!(scheme, ObjectStoreScheme::AmazonS3) {
415        return object_store::parse_url_opts(url, opts(options));
416    }
417    // Harden the HTTP client (idle-connection recycling, explicit timeouts)
418    // before overlaying the operator's `store` options, so the passthrough
419    // still wins — see `harden_client_defaults`.
420    let builder = harden_client_defaults(AmazonS3Builder::from_env().with_url(url.as_str()));
421    let store = apply_options(builder, options).build()?;
422    Ok((Box::new(store), path))
423}
424
425/// Conservative HTTP-client defaults for the S3 backend, applied *before* the
426/// operator's `store` passthrough so the passthrough still overrides them (and
427/// so unrelated client options seeded from the environment — `allow_http`, a
428/// proxy — are left untouched, unlike a wholesale `with_client_options`).
429///
430/// `pool_idle_timeout` is the load-bearing one: object_store leaves it unset, so
431/// a lane can pull a connection the server (or an intermediary) has already
432/// half-closed and fail its next request with an incomplete-message body error
433/// — the churn a back-pressured backfill provokes. Recycling idle connections
434/// after 30s avoids that. `read_timeout` bounds an otherwise-unbounded stalled
435/// body read; `connect_timeout` pins object_store's own 5s default explicitly.
436/// The overall request `timeout` keeps object_store's 30s default: bounded
437/// ranged GETs (see [`fetch`](crate::fetch)) each complete at network speed,
438/// well inside it.
439fn harden_client_defaults(builder: AmazonS3Builder) -> AmazonS3Builder {
440    builder
441        .with_config(
442            AmazonS3ConfigKey::Client(ClientConfigKey::ConnectTimeout),
443            "5s",
444        )
445        .with_config(
446            AmazonS3ConfigKey::Client(ClientConfigKey::ReadTimeout),
447            "30s",
448        )
449        .with_config(
450            AmazonS3ConfigKey::Client(ClientConfigKey::PoolIdleTimeout),
451            "30s",
452        )
453}
454
455/// Overlay the raw `store` passthrough options onto an S3 builder. Keys that
456/// are not recognised object_store configuration are ignored (matching
457/// `parse_url_opts`); a later key wins, so these override any environment
458/// value seeded by [`AmazonS3Builder::from_env`].
459fn apply_options(
460    mut builder: AmazonS3Builder,
461    options: &std::collections::BTreeMap<String, String>,
462) -> AmazonS3Builder {
463    for (k, v) in options {
464        if let Ok(key) = k.to_ascii_lowercase().parse::<AmazonS3ConfigKey>() {
465            builder = builder.with_config(key, v.clone());
466        }
467    }
468    builder
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474    use std::collections::BTreeMap;
475
476    fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
477        pairs
478            .iter()
479            .map(|(k, v)| (k.to_string(), v.to_string()))
480            .collect()
481    }
482
483    #[test]
484    fn apply_options_overrides_env_seeded_values() {
485        // A builder already seeded from the environment (as `from_env` would).
486        let seeded = AmazonS3Builder::new()
487            .with_config(AmazonS3ConfigKey::Region, "env-region")
488            .with_config(AmazonS3ConfigKey::Endpoint, "http://env-endpoint");
489        let b = apply_options(seeded, &map(&[("aws_region", "opt-region")]));
490        assert_eq!(
491            b.get_config_value(&AmazonS3ConfigKey::Region).as_deref(),
492            Some("opt-region"),
493            "an explicit passthrough option overrides the environment"
494        );
495        assert_eq!(
496            b.get_config_value(&AmazonS3ConfigKey::Endpoint).as_deref(),
497            Some("http://env-endpoint"),
498            "a key we did not set leaves the seeded value intact"
499        );
500    }
501
502    #[test]
503    fn apply_options_sets_pod_identity_keys_and_ignores_unknown() {
504        const URI: &str = "http://169.254.170.23/v1/credentials";
505        const TOKEN: &str =
506            "/var/run/secrets/pods.eks.amazonaws.com/serviceaccount/eks-pod-identity-token";
507        let b = apply_options(
508            AmazonS3Builder::new(),
509            &map(&[
510                ("aws_container_credentials_full_uri", URI),
511                ("aws_container_authorization_token_file", TOKEN),
512                // Not an object_store key — must be ignored, not panic.
513                ("not_a_real_key", "whatever"),
514            ]),
515        );
516        assert_eq!(
517            b.get_config_value(&AmazonS3ConfigKey::ContainerCredentialsFullUri)
518                .as_deref(),
519            Some(URI)
520        );
521        assert_eq!(
522            b.get_config_value(&AmazonS3ConfigKey::ContainerAuthorizationTokenFile)
523                .as_deref(),
524            Some(TOKEN)
525        );
526    }
527
528    /// The S3 listing prefix must be byte-identical to what `parse_url_opts`
529    /// produces — planned descriptors and their offsets depend on it.
530    #[test]
531    fn build_store_s3_prefix_matches_parse_url_opts() {
532        let url = Url::parse("s3://bucket/exports/2026/").unwrap();
533        let (_, got) = build_store(&url, &BTreeMap::new()).unwrap();
534        let (_, want) =
535            object_store::parse_url_opts(&url, std::iter::empty::<(String, String)>()).unwrap();
536        assert_eq!(got, want);
537    }
538
539    #[test]
540    fn hardened_client_defaults_are_set_and_overridable() {
541        let idle = AmazonS3ConfigKey::Client(ClientConfigKey::PoolIdleTimeout);
542        let b = harden_client_defaults(AmazonS3Builder::new());
543        assert_eq!(
544            b.get_config_value(&idle).as_deref(),
545            Some("30s"),
546            "pool-idle recycling is on by default, so half-closed connections are \
547             not reused"
548        );
549        // The operator's `store` passthrough still wins over the hardened value.
550        let overridden = apply_options(
551            harden_client_defaults(AmazonS3Builder::new()),
552            &map(&[("pool_idle_timeout", "5s")]),
553        );
554        assert_eq!(
555            overridden.get_config_value(&idle).as_deref(),
556            Some("5s"),
557            "a store: entry overrides the hardened default"
558        );
559    }
560
561    #[test]
562    fn build_store_delegates_non_s3_schemes() {
563        let dir = tempfile::tempdir().unwrap();
564        let url = Url::from_directory_path(dir.path()).unwrap();
565        let (_, got) = build_store(&url, &BTreeMap::new()).unwrap();
566        let (_, want) =
567            object_store::parse_url_opts(&url, std::iter::empty::<(String, String)>()).unwrap();
568        assert_eq!(got, want, "file:// delegates to parse_url_opts unchanged");
569    }
570}