Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
319e885
Full stage based implementation of pull streaming
tryangul Jul 31, 2026
da3d724
Handle N:1 and N:0 use cases. Refactor StageResult, handlers and orga…
tryangul Aug 6, 2026
da211d7
Support concurrent stage application.
tryangul Aug 6, 2026
448488a
Actually commit offsets.
tryangul Aug 6, 2026
ada8559
Add graceful termination aka cancellation support.
tryangul Aug 7, 2026
7acfc20
Comment tweaks.
tryangul Aug 7, 2026
a855d06
handle fatal kafka errors, preserve dlq headers, init coarsetime upda…
tryangul Aug 7, 2026
e65d4ac
Basic rebalance handling.
tryangul Aug 7, 2026
5a36b93
Factor out PipelineRunner. Update examples.
tryangul Aug 7, 2026
543b7df
Formatting and comment cleanup.
tryangul Aug 7, 2026
6a6d6b9
Fix stage name lifetimes.
tryangul Aug 7, 2026
44387cd
Use singleton updater to prevent leak.
tryangul Aug 7, 2026
cf646b1
Synchronize drain on rebalance.
tryangul Aug 7, 2026
3f89896
Drain regardless of outcome
tryangul Aug 7, 2026
2e3d449
Revert drain on rebalance due to potential threading deadlock.
tryangul Aug 7, 2026
ad8cbcc
Add BatchStage
tryangul Aug 17, 2026
840632a
Break out stream collector.
tryangul Aug 18, 2026
bace648
Add flush timer
tryangul Aug 18, 2026
36533f3
Buffer now has an Out type
tryangul Aug 19, 2026
4f3c5a7
Formatting
tryangul Aug 19, 2026
bff165d
Add Pipeline trait
tryangul Aug 19, 2026
15e873a
Fmt.
tryangul Aug 19, 2026
e7af45f
Doc test import
tryangul Aug 19, 2026
c2c4b09
Cleanup types with aliases. Convert kafka stats logging to metrics.
tryangul Aug 21, 2026
f79f05b
Cargo fmt.
tryangul Aug 21, 2026
4b03c33
ref(pull): remove unnecessary Arc around raw payload
tryangul Aug 28, 2026
4d1c367
ref(pull): use coarsetime for stage durations.
tryangul Aug 28, 2026
43fd7ed
ref(pull): use parking_lot mutex for batch stage to avoid mutex syscall.
tryangul Aug 28, 2026
13a872d
ref(pull): boxed dyn producer for dlq handler.
tryangul Aug 28, 2026
498b419
ref(pull): owned streams.
tryangul Aug 28, 2026
47e5e8c
ref(pull): Map_emit.
tryangul Aug 28, 2026
47e1a08
ref(pull): Fix-up pipeline runner.
tryangul Aug 29, 2026
b274934
ref(pull): Add batch time metric with flush reason for parity with push.
tryangul Aug 31, 2026
1bf5057
ref(pull): Fmt.
tryangul Aug 31, 2026
ee52fc4
ref(pull): gate pull/stream API behind `pull` feature flag
tryangul Sep 16, 2026
eb619a8
Remove commit() in favor of run(); drop/reject skip offsets. Drop ext…
tryangul Oct 1, 2026
1d6c5c4
fmt and add test entry for ci.
tryangul Oct 1, 2026
5b15448
Update variant comments for clarity.
tryangul Oct 1, 2026
7ee18ac
StreamCollector now implements a vistor for every variant. Try_commit…
tryangul Oct 1, 2026
527c27e
Pass map of offsets through to accommodate batches containing mulitpl…
tryangul Oct 1, 2026
61e4b6c
Merge branch 'main' into rbroughan/pull-based-arroyo-part-2
tryangul Oct 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/rust-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ jobs:
- name: Install system dependencies
run: sudo apt-get update && sudo apt-get install -y libcurl4-openssl-dev
- run: cargo check
- run: cargo check --features pull
- run: cargo fmt --check

test:
Expand All @@ -40,3 +41,5 @@ jobs:
run: sh scripts/run-kafka.sh
- name: Run tests
run: cargo test
- name: Run tests (pull)
run: cargo test --features pull
12 changes: 11 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ path = "rust-arroyo/src/lib.rs"
[features]
ssl = ["rdkafka/ssl"]
gssapi = ["rdkafka/gssapi"]
pull = []

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seems like CI for the new pull feature is not ran, could we run cargo test --features pull in the Rust test matrix?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Let me see what I can do


[dependencies]
async-stream = "0.3"
chrono = "0.4.26"
coarsetime = "0.1.33"
metrics = "0.24"
Expand All @@ -28,12 +30,15 @@ serde = { version = "1.0.137", features = ["derive"] }
serde_json = "1.0.81"
thiserror = "1.0"
tokio = { version = "1.19.2", features = ["full"] }
futures = "0.3"
tokio-stream = { version = "0.1", features = ["time"] }
tokio-util = "0.7"
Comment on lines +33 to +35

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sync with @lvthanh03 on the dependencies you are adding as he is trying to embed arroyo into several applications and he will have to keep the dependencies under control.

