Skip to content

[fix][broker] Remove lock contention in delayed delivery stats read paths#25990

Merged
nodece merged 10 commits into
apache:masterfrom
nodece:fix/broker-delayed-stats-remove-synchronization
Jul 10, 2026
Merged

[fix][broker] Remove lock contention in delayed delivery stats read paths#25990
nodece merged 10 commits into
apache:masterfrom
nodece:fix/broker-delayed-stats-remove-synchronization

Conversation

@nodece

@nodece nodece commented Jun 10, 2026

Copy link
Copy Markdown
Member

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:

TripleLongPriorityQueue.siftDown()
  -> pop()
  -> createImmutableBucketAndAsyncPersistent()

while holding both:

PersistentDispatcherMultipleConsumers
BucketDelayedDeliveryTracker

As a result, stats collection threads (Prometheus, admin APIs, getStatsAsync)
are blocked waiting for the dispatcher monitor.

The same pattern affects InMemoryDelayedDeliveryTracker, where
getBufferMemoryUsage() iterates the entire TreeMap<Long, TreeMap<Long, Roaring64Bitmap>>
while holding the dispatcher lock.

Modifications

1. BucketDelayedDeliveryTracker — lock-free bucket stats via AtomicLong counters

Introduce two AtomicLong counters (bucketsCount and totalSnapshotLengthBytes)
maintained alongside the existing TreeRangeMap<Long, ImmutableBucket>:

  • Add private helper methods putBucket(), removeBucket(), and updateBucketSnapshotLength()
    that wrap TreeRangeMap operations and update counters atomically
  • putBucket() calculates removed bucket lengths before insertion (since TreeRangeMap.put()
    silently removes/splits overlapping entries) and updates counters by delta
  • removeBucket() decrements counters only when removal succeeds
  • updateBucketSnapshotLength() updates counters when snapshot length changes asynchronously
  • In recoverBucketSnapshot(), recalculate counters after all snapshots are loaded
    to ensure accuracy (since buckets are created with snapshotLength=0 initially)
  • genTopicMetricMap() reads counters without holding any lock

2. InMemoryDelayedDeliveryTracker — lock-free memory usage via delta tracking

Add AtomicLong memoryUsage that is updated by delta at each mutation point
(addMessage, getScheduledMessages, clear). getBufferMemoryUsage() returns
the cached value directly instead of iterating the nested TreeMap.

3. Dispatcher classes — remove synchronized from stats read paths

In both PersistentDispatcherMultipleConsumers and PersistentDispatcherMultipleConsumersClassic:

  • getNumberOfDelayedMessages(), getDelayedTrackerMemoryUsage(),
    getBucketDelayedIndexStats(), shouldPauseDeliveryForDelayTracker()
    removed synchronized; now use Optional.map() with the volatile field
  • delayedDeliveryTracker field changed to volatile for safe publication

4. BucketDelayedMessageIndexStats — synchronized metric map generation

Add synchronized to genTopicMetricMap() to ensure atomic reads of multiple fields
when generating metrics.

5. ImmutableBucket — fix async chain in recover

Fix asyncRecoverBucketSnapshotEntry() to properly chain asyncUpdateSnapshotLength()
using thenCompose() instead of thenApply(), ensuring the snapshot length is updated
before recovery completes.

Verifying this change

  • Existing unit tests in BucketDelayedDeliveryTrackerTest pass
  • Manual verification: bucket count and snapshot length stats remain accurate
    across create, merge, trim, and recover operations

@nodece
nodece requested review from dao-jun and lhotari June 10, 2026 13:10

@lhotari lhotari left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Synchronization was explicitly added in #25681 to fix race conditions. Did you take that into account? I think that synchronization cannot be removed from shouldPauseAllDeliveries. For other methods it's most likely fine.

@lhotari

lhotari commented Jun 10, 2026

Copy link
Copy Markdown
Member

Under a large delayed-delivery workload (~500M delayed messages)

Is this in a single topic and single subscription?

FYI, there's a limit of tracking up to 30M backlogs (with default BK nettyMaxFrameSizeBytes) when using managedLedgerPersistIndividualAckAsLongArray=true. More can be stored to metadata when using LZ4 compression, but there will be failures each time cursor state is attempted to be saved. Issue is #25985.

@nodece
nodece requested a review from lhotari June 11, 2026 11:24
@nodece

nodece commented Jun 11, 2026

Copy link
Copy Markdown
Member Author

Is this in a single topic and single subscription?

Yes.

FYI, there's a limit of tracking up to 30M backlogs (with default BK nettyMaxFrameSizeBytes) when using managedLedgerPersistIndividualAckAsLongArray=true. More can be stored to metadata when using LZ4 compression, but there will be failures each time cursor state is attempted to be saved. Issue is #25985.

I will check this later.

