feat: rate-adaptive read batching with a bounded per-shard in-flight window - #362
Merged
Merged
Conversation
…window Read batching never adapted to the server's service rate: the batcher dispatched a ReadBatch as one RPC the moment the queue drained, with no limit on outstanding read RPCs per shard, so under load the client issued a flood of tiny ReadRequests (~4-5 gets each) instead of growing batches. Every few gets then paid a full gRPC round - stream/header processing dominated server read-path CPU while pebble stayed cheap - and per-worker read throughput capped at the per-shard RPC round rate regardless of cluster size, with client in-flight operations turning into pure queueing delay. Mirror the write-side fix (oxia-db#360) on the read path: at most maxReadBatchesInFlight (default 4) read batches may be outstanding per (client, shard). The window is the same credit mechanism WriteBatch uses - WriteWindow generalized to DispatchWindow, since nothing in it was write-specific - now returned by ReadBatchFactory as well, sized by the new maxReadBatchesInFlight setting. ReadBatch frees its slot in the RPC terminal callbacks (onError/onCompleted, delivered exactly once by the guarded observer), dispatching the next held-back batch. While a shard's window is exhausted the open batch keeps accumulating toward maxRequestsPerBatch, so read batch size tracks the server's service rate exactly like writes: fast server, small batches and low latency; saturated server, large batches and high throughput. Read batches held back on client close are failed promptly through BatchManager.failWindows, and their operations stay bounded by the existing maxPendingBytes limiter. Signed-off-by: Matteo Merli <mmerli@apache.org>
This was referenced Jul 14, 2026
Merged
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Motivation
Read batching never adapts to the server's service rate: the batcher dispatches a
ReadBatchas one RPC the moment its queue drains, and the read dispatch path has no in-flight limit — so under load the client floods the server with tinyReadRequests (~4–5 gets each) instead of growing batches. Every few gets pay a full gRPC round: on saturated clusters, server read-path CPU is dominated by gRPC stream/header handling (~35%) and the per-connectionloopyWriter(~25%) while the actual data work (pebble get) stays ~11%, and per-worker read throughput caps at the per-shard RPC round rate regardless of cluster size. Writes had the identical pathology before #360 — post-#360, writes on a 6-node cluster do 333–362k/s while reads (no replication, no fsync) manage only 189k/s.Modification
Mirror #360 on the read path: at most
maxReadBatchesInFlight(default 4) read batches may be outstanding per (client, shard).WriteWindowis generalized toDispatchWindow(nothing in it was write-specific; it's the same credit mechanism, andBatchFactory/BatchManageralready called it "the dispatch window").BatchFactory.getWriteWindowbecomesgetDispatchWindow. Both are package-private; no API impact.ReadBatchFactorynow keeps a lazily-created per-shardDispatchWindowsized by the newmaxReadBatchesInFlightconfig, and fails held-back batches on close via the existingfailWindowspath.ReadBatchfrees its window slot in the RPC terminal callbacks (onError/onCompleted).GrpcRpcProvider.readroutes every synchronous failure into the guarded observer andGuardedStreamObserverCASes on terminal delivery, so the release is exactly-once per dispatched batch, including across Failsafe retries.maxRequestsPerBatch, full batches queue FIFO in the window, and per-shard credits mean no cross-shard head-of-line blocking and no blocking of the batcher thread.OxiaClientBuilder.maxReadBatchesInFlight(int), naming consistent withmaxWriteBatchesInFlight. Held-back operations remain bounded by the existingmaxPendingByteslimiter.Verifications
Unit tests mirror the #360 set: window release on response/failure/synchronous-failure for
ReadBatch(via a single-slot window and a captured in-process server stream), plus the existing generic batcher tests for exhaustion-accumulation, FIFO dispatch on completion, per-shard independence, and close-fails-held-batches.Loopback A/B (oxia standalone + oxia-benchmark, this branch vs 0.9.3, alternating runs with cool-downs, fresh server per run):
Fixed-rate 10k ops/s read-only (regression gate) — unchanged; the window does not delay dispatch when the server keeps up, and batches stay small on both sides:
¹ first run of the suite; the tail comes from the first measured interval (JIT settling) — its steady-state intervals are p95 0.4 / p99 ≤1.5 ms.
Max-rate read-only (10k outstanding), directional on loopback — the adaptive mechanism works end-to-end: server-observed batch size pins at the
maxRequestsPerBatchceiling and read RPC count drops 34×, with better tail latencies (0.9.3: 1.50M RPCs at 33 gets/RPC, p99 36.5 ms; this PR: 43.6k RPCs at 1000 gets/RPC, p99 26.5 ms). Peak loopback throughput is not the decisive test here — against a many-core server over loopback, per-RPC overhead is near zero, which is the opposite of the saturated-cluster geometry this targets (gRPC per-RPC overhead dominating 2-CPU servers). The cluster expectation stays as in the write-side change: server-observed read batch size well beyond ~5 under load, per-worker reads far above ~19k/s, and 6-node aggregate reads exceeding the 333–362k/s write figure.