Skip to content

Commit 4c24181

Browse files
MeredithAnyaclaude
andauthored
ref(eap-outcomes): updating committing logic, more metrics (#7813)
## Summary This PR improves the committing and backpressure handling in the accepted outcomes consumer, and adds a `--commit-frequency-sec` parameter to control how often commit requests are emitted. ### Changes - **`CommitOutcomes`**: Added a configurable `commit_frequency` (default 10s) so that offsets are only committed on a timer rather than on every poll. Offsets are now only tracked after a successful produce — if the produce step rejects a message, offsets are **not** advanced. On `join`, a forced commit is issued regardless of the timer. - **`OutcomesAggregator`**: Fixed backpressure handling. Previously, when the next step rejected a message during flush, the batch was silently restored and the flush effectively became a no-op. Now, a rejected message is carried over and retried on the next `poll`. While a message is carried over, incoming `submit` calls return `MessageRejected` so upstream applies backpressure. The `join` path also retries carried-over messages until they succeed or the deadline elapses. - **`ProduceOutcome`**: Added a `timer!` metric (`accepted_outcomes.batch_produce_ms`) to track how long each batch produce takes. - **`accepted_outcomes_consumer` CLI**: Added `--commit-frequency-sec` (default `10`) that is plumbed through to `CommitOutcomes`. ### Future considerations - Might have this just run `RunTask` instead of `RunTaskInThreads` given that producing should be pretty quick and batching will be what takes longer, might be better to prevent flushing a batch until the first once is done producing. If we have multiple threads and something fails that would be more data loss if we are committing early --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent b4fdee1 commit 4c24181

5 files changed

Lines changed: 321 additions & 36 deletions

File tree

rust_snuba/src/accepted_outcomes_consumer.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ pub struct AcceptedOutcomesStrategyFactory {
2828
bucket_interval: u64,
2929
max_batch_size: usize,
3030
max_batch_time_ms: Duration,
31+
commit_frequency: Duration,
3132
produce_topic: Topic,
3233
producer: Arc<KafkaProducer>,
3334
concurrency: ConcurrencyConfig,
@@ -47,7 +48,7 @@ impl ProcessingStrategyFactory<KafkaPayload> for AcceptedOutcomesStrategyFactory
4748
&self.concurrency,
4849
self.skip_produce,
4950
);
50-
let commit = CommitOutcomes::new(produce);
51+
let commit = CommitOutcomes::new(produce, Some(self.commit_frequency));
5152
Box::new(OutcomesAggregator::new(
5253
commit,
5354
self.max_batch_size,
@@ -75,6 +76,7 @@ pub fn accepted_outcomes_consumer(
7576
max_batch_size: usize,
7677
max_batch_time_ms: u64,
7778
bucket_interval: u64,
79+
commit_frequency_sec: u64,
7880
) -> usize {
7981
py.allow_threads(|| {
8082
accepted_outcomes_consumer_impl(
@@ -92,6 +94,7 @@ pub fn accepted_outcomes_consumer(
9294
max_batch_size,
9395
max_batch_time_ms,
9496
bucket_interval,
97+
commit_frequency_sec,
9598
)
9699
})
97100
}
@@ -112,6 +115,7 @@ pub fn accepted_outcomes_consumer_impl(
112115
max_batch_size: usize,
113116
max_batch_time_ms: u64,
114117
bucket_interval: u64,
118+
commit_frequency_sec: u64,
115119
) -> usize {
116120
setup_logging();
117121

@@ -203,6 +207,7 @@ pub fn accepted_outcomes_consumer_impl(
203207
bucket_interval,
204208
max_batch_size,
205209
max_batch_time_ms: Duration::from_millis(max_batch_time_ms),
210+
commit_frequency: Duration::from_secs(commit_frequency_sec),
206211
produce_topic,
207212
producer,
208213
concurrency: ConcurrencyConfig::new(concurrency),

rust_snuba/src/strategies/accepted_outcomes/aggregator.rs

Lines changed: 180 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ use std::time::{Duration, Instant};
44
use prost::Message as ProstMessage;
55
use sentry_arroyo::backends::kafka::types::KafkaPayload;
66
use sentry_arroyo::processing::strategies::{
7-
CommitRequest, InvalidMessage, ProcessingStrategy, StrategyError, SubmitError,
7+
merge_commit_request, CommitRequest, InvalidMessage, MessageRejected, ProcessingStrategy,
8+
StrategyError, SubmitError,
89
};
910
use sentry_arroyo::types::{InnerMessage, Message, Partition};
11+
use sentry_arroyo::utils::timing::Deadline;
1012
use sentry_protos::snuba::v1::TraceItem;
1113

1214
use 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

5662
impl<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

Comments
 (0)