#![allow(clippy::print_stdout, clippy::print_stderr)]
use spate::checkpoint::{AckIssuer, AckRef};
use spate::coordination::driver::{CoordinationDriver, SplitOpening, SplitSource};
use spate::coordination::store::memory::MemoryStore;
use spate::coordination::{
CoordinationConfig, CoordinationError, PlanContext, PlanFinality, PlannedSplit,
SplitCoordinator, SplitId, SplitPlan, SplitPlanner, SplitProgress, SplitSpec, StoreCoordinator,
};
use spate::error::SourceError;
use spate::prelude::*;
use spate::record::RawPayload;
use spate::source::{LaneId, PayloadBatch, Source, SourceCtx, SourceEvent, SourceLane};
use spate_test::{TestDeserializer, TestEncoder, capture_sink, decode_rows};
use std::collections::{BTreeMap, BTreeSet};
use std::time::Duration;
const LEASE: Duration = Duration::from_secs(1);
const ROWS: i64 = 1_000;
const SPLITS: i64 = 8;
struct LedgerPlanner;
impl SplitPlanner for LedgerPlanner {
fn fingerprint(&self) -> String {
format!("ledger-demo:v1:rows={ROWS}")
}
fn plan(&mut self, _ctx: PlanContext<'_>) -> Result<SplitPlan, CoordinationError> {
let per_split = ROWS / SPLITS;
let splits = (0..SPLITS)
.map(|i| {
let start = i * per_split;
let end = if i == SPLITS - 1 {
ROWS
} else {
start + per_split
};
let id = SplitId::new(format!("rows-{start:06}-{end:06}"))?;
let descriptor = format!("{start}..{end}").into_bytes();
Ok(PlannedSplit::new(
SplitSpec::new(id, descriptor).with_weight((end - start) as u64),
))
})
.collect::<Result<_, CoordinationError>>()?;
Ok(SplitPlan::new(splits, PlanFinality::Final))
}
}
struct LedgerLane {
lane: LaneId,
partition: PartitionId,
issuer: AckIssuer,
start: i64,
end: i64,
next: i64,
buf: Vec<Vec<u8>>,
}
struct LedgerBatch<'a> {
payloads: &'a [Vec<u8>],
partition: PartitionId,
base_offset: i64,
idx: usize,
ack: AckRef,
}
impl<'a> PayloadBatch<'a> for LedgerBatch<'a> {
fn next_payload(&mut self) -> Option<RawPayload<'a>> {
let bytes = self.payloads.get(self.idx)?;
let offset = self.base_offset + self.idx as i64;
self.idx += 1;
Some(RawPayload {
bytes,
key: None,
partition: self.partition,
offset,
timestamp_ms: offset,
})
}
fn ack(&self) -> &AckRef {
&self.ack
}
}
impl SourceLane for LedgerLane {
type Batch<'a> = LedgerBatch<'a>;
fn id(&self) -> LaneId {
self.lane
}
fn partition(&self) -> PartitionId {
self.partition
}
fn poll(
&mut self,
max_records: usize,
timeout: Duration,
) -> Result<Option<Self::Batch<'_>>, SourceError> {
let len = self.end - self.start;
if self.next >= len {
std::thread::sleep(timeout); return Ok(None);
}
let base = self.next;
let end = (base + max_records as i64).min(len);
self.buf.clear();
self.buf
.extend((base..end).map(|o| (self.start + o).to_string().into_bytes()));
self.next = end;
let ack = self.issuer.issue(self.partition, end - 1);
Ok(Some(LedgerBatch {
payloads: &self.buf,
partition: self.partition,
base_offset: base,
idx: 0,
ack,
}))
}
}
struct LedgerCtx {
issuer: Option<AckIssuer>,
ranges: BTreeMap<String, (i64, i64)>,
}
impl SplitSource for LedgerCtx {
type Lane = LedgerLane;
fn open_split(&mut self, opening: SplitOpening<'_>) -> Result<LedgerLane, SourceError> {
let descriptor = String::from_utf8_lossy(&opening.split.descriptor);
let (start, end) = descriptor
.split_once("..")
.and_then(|(a, b)| Some((a.parse().ok()?, b.parse().ok()?)))
.ok_or_else(|| SourceError::Client {
class: spate::error::ErrorClass::Fatal,
reason: format!("undecodable ledger descriptor {descriptor:?}"),
})?;
let resume = opening.resume.map_or(0, |p| p.watermark);
self.ranges
.insert(opening.split.id.as_str().to_string(), (end - start, resume));
Ok(LedgerLane {
lane: opening.lane,
partition: opening.partition,
issuer: self.issuer.clone().expect("open() ran first"),
start,
end,
next: resume,
buf: Vec::new(),
})
}
fn encode_commit(
&mut self,
split: &SplitId,
watermark: i64,
) -> Result<SplitProgress, SourceError> {
let (len, _) = self.ranges[split.as_str()];
Ok(if watermark >= len {
SplitProgress::completed(watermark, vec![])
} else {
SplitProgress::new(watermark, vec![])
})
}
fn sweep(&mut self, _split: &SplitId) -> Result<Option<SplitProgress>, SourceError> {
Ok(None)
}
fn close_split(&mut self, split: &SplitId) {
self.ranges.remove(split.as_str());
}
}
struct LedgerSource {
driver: CoordinationDriver,
ctx: LedgerCtx,
started: bool,
}
impl LedgerSource {
fn new(coordinator: Box<dyn SplitCoordinator>) -> LedgerSource {
LedgerSource {
driver: CoordinationDriver::new(coordinator),
ctx: LedgerCtx {
issuer: None,
ranges: BTreeMap::new(),
},
started: false,
}
}
}
impl Source for LedgerSource {
type Lane = LedgerLane;
fn open(&mut self, ctx: SourceCtx) -> Result<(), SourceError> {
self.ctx.issuer = Some(ctx.issuer);
Ok(())
}
fn poll_events(&mut self, timeout: Duration) -> Result<SourceEvent<LedgerLane>, SourceError> {
if !self.started {
self.started = true;
return self.driver.start(Box::new(LedgerPlanner));
}
self.driver.poll_events(&mut self.ctx, timeout)
}
fn commit(&mut self, watermarks: &[(PartitionId, i64)]) -> Result<(), SourceError> {
self.driver.commit(&mut self.ctx, watermarks)
}
}
impl Drop for LedgerSource {
fn drop(&mut self) {
self.driver.release();
}
}
const CONFIG: &str = r#"
pipeline: { name: ledger-demo, threads: 2 }
checkpoint: { interval: 100ms }
metrics: { listen: 127.0.0.1:0 }
source: { ledger: {} }
sink: { capture: {} }
"#;
fn run_instance(
instance: &str,
store: MemoryStore,
) -> Result<(ExitState, Vec<i64>), Box<dyn std::error::Error + Send + Sync>> {
let pipeline = Pipeline::from_config(PipelineConfig::from_str(CONFIG)?)?;
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 = LedgerSource::new(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)?.parse::<i64>()?);
}
}
Ok((report.state, rows))
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
spate::telemetry::init(spate::telemetry::LogFormat::Pretty, "info");
let store = MemoryStore::new(LEASE);
let workers: Vec<_> = ["instance-a", "instance-b"]
.into_iter()
.map(|instance| {
let store = store.clone();
std::thread::spawn(move || run_instance(instance, store))
})
.collect();
let mut union: BTreeSet<i64> = 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.len() as i64, ROWS, "the union must cover the ledger");
assert_eq!(*union.first().unwrap(), 0);
assert_eq!(*union.last().unwrap(), ROWS - 1);
println!(
"union covers all {ROWS} rows ({} captured across instances, {} duplicates)",
total_captured,
total_captured as i64 - ROWS
);
Ok(())
}