[#4801] Keep the work package's stored token monotonic from the first store of a claim - #4807
Open
schananas wants to merge 1 commit into
Open
[#4801] Keep the work package's stored token monotonic from the first store of a claim#4807schananas wants to merge 1 commit into
schananas wants to merge 1 commit into
Conversation
schananas
requested review from
hatzlj,
hjohn and
jangalinski
and removed request for
a team
July 29, 2026 11:31
schananas
marked this pull request as draft
July 29, 2026 17:34
… store of a claim The `lastStoredToken` field was left null by the constructor while `lastDeliveredToken` and `lastConsumedToken` were seeded from the claim. Since the monotonicity guard is written as `lastStoredToken != null && ...`, it was skipped on the first store of every claim cycle. A progress strategy offering a low position at that moment durably rewound the segment with no warning, and the whole segment was redelivered on the next claim. The claimed token is what the store last accepted for that segment, so seeding the field from it makes the guard cover the first store too. It also makes the idle-upkeep gate hold on a freshly claimed idle segment, which its own Javadoc already promised. Seeding alone is not enough, because a non-null token can still unwrap to no raw position at all. The default initial token is a `ReplayToken` with a null current token, and `MergedTrackingToken` unwraps to null while one half is empty. `coversWhenUnwrapped` passed that null straight into `covers`, which `GapAwareTrackingToken` -- the token a JPA-backed event store hands out -- rejects with an `IllegalArgumentException`. The throw lands inside the batch unit of work, so the package aborts and the re-claimed package meets it again with the token never stored. That hole was already reachable without the seed, through an idle beat firing before the first event and storing the null-current `ReplayToken`. The guard therefore decides on the unwrapped positions it actually has: a reference without a raw position is nothing to regress from and is covered by anything, while a candidate without one advances to nothing and covers nothing. Neither is handed to `covers`, as raw token types are not required to tolerate a null argument. Fixes #4801
schananas
force-pushed
the
bug/4801/seed-last-stored-token-from-claim
branch
from
July 29, 2026 17:48
a695e9b to
b8f98f6
Compare
schananas
marked this pull request as ready for review
July 29, 2026 17:51
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.
Fixes #4801
Fixes #4836
What changed
Two parts, and the first cannot ship without the second.
1. Seed the work package's stored token from the claim (#4801)
lastStoredTokenhad no initialiser whilelastDeliveredTokenandlastConsumedTokenwere both seeded frombuilder.initialToken. The monotonicity guard is writtenlastStoredToken != null && ..., so it was skipped on the first store of every claim cycle, per segment. A progress strategy offering a low position at that moment durably rewound the segment with no warning, and the whole segment was redelivered on the next claim.2. Make
coversWhenUnwrappednull-tolerant (#4836)WrappedToken.unwrapUpperBoundreturns null for a null-currentReplayTokenand for a half-emptyMergedTrackingToken, and the helper dereferenced it.GlobalSequenceTrackingToken.covers(null)returnstrue, so this hid;GapAwareTrackingToken.covers(null)throws, and the JPA engine'sfirstToken()returns aGapAwareTrackingTokenthat Spring Boot wires by default.A null reference means no raw stream position is recorded, so there is nothing to regress from and accepting the store is behaviour-preserving -- pre-seed, a null
lastStoredTokenskipped the guard and the store went through anyway. Rejecting would wedge a just-reset segment for ever. The candidate side is guarded too: an unguarded version NPEs when the candidate unwraps to null, and returningfalsematches the method's own promise for an incomparable token.There is exactly one production call site,
WorkPackage:518, so the reasoning only has to hold there.The second defect is pre-existing, not introduced here
On pure
mainthe second store of the idle-beat sequence already throwsIllegalArgumentException: Incompatible token type provided: null:upkeepIfThresholdIsMetseesObjects.equals(replayToken, null)as false, drivesonBatchCommitand persists the null-currentReplayToken, and the guard is skipped becauselastStoredTokenis null, so the empty token becomes the reference. The seed only moves that empty reference from store #1 to construction; the helper fix closes both.Tests
With both fixes: 258 testcases, 0 failures. Each half is independently load-bearing: removing the helper fix gives 3 errors (the
WorkPackagecase and bothTrackingTokenUtilscases), removing the seed gives exactly 1 failure,persistProgressIgnoresATokenBehindTheClaimedTokenOnTheFirstStoreOfAClaim.Across
WorkPackageTest(35),TrackingTokenUtilsTest(14),ReplayTokenTest(31),MergedTrackingTokenTest(20),GapAwareTrackingTokenTest(33),CoordinatorTest(22),PooledStreamingEventProcessorTest(70),TokenStoringProgressStrategyTest(5),ReplayTokenWrappingComplexTokenTest(28).Why CI could not have caught this
Every existing test touching the default replay initial token uses
GlobalSequenceTrackingToken, the one type that toleratescovers(null). Nothing in the tree pairs a pooled processor with aGapAwareTrackingToken, so the full 4253-testmessagingsuite stays green on the broken code. The newWorkPackageTestcase usesReplayToken(GapAwareTrackingToken(5), null)to close that blind spot.For the reviewer
The guard in
storeIfAdvancedand the idle-upkeep gate are otherwise unchanged; they simply now have a non-null reference on the first store after a claim.A second intended effect: the idle gate
Objects.equals(lastConsumedToken, lastStoredToken)never held on a freshly claimed idle segment, contradicting its own Javadoc that an idle segment never drives a strategy with an unchanged position. Seeding makes it hold, so a freshly claimed idle segment stops invokingonBatchCommituntil something is consumed. No strategy can be starved:hasPendingWork()routes an idle segment through a commit cycle before the upkeep gate. Claim extension is unaffected.Left alone deliberately:
WrappedToken.unwrapUpperBoundis declared non-null in a@NullMarkedpackage but returns null for these shapes. Marking it@Nullableis the honest fix and cascades to every caller in the tree;ReplayToken:129already null-checks its result, so the precedent for handling it in place exists.