use crate::config::S3SourceConfig;
use crate::lane::S3Lane;
use crate::metrics::S3Metrics;
use crate::planner::{S3Planner, job_fingerprint};
use crate::split_ctx::SplitCtx;
use object_store::aws::{AmazonS3Builder, AmazonS3ConfigKey};
use object_store::{ClientConfigKey, ObjectStore, ObjectStoreScheme};
use spate_coordination::store::memory::MemoryStore;
use spate_coordination::{CoordinationConfig, StoreCoordinator};
use spate_core::config::{ComponentConfig, ConfigError};
use spate_core::coordination::driver::CoordinationDriver;
use spate_core::coordination::{PlanFinality, SplitCoordinator};
use spate_core::error::{ErrorClass, SourceError};
use spate_core::framing::{FramingContract, RecordFramer};
use spate_core::metrics::CoordinationMetrics;
use spate_core::record::PartitionId;
use spate_core::source::{LaneId, Source, SourceCtx, SourceEvent};
use std::sync::Arc;
use std::time::Duration;
use url::Url;
const RETRY_BASE: Duration = Duration::from_millis(200);
enum State {
Created,
Open(Box<OpenState>),
}
struct OpenState {
driver: CoordinationDriver,
ctx: SplitCtx,
planner: Option<S3Planner>,
}
pub struct S3Source {
config: S3SourceConfig,
handle: tokio::runtime::Handle,
framer: Option<crate::framer::FramerFactory>,
coordinator: Option<Box<dyn SplitCoordinator>>,
store: Option<Arc<dyn ObjectStore>>,
state: State,
}
impl std::fmt::Debug for S3Source {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("S3Source")
.field("url", &self.config.url)
.field("opened", &matches!(self.state, State::Open { .. }))
.finish()
}
}
impl S3Source {
#[must_use]
pub fn new(config: S3SourceConfig, io: tokio::runtime::Handle) -> S3Source {
S3Source {
config,
handle: io,
framer: None,
coordinator: None,
store: None,
state: State::Created,
}
}
pub fn from_component_config(
section: &ComponentConfig,
io: tokio::runtime::Handle,
) -> Result<S3Source, ConfigError> {
Ok(S3Source::new(
S3SourceConfig::from_component_config(section)?,
io,
))
}
#[must_use]
pub fn with_framer<F>(mut self, factory: F) -> S3Source
where
F: Fn() -> Box<dyn RecordFramer> + Send + Sync + 'static,
{
self.framer = Some(Arc::new(factory));
self
}
#[must_use]
pub fn with_coordinator(mut self, coordinator: Box<dyn SplitCoordinator>) -> S3Source {
self.coordinator = Some(coordinator);
self
}
#[cfg(feature = "testing")]
#[doc(hidden)]
#[must_use]
pub fn with_store(mut self, store: Arc<dyn ObjectStore>) -> S3Source {
self.store = Some(store);
self
}
}
impl Source for S3Source {
type Lane = S3Lane;
fn component_type(&self) -> &str {
"s3"
}
fn framing_contract(&self) -> FramingContract {
FramingContract::PerRecord
}
fn open(&mut self, ctx: SourceCtx) -> Result<(), SourceError> {
if !matches!(self.state, State::Created) {
return Err(SourceError::Client {
class: ErrorClass::Fatal,
reason: "source opened twice".into(),
});
}
let Some(make_framer) = self.framer.clone() else {
return Err(SourceError::Client {
class: ErrorClass::Fatal,
reason: "S3Source has no record framer; supply one with `with_framer(...)` \
(e.g. spate-json's NdjsonFramer) before running the pipeline"
.into(),
});
};
self.config.validate().map_err(|e| SourceError::Client {
class: ErrorClass::Fatal,
reason: e.to_string(),
})?;
let url = parse(&self.config.url)?;
let (store, prefix) = match self.store.take() {
Some(store) => {
let (_, path) =
ObjectStoreScheme::parse(&url).map_err(|e| SourceError::Client {
class: ErrorClass::Fatal,
reason: format!("parsing {}: {e}", self.config.url),
})?;
(store, path)
}
None => {
let (store, path) =
build_store(&url, &self.config.store).map_err(|e| SourceError::Client {
class: ErrorClass::Fatal,
reason: format!("building the object store for {}: {e}", self.config.url),
})?;
(Arc::from(store), path)
}
};
let metrics = ctx.meter.as_ref().map(S3Metrics::new);
let coordinator = match self.coordinator.take() {
Some(injected) => injected,
None => {
let coord_metrics = ctx
.meter
.as_ref()
.map(|m| CoordinationMetrics::new(m.labels()));
solo_coordinator(self.handle.clone(), coord_metrics)?
}
};
let finality = if self.config.refresh_listing {
PlanFinality::Open
} else {
PlanFinality::Final
};
let planner = S3Planner::new(
Arc::clone(&store),
Some(prefix),
self.handle.clone(),
self.config.split_target_bytes.as_u64(),
finality,
job_fingerprint(
&self.config.url,
self.config.compression,
self.config.split_target_bytes.as_u64(),
self.config.refresh_listing,
),
metrics.clone(),
);
let split_ctx = SplitCtx::new(
store,
self.handle.clone(),
ctx.issuer,
make_framer,
self.config.compression,
self.config.chunk_bytes.as_u64() as usize,
self.config.prefetch_bytes.as_u64() as usize,
RETRY_BASE,
metrics,
);
self.state = State::Open(Box::new(OpenState {
driver: CoordinationDriver::new(coordinator),
ctx: split_ctx,
planner: Some(planner),
}));
Ok(())
}
fn poll_events(&mut self, timeout: Duration) -> Result<SourceEvent<S3Lane>, SourceError> {
let State::Open(open) = &mut self.state else {
return Err(SourceError::Client {
class: ErrorClass::Fatal,
reason: "poll_events before open".into(),
});
};
let OpenState {
driver,
ctx,
planner,
} = open.as_mut();
if let Some(planner) = planner.take() {
return driver.start(Box::new(planner));
}
for report in ctx.drain_poison() {
tracing::warn!(
split = %report.split,
reason = report.kind.reason_label(),
detail = %report.reason,
"object-level failure; handing the split back to the coordinator"
);
driver.fail(ctx, &report.split, &report.reason)?;
}
driver.poll_events(ctx, timeout)
}
fn commit(&mut self, watermarks: &[(PartitionId, i64)]) -> Result<(), SourceError> {
let State::Open(open) = &mut self.state else {
debug_assert!(watermarks.is_empty(), "watermarks before open");
return Ok(());
};
let OpenState { driver, ctx, .. } = open.as_mut();
driver.commit(ctx, watermarks)
}
fn pause(&mut self, lanes: &[LaneId]) -> Result<(), SourceError> {
if let State::Open(open) = &self.state {
open.ctx.set_paused(lanes, true);
}
Ok(())
}
fn resume(&mut self, lanes: &[LaneId]) -> Result<(), SourceError> {
if let State::Open(open) = &self.state {
open.ctx.set_paused(lanes, false);
}
Ok(())
}
}
impl Drop for S3Source {
fn drop(&mut self) {
if let State::Open(open) = &mut self.state {
open.driver.release();
}
}
}
fn solo_coordinator(
handle: tokio::runtime::Handle,
metrics: Option<CoordinationMetrics>,
) -> Result<Box<dyn SplitCoordinator>, SourceError> {
tracing::warn!(
"no coordinator injected: running solo over an in-process store; progress is \
EPHEMERAL and a restart replays the entire prefix (at-least-once, safe but \
wasteful) — inject one via with_coordinator (e.g. a StoreCoordinator over a \
durable backend) for durable resume and multi-instance sharing"
);
let tuning = CoordinationConfig::default();
let store = MemoryStore::new(tuning.lease_duration);
let coordinator =
StoreCoordinator::new(store, tuning, handle, metrics).map_err(|e| SourceError::Client {
class: e.class(),
reason: format!("building the solo coordinator: {e}"),
})?;
Ok(Box::new(coordinator))
}
fn parse(url: &str) -> Result<Url, SourceError> {
Url::parse(url).map_err(|e| SourceError::Client {
class: ErrorClass::Fatal,
reason: format!("invalid URL {url}: {e}"),
})
}
fn opts(map: &std::collections::BTreeMap<String, String>) -> Vec<(String, String)> {
map.iter().map(|(k, v)| (k.clone(), v.clone())).collect()
}
fn build_store(
url: &Url,
options: &std::collections::BTreeMap<String, String>,
) -> Result<(Box<dyn ObjectStore>, object_store::path::Path), object_store::Error> {
let (scheme, path) = ObjectStoreScheme::parse(url)?;
if !matches!(scheme, ObjectStoreScheme::AmazonS3) {
return object_store::parse_url_opts(url, opts(options));
}
let builder = harden_client_defaults(AmazonS3Builder::from_env().with_url(url.as_str()));
let store = apply_options(builder, options).build()?;
Ok((Box::new(store), path))
}
fn harden_client_defaults(builder: AmazonS3Builder) -> AmazonS3Builder {
builder
.with_config(
AmazonS3ConfigKey::Client(ClientConfigKey::ConnectTimeout),
"5s",
)
.with_config(
AmazonS3ConfigKey::Client(ClientConfigKey::ReadTimeout),
"30s",
)
.with_config(
AmazonS3ConfigKey::Client(ClientConfigKey::PoolIdleTimeout),
"30s",
)
}
fn apply_options(
mut builder: AmazonS3Builder,
options: &std::collections::BTreeMap<String, String>,
) -> AmazonS3Builder {
for (k, v) in options {
if let Ok(key) = k.to_ascii_lowercase().parse::<AmazonS3ConfigKey>() {
builder = builder.with_config(key, v.clone());
}
}
builder
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
#[test]
fn apply_options_overrides_env_seeded_values() {
let seeded = AmazonS3Builder::new()
.with_config(AmazonS3ConfigKey::Region, "env-region")
.with_config(AmazonS3ConfigKey::Endpoint, "http://env-endpoint");
let b = apply_options(seeded, &map(&[("aws_region", "opt-region")]));
assert_eq!(
b.get_config_value(&AmazonS3ConfigKey::Region).as_deref(),
Some("opt-region"),
"an explicit passthrough option overrides the environment"
);
assert_eq!(
b.get_config_value(&AmazonS3ConfigKey::Endpoint).as_deref(),
Some("http://env-endpoint"),
"a key we did not set leaves the seeded value intact"
);
}
#[test]
fn apply_options_sets_pod_identity_keys_and_ignores_unknown() {
const URI: &str = "http://169.254.170.23/v1/credentials";
const TOKEN: &str =
"/var/run/secrets/pods.eks.amazonaws.com/serviceaccount/eks-pod-identity-token";
let b = apply_options(
AmazonS3Builder::new(),
&map(&[
("aws_container_credentials_full_uri", URI),
("aws_container_authorization_token_file", TOKEN),
("not_a_real_key", "whatever"),
]),
);
assert_eq!(
b.get_config_value(&AmazonS3ConfigKey::ContainerCredentialsFullUri)
.as_deref(),
Some(URI)
);
assert_eq!(
b.get_config_value(&AmazonS3ConfigKey::ContainerAuthorizationTokenFile)
.as_deref(),
Some(TOKEN)
);
}
#[test]
fn build_store_s3_prefix_matches_parse_url_opts() {
let url = Url::parse("s3://bucket/exports/2026/").unwrap();
let (_, got) = build_store(&url, &BTreeMap::new()).unwrap();
let (_, want) =
object_store::parse_url_opts(&url, std::iter::empty::<(String, String)>()).unwrap();
assert_eq!(got, want);
}
#[test]
fn hardened_client_defaults_are_set_and_overridable() {
let idle = AmazonS3ConfigKey::Client(ClientConfigKey::PoolIdleTimeout);
let b = harden_client_defaults(AmazonS3Builder::new());
assert_eq!(
b.get_config_value(&idle).as_deref(),
Some("30s"),
"pool-idle recycling is on by default, so half-closed connections are \
not reused"
);
let overridden = apply_options(
harden_client_defaults(AmazonS3Builder::new()),
&map(&[("pool_idle_timeout", "5s")]),
);
assert_eq!(
overridden.get_config_value(&idle).as_deref(),
Some("5s"),
"a store: entry overrides the hardened default"
);
}
#[test]
fn build_store_delegates_non_s3_schemes() {
let dir = tempfile::tempdir().unwrap();
let url = Url::from_directory_path(dir.path()).unwrap();
let (_, got) = build_store(&url, &BTreeMap::new()).unwrap();
let (_, want) =
object_store::parse_url_opts(&url, std::iter::empty::<(String, String)>()).unwrap();
assert_eq!(got, want, "file:// delegates to parse_url_opts unchanged");
}
}