mod support;
use spate_coordination::StoreCoordinator;
use spate_coordination::store::memory::MemoryStore;
use spate_core::pipeline::ExitState;
use std::collections::BTreeMap;
use std::fs;
use std::path::PathBuf;
use std::time::{Duration, Instant};
use support::spy::{GetRecord, RangeKind, SpyOptions, StoreSpy, spying_local_store};
use support::{
Launched, TEST_LEASE, captured_rows, launch_customized, line_framer, lines_bytes, recs,
shared_store, sorted, test_options, test_tuning,
};
fn config_yaml(data: &std::path::Path, prefetch: &str, chunk: &str) -> String {
format!(
r#"
pipeline: {{ name: s3-request-shape, threads: 2 }}
admin: {{ listen: none }}
checkpoint: {{ interval: 100ms }}
metrics: {{ exporter: none }}
source:
s3:
url: "file://{data}/"
split_target_bytes: 1MiB
refresh_listing: false
prefetch_bytes: {prefetch}
chunk_bytes: {chunk}
sink: {{ capture: {{}} }}
"#,
data = data.display(),
)
}
struct Staged {
_dir: tempfile::TempDir,
data: PathBuf,
sizes: BTreeMap<String, u64>,
expected: Vec<String>,
}
impl Staged {
fn names(&self) -> Vec<String> {
self.sizes.keys().cloned().collect()
}
fn size(&self, name: &str) -> u64 {
self.sizes[name]
}
}
fn stage(objects: usize, records: usize) -> Staged {
let dir = tempfile::tempdir().unwrap();
let data = dir.path().join("data");
fs::create_dir_all(&data).unwrap();
let mut sizes = BTreeMap::new();
let mut expected = Vec::new();
for i in 0..objects {
let name = format!("obj-{i:02}.ndjson");
let lines = recs(&format!("o{i:02}"), records);
let bytes = lines_bytes(&lines);
sizes.insert(name.clone(), bytes.len() as u64);
fs::write(data.join(&name), &bytes).unwrap();
expected.extend(lines);
}
Staged {
_dir: dir,
data,
sizes,
expected,
}
}
fn launch_spied(
yaml: &str,
store: &MemoryStore,
instance: &str,
max_in_flight: u32,
options: SpyOptions,
) -> (Launched, StoreSpy) {
let (spy_store, spy) = spying_local_store(options);
let store = store.clone();
let mut tuning = test_tuning();
tuning.instance_id = Some(instance.to_string());
tuning.max_in_flight = max_in_flight;
let launched = launch_customized(
yaml,
test_options(),
|_| {},
move |source, io| {
let coordinator =
StoreCoordinator::new(store, tuning, io, None).expect("coordinator builds");
line_framer(source)
.with_coordinator(Box::new(coordinator))
.with_store(spy_store)
},
);
(launched, spy)
}
fn by_object(gets: &[GetRecord]) -> BTreeMap<String, Vec<RangeKind>> {
let mut out: BTreeMap<String, Vec<RangeKind>> = BTreeMap::new();
for get in gets {
out.entry(get.object().to_string())
.or_default()
.push(get.range);
}
out
}
fn assert_tiles_exactly(name: &str, ranges: &[RangeKind], size: u64) {
let mut windows: Vec<(u64, u64)> = ranges
.iter()
.map(|range| match range {
RangeKind::Bounded(start, end) => (*start, *end),
other => panic!(
"{name}: an ETag-pinned object is read as bounded windows, got {other:?} \
(the whole-object streaming fallback re-reads from byte zero on every retry)"
),
})
.collect();
windows.sort_unstable();
let mut at = 0;
for (start, end) in &windows {
assert!(end > start, "{name}: empty window {start}..{end}");
assert_eq!(
*start, at,
"{name}: window {start}..{end} does not continue at byte {at} — an overlapping \
or duplicated read (windows: {windows:?})"
);
at = *end;
}
assert_eq!(
at, size,
"{name}: windows cover {at} bytes of a {size}-byte object (windows: {windows:?})"
);
}
#[test]
fn a_bounded_backfill_lists_once_and_reads_each_object_in_one_get() {
let staged = stage(16, 20);
let yaml = config_yaml(&staged.data, "8MiB", "512KiB");
let (l, spy) = launch_spied(
&yaml,
&shared_store(),
"shape-one-get",
4,
SpyOptions::default(),
);
let report = l.run.wait_exit(Duration::from_secs(60)).unwrap().unwrap();
assert_eq!(report.state, ExitState::Completed);
assert_eq!(
sorted(captured_rows(&l.script)),
sorted(staged.expected.clone()),
"the run delivers every record — request counts only mean something \
against a complete backfill"
);
assert_eq!(
spy.lists(),
1,
"one LIST for the whole run: the elected leader's plan. Workers read \
descriptors, never listings"
);
let gets = by_object(&spy.gets());
assert_eq!(
gets.keys().cloned().collect::<Vec<_>>(),
staged.names(),
"every staged object is read, and nothing else is"
);
for (name, ranges) in &gets {
assert_eq!(
ranges.len(),
1,
"{name}: one GET per object ({ranges:?}) — a second GET is a request \
the backfill pays for twice"
);
assert_tiles_exactly(name, ranges, staged.size(name));
}
}
#[test]
fn windowed_reads_tile_each_object_without_overlap_or_repeat() {
let staged = stage(8, 400);
let yaml = config_yaml(&staged.data, "4KiB", "1KiB");
let (l, spy) = launch_spied(
&yaml,
&shared_store(),
"shape-windows",
4,
SpyOptions::default(),
);
let report = l.run.wait_exit(Duration::from_secs(60)).unwrap().unwrap();
assert_eq!(report.state, ExitState::Completed);
assert_eq!(
sorted(captured_rows(&l.script)),
sorted(staged.expected.clone())
);
let gets = by_object(&spy.gets());
assert_eq!(gets.keys().cloned().collect::<Vec<_>>(), staged.names());
for (name, ranges) in &gets {
assert!(
ranges.len() > 1,
"{name}: a {}-byte object must span several 4 KiB windows, got {ranges:?}",
staged.size(name)
);
assert_tiles_exactly(name, ranges, staged.size(name));
}
}
#[test]
fn concurrent_reads_reach_the_in_flight_budget() {
let staged = stage(64, 20);
let yaml = config_yaml(&staged.data, "8MiB", "512KiB");
let (l, spy) = launch_spied(
&yaml,
&shared_store(),
"shape-depth",
4,
SpyOptions {
gate_depth: 4,
..SpyOptions::default()
},
);
let report = l.run.wait_exit(Duration::from_secs(60)).unwrap().unwrap();
assert_eq!(report.state, ExitState::Completed);
assert_eq!(
sorted(captured_rows(&l.script)),
sorted(staged.expected.clone())
);
assert_eq!(
spy.peak_concurrent_gets(),
4,
"four in-flight splits must issue four concurrent get_opts calls"
);
assert_eq!(spy.lists(), 1, "still one LIST across four lanes");
assert_eq!(
spy.gets().len(),
staged.names().len(),
"one GET per object, whichever lane read it"
);
}
#[test]
fn a_final_plan_is_never_re_listed() {
let staged = stage(12, 20);
let yaml = config_yaml(&staged.data, "8MiB", "512KiB");
let hold = Duration::from_millis(300);
let (l, spy) = launch_spied(
&yaml,
&shared_store(),
"shape-final-plan",
1,
SpyOptions {
hold,
..SpyOptions::default()
},
);
let started = Instant::now();
let report = l.run.wait_exit(Duration::from_secs(120)).unwrap().unwrap();
let elapsed = started.elapsed();
assert_eq!(report.state, ExitState::Completed);
assert_eq!(
sorted(captured_rows(&l.script)),
sorted(staged.expected.clone())
);
assert!(
elapsed > TEST_LEASE * 2,
"the run must outlive the interval an open plan re-lists at, or \
lists == 1 proves nothing: {elapsed:?}"
);
assert_eq!(
spy.lists(),
1,
"a final plan is never re-listed, however long the job runs"
);
}