tracing = "0.1.40"
uuid = { version = "1.5.0", features = ["v4"] }
parking_lot = "0.12.1"

[dev-dependencies]
tracing-subscriber = "0.3.18"
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }

[[test]]
name = "metrics"
Expand All @@ -54,3 +59,8 @@ path = "rust-arroyo/examples/base_consumer.rs"
[[example]]
name = "reloading_producer"
path = "rust-arroyo/examples/reloading_producer.rs"

[[example]]
name = "transform_and_produce_pull"
path = "rust-arroyo/examples/transform_and_produce_pull.rs"
required-features = ["pull"]
104 changes: 104 additions & 0 deletions rust-arroyo/examples/transform_and_produce_pull.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/// Pull-based version of transform_and_produce.
///
/// Pipeline:
/// KafkaSource → apply(reverse) → on_next(produce) → on_reject(log) → run
///
/// `PipelineRunner::run()` handles the rebalance restart loop —
/// the build closure is called once per partition assignment with fresh
/// stages and handlers.
extern crate sentry_arroyo;

use std::time::Duration;

use futures::Stream;
use sentry_arroyo::backends::kafka::config::KafkaConfig;
use sentry_arroyo::backends::kafka::producer::KafkaProducer;
use sentry_arroyo::backends::kafka::types::KafkaPayload;
use sentry_arroyo::backends::kafka::InitialOffset;
use sentry_arroyo::processing::stream::{
KafkaProducerHandler, KafkaSource, LogHandler, Pipeline, PipelineEnvelope, PipelineExt,
PipelineRunner, Stage, StageResult,
};
use sentry_arroyo::types::{Topic, TopicOrPartition};

/// A Stage that reverses the string payload.
struct ReverseStage;

impl Stage for ReverseStage {
type In = KafkaPayload;
type Out = KafkaPayload;

async fn process(&self, envelope: PipelineEnvelope<KafkaPayload>) -> StageResult<KafkaPayload> {
let reversed = envelope.map_payload(|p| {
let bytes = p.payload().unwrap();
let s = std::str::from_utf8(bytes).unwrap();
let reversed: String = s.chars().rev().collect();
println!("transforming: {:?} -> {:?}", s, reversed);
KafkaPayload::new(
p.key().cloned(),
p.headers().cloned(),
Some(reversed.into_bytes()),
)
});

StageResult::Emit(reversed)
}

fn name(&self) -> &'static str {
"reverse_string"
}
}

struct TransformAndProducePipeline {
reverse: ReverseStage,
produce_handler: KafkaProducerHandler,
error_handler: LogHandler,
}

impl Pipeline for TransformAndProducePipeline {
type Output = KafkaPayload;

fn stream(
self,
source: impl Stream<Item = StageResult<KafkaPayload>> + Send,
) -> impl Stream<Item = StageResult<KafkaPayload>> + Send {
source
.apply(self.reverse)
.on_next(self.produce_handler)
.on_reject(self.error_handler)
}
}

#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init();

let consumer_config = KafkaConfig::new_consumer_config(
vec!["0.0.0.0:9092".to_string()],
"my_group".to_string(),
InitialOffset::Latest,
false,
30_000,
None,
);
let source = KafkaSource::new(consumer_config, &[Topic::new("test_in")]);

let producer_config = KafkaConfig::new_producer_config(vec!["0.0.0.0:9092".to_string()], None);

let result = PipelineRunner::run(&source, Duration::from_secs(1), || {
let producer = KafkaProducer::new(producer_config.clone()).unwrap();
TransformAndProducePipeline {
reverse: ReverseStage,
produce_handler: KafkaProducerHandler::new(
producer,
TopicOrPartition::Topic(Topic::new("test_out")),
),
error_handler: LogHandler,
}
})
.await;

if let Err(e) = result {
tracing::error!("Pipeline stopped: {}", e);
}
}
2 changes: 1 addition & 1 deletion rust-arroyo/src/backends/kafka/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ impl KafkaConsumerState {
}

