#![allow(clippy::print_stdout, clippy::print_stderr)]
use spate::checkpoint::{AckIssuer, AckRef};
use spate::deser::Owned;
use spate::error::{SinkError, SourceError};
use spate::prelude::*;
use spate::record::{RawPayload, Record};
use spate::sink::{RowEncoder, SealedBatch, ShardWriter};
use spate::source::{LaneId, PayloadBatch, Source, SourceCtx, SourceEvent, SourceLane};
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
struct CounterLane {
id: LaneId,
partition: PartitionId,
issuer: AckIssuer,
next: i64,
limit: i64,
buf: Vec<Vec<u8>>,
}
struct CounterBatch<'a> {
payloads: &'a [Vec<u8>],
partition: PartitionId,
base_offset: i64,
idx: usize,
ack: AckRef,
}
impl<'a> PayloadBatch<'a> for CounterBatch<'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 CounterLane {
type Batch<'a> = CounterBatch<'a>;
fn id(&self) -> LaneId {
self.id
}
fn partition(&self) -> PartitionId {
self.partition
}
fn poll(
&mut self,
max_records: usize,
timeout: Duration,
) -> Result<Option<Self::Batch<'_>>, SourceError> {
if self.next >= self.limit {
std::thread::sleep(timeout);
return Ok(None);
}
let base = self.next;
let end = (base + max_records as i64).min(self.limit);
self.buf.clear();
self.buf
.extend((base..end).map(|n| n.to_string().into_bytes()));
self.next = end;
let ack = self.issuer.issue(self.partition, end - 1);
Ok(Some(CounterBatch {
payloads: &self.buf,
partition: self.partition,
base_offset: base,
idx: 0,
ack,
}))
}
}
struct CounterSource {
per_partition: i64,
partitions: u32,
issuer: Option<AckIssuer>,
handed_out: bool,
commits: Arc<Mutex<BTreeMap<u32, i64>>>,
}
impl Source for CounterSource {
type Lane = CounterLane;
fn open(&mut self, ctx: SourceCtx) -> Result<(), SourceError> {
self.issuer = Some(ctx.issuer);
Ok(())
}
fn poll_events(&mut self, timeout: Duration) -> Result<SourceEvent<CounterLane>, SourceError> {
if !self.handed_out {
self.handed_out = true;
let issuer = self.issuer.as_ref().expect("open() before poll_events");
let lanes = (0..self.partitions)
.map(|p| CounterLane {
id: LaneId(p),
partition: PartitionId(p),
issuer: issuer.clone(),
next: 0,
limit: self.per_partition,
buf: Vec::new(),
})
.collect();
return Ok(SourceEvent::LanesAssigned(lanes));
}
std::thread::sleep(timeout); Ok(SourceEvent::Idle)
}
fn commit(&mut self, watermarks: &[(PartitionId, i64)]) -> Result<(), SourceError> {
let mut commits = self.commits.lock().expect("commits lock");
for (p, offset) in watermarks {
commits.insert(p.0, *offset);
}
Ok(())
}
}
#[derive(Clone)]
struct JsonLinesEncoder;
impl RowEncoder<Owned<Vec<u8>>> for JsonLinesEncoder {
fn encode<'buf>(
&mut self,
rec: &Record<Vec<u8>>,
buf: &mut bytes::BytesMut,
) -> Result<(), SinkError> {
use bytes::BufMut;
buf.put_slice(b"{\"partition\":");
buf.put_slice(rec.meta.partition.0.to_string().as_bytes());
buf.put_slice(b",\"value\":");
buf.put_slice(&rec.payload);
buf.put_slice(b"}\n");
Ok(())
}
}
struct StdoutWriter;
impl ShardWriter for StdoutWriter {
type Endpoint = String;
fn write_batch(
&self,
endpoint: &String,
batch: &SealedBatch,
) -> impl Future<Output = Result<(), SinkError>> + Send {
let mut out = String::new();
for frame in &batch.frames {
out.push_str(&String::from_utf8_lossy(frame));
}
let header = format!(
"── batch {} → {endpoint}: {} rows ──\n",
batch.dedup_token, batch.rows
);
async move {
print!("{header}{out}");
Ok(())
}
}
}
const CONFIG: &str = r#"
pipeline: { name: counter-demo, threads: 2 }
checkpoint: { interval: 100ms }
source: { counter: {} }
sink: { stdout: {} }
"#;
fn main() -> Result<(), Box<dyn std::error::Error>> {
spate::telemetry::init(spate::telemetry::LogFormat::Pretty, "info");
let pipeline = Pipeline::from_config(PipelineConfig::from_str(CONFIG)?)?;
let per_partition = 100;
let commits = Arc::new(Mutex::new(BTreeMap::new()));
let source = CounterSource {
per_partition,
partitions: 2,
issuer: None,
handed_out: false,
commits: Arc::clone(&commits),
};
let pool_cfg = {
let mut cfg = SinkPoolConfig::default();
cfg.batch.linger = Duration::from_millis(50);
cfg
};
let sink = SinkParts::new(
StdoutWriter,
vec![vec!["shard-0".to_string()], vec!["shard-1".to_string()]],
pool_cfg,
)
.with_component_type("stdout");
let runtime = pipeline
.sink(sink)?
.chains(|ctx| {
let chunk_cfg = ctx.chunk();
chain_owned::<Vec<u8>, _>(spate::deser::BytesPassthrough)
.with_metrics(ctx.pipeline, "main")
.sink(
JsonLinesEncoder,
KeyHashRouter,
chunk_cfg,
ctx.queues,
ctx.budget,
)
.build()
})
.runtime_options(RuntimeOptions {
handle_signals: false,
..RuntimeOptions::default()
})
.into_runtime(source)?;
let shutdown = runtime.shutdown_handle();
let join = std::thread::spawn(move || runtime.run());
let deadline = Instant::now() + Duration::from_secs(10);
loop {
{
let commits = commits.lock().expect("commits lock");
if (0..2).all(|p| commits.get(&p) == Some(&per_partition)) {
break;
}
}
assert!(Instant::now() < deadline, "commits not observed in time");
std::thread::sleep(Duration::from_millis(20));
}
shutdown.trigger();
let report = join.join().expect("pipeline thread")?;
println!("\npipeline exit: {:?}", report.state);
println!("committed: {:?}", commits.lock().expect("commits lock"));
Ok(())
}