1use 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
44const RETRY_BASE: Duration = Duration::from_millis(200);
46
47enum State {
49 Created,
51 Open(Box<OpenState>),
56}
57
58struct OpenState {
60 driver: CoordinationDriver,
61 ctx: SplitCtx,
62 planner: Option<S3Planner>,
63}
64
65pub struct S3Source {
68 config: S3SourceConfig,
69 handle: tokio::runtime::Handle,
70 framer: Option<crate::framer::FramerFactory>,
74 coordinator: Option<Box<dyn SplitCoordinator>>,
77 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 #[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 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 #[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 #[must_use]
154 pub fn with_coordinator(mut self, coordinator: Box<dyn SplitCoordinator>) -> S3Source {
155 self.coordinator = Some(coordinator);
156 self
157 }
158
159 #[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 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 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 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 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 return driver.start(Box::new(planner));
299 }
300 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 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 open.driver.release();
349 }
350 }
351}
352
353fn 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
387fn 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 let (scheme, path) = ObjectStoreScheme::parse(url)?;
412 if !matches!(scheme, ObjectStoreScheme::AmazonS3) {
415 return object_store::parse_url_opts(url, opts(options));
416 }
417 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
425fn 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
455fn 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 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_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 #[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 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}