Skip to content

Commit 3d71e7d

Browse files
committed
Add bytes-based flushing to source collectors
Sources read data in and collect it up into batches. Batches are flushed based on configurable criteria: until now this has been the number of records collected and how long they have been waiting. This commit adds another criteria for flushing, based on the memory usage of the records. `source-batch-max-bytes`, disabled by default, allows triggering flushing when a certain amount of data has been collected. This new feature enables two useful behaviours: 1) Large records: reduce the risk of OOMs when memory-constrained 2) Small records: create larger, more-efficient batches Memory usage is estimated based on input bytes, rather than by instrumenting Arrow buffer memory. Ideally we would implement this based on Arrow buffer usage. However, Arrow's `ArrayBuilder` trait doesn't expose memory size and we would need to maintain a lot of code ourselves to make it work. I think it's reasonable to use input bytes as an approximation.
1 parent a21b030 commit 3d71e7d

5 files changed

Lines changed: 105 additions & 15 deletions

File tree

crates/arroyo-connectors/src/nexmark/operator.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,9 @@ impl SourceOperator for NexmarkSourceFunc {
281281
next_event.bid.as_ref().write_into(&mut bid_builder);
282282
timestamp_builder.append_value(to_nanos(next_event.event_timetamp) as i64);
283283

284-
if should_flush(records, flush_time) {
284+
// Nexmark generates events synthetically with no raw input bytes.
285+
// Pass 0 for bytes buffered.
286+
if should_flush(records, 0, flush_time) {
285287
collector
286288
.collect(RecordBatch::try_new(
287289
ctx.out_schema.schema.clone(),

crates/arroyo-formats/src/de.rs

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ pub enum FieldValueType<'a> {
3636

3737
struct ContextBuffer {
3838
buffer: Vec<Box<dyn ArrayBuilder>>,
39+
/// Accumulated raw input bytes since last flush.
40+
/// Currently measured as pre-deserialization input size rather than Arrow buffer memory.
41+
buffered_bytes: usize,
3942
created: Instant,
4043
}
4144

@@ -49,6 +52,7 @@ impl ContextBuffer {
4952

5053
Self {
5154
buffer,
55+
buffered_bytes: 0,
5256
created: Instant::now(),
5357
}
5458
}
@@ -58,10 +62,11 @@ impl ContextBuffer {
5862
}
5963

6064
pub fn should_flush(&self) -> bool {
61-
should_flush(self.size(), self.created)
65+
should_flush(self.size(), self.buffered_bytes, self.created)
6266
}
6367

6468
pub fn finish(&mut self) -> Vec<ArrayRef> {
69+
self.buffered_bytes = 0;
6570
self.buffer.iter_mut().map(|a| a.finish()).collect()
6671
}
6772
}
@@ -170,6 +175,9 @@ enum BufferDecoder {
170175
JsonDecoder {
171176
decoder: arrow::json::reader::Decoder,
172177
buffered_count: usize,
178+
/// Accumulated raw input bytes since last flush.
179+
/// Currently measured as pre-deserialization input size rather than Arrow buffer memory.
180+
buffered_bytes: usize,
173181
buffered_since: Instant,
174182
},
175183
}
@@ -180,9 +188,10 @@ impl BufferDecoder {
180188
BufferDecoder::Buffer(b) => b.should_flush(),
181189
BufferDecoder::JsonDecoder {
182190
buffered_count,
191+
buffered_bytes,
183192
buffered_since,
184193
..
185-
} => should_flush(*buffered_count, *buffered_since),
194+
} => should_flush(*buffered_count, *buffered_bytes, *buffered_since),
186195
}
187196
}
188197

@@ -203,9 +212,11 @@ impl BufferDecoder {
203212
decoder,
204213
buffered_since,
205214
buffered_count,
215+
buffered_bytes,
206216
} => {
207217
*buffered_since = Instant::now();
208218
*buffered_count = 0;
219+
*buffered_bytes = 0;
209220
Some(match bad_data {
210221
BadData::Fail { .. } => decoder
211222
.flush()
@@ -307,6 +318,13 @@ impl BufferDecoder {
307318
}
308319
}
309320
}
321+
322+
fn add_input_bytes(&mut self, n: usize) {
323+
match self {
324+
BufferDecoder::Buffer(b) => b.buffered_bytes += n,
325+
BufferDecoder::JsonDecoder { buffered_bytes, .. } => *buffered_bytes += n,
326+
}
327+
}
310328
}
311329

312330
pub struct ArrowDeserializer {
@@ -440,6 +458,7 @@ impl ArrowDeserializer {
440458
.build_decoder()
441459
.unwrap(),
442460
buffered_count: 0,
461+
buffered_bytes: 0,
443462
buffered_since: Instant::now(),
444463
},
445464
_ => BufferDecoder::Buffer(ContextBuffer::new(schema_without_additional.clone())),
@@ -497,18 +516,26 @@ impl ArrowDeserializer {
497516
additional_fields: Option<&HashMap<&str, FieldValueType<'_>>>,
498517
) -> Vec<DataflowError> {
499518
let (count, errors) = match &*self.format {
500-
Format::Avro(_) => self.deserialize_slice_avro(msg).await,
519+
Format::Avro(_) => {
520+
let (count, errors) = self.deserialize_slice_avro(msg).await;
521+
if count > 0 {
522+
self.buffer_decoder.add_input_bytes(msg.len());
523+
}
524+
(count, errors)
525+
}
501526
_ => {
502527
let mut count = 0;
503-
let errors = FramingIterator::new(self.framing.clone(), msg)
504-
.map(|t| self.deserialize_single(t))
505-
.filter_map(|t| {
506-
if t.is_ok() {
528+
let mut errors = vec![];
529+
for t in FramingIterator::new(self.framing.clone(), msg) {
530+
let len = t.len();
531+
match self.deserialize_single(t) {
532+
Ok(()) => {
507533
count += 1;
534+
self.buffer_decoder.add_input_bytes(len);
508535
}
509-
t.err()
510-
})
511-
.collect();
536+
Err(e) => errors.push(e),
537+
}
538+
}
512539
(count, errors)
513540
}
514541
};

crates/arroyo-formats/src/lib.rs

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,16 @@ pub mod de;
99
pub mod proto;
1010
pub mod ser;
1111

12-
pub fn should_flush(size: usize, time: Instant) -> bool {
13-
size > 0
14-
&& (size >= config().pipeline.source_batch_size
15-
|| time.elapsed() >= *config().pipeline.source_batch_linger)
12+
pub fn should_flush(buffered_count: usize, buffered_bytes: usize, time: Instant) -> bool {
13+
if buffered_count == 0 {
14+
return false;
15+
}
16+
let cfg = &config().pipeline;
17+
buffered_count >= cfg.source_batch_size
18+
|| time.elapsed() >= *cfg.source_batch_linger
19+
|| cfg
20+
.source_batch_max_bytes
21+
.is_some_and(|max| buffered_bytes >= max)
1622
}
1723

1824
pub(crate) fn float_to_json(f: f64) -> Value {
@@ -30,3 +36,53 @@ pub(crate) fn float_to_json(f: f64) -> Value {
3036
),
3137
}
3238
}
39+
40+
#[cfg(test)]
41+
mod tests {
42+
use super::*;
43+
use arroyo_rpc::config::update;
44+
use std::time::Duration;
45+
46+
/// Set all flush-related config fields so each test is self-contained
47+
/// even if tests run in parallel.
48+
fn set_flush_config(batch_size: usize, linger_ms: u64, max_bytes: Option<usize>) {
49+
// config() auto-initialises from defaults; update() panics without it
50+
let _ = config();
51+
update(|c| {
52+
c.pipeline.source_batch_size = batch_size;
53+
c.pipeline.source_batch_linger = Duration::from_millis(linger_ms).into();
54+
c.pipeline.source_batch_max_bytes = max_bytes;
55+
});
56+
}
57+
58+
#[test]
59+
fn bytes_threshold_triggers_flush() {
60+
set_flush_config(512, 10_000, Some(100));
61+
assert!(should_flush(1, 100, Instant::now()));
62+
}
63+
64+
#[test]
65+
fn below_bytes_threshold_no_flush() {
66+
set_flush_config(512, 10_000, Some(100));
67+
assert!(!should_flush(1, 99, Instant::now()));
68+
}
69+
70+
#[test]
71+
fn bytes_threshold_disabled() {
72+
set_flush_config(512, 10_000, None);
73+
assert!(!should_flush(1, 1_000_000, Instant::now()));
74+
assert!(!should_flush(1, usize::MAX, Instant::now()));
75+
}
76+
77+
#[test]
78+
fn count_threshold_triggers_flush() {
79+
set_flush_config(10, 10_000, None);
80+
assert!(should_flush(10, 0, Instant::now()));
81+
}
82+
83+
#[test]
84+
fn linger_triggers_flush() {
85+
set_flush_config(512, 100, None);
86+
assert!(should_flush(1, 0, Instant::now() - Duration::from_secs(1)));
87+
}
88+
}

crates/arroyo-rpc/default.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ job-controller = "controller"
55
[pipeline]
66
source-batch-size = 512
77
source-batch-linger = "100ms"
8+
# source-batch-max-bytes = 10485760 # flush after ~10MB of input; disabled by default
89
update-aggregate-flush-interval = "1s"
910
allowed-restarts = 20
1011
worker-heartbeat-timeout = "30s"

crates/arroyo-rpc/src/config.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -519,6 +519,10 @@ pub struct PipelineConfig {
519519
/// Batch linger time (how long to wait before flushing)
520520
pub source_batch_linger: HumanReadableDuration,
521521

522+
/// Maximum buffered bytes before flushing (None = disabled).
523+
/// Currently measured as raw input bytes.
524+
pub source_batch_max_bytes: Option<usize>,
525+
522526
/// How often to flush aggregates
523527
pub update_aggregate_flush_interval: HumanReadableDuration,
524528

0 commit comments

Comments
 (0)