Skip to content

[#4453] Fix ReplayToken.wasProcessedBeforeReset() for tokens without gap-walkback lowerBound semantics#4458

Merged
abuijze merged 3 commits intoaxon-4.13.xfrom
fix/replay-token-covers-fallback-for-non-gap-walkback-tokens
Apr 25, 2026
Merged

[#4453] Fix ReplayToken.wasProcessedBeforeReset() for tokens without gap-walkback lowerBound semantics#4458
abuijze merged 3 commits intoaxon-4.13.xfrom
fix/replay-token-covers-fallback-for-non-gap-walkback-tokens

Conversation

@abuijze
Copy link
Copy Markdown
Contributor

@abuijze abuijze commented Apr 24, 2026

ReplayToken.wasProcessedBeforeReset() relied solely on lowerBound() + samePositionAs() to determine whether a replayed event had already been processed before the reset. This works correctly for GapAwareTrackingToken, whose lowerBound() walks back from gap positions, but breaks for token implementations whose lowerBound() computes a set-intersection — such as MongoTrackingToken.

When the reset token and the replayed event token are from completely different time periods, their event-ID sets are disjoint. The intersection is empty, and samePositionAs(empty, other) returns false, causing every replayed event to be misclassified as "new" instead of "replay". This has two observable effects:

  1. @DisallowReplay handlers fire during a replay, and ReplayToken.isReplay() returns false.
  2. advancedTo() falls into the "new event" branch and calls tokenAtReset.upperBound(newToken) for every replayed event, merging all event IDs into the stored token on every step. The token grows without bound until the backing store's size limit is exceeded.

The fix adds a covers() fallback: if resetLowerBound.covers(newTokenLowerBound) is true, the event was processed before the reset regardless of what lowerBound() returned. For GapAwareTrackingToken this fallback is dead code — whenever covers() would return true, the existing lowerBound() + samePositionAs() check already does. For set-intersection tokens it restores correct behaviour by using their time/position-based covers() implementation.

Two regression tests are added using a WindowedTrackingToken (an in-test stub whose lowerBound() returns the intersection of event-ID sets, mirroring MongoTrackingToken):

  • one verifying that a replayed event from an earlier period is classified as a replay
  • one verifying that tokenAtReset does not grow during replay

Also includes an unrelated bump of the TestContainers version to support running integration tests against more recent Docker installations.

Fixes #4453.

abuijze added 2 commits April 24, 2026 11:02
…back lowerBound semantics

wasProcessedBeforeReset() relied solely on lowerBound() + samePositionAs() to detect whether
an event was already processed before a reset. This works for GapAwareTrackingToken, whose
lowerBound() walks back from gap positions, but breaks for token implementations whose
lowerBound() computes a set-intersection (e.g. MongoTrackingToken). When the reset token and
the replayed event token are from completely different time periods, their event-ID sets are
disjoint, the intersection is empty, and samePositionAs(empty, other) returns false — causing
every replayed event to be misclassified as "new" instead of "replay".

This misclassification has two effects:
1. @DisallowReplay handlers fire and ReplayToken.isReplay() returns false during a replay.
2. advancedTo() falls into the "new event" branch and calls tokenAtReset.upperBound(newToken)
   for every replayed event, merging all event IDs into the stored token on every step and
   growing it without bound until the backing store's size limit is exceeded.

The fix adds a covers() fallback: if resetLowerBound.covers(newTokenLowerBound) is true, the
event was processed before the reset regardless of the lowerBound() result. For
GapAwareTrackingToken the fallback is dead code — whenever covers() would return true,
lowerBound() + samePositionAs() already does. For set-intersection tokens the fallback
restores correct behaviour by using their time/position-based covers() implementation.

Fixes: #4453
This allows testcontainers to run on environments with more recent Docker installations.
@abuijze abuijze requested a review from a team as a code owner April 24, 2026 09:43
@abuijze abuijze requested review from hatzlj, hjohn and jangalinski and removed request for a team April 24, 2026 09:43
@abuijze abuijze changed the base branch from main to axon-4.13.x April 24, 2026 09:44
@abuijze abuijze added this to the Release 4.13.1 milestone Apr 24, 2026
@abuijze abuijze added Type: Bug Use to signal issues that describe a bug within the system. Priority 2: Should High priority. Ideally, these issues are part of the release they’re assigned to. labels Apr 24, 2026
@jangalinski
Copy link
Copy Markdown
Collaborator

How (if so) will this affect the 5.1 branch?

@martijnvanderwoud
Copy link
Copy Markdown
Contributor

Did a local test with this fix. Replays are working again for our MongoDB event store, thanks!

@smcvb smcvb added Priority 1: Must Highest priority. A release cannot be made if this issue isn’t resolved. and removed Priority 2: Should High priority. Ideally, these issues are part of the release they’re assigned to. labels Apr 24, 2026
Copy link
Copy Markdown
Contributor

@smcvb smcvb left a comment

Choose a reason for hiding this comment

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

Awesome job, looks good to me.

@smcvb
Copy link
Copy Markdown
Contributor

smcvb commented Apr 24, 2026

Did a local test with this fix. Replays are working again for our MongoDB event store, thanks!

That feedback is extremely helpful, @martijnvanderwoud, thank you!! 🙏
If this fixes replays for you, can I assume we'd thus no longer require to tackle the Mongo Extension specific issue you created, @martijnvanderwoud?

@martijnvanderwoud
Copy link
Copy Markdown
Contributor

martijnvanderwoud commented Apr 24, 2026

Did a local test with this fix. Replays are working again for our MongoDB event store, thanks!

That feedback is extremely helpful, @martijnvanderwoud, thank you!! 🙏 If this fixes replays for you, can I assume we'd thus no longer require to tackle the Mongo Extension specific issue you created, @martijnvanderwoud?

@smcvb you are right, this reduces the impact of AxonFramework/extension-mongo#498 to almost zero. The unbounded growth now only occurs if the initial token at reset has an empty events collection, which is rare and easy to work around

Thanks guys for fixing this so quickly!

@smcvb
Copy link
Copy Markdown
Contributor

smcvb commented Apr 24, 2026

Sure thing @martijnvanderwoud! And thank you for filing this with us!

@smcvb
Copy link
Copy Markdown
Contributor

smcvb commented Apr 24, 2026

How (if so) will this affect the 5.1 branch?

It might, once we introduce tokens that are akin to the MongoTrackingToken in AF5, @jangalinski. That is not the case yet, though, so we're good for the 5.1 release next week

@abuijze abuijze enabled auto-merge April 25, 2026 10:35
@sonarqubecloud
Copy link
Copy Markdown

❌ The last analysis has failed.

See analysis details on SonarQube Cloud

@abuijze abuijze merged commit 0816c45 into axon-4.13.x Apr 25, 2026
5 of 6 checks passed
@abuijze abuijze deleted the fix/replay-token-covers-fallback-for-non-gap-walkback-tokens branch April 25, 2026 10:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Priority 1: Must Highest priority. A release cannot be made if this issue isn’t resolved. Type: Bug Use to signal issues that describe a bug within the system.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ReplayToken.wasProcessedBeforeReset() breaks for token implementations without gap-walk-back lowerBound() semantics

4 participants