[fix][broker] Remove lock contention in delayed delivery stats read paths#25990
Conversation
Is this in a single topic and single subscription? FYI, there's a limit of tracking up to 30M backlogs (with default BK |
Yes.
I will check this later. |
7795eec to
c8f77d9
Compare
a86ce4a to
bdfc915
Compare
|
At a higher level, moving stats reads away from the dispatcher lock looks like the right direction for this PR. The remaining concern is making sure the newly unsynchronized stats paths do not read or mutate tracker internals without a clear concurrency boundary. One possible short-term approach is to use the tracker lock for the small fixed-size stats sections and unsafe |
bdfc915 to
30b06a0
Compare
void-ptr974
left a comment
There was a problem hiding this comment.
I think we should separate the correctness fix from the optimization here.
The earlier discussion mentioned cached/atomic metrics as a possible direction, but the current PR is now trying to solve both synchronization correctness and metrics optimization at the same time. That adds derived state and bucket lifecycle coupling, which makes the change harder to verify.
For this PR, I would prefer to first converge on the minimal correctness fix: keep the existing bucket lifecycle behavior and collect the metrics under the tracker lock where needed. If that later proves too expensive, we can optimize the metrics path in a separate follow-up PR with dedicated invariants and tests.
9ee5160 to
978ed4b
Compare
|
I verified this with a small Guava RangeMap<Long, String> m = TreeRangeMap.create();
m.put(Range.closed(10L, 20L), "bucket");
Map<Range<Long>, String> sub =
m.subRangeMap(Range.lessThan(15L)).asMapOfRanges();
Range<Long> clipped = sub.keySet().iterator().next();
System.out.println("original=" + m.asMapOfRanges());
System.out.println("sub=" + sub);
System.out.println("top-get-clipped=" + m.asMapOfRanges().get(clipped));
m.asMapOfRanges().remove(clipped);
System.out.println("after-exact-remove-clipped=" + m.asMapOfRanges());Output: So In this code path, |
|
Good catch! You're absolutely right about the clipped key issue. I've fixed it in commit 4e4c709 by modifying Range<Long> originalKey = Range.closed(bucket.startLedgerId, bucket.endLedgerId);
toBeDeletedBucketMap.put(originalKey, bucket);This ensures All tests pass now, including the scenarios you identified. |
|
Added regression test in e4219a0:
The test creates multiple buckets, closes the tracker, then recovers to ensure the bucket cleanup path works correctly. |
|
Found and fixed another truncated key issue in Problem: The Example:
Fix (commits 92290bd + 37adfc0):
This ensures buckets are only replaced when the new range truly encloses the original bucket's full range. |
void-ptr974
left a comment
There was a problem hiding this comment.
Thanks for the patience through all the review iterations. This area has quite a few subtle delayed-delivery bucket edge cases, and I appreciate the follow-up fixes and regression tests.
I re-checked the latest version and don’t see a delayed-delivery correctness issue that should block this PR.
No further blocking comments from my side.
…g merged buckets - Changed removeBucket() to use asMapOfRanges().get() for exact key matching - Previously used subRangeMap() which could incorrectly remove merged buckets due to key truncation - Always remove bucket even if snapshot length is 0 (for newly created buckets) - Fixes testWithCreateFailDowngrade (failed bucket cleanup) and testMergeSnapshot (merged bucket deletion)
…Range - subRangeMap().asMapOfRanges() returns clipped range keys that don't match original keys - Use Range.closed(bucket.startLedgerId, bucket.endLedgerId) to get original key - Ensures removeBucket() with exact key matching can find and remove buckets - Fixes issue identified by void-ptr974 in PR review
- Verifies fix for subRangeMap clipped key issue - Ensures removeBucket() correctly handles keys from putAndCleanOverlapRange - Tests that no orphaned buckets remain after recovery - Prevents regression of the clipped key bug identified by void-ptr974
…nOverlapRange - subRangeMap() returns truncated keys which could incorrectly pass encloses() check - Now reconstruct original key first, then check if new range encloses it - Prevents incorrectly replacing a bucket when new range is subset of existing bucket - Related to the clipped key fix - ensures encloses() check uses correct range bounds
- Verifies that original keys are used instead of truncated keys in encloses() check - Ensures buckets are not incorrectly replaced when ranges don't truly enclose - Tests that adding non-overlapping messages maintains correct bucket and message counts - Prevents regression where truncated keys from subRangeMap() pass encloses() incorrectly
37adfc0 to
e0687e2
Compare
dao-jun
left a comment
There was a problem hiding this comment.
Approving - the lock-free stats reads only touch AtomicLong / primitives and never the non-thread-safe maps/bitmaps, so no blocking concerns.
One metrics-only regression worth a follow-up (or a one-liner here, since it's in code this PR already touches): totalSnapshotLengthBytes permanently inflates when a bucket is removed before its async asyncUpdateSnapshotLength completes - the thenAccept(updateBucketSnapshotLength) at line 359 is fire-and-forget, and
updateBucketSnapshotLength (line 949) has no map-membership guard, so a late length fetch adds newLength to a bucket already removed via clear/merge/trim. Pre-PR this couldn't happen since genTopicMetricMap summed the live map. Minimal fix: guard the counter update with
immutableBuckets.asMapOfRanges().containsValue(bucket) (both paths already hold synchronized(this)).
Also worth adding a test asserting bucketsCount / totalSnapshotLengthBytes match the live map across create+merge+trim+recover+clear - the current >= 0 assertion is how this slipped through.
Motivation
Under a large delayed-delivery workload (~500M delayed messages), jstack analysis
shows the dispatcher lock is held while sealing delayed-delivery buckets.
A broker worker thread spends significant CPU time in:
while holding both:
As a result, stats collection threads (Prometheus, admin APIs, getStatsAsync)
are blocked waiting for the dispatcher monitor.
The same pattern affects
InMemoryDelayedDeliveryTracker, wheregetBufferMemoryUsage()iterates the entireTreeMap<Long, TreeMap<Long, Roaring64Bitmap>>while holding the dispatcher lock.
Modifications
1.
BucketDelayedDeliveryTracker— lock-free bucket stats via AtomicLong countersIntroduce two
AtomicLongcounters (bucketsCountandtotalSnapshotLengthBytes)maintained alongside the existing
TreeRangeMap<Long, ImmutableBucket>:putBucket(),removeBucket(), andupdateBucketSnapshotLength()that wrap
TreeRangeMapoperations and update counters atomicallyputBucket()calculates removed bucket lengths before insertion (sinceTreeRangeMap.put()silently removes/splits overlapping entries) and updates counters by delta
removeBucket()decrements counters only when removal succeedsupdateBucketSnapshotLength()updates counters when snapshot length changes asynchronouslyrecoverBucketSnapshot(), recalculate counters after all snapshots are loadedto ensure accuracy (since buckets are created with
snapshotLength=0initially)genTopicMetricMap()reads counters without holding any lock2.
InMemoryDelayedDeliveryTracker— lock-free memory usage via delta trackingAdd
AtomicLong memoryUsagethat is updated by delta at each mutation point(
addMessage,getScheduledMessages,clear).getBufferMemoryUsage()returnsthe cached value directly instead of iterating the nested
TreeMap.3. Dispatcher classes — remove
synchronizedfrom stats read pathsIn both
PersistentDispatcherMultipleConsumersandPersistentDispatcherMultipleConsumersClassic:getNumberOfDelayedMessages(),getDelayedTrackerMemoryUsage(),getBucketDelayedIndexStats(),shouldPauseDeliveryForDelayTracker()—removed
synchronized; now useOptional.map()with thevolatilefielddelayedDeliveryTrackerfield changed tovolatilefor safe publication4.
BucketDelayedMessageIndexStats— synchronized metric map generationAdd
synchronizedtogenTopicMetricMap()to ensure atomic reads of multiple fieldswhen generating metrics.
5.
ImmutableBucket— fix async chain in recoverFix
asyncRecoverBucketSnapshotEntry()to properly chainasyncUpdateSnapshotLength()using
thenCompose()instead ofthenApply(), ensuring the snapshot length is updatedbefore recovery completes.
Verifying this change
BucketDelayedDeliveryTrackerTestpassacross create, merge, trim, and recover operations