#![allow(clippy::print_stdout, clippy::print_stderr)]
use spate::coordination::store::memory::MemoryStore;
use spate::coordination::{CoordinationConfig, StoreCoordinator};
use spate::json::NdjsonFramer;
use spate::prelude::*;
use spate::s3::S3Source;
use spate_test::{TestDeserializer, TestEncoder, capture_sink, decode_rows};
use std::collections::BTreeSet;
use std::time::Duration;
const LEASE: Duration = Duration::from_secs(1);
const OBJECTS: usize = 48;
const RECORDS_PER_OBJECT: usize = 50;
fn config_yaml(data: &std::path::Path) -> String {
format!(
r#"
pipeline: {{ name: s3-coordinated-demo, threads: 2 }}
checkpoint: {{ interval: 100ms }}
metrics: {{ listen: "127.0.0.1:0" }}
source:
s3:
url: "file://{data}/"
split_target_bytes: 1MiB
sink: {{ capture: {{}} }}
"#,
data = data.display(),
)
}
fn run_instance(
instance: &str,
yaml: String,
store: MemoryStore,
) -> Result<(ExitState, Vec<String>), Box<dyn std::error::Error + Send + Sync>> {
let pipeline = Pipeline::from_config(PipelineConfig::from_str(&yaml)?)?;
let coordinator = StoreCoordinator::new(
store,
CoordinationConfig {
lease_duration: LEASE,
op_timeout: Duration::from_millis(250),
instance_id: Some(instance.to_string()),
replan_interval: Duration::from_secs(1),
..CoordinationConfig::default()
},
pipeline.io_handle(),
None,
)?;
let source = S3Source::from_component_config(&pipeline.config().source, pipeline.io_handle())?
.with_framer(|| Box::new(NdjsonFramer::new(1 << 20)))
.with_coordinator(Box::new(coordinator));
let (sink, script) = capture_sink(1, 1);
let pool_cfg = {
let mut cfg = SinkPoolConfig::default();
cfg.batch.linger = Duration::from_millis(20);
cfg
};
let report = pipeline
.sink(sink.with_pool_config(pool_cfg))?
.chains(|ctx| {
let chunk_cfg = ctx.chunk();
chain_owned::<Vec<u8>, _>(TestDeserializer::passthrough())
.with_metrics(ctx.pipeline, "main")
.sink(
TestEncoder,
KeyHashRouter,
chunk_cfg,
ctx.queues,
ctx.budget,
)
.build()
})
.runtime_options(RuntimeOptions {
handle_signals: false,
..RuntimeOptions::default()
})
.run(source)?;
let mut rows = Vec::new();
for write in script.writes() {
for row in decode_rows(&write.payload) {
rows.push(String::from_utf8(row)?);
}
}
Ok((report.state, rows))
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
spate::telemetry::init(spate::telemetry::LogFormat::Pretty, "info");
let root = tempfile::tempdir()?;
let data = root.path().join("data");
std::fs::create_dir_all(&data)?;
let mut expected: BTreeSet<String> = BTreeSet::new();
for o in 0..OBJECTS {
let mut body = String::new();
for r in 0..RECORDS_PER_OBJECT {
let line = format!("{{\"k\":\"obj-{o:02}-{r}\"}}");
body.push_str(&line);
body.push('\n');
expected.insert(line);
}
std::fs::write(data.join(format!("obj-{o:02}.ndjson")), body)?;
}
let store = MemoryStore::new(LEASE);
let yaml = config_yaml(&data);
let workers: Vec<_> = ["instance-a", "instance-b"]
.into_iter()
.map(|instance| {
let store = store.clone();
let yaml = yaml.clone();
std::thread::spawn(move || run_instance(instance, yaml, store))
})
.collect();
let mut union: BTreeSet<String> = BTreeSet::new();
let mut total_captured = 0usize;
for (instance, worker) in ["instance-a", "instance-b"].iter().zip(workers) {
let (state, rows) = worker
.join()
.expect("instance thread")
.map_err(|e| format!("{instance}: {e}"))?;
println!("{instance}: exit={state:?}, rows={}", rows.len());
assert!(
matches!(state, ExitState::Completed),
"{instance} must complete, got {state:?}"
);
total_captured += rows.len();
union.extend(rows);
}
assert_eq!(union, expected, "the union must cover every staged record");
println!(
"union covers all {} records ({total_captured} captured across instances, {} duplicates)",
expected.len(),
total_captured - expected.len()
);
Ok(())
}