Skip to content

Commit 23b5c96

Browse files
[worker] Change background appender channel to be memory-bound instead of record count bound (#5076)
* [MemoryPool] Add support for overdrafts in MemoryPool This PR adds support for overdrafts in `MemoryPool` via the `force_reserve` API. This allows the memory pool to issue leases for more capacity than it holds. It also introduces a new `overdraft` API to query how much in the negative the memory pool is. Also, it introduces a new `wait_until_available` which waits until the pool is out of the overdraft mode (just as a notification without reserving anything). * [worker] Change background appender channel to be memory-bound instead of record count bound Context: Before this PR, the background appender's channel was a bounded channel of size 50. This has two caveats: 1. Large commands (e.g. large schema upserts) can inflate the size of this channel in the worst case to 1.5GB (per partition). A coordinated event (e.g. schema change) can end accumulating 10s of GBs of unaccounted memory across all partitions causing the node to OOM. 2. On the other hands, if the append latency is high, and the records are tiny (as shown later in the benchmarks), this 50 records arbitrary limits hinders the batching efficiency quite a bit. Solution: The main problem is that the number of records is just hard to configure correctly across different workloads. We want reasonable batching efficiency with bounded memory growth. As such, this PR switches the background appender channel to be memory-bound instead of record-count bound. The memory bound is configurable and will default to 64MB (i.e. allowing the background appender to do up to two full batches per run, though we might want to tune how large we want a single batch to be). Implementation wise: 1. We're switching the underlying channel to be unbounded channel with a `MemoryPool` on top. 2. Because we don't know how much to reserve from the memory pool beforehand, we're going to allow overcomitting the memory pool by at most once `enqueueXX` call. a. To simplify reasoning about the overcomitting, I'm dropping the `Clone` support for the `LogSender`. Otherwise, we might overcomitt by more than one enqueue calls across concurrent senders. We don't currently need it to be `Clone`, so this was a no-op. 3. Once the memory pool is fully exhusted, the background appender is going to start rejecting future appends. As such, the `LeaderState` stops polling the effects stream once the pool is exhusted, and waits for it to become available again. Because leader state has an exclusive reference over the self proposer (and its underlying sender), it can be sure that if it sees that the pool has capacity, it'll be able to send without getting any reservation. 4. As a nice side effect for all the self proposer APIs becoming `sync`, we no longer need to worry about cancellation safety, so the events are handled inline inside `LeaderState::run`. Benchmarks (Steady State): Started a local one node cluster comparing base against this commit. The workfload is a 30s of Counter::get on a virtual object coming from 1000 concurrent connections. ``` # Base ❯❯❯ wrk -t1 -c1000 --latency -d30s -s ./scripts/wrk/counter.lua http://localhost:8080 ✘ 130 main thread 1784573163 started Running 30s test @ http://localhost:8080 1 threads and 1000 connections Thread Stats Avg Stdev Max +/- Stdev Latency 56.97ms 89.58ms 1.36s 98.26% Req/Sec 21.23k 2.93k 29.85k 76.59% Latency Distribution 50% 45.84ms 75% 53.83ms 90% 62.58ms 99% 581.58ms 631995 requests in 30.04s, 168.16MB read Requests/sec: 21036.54 Transfer/sec: 5.60MB thread 1784573163 made 632995 requests and got 631995 responses ``` ``` # This commit ❯❯❯ wrk -t1 -c1000 --latency -d30s -s ./scripts/wrk/counter.lua http://localhost:8080 main thread 1784573695 started Running 30s test @ http://localhost:8080 1 threads and 1000 connections Thread Stats Avg Stdev Max +/- Stdev Latency 55.14ms 88.14ms 1.17s 98.28% Req/Sec 22.02k 2.79k 31.03k 76.92% Latency Distribution 50% 44.09ms 75% 51.81ms 90% 60.31ms 99% 574.92ms 655652 requests in 30.07s, 174.45MB read Requests/sec: 21805.57 Transfer/sec: 5.80MB thread 1784573695 made 656652 requests and got 655652 responses ``` So there's no degradation perf wise for this whole branch. Benchmarks (higher append latency): Where this becomes interesting is when append latency is higher because the 50recs bounded channel gets filled faster, and starts pushing back specially with tiny records, and due to the lack of pipelining, the throughput of the system collapses. So doing the same benchmarks but with a 200ms sleep in the background appender: ``` # Base ❯❯❯ wrk -t1 -c1000 --latency -d30s -s ./scripts/wrk/counter.lua http://localhost:8080 main thread 1784574220 started Running 30s test @ http://localhost:8080 1 threads and 1000 connections Thread Stats Avg Stdev Max +/- Stdev Latency 1.04s 308.07ms 2.00s 68.70% Req/Sec 0.87k 592.52 2.87k 65.53% Latency Distribution 50% 916.07ms 75% 1.14s 90% 1.52s 99% 1.97s 25328 requests in 30.08s, 6.74MB read Socket errors: connect 0, read 0, write 0, timeout 1437 Requests/sec: 842.15 Transfer/sec: 229.45KB thread 1784574220 made 26329 requests and got 25328 responses ``` vs: ``` # This commit ❯❯❯ wrk -t1 -c1000 --latency -d30s -s ./scripts/wrk/counter.lua http://localhost:8080 main thread 1784573956 started Running 30s test @ http://localhost:8080 1 threads and 1000 connections Thread Stats Avg Stdev Max +/- Stdev Latency 590.43ms 136.85ms 1.85s 81.02% Req/Sec 1.72k 679.51 3.67k 71.75% Latency Distribution 50% 605.69ms 75% 611.69ms 90% 617.10ms 99% 1.44s 50304 requests in 30.00s, 13.38MB read Requests/sec: 1676.56 Transfer/sec: 456.80KB thread 1784573956 made 51305 requests and got 50304 responses ``` This commit was able to have twice the throughput of the base branch under high latency.
1 parent 3d560ac commit 23b5c96

12 files changed

Lines changed: 587 additions & 496 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/bifrost/src/background_appender.rs

Lines changed: 195 additions & 130 deletions
Large diffs are not rendered by default.

crates/bifrost/src/bifrost.rs

Lines changed: 17 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ use restate_types::live::LiveLoadExt;
2525
use restate_core::MetadataWriter;
2626
use restate_core::my_node_id;
2727
use restate_core::{Metadata, ShutdownError};
28+
use restate_memory::NonZeroByteCount;
2829
use restate_types::config::Configuration;
2930
use restate_types::logs::metadata::SealMetadata;
3031
use restate_types::logs::metadata::{LogletParams, Logs, SegmentIndex};
@@ -206,16 +207,17 @@ impl Bifrost {
206207
))
207208
}
208209

