-
-
Notifications
You must be signed in to change notification settings - Fork 6
feat(stream): add pull-based pipeline runtime [STREAM-1668] #559
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
c06dc04
Full stage based implementation of pull streaming
tryangul b13a354
Handle N:1 and N:0 use cases. Refactor StageResult, handlers and orga…
tryangul bca22ab
Support concurrent stage application.
tryangul 8ec1d2e
Actually commit offsets.
tryangul ce7c45f
Add graceful termination aka cancellation support.
tryangul 4711018
Comment tweaks.
tryangul cfe9d89
handle fatal kafka errors, preserve dlq headers, init coarsetime upda…
tryangul a9cd406
Basic rebalance handling.
tryangul f51cd71
Factor out PipelineRunner. Update examples.
tryangul 58eac3f
Formatting and comment cleanup.
tryangul e0c2e45
Fix stage name lifetimes.
tryangul 546890a
Use singleton updater to prevent leak.
tryangul 7db0dbb
Synchronize drain on rebalance.
tryangul ef7a538
Drain regardless of outcome
tryangul e625c3a
Revert drain on rebalance due to potential threading deadlock.
tryangul File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,97 @@ | ||
| /// Pull-based version of transform_and_produce. | ||
| /// | ||
| /// Pipeline: | ||
| /// KafkaSource → apply(reverse) → on_next(produce) → on_reject(log) → commit | ||
| /// | ||
| /// `PipelineRunner::run()` handles the rebalance restart loop — | ||
| /// the closure is called once per partition assignment with fresh | ||
| /// stages, handlers, and tracker. | ||
| extern crate sentry_arroyo; | ||
|
|
||
| use std::time::Duration; | ||
|
|
||
| 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, OffsetTracker, 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" | ||
| } | ||
| } | ||
|
|
||
| #[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); | ||
| // The pipeline reads left to right, top to bottom: | ||
| // stream — async stream of Kafka messages | ||
| // .apply(&stage) — transform/filter/batch each message | ||
| // .on_next(&handler) — side-effect on successful items (produce, upload) | ||
| // .on_reject(&handler) — handle rejected items (DLQ, log) | ||
| // .commit(&mut tracker) — track offsets, flush on interval | ||
| // | ||
| // PipelineRunner::run() handles the lifecycle: | ||
| // - calls the closure once per partition assignment | ||
| // - on rebalance: closure is called again with a fresh stream | ||
| // - on shutdown or stream end: exits | ||
| let result = PipelineRunner::run(&source, |stream, committer| async { | ||
| let reverse = ReverseStage; | ||
| let producer = KafkaProducer::new(producer_config.clone()); | ||
| let produce_handler = | ||
| KafkaProducerHandler::new(producer, TopicOrPartition::Topic(Topic::new("test_out"))); | ||
| let error_handler = LogHandler; | ||
| let mut tracker = OffsetTracker::new(Duration::from_secs(1), committer); | ||
|
|
||
| stream | ||
| .apply(&reverse) | ||
| .on_next(&produce_handler) | ||
| .on_reject(&error_handler) | ||
| .commit(&mut tracker) | ||
| .await | ||
| }) | ||
| .await; | ||
|
|
||
| if let Err(e) = result { | ||
| tracing::error!("Pipeline stopped: {}", e); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,199 @@ | ||
| use std::time::Instant; | ||
|
|
||
| use futures::stream::Stream; | ||
| use futures::StreamExt; | ||
|
|
||
| use super::offset_tracker::OffsetTracker; | ||
| use crate::{counter, timer}; | ||
|
|
||
| use super::handlers::next::NextHandler; | ||
| use super::handlers::rejection::{RejectionHandler, RejectionMetadata}; | ||
| use super::pipeline_envelope::PipelineEnvelope; | ||
| use super::stage::{PipelineExit, Stage, StageResult}; | ||
|
|
||
| /// Run a stage on an envelope and record metrics. | ||
| async fn run_stage<S: Stage>(stage: &S, envelope: PipelineEnvelope<S::In>) -> StageResult<S::Out> { | ||
| let start = Instant::now(); | ||
| let result = stage.process(envelope).await; | ||
|
|
||
| timer!("arroyo.stage.duration", start.elapsed(), "stage" => stage.name()); | ||
| match &result { | ||
| StageResult::Emit(_) => { | ||
| counter!("arroyo.stage.success", 1, "stage" => stage.name()); | ||
| } | ||
| StageResult::Drop { .. } => { | ||
| counter!("arroyo.stage.drop", 1, "stage" => stage.name()); | ||
| } | ||
| StageResult::Skip => { | ||
| counter!("arroyo.stage.skip", 1, "stage" => stage.name()); | ||
| } | ||
| StageResult::Reject { .. } => { | ||
| counter!("arroyo.stage.reject", 1, "stage" => stage.name()); | ||
| } | ||
| StageResult::Fail(_) => { | ||
| counter!("arroyo.stage.fail", 1, "stage" => stage.name()); | ||
| } | ||
| StageResult::Exit(_) => {} | ||
| } | ||
|
|
||
| result | ||
| } | ||
|
|
||
| /// Extension trait that adds pipeline combinators to any | ||
| /// Stream<Item = StageResult<T>>. | ||
| /// | ||
| /// Combinators: `.apply()`, `.apply_concurrent()`, `.on_next()`, | ||
| /// `.on_reject()`, `.commit()`. | ||
| /// | ||
| /// See `PipelineRunner` for the recommended way to run a pipeline | ||
| /// with rebalance handling. | ||
| pub trait PipelineExt<T: Send>: Stream<Item = StageResult<T>> + Sized { | ||
| /// Apply a processing stage sequentially to each Emit envelope. | ||
| /// Equivalent to apply_concurrent(stage, 1). | ||
| fn apply<'a, S>(self, stage: &'a S) -> impl Stream<Item = StageResult<S::Out>> + 'a | ||
| where | ||
| S: Stage<In = T>, | ||
| Self: 'a, | ||
| T: 'a, | ||
| { | ||
| self.apply_concurrent(stage, 1) | ||
| } | ||
|
|
||
| /// Apply a processing stage concurrently to up to `concurrency` Emit | ||
| /// envelopes at once. Results are yielded in input order. | ||
| /// Non-Emit items pass through immediately. | ||
| fn apply_concurrent<'a, S>( | ||
| self, | ||
| stage: &'a S, | ||
| concurrency: usize, | ||
| ) -> impl Stream<Item = StageResult<S::Out>> + 'a | ||
| where | ||
| S: Stage<In = T>, | ||
| Self: 'a, | ||
| T: 'a, | ||
| { | ||
| assert!(concurrency > 0, "concurrency must be at least 1"); | ||
| self.map(move |item| async move { | ||
| match item { | ||
| StageResult::Emit(e) => run_stage(stage, e).await, | ||
| StageResult::Drop { metadata } => StageResult::Drop { metadata }, | ||
| StageResult::Skip => StageResult::Skip, | ||
| StageResult::Reject { | ||
| metadata, | ||
| raw, | ||
| reason, | ||
| } => StageResult::Reject { | ||
| metadata, | ||
| raw, | ||
| reason, | ||
| }, | ||
| StageResult::Fail(err) => StageResult::Fail(err), | ||
| StageResult::Exit(reason) => StageResult::Exit(reason), | ||
| } | ||
| }) | ||
| .buffered(concurrency) | ||
| } | ||
|
|
||
| /// Call the next handler for each Emit envelope. | ||
| /// If the handler fails, the item becomes Fail. | ||
| /// All other variants pass through untouched. | ||
| fn on_next<'a, H>(self, handler: &'a H) -> impl Stream<Item = StageResult<T>> + 'a | ||
| where | ||
| H: NextHandler<T>, | ||
| Self: 'a, | ||
| T: 'a, | ||
| { | ||
| self.then(move |item| async move { | ||
| let envelope = match item { | ||
| StageResult::Emit(e) => e, | ||
| other => return other, | ||
| }; | ||
|
|
||
| match handler.handle(&envelope).await { | ||
| Ok(()) => StageResult::Emit(envelope), | ||
| Err(produce_err) => StageResult::Fail(produce_err), | ||
| } | ||
| }) | ||
| } | ||
|
|
||
| /// Call the rejection handler for each Reject item. | ||
| /// All other variants pass through untouched. | ||
| /// If the handler itself fails, the item becomes Fail. | ||
| fn on_reject<'a, H>(self, handler: &'a H) -> impl Stream<Item = StageResult<T>> + 'a | ||
| where | ||
| H: RejectionHandler, | ||
| Self: 'a, | ||
| T: 'a, | ||
| { | ||
| self.then(move |item| async move { | ||
| match item { | ||
| StageResult::Reject { | ||
| metadata, | ||
| raw, | ||
| reason, | ||
| } => { | ||
| let rejected = RejectionMetadata { | ||
| metadata: metadata.clone(), | ||
| raw: raw.clone(), | ||
| reason, | ||
| }; | ||
|
|
||
| match handler.handle(&rejected).await { | ||
| Ok(()) => StageResult::Reject { | ||
| metadata, | ||
| raw, | ||
| reason, | ||
| }, | ||
| Err(handler_err) => StageResult::Fail(handler_err), | ||
| } | ||
| } | ||
| other => other, | ||
| } | ||
| }) | ||
| } | ||
|
|
||
| /// Terminal: drive the pipeline to completion. | ||
| /// Tracks offsets for Emit, Drop, and Reject items. | ||
| /// Returns the exit reason (Rebalance, Shutdown, or Complete). | ||
| /// Fail stops the pipeline with an error. | ||
| #[allow(async_fn_in_trait)] | ||
| async fn commit( | ||
| self, | ||
| tracker: &mut OffsetTracker<'_>, | ||
| ) -> Result<PipelineExit, Box<dyn std::error::Error + Send>> { | ||
| let mut stream = Box::pin(self); | ||
|
|
||
| while let Some(item) = stream.next().await { | ||
| match item { | ||
| StageResult::Emit(envelope) => { | ||
| tracker.track(envelope.metadata.partition, envelope.metadata.offset + 1); | ||
| tracker.record_latency(envelope.metadata.timestamp); | ||
| } | ||
| StageResult::Drop { metadata } => { | ||
| tracker.track(metadata.partition, metadata.offset + 1); | ||
| } | ||
| StageResult::Skip => {} | ||
| StageResult::Reject { metadata, .. } => { | ||
| tracker.track(metadata.partition, metadata.offset + 1); | ||
| } | ||
| StageResult::Fail(err) => { | ||
| let _ = tracker.flush(); | ||
| return Err(err); | ||
| } | ||
| StageResult::Exit(reason) => { | ||
| tracker.flush()?; | ||
| return Ok(reason); | ||
| } | ||
| } | ||
|
|
||
| tracker.maybe_commit()?; | ||
| } | ||
|
|
||
| // Stream ended naturally (no Exit item) | ||
| tracker.flush()?; | ||
| Ok(PipelineExit::Complete) | ||
| } | ||
| } | ||
|
|
||
| // Blanket impl: any Stream of StageResult<T> gets these methods. | ||
| impl<T: Send, S> PipelineExt<T> for S where S: Stream<Item = StageResult<T>> {} | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.