@@ -4,9 +4,11 @@ use std::time::{Duration, Instant};
44use prost:: Message as ProstMessage ;
55use sentry_arroyo:: backends:: kafka:: types:: KafkaPayload ;
66use sentry_arroyo:: processing:: strategies:: {
7- CommitRequest , InvalidMessage , ProcessingStrategy , StrategyError , SubmitError ,
7+ merge_commit_request, CommitRequest , InvalidMessage , MessageRejected , ProcessingStrategy ,
8+ StrategyError , SubmitError ,
89} ;
910use sentry_arroyo:: types:: { InnerMessage , Message , Partition } ;
11+ use sentry_arroyo:: utils:: timing:: Deadline ;
1012use sentry_protos:: snuba:: v1:: TraceItem ;
1113
1214use crate :: types:: { AggregatedOutcomesBatch , BucketKey } ;
@@ -51,6 +53,10 @@ pub struct OutcomesAggregator<TNext> {
5153 batch : AggregatedOutcomesBatch ,
5254 /// Latest broker offset seen per partition across all buckets.
5355 latest_offsets : HashMap < Partition , u64 > ,
56+ /// A message rejected by the next step, to be retried on the next poll.
57+ message_carried_over : Option < Message < AggregatedOutcomesBatch > > ,
58+ /// Commit request carried over from a poll where we had a message to retry.
59+ commit_request_carried_over : Option < CommitRequest > ,
5460}
5561
5662impl < TNext > OutcomesAggregator < TNext > {
@@ -68,13 +74,16 @@ impl<TNext> OutcomesAggregator<TNext> {
6874 last_flush : Instant :: now ( ) ,
6975 batch : AggregatedOutcomesBatch :: new ( bucket_interval) ,
7076 latest_offsets : HashMap :: new ( ) ,
77+ message_carried_over : None ,
78+ commit_request_carried_over : None ,
7179 }
7280 }
7381
7482 fn flush ( & mut self ) -> Result < ( ) , StrategyError >
7583 where
7684 TNext : ProcessingStrategy < AggregatedOutcomesBatch > ,
7785 {
86+ let num_buckets = self . batch . num_buckets ( ) ;
7887 let batch = std:: mem:: replace (
7988 & mut self . batch ,
8089 AggregatedOutcomesBatch :: new ( self . bucket_interval ) ,
@@ -87,25 +96,26 @@ impl<TNext> OutcomesAggregator<TNext> {
8796 . map ( |( partition, offset) | ( * partition, offset + 1 ) )
8897 . collect ( ) ;
8998
90- let message = Message :: new_any_message ( batch. clone ( ) , committable) ;
99+ let message = Message :: new_any_message ( batch, committable) ;
91100
92101 match self . next_step . submit ( message) {
93102 Ok ( ( ) ) => {
94- // Keep the batch cleared only after a successful forward to next_step.
95- self . last_flush = Instant :: now ( ) ;
103+ let now = Instant :: now ( ) ;
104+ let seconds = ( now - self . last_flush ) . as_secs_f64 ( ) ;
105+ tracing:: debug!(
106+ "flushed {} buckets after {} seconds, with committable {:?}" ,
107+ num_buckets,
108+ seconds,
109+ latest_offsets
110+ ) ;
111+ self . last_flush = now;
96112 Ok ( ( ) )
97113 }
98- Err ( SubmitError :: MessageRejected ( _) ) => {
99- tracing:: warn!( "Message rejected by CommitOutcomes during flush" ) ;
100- self . batch = batch;
101- self . latest_offsets = latest_offsets;
114+ Err ( SubmitError :: MessageRejected ( rejected) ) => {
115+ self . message_carried_over = Some ( rejected. message ) ;
102116 Ok ( ( ) )
103117 }
104- Err ( SubmitError :: InvalidMessage ( e) ) => {
105- self . batch = batch;
106- self . latest_offsets = latest_offsets;
107- Err ( StrategyError :: InvalidMessage ( e) )
108- }
118+ Err ( SubmitError :: InvalidMessage ( e) ) => Err ( StrategyError :: InvalidMessage ( e) ) ,
109119 }
110120 }
111121}
@@ -114,15 +124,39 @@ impl<TNext: ProcessingStrategy<AggregatedOutcomesBatch>> ProcessingStrategy<Kafk
114124 for OutcomesAggregator < TNext >
115125{
116126 fn poll ( & mut self ) -> Result < Option < CommitRequest > , StrategyError > {
117- if self . batch . num_buckets ( ) >= self . max_batch_size
118- || self . last_flush . elapsed ( ) >= self . max_batch_time_ms
127+ let commit_request = self . next_step . poll ( ) ?;
128+ self . commit_request_carried_over =
129+ merge_commit_request ( self . commit_request_carried_over . take ( ) , commit_request) ;
130+
131+ if let Some ( msg) = self . message_carried_over . take ( ) {
132+ match self . next_step . submit ( msg) {
133+ Ok ( ( ) ) => { }
134+ Err ( SubmitError :: MessageRejected ( MessageRejected {
135+ message : carried_message,
136+ } ) ) => {
137+ self . message_carried_over = Some ( carried_message) ;
138+ }
139+ Err ( SubmitError :: InvalidMessage ( e) ) => {
140+ return Err ( StrategyError :: InvalidMessage ( e) ) ;
141+ }
142+ }
143+ }
144+
145+ if self . message_carried_over . is_none ( )
146+ && ( self . batch . num_buckets ( ) >= self . max_batch_size
147+ || self . last_flush . elapsed ( ) >= self . max_batch_time_ms )
119148 {
120149 self . flush ( ) ?;
121150 }
122- self . next_step . poll ( )
151+
152+ Ok ( self . commit_request_carried_over . take ( ) )
123153 }
124154
125155 fn submit ( & mut self , message : Message < KafkaPayload > ) -> Result < ( ) , SubmitError < KafkaPayload > > {
156+ if self . message_carried_over . is_some ( ) {
157+ return Err ( SubmitError :: MessageRejected ( MessageRejected { message } ) ) ;
158+ }
159+
126160 let InnerMessage :: BrokerMessage ( ref broker_msg) = message. inner_message else {
127161 unreachable ! ( "Unexpected message type" ) ;
128162 } ;
@@ -183,8 +217,28 @@ impl<TNext: ProcessingStrategy<AggregatedOutcomesBatch>> ProcessingStrategy<Kafk
183217 }
184218
185219 fn join ( & mut self , timeout : Option < Duration > ) -> Result < Option < CommitRequest > , StrategyError > {
186- self . flush ( ) ?;
187- self . next_step . join ( timeout)
220+ let deadline = timeout. map ( Deadline :: new) ;
221+
222+ if self . message_carried_over . is_none ( ) {
223+ self . flush ( ) ?;
224+ }
225+
226+ while self . message_carried_over . is_some ( ) {
227+ if deadline. is_some_and ( |d| d. has_elapsed ( ) ) {
228+ tracing:: warn!( "Timeout reached while waiting for carried-over outcomes" ) ;
229+ break ;
230+ }
231+
232+ let commit_request = self . poll ( ) ?;
233+ self . commit_request_carried_over =
234+ merge_commit_request ( self . commit_request_carried_over . take ( ) , commit_request) ;
235+ }
236+
237+ let next_commit = self . next_step . join ( deadline. map ( |d| d. remaining ( ) ) ) ?;
238+ Ok ( merge_commit_request (
239+ self . commit_request_carried_over . take ( ) ,
240+ next_commit,
241+ ) )
188242 }
189243}
190244
@@ -407,4 +461,112 @@ mod tests {
407461 // make sure new batch retains bucket_interval
408462 assert_eq ! ( aggregator. batch. bucket_interval, 60 ) ;
409463 }
464+
465+ #[ test]
466+ fn submit_returns_backpressure_when_message_carried_over ( ) {
467+ struct RejectOnce {
468+ rejected : bool ,
469+ }
470+ impl ProcessingStrategy < AggregatedOutcomesBatch > for RejectOnce {
471+ fn poll ( & mut self ) -> Result < Option < CommitRequest > , StrategyError > {
472+ Ok ( None )
473+ }
474+ fn submit (
475+ & mut self ,
476+ message : Message < AggregatedOutcomesBatch > ,
477+ ) -> Result < ( ) , SubmitError < AggregatedOutcomesBatch > > {
478+ if !self . rejected {
479+ self . rejected = true ;
480+ Err ( SubmitError :: MessageRejected ( MessageRejected { message } ) )
481+ } else {
482+ Ok ( ( ) )
483+ }
484+ }
485+ fn terminate ( & mut self ) { }
486+ fn join (
487+ & mut self ,
488+ _: Option < Duration > ,
489+ ) -> Result < Option < CommitRequest > , StrategyError > {
490+ Ok ( None )
491+ }
492+ }
493+
494+ let mut aggregator = OutcomesAggregator :: new (
495+ RejectOnce { rejected : false } ,
496+ 1 , // flush after 1 bucket
497+ Duration :: from_millis ( 30_000 ) ,
498+ 60 ,
499+ ) ;
500+
501+ let partition = Partition :: new ( Topic :: new ( "test" ) , 0 ) ;
502+ let payload = make_payload ( 6_000 , 1 , 2 , 3 , & [ ( 4 , 1 ) ] ) ;
503+
504+ // First submit accumulates into batch
505+ aggregator
506+ . submit ( Message :: new_broker_message (
507+ payload. clone ( ) ,
508+ partition,
509+ 0 ,
510+ Utc :: now ( ) ,
511+ ) )
512+ . unwrap ( ) ;
513+
514+ // poll triggers flush; next_step rejects → message_carried_over is set
515+ aggregator. poll ( ) . unwrap ( ) ;
516+ assert ! ( aggregator. message_carried_over. is_some( ) ) ;
517+
518+ // While carrying over, submit should return MessageRejected
519+ let result = aggregator. submit ( Message :: new_broker_message (
520+ payload. clone ( ) ,
521+ partition,
522+ 1 ,
523+ Utc :: now ( ) ,
524+ ) ) ;
525+ assert ! ( matches!( result, Err ( SubmitError :: MessageRejected ( _) ) ) ) ;
526+
527+ // Next poll retries and succeeds; carried-over message clears
528+ aggregator. poll ( ) . unwrap ( ) ;
529+ assert ! ( aggregator. message_carried_over. is_none( ) ) ;
530+ }
531+
532+ #[ test]
533+ fn join_honors_timeout_when_message_stays_carried_over ( ) {
534+ struct AlwaysReject ;
535+ impl ProcessingStrategy < AggregatedOutcomesBatch > for AlwaysReject {
536+ fn poll ( & mut self ) -> Result < Option < CommitRequest > , StrategyError > {
537+ Ok ( None )
538+ }
539+ fn submit (
540+ & mut self ,
541+ message : Message < AggregatedOutcomesBatch > ,
542+ ) -> Result < ( ) , SubmitError < AggregatedOutcomesBatch > > {
543+ Err ( SubmitError :: MessageRejected ( MessageRejected { message } ) )
544+ }
545+ fn terminate ( & mut self ) { }
546+ fn join (
547+ & mut self ,
548+ _: Option < Duration > ,
549+ ) -> Result < Option < CommitRequest > , StrategyError > {
550+ Ok ( None )
551+ }
552+ }
553+
554+ let mut aggregator =
555+ OutcomesAggregator :: new ( AlwaysReject , 1 , Duration :: from_millis ( 30_000 ) , 60 ) ;
556+ let partition = Partition :: new ( Topic :: new ( "test" ) , 0 ) ;
557+ let payload = make_payload ( 6_000 , 1 , 2 , 3 , & [ ( 4 , 1 ) ] ) ;
558+
559+ aggregator
560+ . submit ( Message :: new_broker_message (
561+ payload,
562+ partition,
563+ 0 ,
564+ Utc :: now ( ) ,
565+ ) )
566+ . unwrap ( ) ;
567+
568+ let commit = aggregator. join ( Some ( Duration :: from_millis ( 0 ) ) ) . unwrap ( ) ;
569+ assert ! ( commit. is_none( ) ) ;
570+ assert ! ( aggregator. message_carried_over. is_some( ) ) ;
571+ }
410572}
0 commit comments