Conversation
lhotari
added this pull request to stack #26718
September 25, 2026 19:28
lhotari
marked this pull request as draft
September 25, 2026 19:29
lhotari
marked this pull request as ready for review
September 25, 2026 19:30
11 tasks
lhotari
force-pushed
the
lh-ml-mpsc-add-handoff
branch
from
September 25, 2026 21:37
09e083c to
a7036c2
Compare
lhotari
force-pushed
the
lh-ml-mpsc-add-handoff
branch
from
September 25, 2026 22:22
a7036c2 to
1e80d28
Compare
11 tasks
Open
11 tasks
lhotari
force-pushed
the
lh-ml-mpsc-add-handoff
branch
from
September 26, 2026 11:33
1e80d28 to
9d0081d
Compare
lhotari
force-pushed
the
lh-ml-mpsc-add-handoff
branch
from
September 26, 2026 12:02
9d0081d to
77dcc9c
Compare
lhotari
force-pushed
the
lh-ml-mpsc-add-handoff
branch
from
September 26, 2026 12:32
77dcc9c to
ec23e49
Compare
lhotari
force-pushed
the
lh-ml-mpsc-add-handoff
branch
from
September 26, 2026 17:19
ec23e49 to
3754c95
Compare
… MPSC queue Motivation ManagedLedgerImpl.asyncAddEntry submitted one executor task per entry. With hundreds of producer connections on one topic, the connection threads contend on the ledger executor's queue lock (BookKeeper GrowableBatchedArrayBlockingQueue) once per message: off-CPU profiling with jonoffcpu shows it as the largest remaining blocked time with an application frame in a 500-producer Key_Shared load, and 4.9 s of blocked time in the IoT high-rate scenario. Modifications - asyncAddEntry offers the add to a JCTools MPSC queue and schedules a drain task only when none is scheduled, so concurrent publishers contend on the executor's queue once per batch of adds. - The drain task processes at most 1024 adds and reschedules itself for the rest, so add completions and other executor tasks keep running under load. A failing add is logged and does not stop the drain; a rejected drain task clears the scheduled flag and fails the caller as before. - The add is still created and processed on the executor, so the ledger's threading and each thread's add order are unchanged. Assisted-by: Claude Code (claude-opus-5-5)
…it does Batch adds across the thread boundary under names that say so: the add batch queue, scheduleAddBatch and runAddBatch replace the hand-off and drain names, and the comments describe the batching. Create the queue on the first add with a compare-and-set on a volatile field, so managed ledgers that are never written to do not allocate it. The queue is never replaced, so racing first adds all use the queue that won the compare-and-set. With that, use 512-entry chunks instead of 256, so that a batch backing up during a burst links new chunks less often: an idle ledger costs nothing and a ledger that has been written to about 2.7 KB. Assisted-by: Claude Code (claude-opus-5-5)
…edLedgerMaxAddBatchSize The number of adds that the managed ledger's executor thread processes in one batch was fixed at 1024. A larger batch reduces scheduling overhead and contention between publishing threads, but occupies the executor thread for longer, which can delay add completions, reads and cursor notifications for the ledgers that share the thread. Make it tunable so that the trade-off can be adapted to the workload. - New dynamic broker setting managedLedgerMaxAddBatchSize (default 1024), passed to ManagedLedgerConfig as maxAddBatchSize. A managed ledger captures the value when it opens, so the add path reads no shared state; updates apply to managed ledgers opened afterwards. - 0 disables batching: each add is submitted to the executor as a task of its own, as before the batching. - Negative values are rejected by the dynamic configuration validator and by ManagedLedgerConfig. Assisted-by: Claude Code (claude-opus-5-5)
…AddEntryHandoverBatchSize Name the setting after what it limits, the adds handed over to the managed ledger's executor thread in one batch, and rename the related ManagedLedgerConfig property, fields, methods and tests to match: the add batch queue becomes the add entry handover queue. Assisted-by: Claude Code (claude-opus-5-5)
…agedLedgerAddEntryHandoverMaxBatchSize Lead with the add entry handover it configures, so that the setting reads as the maximum batch size of the handover, and rename the ManagedLedgerConfig property, fields, methods and tests to match. Assisted-by: Claude Code (claude-opus-5-5)
…efore running it Take up to addEntryHandoverMaxBatchSize adds out of the handover queue with a single drain call and then run them, instead of polling the queue once per add. The list is local to the batch run on the ledger's executor thread. An add that is still being offered when the drain stops is picked up by the next batch, which the existing emptiness check schedules. Assisted-by: Claude Code (claude-opus-5-5)
lhotari
force-pushed
the
lh-ml-mpsc-add-handoff
branch
from
September 26, 2026 18:18
3754c95 to
cf3ff5a
Compare
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
ManagedLedgerImpl.asyncAddEntrysubmits one executor task per entry to the managed ledger's ordered executor. With hundreds of producer connections on one topic, the connection threads contend on the executor's queue lock (BookKeeper'sGrowableBatchedArrayBlockingQueue.offer→ReentrantLock.lock) once per message. The ledger thread also runs one task per message, and other work on that thread, such as waiting-cursor wake-ups for tailing dispatchers, queues behind those tasks.Off-CPU profiling of 500 producers and a 20-member Key_Shared subscription shows this queue lock as the largest blocked time with an application frame in the broker once the dispatcher Flow contention is removed.
Modifications
Batch the adds across the thread boundary between the publishing threads and the ledger executor:
asyncAddEntryappends the add to the ledger's add entry handover queue, a JCToolsMpscUnboundedArrayQueue(already used byRangeCacheRemovalQueue). The thread that finds no batch task scheduled submits one to the ledger executor, and that task runs every add queued by then. Concurrent publishers contend on the executor's queue once per batch instead of once per add, and other executor work no longer waits behind one task per published message.managedLedgerAddEntryHandoverMaxBatchSizeadds (default 1024) and schedules the next batch for the rest, so add completions and other executor tasks keep running under load. A failing add is logged and does not stop the batch. If the executor rejects the batch task, the flag is cleared and the caller fails as before; the add stays queued, but the managed ledger's executor rejects tasks only once it is shut down, after which no batch runs.managedLedgerAddEntryHandoverMaxBatchSize(default 1024), passed to the managed ledger asManagedLedgerConfig.addEntryHandoverMaxBatchSize. A managed ledger captures the value when it opens, so the add path reads no shared configuration; an update applies to managed ledgers opened after it.0disables batching, and each add is then submitted to the executor as a task of its own, as before this change. Negative values are rejected.Trade-offs of the handover batch size
A batch runs to completion on the ledger's executor thread before any other task on that thread. A larger
managedLedgerAddEntryHandoverMaxBatchSizereduces scheduling overhead and contention between publishing threads at high publish rates, but keeps the executor thread occupied for longer per batch, which can delay add completions, reads and cursor notifications for the managed ledgers that share the thread. A smaller value favors that latency over add throughput. The setting is tunable so that this balance can be adapted to the workload.Measurements
The Key_Shared 500×20 (
iot-key-shared-500x20.yaml) and IoT telemetry high-rate (iot-telemetry-high-rate.yaml) performance scenarios oftests/performance, run with the performance launcher on one host (Intel i9-9980HK, 8 cores, 16 hardware threads) running the broker, 3 bookies and the clients, on JDK 25.0.4 with ZGC. Baseline is the parent of this change (#26716,cd9726ba76f0, which applies Shared and Key_Shared Flow permits without the dispatcher monitor), candidate is this change (1e80d28e8956, with the defaultmanagedLedgerAddEntryHandoverMaxBatchSizeof 1024). The measurements come from one rotation over the stack and its follow-up experiments, with the builds interleaved run by run: three unprofiled Key_Shared runs, two unprofiled high-rate runs and one profiled Key_Shared run (iot-key-shared-500x20-profile.yaml) of each build.thermaldwas stopped, and the launcher let the CPU package cool down to 55 °C before each run and again before each measurement (-Pperformance.cooldownTemperature=55), so every run started at 50–55 °C. None of the Key_Shared runs throttled thermally; in the high-rate scenario the host throttled thermally during both of the two runs of the baseline and both of the two runs of this change.Every run delivered all messages to every application with no duplicates, no ordering violations and no invalid messages.
Key_Shared 500×20, unprofiled:
IoT telemetry high rate, unprofiled (end-to-end latency and backlog are the ranges over the 5 applications, and their change is that of the mean over the applications):
Broker, profiled Key_Shared run (JFR recording, flame graphs and off-CPU profile of the measurement period, 5,000,000 messages on each side):
GrowableBatchedArrayBlockingQueue.offer(off-CPU, observed)In the baseline, the connection threads blocking on the ledger executor's queue lock from
ServerCnx.handleSendare 62 % of the broker's blocked time with an application frame; with this change that wait is practically gone.The managed-ledger thread (
BookKeeperClientWorker-OrderedExecutor-12-0) stays the serial stage, so throughput follows its cost per message, which drops with fewer executor tasks. End-to-end latency now follows the publish latency closely (the Key_Shared end-to-end p50 is on average 13 ms above the publish p50, against 283 ms in the baseline), most likely because dispatch work on the ledger executor no longer queues behind one task per published message, and the subscription's backlog stays smaller. Both scenarios improve: the Key_Shared throughput by 6.1 % and the high-rate throughput by 7.0 %, with every run of this change faster than every baseline run in both.In an earlier session, an eagerly created queue with 256-entry chunks and the final lazily created queue with 512-entry chunks were compared in 3 interleaved unprofiled runs each (means 110.7k and 108.7k msg/s, within the run-to-run spread).
Charts of the median unprofiled Key_Shared run of each side (baseline 99,672 msg/s, this change 105,937 msg/s), as the run report renders them:
Verifying this change
This change added tests and can be verified as follows:
ManagedLedgerTest.testConcurrentAsyncAddEntriesKeepPerThreadOrder: 8 threads each add 2,000 entries concurrently to a fresh ledger, racing to create the queue; all adds complete and each thread's entries are written in the order that thread added them.ManagedLedgerTest.testAddEntryHandoverQueueIsCreatedByTheFirstAdd: an opened ledger has no add entry handover queue until the first add.ManagedLedgerTest.testConcurrentAsyncAddEntriesKeepPerThreadOrderruns with batch sizes 0, 1 and 1024;testAddEntryHandoverBatchingDisabled,testAddEntryHandoverMaxBatchSizeIsCapturedWhenOpenedandtestAddEntryHandoverMaxBatchSizeRejectsNegativeValuescover the disabled mode, the value captured at open and validation.BrokerServiceTest.testManagedLedgerAddEntryHandoverMaxBatchSizeConfigurationandtestManagedLedgerAddEntryHandoverMaxBatchSizeDynamicUpdate: the broker setting reaches the managed ledger configuration, and a dynamic update is validated and applied.ManagedLedgerErrorsTest(22 cases) passes locally; it exercises add failures and recovery through the changed hand-off.Does this pull request potentially affect one of the following parts:
If the box was checked, please highlight the changes
Threading model: adds reach the managed ledger's executor in batches, through an MPSC queue run by one batch task at a time, instead of one executor task per add. The adds still run on the same executor thread, in the same per-thread order.
This PR was prepared with AI assistance (Claude Code) and reviewed by a human contributor.