/// Treat recoverable `librdkafka` errors as an empty poll the same way Python treats `KafkaError._TRANSPORT`.
fn kafka_poll_error_is_recoverable(err: &KafkaError) -> bool {
pub fn kafka_poll_error_is_recoverable(err: &KafkaError) -> bool {
matches!(
err,
KafkaError::MessageConsumption(RDKafkaErrorCode::BrokerTransportFailure)
Expand Down
3 changes: 3 additions & 0 deletions rust-arroyo/src/processing/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ use crate::utils::timing::Deadline;
pub mod dlq;
mod metrics_buffer;
pub mod strategies;
/// Experimental pull-based pipeline API. Gated behind the `pull` feature flag.
#[cfg(feature = "pull")]
pub mod stream;

use strategies::{ProcessingStrategy, ProcessingStrategyFactory};

Expand Down
26 changes: 26 additions & 0 deletions rust-arroyo/src/processing/stream/batch/buffer.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/// Accumulates items for batching. Implementations define how items
/// are stored, how their byte size is measured, and what the flushed
/// output type is.
///
/// The batch stage calls `push()` for each item, uses the returned
/// byte count to track size-based thresholds, and calls `flush()`
/// when a threshold is reached.
pub trait Buffer<T>: Send + Sync {
/// The type returned by `flush()`. For collecting buffers this is `Vec<T>`.
/// For merging buffers this can be a single aggregated value.
type Output: Send + Sync;

/// Add an item to the buffer. Returns the item's size in bytes.
fn push(&mut self, item: T) -> u64;

/// Number of items in the buffer.
fn len(&self) -> u64;

/// Whether the buffer is empty.
fn is_empty(&self) -> bool {
self.len() == 0
}

/// Drain the buffer and return the accumulated output.
fn flush(&mut self) -> Self::Output;
}
139 changes: 139 additions & 0 deletions rust-arroyo/src/processing/stream/batch/flush_timer.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
use std::time::Duration;

use tokio::time::Interval;

use crate::processing::stream::offset_tracker::ensure_time_updater;

/// A monotonic time source for `FlushTimer`.
///
/// Production uses `CoarseClock` (coarsetime's cached global, ~1ns reads).
/// Tests can inject a controllable clock for deterministic timing.
pub trait Clock: Send + Sync {
fn now(&self) -> coarsetime::Instant;
}

/// Production clock — reads from coarsetime's cached global.
pub struct CoarseClock;

impl Clock for CoarseClock {
fn now(&self) -> coarsetime::Instant {
coarsetime::Instant::recent()
}
}

/// Manages time-based flush triggers for `apply_with_timer`.
///
/// Uses coarsetime watermarks checked on each interval tick.
/// No per-item timer wheel operations.
///
/// Two triggers:
/// - **Idle**: flush if no upstream item arrives within `idle_timeout`.
/// Watermark `last_activity` resets on every item.
/// - **Cadence**: flush at `max_cadence` after the first item accumulated.
/// Watermark `batch_start` set once per batch.
pub struct FlushTimer<C: Clock> {
clock: C,
/// Interval tick — poll this in `select!`.
pub interval: Interval,

idle_dur: coarsetime::Duration,
cadence_dur: coarsetime::Duration,

batch_start: coarsetime::Instant,
last_activity: coarsetime::Instant,
}

/// Far-future offset used as a sentinel — makes `is_active()` return false
/// and `should_flush()` return false without branches.
fn far_future() -> coarsetime::Duration {
coarsetime::Duration::from_secs(365 * 24 * 3600)
}

impl FlushTimer<CoarseClock> {
/// Create a production `FlushTimer` with coarsetime and a tokio interval.
pub fn new(idle_timeout: Option<Duration>, max_cadence: Option<Duration>) -> Self {
ensure_time_updater();

let min_dur = [idle_timeout, max_cadence]
.into_iter()
.flatten()
.min()
.unwrap_or(Duration::from_secs(1));
let tick = (min_dur / 10)
.max(Duration::from_millis(1))
.min(Duration::from_millis(100));

Self::with_clock(
CoarseClock,
tokio::time::interval(tick),
idle_timeout,
max_cadence,
)
}
}

impl<C: Clock> FlushTimer<C> {
/// Create a `FlushTimer` with an injected clock and interval.
pub fn with_clock(
clock: C,
interval: Interval,
idle_timeout: Option<Duration>,
max_cadence: Option<Duration>,
) -> Self {
let far = clock.now() + far_future();

Self {
clock,
interval,
idle_dur: idle_timeout.map_or(far_future(), |d| d.into()),
cadence_dur: max_cadence.map_or(far_future(), |d| d.into()),
batch_start: far,
last_activity: far,
}
}

/// Whether a batch is currently accumulating.
pub fn is_active(&self) -> bool {
self.batch_start <= self.clock.now()
}

/// An item was accumulated. Updates watermarks (~1ns).
pub fn on_accumulate(&mut self) {
let now = self.clock.now();
self.last_activity = now;
if self.batch_start > now {
self.batch_start = now;
}
}

/// The batch was flushed. Unsets watermarks.
pub fn on_flush(&mut self) {
self.unset();
}

/// Duration since the batch started accumulating.
/// Returns zero if no batch is active.
pub fn batch_time(&self) -> Duration {
let now = self.clock.now();
if self.batch_start > now {
Duration::ZERO
} else {
now.duration_since(self.batch_start).into()
}
}

/// Check if a flush trigger has fired based on watermarks.
/// Called on each interval tick.
pub fn should_flush(&self) -> bool {
let now = self.clock.now();
now.duration_since(self.batch_start) >= self.cadence_dur
|| now.duration_since(self.last_activity) >= self.idle_dur
}

/// Reset watermarks to far-future sentinels.
fn unset(&mut self) {
let far = self.clock.now() + far_future();
self.batch_start = far;
self.last_activity = far;
}
}
4 changes: 4 additions & 0 deletions rust-arroyo/src/processing/stream/batch/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
pub mod buffer;
pub mod flush_timer;
pub mod stage;
pub mod triggers;
Loading
Loading