@nodece
nodece force-pushed the fix/broker-delayed-stats-remove-synchronization branch from 7795eec to c8f77d9 Compare June 11, 2026 13:12
@nodece
nodece marked this pull request as draft June 17, 2026 10:36
@nodece
nodece force-pushed the fix/broker-delayed-stats-remove-synchronization branch from a86ce4a to bdfc915 Compare June 22, 2026 10:04
@nodece
nodece marked this pull request as ready for review June 22, 2026 10:04
@nodece
nodece requested a review from void-ptr974 June 22, 2026 13:10
@void-ptr974

Copy link
Copy Markdown
Contributor

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 TreeRangeMap access, while keeping the dispatcher lock out of these stats paths. Longer term, it may be worth moving more mutable TreeRangeMap access and cached-counter maintenance behind ImmutableBucketIndex APIs, so callers do not need to touch live map views directly and stats paths can read cached/atomic values.

@nodece
nodece marked this pull request as draft June 23, 2026 10:33
@nodece
nodece force-pushed the fix/broker-delayed-stats-remove-synchronization branch from bdfc915 to 30b06a0 Compare June 24, 2026 09:43
@nodece nodece changed the title [fix][broker] remove lock contention in delayed delivery stats read paths [fix][broker] Remove lock contention in delayed delivery stats read paths Jun 24, 2026
@nodece
nodece marked this pull request as ready for review June 24, 2026 09:48
@nodece
nodece requested a review from void-ptr974 June 26, 2026 06:54

@void-ptr974 void-ptr974 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@nodece
nodece force-pushed the fix/broker-delayed-stats-remove-synchronization branch from 9ee5160 to 978ed4b Compare July 1, 2026 11:22
@void-ptr974

Copy link
Copy Markdown
Contributor

I verified this with a small Guava TreeRangeMap example.

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:

original={[10..20]=bucket}
sub={[10..15)=bucket}
top-get-clipped=null
after-exact-remove-clipped={[10..20]=bucket}

So subRangeMap(...).asMapOfRanges() can return a clipped range key, while immutableBuckets.asMapOfRanges().get/remove(range) requires an exact key.

In this code path, removeBucket(range) can be a no-op, but deleteBucketSnapshot() has already deleted the snapshot and still decrements numberDelayedMessages. The tracker may then keep an immutableBuckets entry whose snapshot no longer exists and whose messages were already subtracted. Later load/trim/merge can operate on that stale bucket state.

@nodece

nodece commented Jul 3, 2026

Copy link
Copy Markdown
Member Author

Good catch! You're absolutely right about the clipped key issue.

I've fixed it in commit 4e4c709 by modifying putAndCleanOverlapRange to reconstruct the original key from the bucket object:

Range<Long> originalKey = Range.closed(bucket.startLedgerId, bucket.endLedgerId);
toBeDeletedBucketMap.put(originalKey, bucket);

This ensures removeBucket() receives exact keys instead of clipped keys from subRangeMap(), so the exact key matching works correctly.

All tests pass now, including the scenarios you identified.

@nodece

nodece commented Jul 3, 2026

Copy link
Copy Markdown
Member Author

Added regression test in e4219a0:

testOverlappingBucketsCleanupDuringRecovery verifies:

  • Buckets are correctly cleaned up during recovery with overlapping ranges
  • No orphaned buckets remain due to clipped key issues
  • Snapshot length tracking remains consistent
  • The fix prevents the specific failure mode where removeBucket() couldn't find buckets due to clipped keys from subRangeMap()

The test creates multiple buckets, closes the tracker, then recovers to ensure the bucket cleanup path works correctly.

@nodece

nodece commented Jul 3, 2026

Copy link
Copy Markdown
Member Author

Found and fixed another truncated key issue in putAndCleanOverlapRange:

Problem: The encloses() check was using truncated keys from subRangeMap(), which could incorrectly return true when the new range doesn't actually enclose the full original bucket.

Example:

  • Existing bucket: [10, 30]
  • New range: [15, 20]
  • subRangeMap([15, 20]) returns truncated key [15, 20]
  • [15, 20].encloses([15, 20]) = true ✗ (incorrect)
  • Should check: [15, 20].encloses([10, 30]) = false ✓ (correct)

Fix (commits 92290bd + 37adfc0):

  • Reconstruct original key from bucket object before encloses() check
  • Added test testPutAndCleanOverlapRangeWithTruncatedKeys to prevent regression

This ensures buckets are only replaced when the new range truly encloses the original bucket's full range.

@nodece
nodece requested a review from void-ptr974 July 4, 2026 04:35

@void-ptr974 void-ptr974 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

nodece added 6 commits July 10, 2026 11:10
…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
@nodece
nodece force-pushed the fix/broker-delayed-stats-remove-synchronization branch from 37adfc0 to e0687e2 Compare July 10, 2026 03:25

@dao-jun dao-jun left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@nodece
nodece merged commit 3529aba into apache:master Jul 10, 2026
43 checks passed
@nodece
nodece deleted the fix/broker-delayed-stats-remove-synchronization branch July 10, 2026 13:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants