[fix][broker] Manage in-flight bucket work during clear - #26401
Conversation
|
Ping @void-ptr974 @Denovo1998 |
|
Thanks for the fix. I think there are two edge cases that may still need attention.
For example, suppose buckets A later
For example, suppose a stale delete is deleting bucket key The retry is only for |
|
|
||
| private CompletableFuture<Void> doDeleteBucketSnapshot(String ledgerName, | ||
| Range<Long> range, ImmutableBucket bucket) { | ||
| return bucket.asyncDeleteBucketSnapshot(stats) |
There was a problem hiding this comment.
The resolveBucketIdForDelete() method may wait for the snapshotCreateFuture, but the orphan decision is already captured earlier in asyncTrimImmutableBuckets(). This continuation does not revalidate either condition—whether range.upperEndpoint() < firstActiveLedgerId() or immutableBuckets.asMapOfRanges().get(range) == bucket—before deleting or removing tracker state.
As a result, a cursor reset, a backward mark-delete movement, or a same-range bucket replacement while bucket creation is pending could delete or untrack live delayed indexes. The removeBucket(range) operation may also remove a replacement bucket because it does not verify object identity. This race condition is described in #26260/#26280. However, this PR is based independently on master and does not include the reset fencing introduced in #26280.
Could we revalidate eligibility and the exact range-to-bucket identity before initiating storage deletion, and again before mutating tracker state at lines 942-945?
synchronized (this) {
snapshotSegmentLastIndexMap.entrySet().removeIf(entry -> entry.getValue() == bucket);
removeBucket(range);
bucket.getDelayedIndexBitMap().forEach((ledgerId, bitmap) ->
bitmap.forEachLong(entryId -> index.untrack(ledgerId, entryId)));
}For destructive trim operations, using the managed ledger’s monotonic retained-ledger boundary would also prevent mark-delete from moving backward. Please add a deterministic test that simulates a blocked create operation followed by a cursor reset or bucket replacement.
There was a problem hiding this comment.
Thanks for the detailed review. This PR is now scoped to in-flight future management only. I agree that trim eligibility and exact range-to-bucket identity need revalidation before destructive storage deletion and before mutating tracker state; I am splitting that ownership/revalidation fix into a follow-up PR together with the reset fencing discussion from #26280. The current change keeps the trim delete inside the merge/trim future so clear() waits for it, and the follow-up will add deterministic tests for blocked creation followed by reset or same-range replacement.
| if (!String.valueOf(bucketId).equals(currentBucketId)) { | ||
| return CompletableFuture.completedFuture(null); | ||
| } | ||
| return executeWithRetry(() -> ctx.cursor().removeCursorProperty(bucketKey), |
There was a problem hiding this comment.
The expected-bucket-ID check is located outside the executeWithRetry supplier. Since ManagedCursorImpl persists cursor properties using optimistic versioning, a concurrent writer from another tracker or sequencer may update this property after the check, causing the first removal attempt to fail with a BadVersionException.
Then, in a retry, executeWithRetry only calls removeCursorProperty(bucketKey) again, without revalidating the expected ID. This retry could inadvertently delete a replacement bucket’s property. Sharing the same BucketContext sequencer does not safeguard against concurrent writes from a new tracker or direct cursor‑property modifications.
Should we place the expected‑value check inside the retry supplier, or introduce an atomic compare‑and‑remove cursor‑property operation? The regression test should update the property so the first removal returns BadVersionException, then confirm that the retry avoids deletion.
There was a problem hiding this comment.
Agreed — the expected-value check must be retried together with removeCursorProperty(), otherwise a BadVersion retry can delete a replacement property. The unconditional/stale cursor-property cleanup and atomic compare-and-remove behavior belong to the data-ownership follow-up, so I have removed that item from this PR's scope. The current PR only ensures deletes are represented in the future chain waited on by clear()/trim.
| return null; | ||
| }) | ||
| .thenCompose(__ -> { | ||
| List<CompletableFuture<?>> pending = new ArrayList<>(); |
There was a problem hiding this comment.
This takes a one-time snapshot of pendingLoad and pendingDeletes, but clear() does not mark the tracker as clearing or prevent getScheduledMessages() from starting another load.
While clear() waits for trimFuture or a captured load, the dispatcher or timer may still enter the tracker. Once the captured load completes, another call could acquire the tracker lock, see that pendingLoad is finished, and schedule the next load before the cleanup routine runs. That new callback could then repopulate queued and mapped items after lines 835–838 clear them. shouldPauseAllDeliveries() would also remain false.
Could clear() establish a lifecycle generation or clearing state synchronously on entry, prevent new loads during clearing, and make every async callback verify the generation before publishing its state?
There was a problem hiding this comment.
Thanks. This PR intentionally fixes the quiescence snapshot: clear() now waits for trimFuture, the current pendingLoad, and tracked pendingDeletes before resetting state, with tests for segment load, terminal-load delete, merge create/load/delete, and failed tracked deletes. A full clearing lifecycle/generation fence is broader than inflight-future management and is planned for the follow-up PR; it will prevent newly scheduled loads from starting during clear and verify generation before publishing callbacks.
There was a problem hiding this comment.
I re‑checked the production call path. PersistentSubscription.clearBacklog() calls dispatcher.clearDelayedMessages() while the dispatcher remains usable, and the normal dispatcher path can still call getScheduledMessages().
Thus, a single snapshot of pendingLoad / pendingDeletes does not serve as a quiescence barrier: after the captured load completes, another load can begin before the cleanup continuation acquires the tracker lock, and that callback could publish state after clear() has finished.
This appears to fall directly within the guarantee stated in this PR, not just as a broader reset/ownership issue. Could clear() establish a clearing generation or state before releasing the lock, prevent new async work from starting, and require pre‑clear callbacks to verify that generation before publishing?
There was a problem hiding this comment.
I'd still keep this one out of this PR. What this change promises is that clear() drains everything that was in flight when it entered. A load scheduled after that is new-lifecycle work, and what it publishes after the reset is inert — entries get popped with removeIndexBit() returning false and silently dropped (never delivered), and the boundary entries point at buckets that are no longer in immutableBuckets, so they can't chain more loads. It burns a few wasted reads and stops by itself. Master today doesn't drain anything at all, so this is strictly narrower already.
Doing what you suggest properly — generation + blocking new work, verified at publish — is a real lifecycle change of its own, and I'd rather not bolt it onto this one. I'll take it in the follow-up, happy to discuss the shape there.
33d7c2c to
7f22e56
Compare
| return immutableBucketDelayedIndexPair.getLeft().getSnapshotCreateFuture() | ||
| .orElse(NULL_LONG_PROMISE) | ||
| .thenCompose(___ -> FutureUtil.waitForAll( | ||
| buckets.stream() | ||
| .map(bucket -> bucket.asyncDeleteBucketSnapshot(stats)) |
There was a problem hiding this comment.
The source-deletion chain is gated by the post-processed snapshotCreateFuture; however, afterCreateImmutableBucket() converts a failed snapshot creation into a normally completed INVALID_BUCKET_ID. Consequently, this thenCompose still deletes all source snapshots even when the merged snapshot was never created. Because the source bucket mappings were previously removed, a restart following this path can lose the only durable delayed-index state.
There is also a second variant: ImmutableBucket.asyncSaveBucketSnapshot() currently suppresses putBucketKeyId() failure and still completes with the new bucket id, so a positive id does not guarantee the merged snapshot is recoverable.
Could source deletion be gated on successful durable handoff — including both snapshot persistence and cursor-property publication — while keeping the source buckets recoverable otherwise?
There was a problem hiding this comment.
Agreed, the delete was wrong — fixed in 9781754: the source-delete chain now skips when the merged creation completed with INVALID_BUCKET_ID, so the source buckets stay recoverable from their cursor properties. Added a test that fails the merged creation and recreates the tracker with the same cursor/storage: both source buckets come back.
The second variant — putBucketKeyId() failing but still completing the create future with a positive id — is create-path semantics from master; changing it here felt too invasive. Even in that case delivery is not at risk: deliverAtTime is in the message metadata, and undelivered messages are just re-read from the backlog and re-tracked on restart (the same fallback the in-memory tracker uses). That part fits the follow-up / #26280.
| return FutureUtil.waitForAll(pending) | ||
| .exceptionally(t -> { | ||
| log.warn().exception(t) | ||
| .log("Failed to wait for pending delayed delivery work, but still clear"); | ||
| return null; | ||
| }) |
There was a problem hiding this comment.
I think clear() may still return success even when some snapshot cleanup actually failed.
asyncDeleteBucketSnapshot() only removes the cursor property after the snapshot delete succeeds. But in both the terminal-load path and merge path, the bucket is already removed from immutableBuckets before the delete finishes.
So if the delete keeps failing after retries, clear() ignores the error here and continues. At that point cleanImmutableBuckets() also can't find this bucket anymore. The old cursor property may still be there, and after recreating the tracker the old bucket could be recovered again.
The current tests only check that clear() completes successfully. Could we also recreate the tracker with the same cursor/storage and verify that the deleted bucket isn't recovered again?
I think either clear() should propagate this cleanup failure, or we need to keep track of the failed cleanup until its cursor property is actually removed.
There was a problem hiding this comment.
Looked at both failure shapes, I think they both converge:
- delete keeps failing: snapshot and property survive as a consistent pair — recovery just loads the bucket again (it still exists) and the next trim/clear retries the cleanup.
- delete ok but property removal failed: recovery resolves the property, hits BucketNotExist, and the toBeDeleted path in recoverBucketSnapshot() removes it.
While writing the recreate test for the second shape I found that path was actually dead: the chain delivers the BucketNotExistException wrapped in a CompletionException, so the instanceof never matched and recovery would just throw. Fixed by unwrapping before the check (e3c264a), and the test now recreates the tracker with the same cursor/storage and watches the stale property get cleaned up — plus a test for the consistent-pair shape, where the bucket just loads normally again.
| return null; | ||
| }) | ||
| .thenCompose(__ -> { | ||
| List<CompletableFuture<?>> pending = new ArrayList<>(); |
There was a problem hiding this comment.
I re‑checked the production call path. PersistentSubscription.clearBacklog() calls dispatcher.clearDelayedMessages() while the dispatcher remains usable, and the normal dispatcher path can still call getScheduledMessages().
Thus, a single snapshot of pendingLoad / pendingDeletes does not serve as a quiescence barrier: after the captured load completes, another load can begin before the cleanup continuation acquires the tracker lock, and that callback could publish state after clear() has finished.
This appears to fall directly within the guarantee stated in this PR, not just as a broader reset/ownership issue. Could clear() establish a clearing generation or state before releasing the lock, prevent new async work from starting, and require pre‑clear callbacks to verify that generation before publishing?
The BucketNotExist branch in handleRecoverBucketSnapshotEntry never matched: dependent future stages wrap the checked exception in a CompletionException, so a stale cursor property pointing to a deleted snapshot failed the whole recovery instead of being cleaned up. Unwrap before the check so the bucket is deleted and the property is removed, like the path was designed to do. Also make MockBucketSnapshotStorage re-readable (parse from a slice, same as the metadata read) and add recreate-tracker tests for both cleanup-failure shapes: consistent snapshot+property residue loads normally, property-only residue is removed on recovery.
A failed merged-bucket creation completes with the INVALID_BUCKET_ID sentinel, so the source-delete chain still ran and deleted the source snapshots although their ledger ranges were already removed from immutableBuckets - after a restart the only durable delayed-index state for those ranges was gone. Skip the source deletes when the merged creation failed, so the source buckets stay recoverable from their cursor properties.
Motivation
The bucket delayed delivery tracker can complete
clear()while bucket work is still in flight:clear()reset in-memory state without waiting for in-flight snapshot segment loads, so a completing load callback could republish pre-clear entries.clear()could return while those deletes were still running.This is the in-flight-future subset of #26280. Data-ownership races around trim eligibility, same-range bucket replacement, stale cursor-property removal, and clear fencing require lifecycle/revalidation changes and will be handled in a follow-up PR.
Modifications
clear()now waits for the in-flight trim/merge chain (trimFuture), pending snapshot segment loads (pendingLoad), and tracked snapshot deletes (pendingDeletes) before resetting in-memory state.pendingLoad; recovery and trim deletes are tracked throughpendingDeletes.trimFuturecovers them.