Skip to content

[#4828] Append at the end of the aggregate stream when no consistency condition is given - #4830

Open
schananas wants to merge 1 commit into
mainfrom
bug/4828/unconditional-append-at-end-of-aggregate-stream
Open

[#4828] Append at the end of the aggregate stream when no consistency condition is given#4830
schananas wants to merge 1 commit into
mainfrom
bug/4828/unconditional-append-at-end-of-aggregate-stream

Conversation

@schananas

@schananas schananas commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Fixes #4828

What changed

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 resolves MAX(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-reads MAX and 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

AggregateBasedStorageEngineTestSuite gains unconditionalAppendContinuesAnAggregateThatAlreadyHoldsEvents, so every aggregate-store implementation inherits it. On unfixed code:

AppendEventsTransactionRejectedException: Event matching append criteria have been detected beyond
provided consistency marker: AggregateBasedConsistencyMarker{aggregatePositions={}}
  Suppressed: ConstraintViolationException: unique constraint or index violation ... AGGREGATEEVENTENTRY

AggregateBasedJpaEventStorageEngineIT: 34 testcases, 0 failures, new case passing.

Note: the IT exercising the shared suite lives in extensions/spring/spring-boot-autoconfigure, not eventsourcing, and needs -Pintegration-test. -pl eventsourcing alone never runs the new test.

No conflict with #4819

That branch touches only AggregateBasedConsistencyMarker and its test, to add doLowerBound. 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 for ORIGIN and INFINITY alike, and AggregateSequencer.positionOf() defaults to 0. This change compensates in a private helper inside the JPA engine instead.

Safe today, since from() and createSequencer() have exactly one production consumer. But a future aggregate-based engine, or a port from stash/, inherits the original bug and must re-implement the compensation.

… 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
schananas requested a review from a team as a code owner July 29, 2026 16:41
@schananas
schananas requested review from MateuszNaKodach, hatzlj and laura-devriendt-lemon and removed request for a team July 29, 2026 16:41
@schananas schananas self-assigned this Jul 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

An append with no consistency condition is rejected as conflicting on the JPA event store

1 participant