210+
/// See [`BackgroundAppender::new`] for the semantics of `memory_limit`.
209211
pub fn create_background_appender<T: StorageEncode>(
210212
&self,
211213
log_id: LogId,
212214
error_recovery_strategy: ErrorRecoveryStrategy,
213-
queue_capacity: usize,
215+
memory_limit: Option<NonZeroByteCount>,
214216
max_batch_size: usize,
215217
) -> Result<BackgroundAppender<T>> {
216218
Ok(BackgroundAppender::new(
217219
self.create_appender(log_id, error_recovery_strategy)?,
218-
queue_capacity,
220+
memory_limit,
219221
max_batch_size,
220222
))
221223
}
@@ -1359,16 +1361,16 @@ mod tests {
13591361
let bifrost = Bifrost::init_in_memory(env.metadata_writer).await;
13601362

13611363
let background_appender: crate::BackgroundAppender<String> = bifrost
1362-
.create_background_appender(LogId::new(0), ErrorRecoveryStrategy::Wait, 10, 10)?;
1364+
.create_background_appender(LogId::new(0), ErrorRecoveryStrategy::Wait, None, 10)?;
13631365

13641366
let mut handle = background_appender.start("test-appender")?;
13651367
let sender = handle.sender();
13661368

13671369
// A string with 100 bytes
13681370
let payload = String::from_utf8(vec![b't'; 100]).unwrap();
13691371

1370-
// try_enqueue should fail with RecordTooLarge
1371-
let result = sender.try_enqueue(payload.clone());
1372+
// enqueue should fail with RecordTooLarge
1373+
let result = sender.enqueue(payload.clone());
13721374
assert_that!(
13731375
result,
13741376
pat!(Err(pat!(EnqueueError::RecordTooLarge {
@@ -1378,7 +1380,7 @@ mod tests {
13781380
);
13791381

13801382
// enqueue (async) should also fail with RecordTooLarge
1381-
let result = sender.enqueue(payload.clone()).await;
1383+
let result = sender.enqueue(payload.clone());
13821384
assert_that!(
13831385
result,
13841386
pat!(Err(pat!(EnqueueError::RecordTooLarge {
@@ -1387,8 +1389,8 @@ mod tests {
13871389
})))
13881390
);
13891391

1390-
// try_enqueue_with_notification should also fail
1391-
let result = sender.try_enqueue_with_notification(payload.clone());
1392+
// enqueue_with_notification should also fail
1393+
let result = sender.enqueue_with_notification(payload.clone());
13921394
assert!(matches!(
13931395
result,
13941396
Err(EnqueueError::RecordTooLarge {
@@ -1415,14 +1417,14 @@ mod tests {
14151417
let bifrost = Bifrost::init_in_memory(env.metadata_writer).await;
14161418

14171419
let background_appender: crate::BackgroundAppender<String> = bifrost
1418-
.create_background_appender(LogId::new(0), ErrorRecoveryStrategy::Wait, 10, 10)?;
1420+
.create_background_appender(LogId::new(0), ErrorRecoveryStrategy::Wait, None, 10)?;
14191421

14201422
let mut handle = background_appender.start("test-appender")?;
14211423
let sender = handle.sender();
14221424

14231425
// With a 10KB limit, the ~2KB estimated record should succeed
14241426
let payload = "test".to_string();
1425-
sender.enqueue(payload).await?;
1427+
sender.enqueue(payload)?;
14261428

14271429
// Drain and wait for commit
14281430
handle.drain().await?;
@@ -1442,29 +1444,24 @@ mod tests {
14421444
let bifrost = Bifrost::init_in_memory(env.metadata_writer).await;
14431445

14441446
let background_appender: crate::BackgroundAppender<String> = bifrost
1445-
.create_background_appender(LogId::new(0), ErrorRecoveryStrategy::Wait, 1000, 100)?;
1447+
.create_background_appender(LogId::new(0), ErrorRecoveryStrategy::Wait, None, 100)?;
14461448

14471449
let mut handle = background_appender.start("test-appender")?;
14481450
let sender = handle.sender();
14491451

1450-
// Rapidly enqueue many records using try_enqueue (non-blocking)
1452+
// Rapidly enqueue many records using enqueue
14511453
let mut enqueued = 0;
14521454
for i in 0..100 {
1453-
match sender.try_enqueue(format!("rapid-record-{i}")) {
1454-
Ok(()) => enqueued += 1,
1455-
Err(EnqueueError::Full(_)) => {
1456-
// Queue is full, use async enqueue
1457-
sender.enqueue(format!("rapid-record-{i}")).await?;
1458-
enqueued += 1;
1459-
}
1455+
match sender.enqueue(format!("rapid-record-{i}")) {
1456+
Ok(_) => enqueued += 1,
14601457
Err(e) => return Err(e.into()),
14611458
}
14621459
}
14631460

14641461
assert_that!(enqueued, eq(100));
14651462

14661463
// Wait for all to be committed
1467-
let token = sender.notify_committed().await?;
1464+
let token = sender.notify_committed()?;
14681465
token.await?;
14691466

14701467
handle.drain().await?;

crates/bifrost/src/lib.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@ mod types;
2727
mod watchdog;
2828

2929
pub use appender::Appender;
30-
pub use background_appender::{AppenderHandle, BackgroundAppender, CommitToken, LogSender};
30+
pub use background_appender::{
31+
AppenderHandle, BackgroundAppender, CommitToken, EnqueueWithNotificationResult, LogSender,
32+
};
3133
pub use bifrost::{Bifrost, ErrorRecoveryStrategy};
3234
pub use bifrost_admin::{BifrostAdmin, MaybeSealedSegment};
3335
pub use data_record::{DataRecord, DataRecordError};

crates/types/src/config/worker.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,19 @@ pub struct WorkerOptions {
154154
#[cfg_attr(feature = "schemars", schemars(skip))]
155155
#[serde(skip_serializing_if = "std::ops::Not::not", default)]
156156
pub use_multi_db_layout: bool,
157+
158+
/// # Self-proposal queue memory limit
159+
///
160+
/// The amount of memory a partition leader may use to buffer commands it proposes to its
161+
/// own log (timers, invoker effects, RPCs, etc.) before pushing back on their producers.
162+
/// Larger values improve append batching and throughput at the cost of memory usage and
163+
/// commit tail latency. The limit applies to each partition individually.
164+
///
165+
/// Default: 64 MiB
166+
///
167+
/// Since v1.7.3
168+
#[cfg_attr(feature = "schemars", schemars(skip))]
169+
pub self_proposal_queue_memory_limit: NonZeroByteCount,
157170
}
158171

159172
impl WorkerOptions {
@@ -217,6 +230,9 @@ impl Default for WorkerOptions {
217230
),
218231
rule_book_poll_interval: NonZeroFriendlyDuration::from_secs_unchecked(30),
219232
use_multi_db_layout: false,
233+
self_proposal_queue_memory_limit: NonZeroByteCount::new(
234+
NonZeroUsize::new(64 * 1024 * 1024).expect("non zero"),
235+
),
220236
}
221237
}
222238
}

0 commit comments

Comments
 (0)