[fix][broker] Fix ownership-generation races in OwnershipCache removeOwnership and lock-expiry cleanup - #26197
Conversation
| @@ -150,8 +170,8 @@ public CompletableFuture<Void> handleUnloadRequest(PulsarService pulsar, long ti | |||
| return null; | |||
| }) | |||
There was a problem hiding this comment.
The lock release is now generation-safe, but the stale unload is still bundle-wide at the topic layer.
BrokerService.unloadServiceUnit operates on the broker's current topic map, and cleanUnloadedTopicFromCache later scans the current map again and eventually calls topics.remove(topic) because no create future is supplied. If this unload belongs to an expired OwnedBundle and the bundle is re-acquired while the old close futures are still running, the stale cleanup can remove a topic future installed by the newer ownership generation.
The new test mocks BrokerService, so it only verifies that the new lock/znode/OwnedBundle survives and cannot detect topic-cache corruption.
Could we either prevent re-acquisition until the previous generation's unload cleanup finishes, or make the cleanup identity-safe by capturing the topic futures at unload start and removing them with topics.remove(topicName, capturedFuture)?
There was a problem hiding this comment.
@Denovo1998
Fixed — thanks for the detailed repro path, that's exactly what was happening.
We went with the identity-safe cleanup option, but implemented it as a single shared snapshot rather than two independent scans (capturing once for unload, again for cleanup would leave its own gap — see below):
- BrokerService.getTopicFuturesInBundle(NamespaceBundle) captures (topicName → topicFuture) for the bundle in one pass, at unload start.
- unloadServiceUnit now has an overload that consumes that snapshot directly instead of re-scanning topics, so the set of topics it closes and the set cleanUnloadedTopicFromCache later validates against are guaranteed to be identical — no second scan, no window for a topic to be missed by one side and caught by the other.
- cleanUnloadedTopicFromCache now takes that same snapshot and removes via topics.remove(topic, capturedFuture) instead of the old null-future call, so a stale cleanup can only ever touch the exact future it observed at unload start. A newer generation's topic future is a different object and simply won't match the CAS.
- Both call sites — OwnedBundle.handleUnloadRequest and ServiceUnitStateChannelImpl.closeServiceUnit — capture the snapshot once and thread it through both calls.
On re-acquisition: we didn't block it. Blocking re-acquisition until the previous generation's cleanup finishes would add a synchronization point across OwnershipCache and BrokerService and slow down bundle flapping recovery; identity-safe removal gets full correctness without that coupling.
On the test: agreed the mocked-BrokerService test can't see this class of bug. Added BrokerServiceTest#testCleanUnloadedTopicFromCacheIsGenerationSafe against a real embedded broker — it loads a topic, closes+reloads it to install a new generation's future under the same name (standing in for the re-acquire), then invokes the stale cleanup with the old snapshot and asserts the new generation's topic is still present. Confirmed it fails without the identity check (topics.remove(topic, null)-equivalent) and passes with it.
While implementing this we also caught a follow-on issue in review: unloadServiceUnit's topic-closing scan and the pre-captured cleanup snapshot were originally two separate scans of topics, which reopened a smaller version of the same gap for any topic created in between. Fixed by making unloadServiceUnit consume the caller-supplied snapshot directly (see above) instead of doing its own scan — now there's exactly one point-in-time view shared by both steps.
| */ | ||
| private void invalidateLocalOwnerCache(NamespaceBundle namespaceBundle, OwnedBundle expectedOwnedBundle) { | ||
| CompletableFuture<OwnedBundle> future = ownedBundlesCache.getIfPresent(namespaceBundle); | ||
| if (future != null && future.isDone() && !future.isCompletedExceptionally() |
There was a problem hiding this comment.
The future.isDone() guard creates an expiry-before-publication race.
An expiry callback is registered inside the cache loader's thenApply, before the load future completes. If the lock has already expired, or expires concurrently during callback registration, the callback may execute while the cache future is still incomplete:
- The lock is removed from
locallyAcquiredLocks. unloadNamespaceBundlecannot find a completedOwnedBundle.- This method skips invalidation because
future.isDone()is false. - The loader then returns and publishes an active
OwnedBundlewhose lock has already expired.
This results in the local cache claiming active ownership without a corresponding lock.
Incomplete futures should be handled by attaching a completion callback and removing the future via ownedBundlesCache.asMap().remove(bundle, future) if it resolves to expectedOwnedBundle. Unloading should also be deferred until that generation is published, or the acquisition should fail if expiry wins the race. A deterministic test could use a ResourceLock whose expired future has already completed when the listener is attached.
There was a problem hiding this comment.
@Denovo1998
Confirmed, and thanks for spelling out the exact mechanism — traced it and it's real: the listener is registered inside .thenApply, so if rl.getLockExpiredFuture() is already done (or completes concurrently) at that point, .thenRun(...) fires inline, before the loader's own future is published. invalidateLocalOwnerCache's future.isDone() guard is false at that moment, so it no-ops, and the loader goes on to return ownedBundle, publishing an "active" OwnedBundle with no backing lock.
Did both things you suggested, plus one more:
-
invalidateLocalOwnerCache — replaced the isDone() guard with future.whenComplete(...):
private void invalidateLocalOwnerCache(NamespaceBundle namespaceBundle, OwnedBundle expectedOwnedBundle) { CompletableFuture<OwnedBundle> future = ownedBundlesCache.getIfPresent(namespaceBundle); if (future == null) { return; } future.whenComplete((ownedBundle, ex) -> { if (ex == null && ownedBundle == expectedOwnedBundle) { ownedBundlesCache.asMap().remove(namespaceBundle, future); } }); }
Runs immediately if already done, deferred otherwise — same identity check as before, just no longer gated on timing. -
Went with "fail the acquisition" over "defer unload." Deferring unload still leaves unloadNamespaceBundle's caller-visible behavior wrong for that window, and — more importantly — it leaves onNamespaceBundleUnload firing before onNamespaceBundleOwned for this generation (the unload listener call happens synchronously inside the racing callback, ownership-owned fires only once the future publishes afterward), so every NamespaceBundleOwnershipListener sees "unloaded" before it ever saw "owned," and tryAcquiringOwnership's caller gets a fleeting, already-invalid success. Failing the load outright avoids all three:
AtomicBoolean expiredBeforePublication = new AtomicBoolean(false); rl.getLockExpiredFuture() .thenRun(() -> { ... expiredBeforePublication.set(true); ... }); if (expiredBeforePublication.get()) { throw new IllegalStateException( "Lock for bundle " + namespaceBundle + " expired before ownership could be published"); } return ownedBundle;
The AtomicBoolean check right after .thenRun(...) returns is race-free by construction — thenRun guarantees the action runs inline, before it returns to the caller, exactly when the upstream future is already complete at attach time. No polling, no separate timing check. Caffeine auto-evicts a failed load, so there's no zombie cache entry to separately clean up here; (1) above still matters for the normal case where expiry happens strictly after publication.
- Test — used your suggested approach almost exactly: a ResourceLock whose getLockExpiredFuture() is pre-completed, plus a gated LockManager.acquireLock so the acquisition is genuinely in-flight (registered in the cache, not yet published) when the listener attaches — otherwise everything resolves synchronously and Caffeine hasn't even registered a placeholder yet, which doesn't reproduce your scenario. testExpiryBeforePublicationDoesNotLeaveActiveZombieOwnership asserts tryAcquiringOwnership itself fails and leaves no trace in locallyAcquiredLocks/ownedBundlesCache. Verified it fails without both fixes and passes with them.
| CompletableFuture<OwnedBundle> ownedBundleFuture = ownedBundlesCache.getIfPresent(bundle); | ||
| if (ownedBundleFuture != null && !ownedBundleFuture.isDone()) { | ||
| // An acquisition of this bundle is in flight. Wait for it to settle and then release what it | ||
| // acquired: reporting success while the pending acquisition installs its lock afterwards would | ||
| // leave the broker silently owning the bundle. | ||
| return ownedBundleFuture.handle((ownedBundle, ex) -> null) | ||
| .thenCompose(ignore -> removeOwnership(bundle)); | ||
| } |
There was a problem hiding this comment.
The new wait handles an acquisition that is already visible when removeOwnership starts, but it does not establish an acquire/remove barrier. A new cache load can begin after getIfPresent is checked and install another lock before or after this method completes.
Also, NamespaceService#splitAndOwnBundleOnceAndRetry currently ignores the returned future and completes the split immediately, so a delayed or failed release is not observed by the caller.
Would it be safer to serialize acquisition and removal per bundle, for example with an explicit ACQUIRING / OWNED / UNLOADING / RELEASED state or an unload barrier, and make the split path compose the removal future?
There was a problem hiding this comment.
@Denovo1998
Agreed on both points — the wait was reactive (only caught an already-visible in-flight future) rather than an actual barrier, and yes, the split path was firing-and-forgetting the release. Went with your "serialize per
bundle" suggestion, implemented as an unload/acquire barrier rather than an explicit state enum — reasoning below.
- Per-bundle async barrier. Added bundleOperationBarriers (Map<NamespaceBundle, CompletableFuture>) and a private serialize(bundle, operation) helper that both tryAcquiringOwnership and removeOwnership(NamespaceBundle) now route through, so the two can never interleave for the same bundle. serialize uses ConcurrentHashMap.compute to atomically chain each new operation behind whatever's currently queued for that bundle, and cleans its own entry back out once nothing's queued behind it. removeOwnership(NamespaceBundle)'s body is now just the blind release, wrapped in serialize — that subsumes the old "wait if visible" logic, since any acquire already queued on
the barrier is guaranteed to settle before a subsequent remove runs, whether or not it was visible at call time.
Went with this over an explicit ACQUIRING/OWNED/UNLOADING/RELEASED enum because the enum would still need the same kind of per-bundle mutex underneath its transitions (it doesn't replace serialize, only adds a label on top), and every existing external caller of checkOwnershipAsync/isNamespaceBundleOwned/getOwnerAsync would need to be individually re-audited for what an in-between state should mean to it — that's a much bigger surface (15 call sites in NamespaceService alone) than this bug warrants. Happy to revisit as a separate proposal if the intermediate-state visibility is independently wanted.
removeOwnership(OwnedBundle) (the generation-identity CAS release used by the normal unload path) deliberately isn't routed through serialize — it's already race-safe by construction, so serializing it would only add latency to the main unload path for no correctness gain.
- Split path composes the release. In splitAndOwnBundleOnceAndRetry, the removeOwnership(bundle) call is now thenComposed into the chain (with an .exceptionally that logs and swallows) before completionFuture.complete(null) runs, instead of being fired and discarded. A release failure is logged rather than failing the split — the split itself already succeeded by that point, so a release hiccup is cleanup-level, not split-level, failure. While rewriting this block I also caught that its adjacent exception handler referenced the outer, always-null ex instead of its own parameter — would have NPE'd and left completionFuture never completing on a real updateBundleState failure; fixed alongside since it's the same block.
Tests: testRemoveOwnershipWithAcquisitionInFlight covers the barrier (gates the lock acquisition so it's genuinely in-flight when removeOwnership is called concurrently, asserts no zombie lock survives once both settle); existing NamespaceServiceTest#testSplitAndOwnBundles / #testSplitBundleAndRemoveOldBundleFromOwnerShipCache continue to pass against the composed split path.
1f7e054 to
8f4d0b6
Compare
|
I ran an AI-assisted review of this PR (Claude as the local reviewer plus OpenAI Codex 1. In 2. The "race-free" The comment on 3. (optional) In the common uncontended case ( 4. (optional) Straggler-topic semantics change A topic installed in 5. (optional) Barrier latency notes, for awareness only
Also: nice catch on the |
…Ownership and lock-expiry cleanup
8f4d0b6 to
724a40a
Compare
|
@lhotari
|
|
please merge origin/master (don't rebase) to the PR branch and resolve conflicts |
# Conflicts: # pulsar-broker/src/main/java/org/apache/pulsar/broker/service/BrokerService.java # pulsar-broker/src/test/java/org/apache/pulsar/broker/service/BrokerServiceTest.java
merged @lhotari |
Motivation
OwnershipCache.removeOwnership(bundle)removes whicheverResourceLockhappens to be inlocallyAcquiredLocksat that moment, without checking that it belongs to the ownership"generation" the caller was working on. Three defects follow from this:
A stale unload chain can destroy a re-acquired ownership. When a bundle's resource lock
expires, the lock-expiry callback in the cache loader fires
unloadNamespaceBundle(...)asynchronously (dropping its future) and invalidates the local owner cache immediately. A
concurrent lookup can then re-acquire the bundle (new lock, new
OwnedBundle) while the staleunload chain is still running. When that chain reaches its final
removeOwnership(bundle), itremoves and releases the newly acquired lock, deleting the new owner's znode while the local
cache still claims active ownership. This opens a transient dual-serving window (fenced-ledger
errors on persistent topics, silent message loss on non-persistent topics) and causes ownership
flapping. The same stale chain also deactivates the newer
OwnedBundlethrough the cache lookupin
updateBundleState(bundle, false).removeOwnershipreports success while an acquisition is in flight, leaving zombieownership. The lock is only put into
locallyAcquiredLocksafteracquireLockcompletes, soa
removeOwnershipcall racing with an in-flight acquisition finds the map empty, returns acompleted future ("released"), and the lock installed afterwards is never cleaned up: the broker
silently keeps the lock and the cache entry for a bundle the caller believes released (e.g. a
deleted bundle/namespace), polluting load reports until restart.
The lock-expiry callback never removes the expired lock from
locallyAcquiredLocks(asymmetric add/remove bookkeeping), so a dead lock handle lingers in the map and drifts from
the owned-bundle cache. The callback also drops the future returned by
unloadNamespaceBundle(...), so failures of the triggered unload are silent.Modifications
OwnedBundleto theResourceLockacquired for it, so anOwnedBundleinstancerepresents a single ownership generation. Add
removeOwnership(OwnedBundle)which releases onlythat generation via
ConcurrentHashMap.remove(key, value); if the bundle has since beenre-acquired, the newer ownership is left untouched.
OwnedBundle.handleUnloadRequestnow uses itand no longer calls
updateBundleState(its ownisActiveflag is already flipped via CASearlier in the method; the cache lookup could deactivate a newer generation).
removeOwnership(NamespaceBundle)now waits for an in-flight acquisition to settle and thenreleases whatever it installed, instead of reporting success while the pending lock leaks. Its
"release the current ownership, whatever generation" semantics are intentionally kept for the
bundle-deletion and split paths.
locallyAcquiredLockswith ageneration-safe two-arg remove (mirroring
LockManagerImpl's own lock bookkeeping), invalidatesthe owned-bundle cache entry only if it still holds this generation, and logs failures of the
triggered unload instead of dropping the future.
Verifying this change
This change added tests and can be verified as follows:
Does this pull request potentially affect one of the following parts:
If the box was checked, please highlight the changes