[#4828] Append at the end of the aggregate stream when no consistency condition is given - #4830
Open
schananas wants to merge 1 commit into
Open
Conversation
… condition is given AggregateBasedConsistencyMarker.from returns an empty position map for ORIGIN and INFINITY alike, and the sequencer defaults a missing aggregate to 0, so an append made with AppendCondition.none() always asked for aggregate sequence 0. That succeeded exactly once per aggregate and then hit the unique index on (aggregateIdentifier, aggregateSequenceNumber) for ever after, reported as AppendEventsTransactionRejectedException -- the framework's "you conflicted, source again and retry" answer, where the retry re-derived sequence 0 and failed identically. The pre-commit marker now comes from one helper. A conditional append keeps from(condition) unchanged, so ORIGIN still means the aggregate must be empty. An INFINITY marker instead resolves MAX(aggregateSequenceNumber) per distinct tagged aggregate and folds it in, so the append takes the next sequence. Cost is one SELECT MAX(...) per distinct tagged aggregate, on unconditional appends only. This does not close the residual race. Two concurrent unconditional appends to the same aggregate can both read the same MAX, and one loses the unique-index race, so it is still rejected as a conflict. The difference is that a retry now succeeds instead of failing for ever. Fixes #4828
schananas
requested review from
MateuszNaKodach,
hatzlj and
laura-devriendt-lemon
and removed request for
a team
July 29, 2026 16:41
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 #4828
What changed
AggregateBasedConsistencyMarker.fromreturns an empty position map forORIGINandINFINITYalike, and the sequencer defaults a missing aggregate to0. So an append made withAppendCondition.none()always asked for aggregate sequence 0.That succeeded exactly once per aggregate and then hit the unique index on
(aggregateIdentifier, aggregateSequenceNumber)for ever after -- reported asAppendEventsTransactionRejectedException, the framework's "you conflicted, source again and retry" answer, where the retry re-derived sequence 0 and failed identically.The pre-commit marker now comes from one helper. A conditional append keeps
from(condition)unchanged, soORIGINstill means "the aggregate must be empty". AnINFINITYmarker resolvesMAX(aggregateSequenceNumber)per distinct tagged aggregate and folds it in, so the append takes the next sequence.Cost: one
SELECT MAX(...)per distinct tagged aggregate, on unconditional appends only. Conditional appends issue no extra query.This does NOT close the residual race
Two concurrent unconditional appends to the same aggregate can both read the same
MAX, and one still loses the unique-index race and is rejected as a conflict. The difference is that a retry now re-readsMAXand succeeds, instead of failing for ever.That is ordinary optimistic concurrency on a per-aggregate sequence column, and retry converges: 8 concurrent writers on one populated aggregate needed at most 7 attempts, ending with contiguous sequences and no lost write.
The same check also fires on the Axon Server arm, so "an unconditional append is never rejected" is not absolutely honoured by any store under contention. The residual violations are plausibly a different root cause: the same checker is documented firing 18 times purely from partition-window misclassification, which this fix cannot address.
Closing it fully needs row locking, or a rejection shaped differently from a conflict. That is a design decision beyond this fix.
Tests
AggregateBasedStorageEngineTestSuitegainsunconditionalAppendContinuesAnAggregateThatAlreadyHoldsEvents, so every aggregate-store implementation inherits it. On unfixed code:AggregateBasedJpaEventStorageEngineIT: 34 testcases, 0 failures, new case passing.Note: the IT exercising the shared suite lives in
extensions/spring/spring-boot-autoconfigure, noteventsourcing, and needs-Pintegration-test.-pl eventsourcingalone never runs the new test.No conflict with #4819
That branch touches only
AggregateBasedConsistencyMarkerand its test, to adddoLowerBound. This commit touches neither. Zero file overlap, so the two can land in either order.The conflation stays in place
The shared root cause is untouched:
AggregateBasedConsistencyMarker.from()returns an empty position map forORIGINandINFINITYalike, andAggregateSequencer.positionOf()defaults to 0. This change compensates in a private helper inside the JPA engine instead.Safe today, since
from()andcreateSequencer()have exactly one production consumer. But a future aggregate-based engine, or a port fromstash/, inherits the original bug and must re-implement the compensation.