use crate::config::{S3SourceConfig, s3_store_key};
use crate::job_end::{Finish, JobEnd, no_depth};
use crate::lane::S3Lane;
use crate::listing::{UnaddressableConnector, UnaddressableKeys};
use crate::location::{Location, await_fingerprint, feed_fingerprint};
use crate::metrics::S3Metrics;
use crate::split::Packing;
use crate::unit::Scope;
use crate::unit_reader::{LaneParts, UNITS_HELD, UnitReader};
use futures_util::FutureExt as _;
use object_store::aws::{AmazonS3, AmazonS3Builder, AmazonS3ConfigKey};
use object_store::{ClientConfigKey, ObjectStore, ObjectStoreScheme};
use spate_core::clock::tokio::{Clock, SystemClock};
use spate_core::config::{ComponentConfig, ConfigError, FeedSection};
use spate_core::error::{ErrorClass, SourceError};
use spate_core::feed::{DepthOf, Feed, FeedOrigin};
use spate_core::framing::{FramingContract, RecordFramer};
use spate_core::record::PartitionId;
use spate_core::source::{LaneId, Source, SourceCtx, SourceEvent};
use spate_core::task::{self, TaskHandle};
use std::num::{NonZeroU32, NonZeroUsize};
use std::sync::Arc;
use std::time::Duration;
use url::Url;
const RETRY_BASE: Duration = Duration::from_millis(200);
pub(crate) const DEFAULT_RE_ENQUEUE_CAP: u32 = 10;
enum State {
Created,
Feed(Box<UnitReader>),
}
pub struct S3Source {
config: S3SourceConfig,
handle: tokio::runtime::Handle,
framer: Option<crate::framer::FramerFactory>,
store: Option<Arc<dyn ObjectStore>>,
feed: Option<Arc<dyn Feed>>,
units_held: Option<NonZeroUsize>,
re_enqueue_cap: u32,
location: Option<Location>,
location_store: Option<Arc<dyn ObjectStore>>,
clock: Arc<dyn Clock>,
settling_window: Duration,
solo_lister: bool,
solo_ends: bool,
lister_task: Option<TaskHandle<Result<(), SourceError>>>,
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::Created))
.finish()
}
}
impl S3Source {
#[must_use]
pub fn new(config: S3SourceConfig, io: tokio::runtime::Handle) -> S3Source {
S3Source {
config,
handle: io,
framer: None,
store: None,
feed: None,
units_held: None,
re_enqueue_cap: DEFAULT_RE_ENQUEUE_CAP,
location: None,
location_store: None,
clock: Arc::new(SystemClock),
settling_window: spate_core::feed::DEFAULT_SETTLING_WINDOW,
solo_lister: false,
solo_ends: false,
lister_task: 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
}
#[cfg(feature = "testing")]
#[doc(hidden)]
#[must_use]
pub fn with_store(mut self, store: Arc<dyn ObjectStore>) -> S3Source {
self.store = Some(store);
self
}
#[cfg(feature = "testing")]
#[doc(hidden)]
#[must_use]
pub fn with_feed(mut self, feed: Arc<dyn Feed>) -> S3Source {
self.feed = Some(feed);
self
}
#[cfg(feature = "testing")]
#[doc(hidden)]
#[must_use]
pub fn with_location_store(mut self, store: Arc<dyn ObjectStore>) -> S3Source {
self.location_store = Some(store);
self
}
#[cfg(feature = "testing")]
#[doc(hidden)]
#[must_use]
pub fn with_clock(mut self, clock: Arc<dyn Clock>) -> S3Source {
self.clock = clock;
self
}
pub fn list_once(
mut self,
section: &FeedSection,
feed: Arc<dyn Feed>,
) -> Result<(), SourceError> {
let fatal = |reason: String| SourceError::Client {
class: ErrorClass::Fatal,
reason,
};
let backend = section.backend().type_tag();
let location = section
.location
.as_deref()
.map(|location| self.parse_location(location))
.transpose()
.map_err(|e| fatal(e.to_string()))?;
if location.is_some() && backend == "memory" {
return Err(fatal(
"the in-process feed keeps no job location; remove `feed.location`".into(),
));
}
if backend == "memory" {
return Err(fatal(
"a lister run publishes to a durable feed; the in-process feed lives in one \
process, whose own source lists for it"
.into(),
));
}
if location.is_none() {
return Err(fatal(format!(
"a lister on the `{backend}` feed runs a bounded job, which keeps its \
fingerprint at a job location; set `feed.location`"
)));
}
if feed.backend() != backend {
return Err(fatal(format!(
"the `feed:` section names the `{backend}` feed, and the lister run was given \
the `{}` feed; build the feed the section names",
feed.backend()
)));
}
if tokio::runtime::Handle::try_current().is_ok() {
return Err(fatal(
"a lister run blocks its thread until it ends, so it is called from a plain \
thread such as `main`'s, outside any Tokio runtime context: no async code, \
`spawn_blocking` closure or `Handle::enter` guard"
.into(),
));
}
let make_framer = self.framer.clone().ok_or_else(|| {
fatal("S3Source has no record framer; supply one with `with_framer(...)`".into())
})?;
self.config.validate().map_err(|e| fatal(e.to_string()))?;
let (store, prefix, unaddressable, _, resume) = self.data_store()?;
let delimiter = make_framer().resync_delimiter();
let location = match location {
Some(location) => Some((self.location_store(&location)?, location)),
None => None,
};
let lister = crate::lister::Lister {
store,
prefix,
packing: Packing {
target_bytes: self.config.split_target_bytes.as_u64(),
compression: self.config.compression,
delimiter,
},
feed,
location,
resume,
checkpoint_every: crate::lister::CHECKPOINT_EVERY,
unaddressable,
fingerprint: feed_fingerprint(
&self.config.url,
self.config.compression,
self.config.split_target_bytes.as_u64(),
delimiter,
),
retry_base: crate::lister::LISTER_RETRY_BASE,
};
self.handle.block_on(lister.run())
}
}
impl S3Source {
fn data_store(&mut self) -> Result<DataStore, SourceError> {
let url = parse(&self.config.url)?;
Ok(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),
})?;
let bucket = matches!(url.scheme(), "s3" | "s3a")
.then(|| url.host_str().map(str::to_owned))
.flatten();
let resumable = url.scheme() != "file";
(store, path, None, bucket, resumable)
}
None => {
let built =
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(built.store),
built.prefix,
built.unaddressable,
built.bucket,
built.resumable,
)
}
})
}
fn parse_location(&self, location: &str) -> Result<Location, ConfigError> {
let parsed = Location::parse(location, &self.config.url)?;
if !matches!(
ObjectStoreScheme::parse(parsed.url()),
Ok((ObjectStoreScheme::AmazonS3, _))
) {
return Ok(parsed);
}
let refuse = |why: String| ConfigError::Component {
context: "feed".into(),
message: format!("`location` {}: {why}", parsed.shown()),
};
if let Some(key) = self.config.store.keys().find(|k| s3_store_key(k).is_none()) {
return Err(refuse(format!(
"source.s3.store.\"{key}\" is not an object_store S3 option, and the \
location's store is built from it"
)));
}
let options = apply_options(AmazonS3Builder::from_env(), &self.config.store);
let virtual_hosted = flag_set(&options, AmazonS3ConfigKey::VirtualHostedStyleRequest);
let endpoint = [AmazonS3ConfigKey::Endpoint, AmazonS3ConfigKey::S3Endpoint]
.iter()
.any(|key| options.get_config_value(key).is_some());
let source_by_host = Url::parse(&self.config.url).is_ok_and(|u| {
matches!(u.scheme(), "https" | "http")
&& !u
.host_str()
.is_some_and(|h| h == "amazonaws.com" || h.ends_with(".amazonaws.com"))
});
if source_by_host && !endpoint {
return Err(refuse(
"the source's `url` names a store other than AWS by its host, which an `s3://` \
location does not carry; set that store's endpoint under the source's `store`"
.into(),
));
}
if virtual_hosted && endpoint {
return Err(refuse(
"the source's `store` or the environment sends virtual-hosted requests to a \
set endpoint, which names one bucket whatever the location names; remove \
`location`, or `virtual_hosted_style_request` from `store` and \
`AWS_VIRTUAL_HOSTED_STYLE_REQUEST` from the environment"
.into(),
));
}
Ok(parsed)
}
fn location_store(&self, location: &Location) -> Result<Arc<dyn ObjectStore>, SourceError> {
if let Some(store) = &self.location_store {
return Ok(Arc::clone(store));
}
build_store(location.url(), &self.config.store)
.map(|built| Arc::from(built.store))
.map_err(|e| SourceError::Client {
class: ErrorClass::Fatal,
reason: format!(
"building the object store for the job location {}: {e}",
location.shown()
),
})
}
}
impl Source for S3Source {
type Lane = S3Lane;
fn component_type(&self) -> &str {
"s3"
}
fn framing_contract(&self) -> FramingContract {
FramingContract::PerRecord
}
fn configure_feed(
&mut self,
section: &FeedSection,
feed: Arc<dyn Feed>,
origin: FeedOrigin,
) -> Result<(), ConfigError> {
if self.feed.is_some() {
return Err(ConfigError::Component {
context: "feed".into(),
message: "the S3 source was also given a feed with `with_feed`; \
remove the `feed:` section or drop `with_feed`"
.into(),
});
}
self.location = section
.location
.as_deref()
.map(|location| self.parse_location(location))
.transpose()?;
let memory = section.backend().type_tag() == "memory";
if section.settling_window.is_some() && self.location.is_none() && !memory {
return Err(ConfigError::Component {
context: "feed".into(),
message: "`settling_window` times the end of a bounded job, and a job on a \
durable feed is bounded only with a `location`; set `location`, or \
remove `settling_window`"
.into(),
});
}
self.feed = Some(feed);
self.units_held = section.units_held;
self.re_enqueue_cap = section
.attempt_cap
.map_or(DEFAULT_RE_ENQUEUE_CAP, NonZeroU32::get);
self.settling_window = section.settling_window_or_default();
self.solo_lister = memory;
self.solo_ends = memory && origin == FeedOrigin::Section;
Ok(())
}
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 Some(feed) = self.feed.take() else {
return Err(SourceError::Client {
class: ErrorClass::Fatal,
reason: "the S3 source reads work units from a feed, and the pipeline names \
none; add a `feed:` section, such as `feed: { memory: {} }` for a run \
in one process"
.into(),
});
};
let (store, prefix, unaddressable, bucket, resume) = self.data_store()?;
let (units, origin) = units_held(self.units_held, ctx.threads);
tracing::info!(
units_held = units.get(),
origin = origin.as_str(),
threads = ctx.threads.map(NonZeroUsize::get),
"the S3 source holds up to {units} units at once"
);
let metrics = ctx.meter.as_ref().map(S3Metrics::new);
let delimiter = make_framer().resync_delimiter();
if delimiter.is_none() {
tracing::info!(
"the record framer declares no resync delimiter, so objects above \
split_target_bytes are read whole and units naming a byte range are \
failed back to the feed"
);
}
if (self.location.is_some() || self.solo_ends)
&& [DepthOf::Feed, DepthOf::DeadLetters]
.into_iter()
.any(|of| feed.read_depth(of).is_none())
{
return Err(no_depth(feed.as_ref()));
}
let mut finish = None;
if let Some(location) = &self.location {
let location_store = self.location_store(location)?;
let mine = feed_fingerprint(
&self.config.url,
self.config.compression,
self.config.split_target_bytes.as_u64(),
delimiter,
);
tracing::info!(
location = %location.shown(),
window = ?self.settling_window,
"waiting for the job fingerprint at the job location"
);
self.handle.block_on(await_fingerprint(
location_store.as_ref(),
location,
&mine,
self.clock.as_ref(),
&ctx.stop,
self.settling_window,
))?;
finish = Some(Finish::Marker {
store: location_store,
location: location.clone(),
window: self.settling_window,
});
} else if self.solo_ends {
finish = Some(Finish::Solo { finished: false });
}
let end = finish.map(|finish| {
JobEnd::new(
finish,
Arc::clone(&self.clock),
self.handle.clone(),
ctx.stop.clone(),
)
});
let lister_store = Arc::clone(&store);
let parts = LaneParts {
store,
handle: self.handle.clone(),
clock: Arc::clone(&self.clock),
issuer: ctx.issuer,
make_framer,
packing: Packing {
target_bytes: self.config.split_target_bytes.as_u64(),
compression: self.config.compression,
delimiter,
},
chunk_bytes: self.config.chunk_bytes.as_u64() as usize,
range_bytes: self.config.prefetch_bytes.as_u64() as usize,
retry_base: RETRY_BASE,
metrics,
re_enqueue_cap: self.re_enqueue_cap,
};
if self.solo_lister {
tracing::warn!(
"this run lists the prefix into the in-process feed, which keeps nothing \
across a restart, so a restart lists and reads the prefix from the \
beginning; a run that must resume reads a durable feed with a `location`, \
filled by a separate lister run"
);
let lister = crate::lister::Lister {
store: lister_store,
prefix: prefix.clone(),
packing: parts.packing,
feed: Arc::clone(&feed),
location: None,
resume,
checkpoint_every: crate::lister::CHECKPOINT_EVERY,
unaddressable,
fingerprint: String::new(),
retry_base: crate::lister::LISTER_RETRY_BASE,
};
self.lister_task = Some(task::spawn_on("s3-lister", lister.run(), &self.handle));
} else {
tracing::warn!(
feed = feed.backend(),
"the S3 source lists nothing on this feed: it reads the units and storage \
notifications published to it, and publishes only a notification unit it \
re-enqueues after an overwrite"
);
}
let scope = Scope {
bucket,
prefix: prefix.clone(),
};
self.state = State::Feed(Box::new(UnitReader::new(
feed,
parts,
scope,
Some(units),
end,
)));
Ok(())
}
fn poll_events(&mut self, timeout: Duration) -> Result<SourceEvent<S3Lane>, SourceError> {
if let Some(task) = &mut self.lister_task
&& let Some(outcome) = task.now_or_never()
{
self.lister_task = None;
let reason = match outcome {
Ok(Ok(())) => {
if let State::Feed(reader) = &mut self.state {
reader.lister_finished();
}
None
}
Ok(Err(e)) => Some(format!("the solo run's lister failed: {e}")),
Err(e) => Some(format!("the solo run's lister ended: {e}")),
};
if let Some(reason) = reason {
return Err(SourceError::Client {
class: ErrorClass::Fatal,
reason,
});
}
}
match &mut self.state {
State::Feed(reader) => reader.poll_events(timeout),
State::Created => Err(SourceError::Client {
class: ErrorClass::Fatal,
reason: "poll_events before open".into(),
}),
}
}
fn commit(&mut self, watermarks: &[(PartitionId, i64)]) -> Result<(), SourceError> {
match &mut self.state {
State::Feed(reader) => reader.commit(watermarks),
State::Created => {
debug_assert!(watermarks.is_empty(), "watermarks before open");
Ok(())
}
}
}
fn pause(&mut self, lanes: &[LaneId]) -> Result<(), SourceError> {
match &self.state {
State::Feed(reader) => reader.set_paused(lanes, true),
State::Created => {}
}
Ok(())
}
fn resume(&mut self, lanes: &[LaneId]) -> Result<(), SourceError> {
match &self.state {
State::Feed(reader) => reader.set_paused(lanes, false),
State::Created => {}
}
Ok(())
}
fn renew_held(&mut self) {
match &mut self.state {
State::Feed(reader) => reader.renew_held(),
State::Created => {}
}
}
fn reading_on_stop(&mut self) -> bool {
match &mut self.state {
State::Feed(reader) => reader.reading_on_stop(),
State::Created => false,
}
}
fn stop_reading(&mut self) -> Result<(), SourceError> {
match &mut self.state {
State::Feed(reader) => reader.stop_reading(),
State::Created => Ok(()),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum UnitsHeldOrigin {
Explicit,
Derived,
Fallback,
}
impl UnitsHeldOrigin {
fn as_str(self) -> &'static str {
match self {
UnitsHeldOrigin::Explicit => "explicit",
UnitsHeldOrigin::Derived => "derived",
UnitsHeldOrigin::Fallback => "fallback",
}
}
}
fn units_held(
explicit: Option<NonZeroUsize>,
threads: Option<NonZeroUsize>,
) -> (NonZeroUsize, UnitsHeldOrigin) {
match (explicit, threads) {
(Some(units), _) => (units, UnitsHeldOrigin::Explicit),
(None, Some(threads)) => (
threads.saturating_mul(NonZeroUsize::new(2).expect("non-zero")),
UnitsHeldOrigin::Derived,
),
(None, None) => (UNITS_HELD, UnitsHeldOrigin::Fallback),
}
}
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<BuiltStore, object_store::Error> {
let (scheme, path) = ObjectStoreScheme::parse(url)?;
if !matches!(scheme, ObjectStoreScheme::AmazonS3) {
let (store, prefix) = object_store::parse_url_opts(url, opts(options))?;
return Ok(BuiltStore {
store,
prefix,
unaddressable: None,
bucket: None,
resumable: false,
});
}
let builder = harden_client_defaults(AmazonS3Builder::from_env().with_url(url.as_str()));
let unaddressable = UnaddressableKeys::default();
let builder = apply_options(builder, options);
let resumable = !flag_set(&builder, AmazonS3ConfigKey::S3Express);
let store = builder
.with_http_connector(UnaddressableConnector::new(unaddressable.clone()))
.build()?;
Ok(BuiltStore {
bucket: scope_bucket(&store),
store: Box::new(store),
prefix: path,
unaddressable: Some(unaddressable),
resumable,
})
}
fn flag_set(builder: &AmazonS3Builder, key: AmazonS3ConfigKey) -> bool {
builder.get_config_value(&key).is_some_and(|v| {
matches!(
v.to_ascii_lowercase().as_str(),
"1" | "true" | "on" | "yes" | "y"
)
})
}
pub(crate) fn scope_bucket(store: &AmazonS3) -> Option<String> {
store
.to_string()
.strip_prefix("AmazonS3(")
.and_then(|rest| rest.strip_suffix(')'))
.filter(|bucket| !bucket.is_empty())
.map(str::to_owned)
}
type DataStore = (
Arc<dyn ObjectStore>,
object_store::path::Path,
Option<UnaddressableKeys>,
Option<String>,
bool,
);
struct BuiltStore {
store: Box<dyn ObjectStore>,
prefix: object_store::path::Path,
unaddressable: Option<UnaddressableKeys>,
bucket: Option<String>,
resumable: bool,
}
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 Some(key) = s3_store_key(k) {
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() {
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),
]),
);
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 the_feed_sections_attempt_cap_caps_re_enqueues() {
use spate_core::config::FeedSection;
use spate_core::feed::{FeedOrigin, memory::MemoryFeed};
use spate_core::source::Source as _;
let rt = tokio::runtime::Builder::new_current_thread()
.build()
.unwrap();
let source = || {
S3Source::from_component_config(
&spate_test::component_config("s3", "url: \"s3://b/data/\"\n"),
rt.handle().clone(),
)
.unwrap()
};
let configured = |cap: Option<u32>| {
let mut section = FeedSection::new(spate_test::component_config("memory", "{}"));
section.attempt_cap = cap.and_then(NonZeroU32::new);
let mut source = source();
source
.configure_feed(§ion, Arc::new(MemoryFeed::new()), FeedOrigin::Section)
.unwrap();
source.re_enqueue_cap
};
assert_eq!(configured(Some(2)), 2);
assert_eq!(configured(None), DEFAULT_RE_ENQUEUE_CAP);
let given = source().with_feed(Arc::new(MemoryFeed::new()));
assert_eq!(given.re_enqueue_cap, DEFAULT_RE_ENQUEUE_CAP);
assert_eq!(DEFAULT_RE_ENQUEUE_CAP, 10);
}
#[test]
fn pause_and_resume_reach_the_held_lanes() {
use crate::split::{DescriptorObject, SplitDescriptor};
use spate_core::checkpoint::Checkpointer;
use spate_core::feed::Unit;
use spate_core::feed::memory::MemoryFeed;
use spate_core::source::{SourceCtx, SourceLane as _};
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("a.ndjson"), b"{\"k\":1}\n").unwrap();
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(1)
.enable_all()
.build()
.unwrap();
let feed = MemoryFeed::new();
let key = dir
.path()
.join("a.ndjson")
.to_string_lossy()
.trim_start_matches('/')
.to_owned();
let body = SplitDescriptor::new(vec![DescriptorObject {
key,
size: 9,
etag: None,
last_modified_ms: 1,
}])
.encode()
.unwrap();
drop(feed.publish(Unit::new("a", body)));
let mut source = S3Source::from_component_config(
&spate_test::component_config(
"s3",
&format!("url: \"file://{}/\"\n", dir.path().display()),
),
rt.handle().clone(),
)
.unwrap()
.with_framer(|| Box::new(crate::testutil::TestLineFramer::new(1 << 20)))
.with_feed(Arc::new(feed));
source
.open(SourceCtx::new(Checkpointer::new().handle()))
.unwrap();
let mut added = None;
spate_test::wait_until(
Duration::from_secs(30),
"the source adds the unit's lane",
|| {
if let SourceEvent::LanesAdded(mut lanes) =
source.poll_events(Duration::from_millis(50)).unwrap()
{
added = Some(lanes.remove(0).id());
}
added.is_some()
},
);
let lane = added.expect("a lane was added");
let paused = |source: &S3Source| match &source.state {
State::Feed(reader) => reader.is_paused(lane),
State::Created => None,
};
assert_eq!(paused(&source), Some(false));
source.pause(&[lane]).unwrap();
assert_eq!(paused(&source), Some(true));
source.resume(&[lane]).unwrap();
assert_eq!(paused(&source), Some(false));
}
#[test]
fn apply_options_applies_upper_case_keys() {
let b = apply_options(
AmazonS3Builder::new(),
&map(&[("AWS_ENDPOINT_URL", "http://127.0.0.1:9000")]),
);
assert_eq!(
b.get_config_value(&AmazonS3ConfigKey::Endpoint).as_deref(),
Some("http://127.0.0.1:9000")
);
}
#[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().prefix;
let (_, want) =
object_store::parse_url_opts(&url, std::iter::empty::<(String, String)>()).unwrap();
assert_eq!(got, want);
}
#[test]
fn the_scope_bucket_is_the_built_stores_for_every_s3_url_form() {
for url in [
"s3://my-bucket/data/",
"s3a://my-bucket/data/",
"https://s3.eu-west-2.amazonaws.com/my-bucket/data/",
"https://my-bucket.s3.eu-west-2.amazonaws.com/data/",
] {
let built = build_store(&Url::parse(url).unwrap(), &BTreeMap::new()).unwrap();
assert_eq!(built.bucket.as_deref(), Some("my-bucket"), "{url}");
assert_eq!(built.prefix.as_ref(), "data", "{url}");
}
for url in [
"https://s3.eu-west-2.amazonaws.com/",
"https://s3.eu-west-2.amazonaws.com//data/",
"https://.s3.eu-west-2.amazonaws.com/data/",
] {
let built = build_store(&Url::parse(url).unwrap(), &BTreeMap::new()).unwrap();
assert_eq!(built.bucket, None, "{url} names no bucket");
}
let dir = tempfile::tempdir().unwrap();
let file = Url::from_directory_path(dir.path()).unwrap();
assert_eq!(build_store(&file, &BTreeMap::new()).unwrap().bucket, None);
}
#[test]
fn only_an_s3_client_without_s3_express_resumes() {
let built = build_store(&Url::parse("s3://b/data/").unwrap(), &BTreeMap::new()).unwrap();
assert!(built.resumable);
let express = BTreeMap::from([("aws_s3_express".to_owned(), "true".to_owned())]);
let built = build_store(
&Url::parse("s3://b--usw2-az1--x-s3/data/").unwrap(),
&express,
)
.unwrap();
assert!(!built.resumable);
let dir = tempfile::tempdir().unwrap();
let file = Url::from_directory_path(dir.path()).unwrap();
assert!(!build_store(&file, &BTreeMap::new()).unwrap().resumable);
}
#[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().prefix;
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");
}
fn source_with_store(store: &str) -> (S3Source, tokio::runtime::Runtime) {
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(1)
.enable_all()
.build()
.unwrap();
let body = format!("url: \"file:///tmp/spate-location/data/\"\nstore:\n{store}");
let source = S3Source::from_component_config(
&spate_test::component_config("s3", &body),
rt.handle().clone(),
)
.unwrap();
(source, rt)
}
#[test]
fn the_location_store_uses_the_source_store_options() {
let endpoint = crate::test_servers::status_server("403 Forbidden");
let (source, rt) = source_with_store(&format!(
" endpoint: \"{endpoint}\"\n allow_http: \"true\"\n access_key_id: \"AKIDTEST\"\n \
secret_access_key: \"test\"\n region: \"us-east-1\"\n"
));
let location = source.parse_location("s3://ops/jobs/a/").unwrap();
let store = source.location_store(&location).unwrap();
let err = rt
.block_on(crate::location::read_object(
store.as_ref(),
&location,
&location.fingerprint(),
))
.unwrap_err()
.to_string();
assert!(
err.contains("403") && err.contains("s3:ListBucket"),
"{err}"
);
}
#[test]
fn a_location_beside_a_virtual_hosted_endpoint_is_refused() {
let (source, _rt) = source_with_store(
" endpoint: \"https://b.store.example\"\n virtual_hosted_style_request: \"true\"\n",
);
let err = source
.parse_location("s3://elsewhere/jobs/a/")
.unwrap_err()
.to_string();
assert!(err.contains("virtual_hosted_style_request"), "{err}");
for store in [
" endpoint: \"https://b.store.example\"\n virtual_hosted_style_request: \"True\"\n",
" endpoint: \"https://b.store.example\"\n virtual_hosted_style_request: \"1\"\n",
" aws_endpoint_url_s3: \"https://b.store.example\"\n virtual_hosted_style_request: \"yes\"\n",
" endpoint: \"https://b.store.example\"\n virtual_hosted_style_request: \"on\"\n",
" endpoint: \"https://b.store.example\"\n virtual_hosted_style_request: \"Y\"\n",
] {
let (source, _rt) = source_with_store(store);
let err = source
.parse_location("s3://elsewhere/jobs/a/")
.unwrap_err()
.to_string();
assert!(
err.contains("virtual_hosted_style_request"),
"{store}: {err}"
);
assert!(
err.contains("AWS_VIRTUAL_HOSTED_STYLE_REQUEST"),
"{store}: {err}"
);
}
for store in [
" endpoint: \"https://store.example\"\n",
" virtual_hosted_style_request: \"true\"\n",
" endpoint: \"https://store.example\"\n virtual_hosted_style_request: \"false\"\n",
] {
let (source, _rt) = source_with_store(store);
source.parse_location("s3://elsewhere/jobs/a/").unwrap();
}
}
#[test]
fn a_location_beside_a_source_named_by_another_host_needs_an_endpoint() {
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(1)
.enable_all()
.build()
.unwrap();
let source = |store: &str| {
let body =
format!("url: \"https://0123abcd.r2.cloudflarestorage.com/data/events/\"\n{store}");
S3Source::from_component_config(
&spate_test::component_config("s3", &body),
rt.handle().clone(),
)
.unwrap()
};
let err = source("")
.parse_location("s3://ops/jobs/a/")
.unwrap_err()
.to_string();
assert!(err.contains("endpoint"), "{err}");
source("store:\n endpoint: \"https://0123abcd.r2.cloudflarestorage.com\"\n")
.parse_location("s3://ops/jobs/a/")
.unwrap();
let aws = S3Source::from_component_config(
&spate_test::component_config(
"s3",
"url: \"https://data.s3.eu-west-2.amazonaws.com/events/\"\n",
),
rt.handle().clone(),
)
.unwrap();
aws.parse_location("s3://ops/jobs/a/").unwrap();
}
#[test]
fn an_s3_location_refuses_a_store_key_the_s3_client_does_not_know() {
let (source, _rt) = source_with_store(" endpont: \"http://127.0.0.1:9000\"\n");
let err = source
.parse_location("s3://ops/jobs/a/")
.unwrap_err()
.to_string();
assert!(err.contains("\"endpont\""), "{err}");
let (source, _rt) = source_with_store(" endpont: \"http://127.0.0.1:9000\"\n");
let dir = tempfile::tempdir().unwrap();
source
.parse_location(&format!("file://{}/jobs/a/", dir.path().display()))
.unwrap();
}
#[test]
fn units_held_resolves_the_section_then_the_thread_count_then_the_fallback() {
let n = |v: usize| NonZeroUsize::new(v);
let cases = [
((None, None), (96, UnitsHeldOrigin::Fallback)),
((None, n(1)), (2, UnitsHeldOrigin::Derived)),
((None, n(3)), (6, UnitsHeldOrigin::Derived)),
(
(None, n(usize::MAX)),
(usize::MAX, UnitsHeldOrigin::Derived),
),
((n(5), n(3)), (5, UnitsHeldOrigin::Explicit)),
((n(6), n(3)), (6, UnitsHeldOrigin::Explicit)),
((n(7), None), (7, UnitsHeldOrigin::Explicit)),
];
for ((explicit, threads), (units, origin)) in cases {
let (got, got_origin) = units_held(explicit, threads);
assert_eq!(
(got.get(), got_origin),
(units, origin),
"explicit {explicit:?}, threads {threads:?}"
);
}
}
}