[#3728] Stop losing events appended during prepare-commit, and fix the guards that hid it - #5013
Closed
MateuszNaKodach wants to merge 4 commits into
Closed
[#3728] Stop losing events appended during prepare-commit, and fix the guards that hid it#5013MateuszNaKodach wants to merge 4 commits into
MateuszNaKodach wants to merge 4 commits into
Conversation
MateuszNaKodach
requested review from
hatzlj,
hjohn and
zambrovski
and removed request for
a team
September 3, 2026 13:49
…uring An event appended once the append step had run was lost without a trace on any storage engine that flushes what it is handed. AggregateBasedJpaEventStorage- Engine persists and flushes inside appendEvents and its commit() is a no-op, so a later addition to that list is never seen -- while SimpleEventBus has already delivered the event to every subscriber. Store and subscribers diverge, silently. Nothing caught it because InMemoryEventStorageEngine closes over the list it is given and re-reads it when committing, so the in-memory engine picks the late event up by accident. The new test therefore asserts what the engine was handed, not only what ended up stored, and uses an engine that flushes its batch eagerly. The reachable case is ordinary and is the same one the saga write just moved out of: SimpleEventBus drains during PREPARE_COMMIT and a subscribing processor's handlers append from there. Whether it worked came down to which of the two prepare-commit actions happened to be registered first. There was a second symptom too, which the test suite now covers: a component whose *first* append happened in that phase did not lose the event, it failed outright, because attachAppendEventsStep registers lazily and PREPARE_COMMIT cannot register for itself. So the batch is handed over in a phase of its own, APPEND_EVENTS at order 29000. That is deliberately the last slot before COMMIT rather than the first one above PREPARE_COMMIT: SAGA_WRITE sits at 25000, and a saga is exactly the kind of component that may append. Every phase a caller puts in the gap gets to append before the events go over, and appending stays possible from any of them. Adding a second append transaction for the late events was the alternative, and it loses: its consistency marker predates the first batch, so the in-memory engine reports a false conflict and the aggregate-based engine restarts sequence numbers and collides on its own primary key. Nothing can produce a fresh marker mid-context, since the append position is only updated after commit. Appending after that phase is now refused instead of queued into a batch that is already gone. It is unreachable from a message handler and was never going to be stored, so a loud failure beats losing the event. One assertion moved with it: a failing subscriber used to leave an append transaction to roll back, and now leaves none, because the engine is not asked for one until after every subscriber has been notified. The test asserts that directly, which is the stronger claim.
…ims to The saga tests had to be written against a real UnitOfWork rather than StubProcessingContext, and the reason was recorded in a commit message: the stub assigns its current phase after running that phase's actions, so during a PREPARE_COMMIT action it still reports the earlier phase and accepts registrations production rejects. It also throws its own exception type with its own wording, so a test asserting on that pinned the stub instead of the framework. Working around a broken test double is worth doing once; leaving it broken means the next person has to know to work around it too. It now enters a phase before running its actions and rejects the same registrations with the same exception and message as UnitOfWork -- including the case that matters here, an action registering for the phase it is already running in. Behaviour is otherwise unchanged, and the whole messaging suite passes. The javadoc says where the stub still stops, so the next reader does not have to find out by experiment: a phase's actions are chained rather than run in parallel, and a registration for a later phase that a moveToPhase call is already past is not picked up. publishingAfterContextCommittedThrowsException changes with it. The behaviour it describes is unchanged -- publishing into a committed context still fails -- but the reason is now visible: the bus cannot register the prepare-commit hook that would have delivered the events, and the message says which phase blocked it.
The rule had no documentation anywhere: no @throws on ProcessingLifecycle.on, no comment at the throw, nothing in the reference guide. A caller could only discover it by running into the exception, and could not check beforehand either, since Axon Framework 5 has no public equivalent of Axon Framework 4's UnitOfWork.phase(). Two components now depend on the answer, so leaving it undocumented means each next one rediscovers it the same way. Both places state the rule and, more usefully, the way out of it: a component invoked from within a late phase registers for a custom phase in the gap above it, which is legal, runs after the current phase completes, and still lands before the next default phase. That idiom is what the saga write and the event store's hand-off to the storage engine are both built on, and the reference guide names them so the pattern reads as intended design rather than as a workaround someone invented twice.
SimpleEventBus guarded publication with context.isCommitted(), which is status == COMPLETED and therefore only true once every phase has finished and the completion handlers are about to run. During COMMIT and AFTER_COMMIT it is false, so the guard did not fire where it was meant to. What happened instead depended on whether anything had published in the context before. On a first publish the queue is absent, so the bus tried to register its prepare-commit hook and the ProcessingContext rejected that -- an error about phase registration rather than about publishing, but at least an error. On a later publish the queue already existed, so computeResourceIfAbsent returned it, the events were added, and nobody ever read them again. The event was accepted and silently dropped. The right question is not which phase the context is in, but whether the queue has already been drained, and the bus is the only one that knows that. It now records it, the way DefaultEventStoreTransaction records its append step having run, and refuses a publish afterwards with a message naming the phase that delivery happens in. The flag is set as soon as processEventsInPhase returns rather than when its delivery future completes, and the difference matters. processEventsInPhase loops while the queue keeps growing, so it catches anything a synchronous subscriber publishes; once it returns, the loop is over and no later addition is picked up, even though subscribers may still be running. Setting the flag at that exact point is what turns a publish from a still-running asynchronous subscriber into a failure rather than another silent drop. The isCommitted() check stays as the coarser case, since it still gives a better message for a publish from a completion handler in a context that never published anything.
MateuszNaKodach
force-pushed
the
feat/saga-repository-v1
branch
from
September 3, 2026 14:09
991c98c to
c1ee57d
Compare
Contributor
Author
|
Superseded by #5014. GitHub closed this automatically when the head branch was renamed from |
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.
While investigating why the saga was stuck during #5009 development, the same root cause turned out to hit two other components that also register for
PREPARE_COMMITlazily. One of them loses events silently on JPA. This PR fixes those two, and repairs the test double that was hiding them.No change to
UnitOfWorkor to the phase rule itself.Background: the rule, and who trips over it
ProcessingContextrejects a registration for a phase whose order is at or below the phase currently running:That rule is fine. The problem is that a handler is not always invoked in the phase it thinks it is. Events published with a
ProcessingContextare queued bySimpleEventBusand drained duringPREPARE_COMMIT, andSubscribingEventProcessorhands them to its components in that same context:sequenceDiagram participant CH as Command handler participant UoW as UnitOfWork participant Bus as SimpleEventBus participant SEP as SubscribingEventProcessor participant H as Your event handler CH->>Bus: publish(context, events) Note over Bus: queued, hook registered for PREPARE_COMMIT UoW->>Bus: PREPARE_COMMIT Bus->>SEP: notify(events, context) SEP->>H: handle(event, context) Note over H: running INSIDE PREPARE_COMMITSo any component reached that way cannot register for
PREPARE_COMMIT. That is what blocked the saga write, and PR #5009 fixes it by registering forSAGA_WRITEinstead. But the saga is not the only component that registers forPREPARE_COMMITlazily.Bug 1: the event store hands its batch over too early, and JPA loses the events
DefaultEventStoreTransactioncollects appended events in a queue and hands the whole queue to theEventStorageEnginein a step registered on first append, forPREPARE_COMMIT. Two things go wrong.1a. First append during
PREPARE_COMMITthrowsattachAppendEventsStep()registers lazily, so a component whose first append happens in that phase hits the identical rejection the saga hit. No saga involved, plain event append.1b. Append after the step already ran is silently dropped
If something appended earlier, the queue exists,
computeResourceIfAbsentshort-circuits, and the event is added to a list the engine already received. Whether it survives depends entirely on how the engine treats that list:InMemoryEventStorageEnginecommit()AggregateBasedJpaEventStorageEngineforEach(em::persist)thenem.flush()insideappendEvents;commit()is a no-opMeanwhile
SimpleEventBus.processEventsInPhasehas already delivered that event to every subscriber. Store and subscribers diverge, with nothing logged.sequenceDiagram participant H as Subscribing handler participant Tx as DefaultEventStoreTransaction participant Q as event queue participant E as JPA storage engine Note over Tx,E: PREPARE_COMMIT Tx->>Q: read queue [e0] Tx->>E: appendEvents([e0]) E->>E: persist + flush (e0 only) H->>Tx: appendEvent(e1) Tx->>Q: add e1 (queue now [e0, e1]) Note over E: never called again. e1 is gone. Note over H: subscribers already saw e1This is why no existing test caught it: the in-memory engine hides it.
The fix
The batch is handed over in a phase of its own,
EventStoreTransaction.Phases.APPEND_EVENTS, at order 29000.That is deliberately the last slot before
COMMIT, not the first one abovePREPARE_COMMIT.SAGA_WRITEsits at 25000, and a saga is exactly the kind of component that may append events. Placing the hand-off last means every phase a caller puts in the gap still gets to append.graph LR A["PRE_INVOCATION<br/>-10000"] --> B["INVOCATION<br/>0"] B --> C["POST_INVOCATION<br/>10000"] C --> D["PREPARE_COMMIT<br/>20000<br/><i>event delivery, token store</i>"] D --> E["SAGA_WRITE<br/>25000<br/><i>saga written</i>"] E --> F["APPEND_EVENTS<br/>29000<br/><i>batch to storage engine</i>"] F --> G["COMMIT<br/>30000<br/><i>transaction commits</i>"] G --> H["AFTER_COMMIT<br/>40000"]Appending after that phase is now refused rather than queued into a batch that is already gone.
How it was proved
DefaultEventStoreTransactionTest.AppendingDuringPrepareCommit.The key move is that the test asserts what the engine was handed, not only what ended up stored, and runs against
EagerFlushEventStorageEngine(anInMemoryEventStorageEnginethat snapshots its batch withList.copyOf, modelling what the JPA engine does).anEventAppendedByALaterPrepareCommitActionJoinsTheSameBatchbefore the fix:The engine received one event.
event-1was dropped, after subscribers had seen it.aFirstEventAppendedDuringPrepareCommitIsStoredbefore the fix:Both green after. A third test,
appendingOnceTheBatchWasHandedToTheStorageEngineIsRejected, pins the new loud failure.Alternative that was rejected
Opening a second
AppendTransactionfor the late events does not work: its consistency marker predates the first batch, soInMemoryEventStorageEnginereports a false conflict andAggregateBasedJpaEventStorageEnginerestarts sequence numbers and collides on its own primary key. Nothing can produce a fresh marker mid-context, because the append position is only updated after commit.One assertion moved
SimpleEventStoreTest.subscriberExceptionRollsBackTransactionbecamesubscriberExceptionPreventsTheEventsFromReachingTheStorageEngine. A failing subscriber used to leave an append transaction to roll back; it now leaves none, because the engine is not asked for one until every subscriber has been notified. The test asserts that directly, which is the stronger claim.Bug 2:
SimpleEventBusaccepts a publish it can never deliverisCommitted()isstatus == COMPLETED, true only once every phase has finished. DuringCOMMITandAFTER_COMMITit isfalse, so the guard never fires where it was written to fire.What happened instead depended on whether anything had published earlier in the same context:
computeResourceIfAbsentreturns it, events are added, and nothing ever reads them again. Accepted and silently dropped.The fix
The right question is not which phase the context is in, but whether the queue has already been drained, and only the bus knows that. It now records it, the same way
DefaultEventStoreTransactionrecordsprepareCommitExecuted.The placement matters:
processEventsInPhaseloops while the queue keeps growing, so it catches anything a synchronous subscriber publishes. The instant it returns, that loop is over and no later addition will be picked up, even though subscribers may still be running. Setting the flag at that exact point is what turns a publish from a still-running asynchronous subscriber into a failure rather than a second silent drop. Setting it inwhenCompletewould have left that case as broken as before.Note this deliberately does not need a public phase accessor. The phase would not be precise enough: during
PREPARE_COMMITpublishing is fine while the drain is running and wrong once it is not, and no phase can tell those apart.isCommitted()stays as the coarser case, since it still gives a better message for a publish from a completion handler in a context that never published anything.How it was proved
SimpleEventBusTest.publishingOnceTheQueueWasDrainedIsRejectedRatherThanQueuedAgainpublishes duringINVOCATION, then again from aCOMMIT-phase action. Before the fix:That is the captured throwable being
null: no exception at all. The publish was accepted and the event vanished. After the fix it throwsIllegalStateException, and the recording listener confirms onlyevent1was ever delivered.Bug 3:
StubProcessingContextdid not apply the rules it claims toBoth the saga work and this PR had to write their phase tests against a real
UnitOfWork. The reason is recorded in8a6e44f753's commit message: the stub assignscurrentPhaseafter running that phase's actions, so during aPREPARE_COMMITaction it still reports the earlier phase and accepts registrations production rejects. It also threwIllegalArgumentExceptionwith its own wording, so a test asserting on that pinned the stub instead of the framework.Working around a broken test double once is fine; leaving it broken means everyone after has to know to work around it too.
It now enters a phase before running its actions, and rejects the same registrations with the same exception type and message as
UnitOfWork. Behaviour is otherwise unchanged and the whole messaging suite passes, so nothing was leaning on the old leniency.The javadoc now states where the stub still stops, so the next reader does not have to find out by experiment: a phase's actions are chained rather than run in parallel, and a registration for a later phase that a
moveToPhasecall is already past is not picked up.publishingAfterContextCommittedThrowsExceptionchanges with it. The behaviour it describes is unchanged, but the reason is now visible: the bus cannot register the prepare-commit hook that would have delivered the events, and the message names the phase that blocked it.Documentation
The rule had no documentation anywhere: no
@throwsonProcessingLifecycle.on, no comment at the throw, nothing in the reference guide. A caller could only discover it by hitting the exception, and could not check beforehand either, since Axon Framework 5 has no public equivalent of Axon Framework 4'sUnitOfWork.phase().Both the javadoc and
processing-context.adocnow state the rule and the way out of it: a component invoked from within a late phase registers for a custom phase in the gap above it. The reference guide names the saga write and the event store hand-off as the two users, so the pattern reads as intended design rather than something invented twice.Verification
-Pintegration-test verify -pl integrationtests: green (84 + 83)-Pexamples verify: greenOne pre-existing failure is unrelated to this PR:
AnnotatedEventHandlerInterceptorTest.interceptorIsInvokedBeforeEventHandler(handler invoked three times, interceptor once). It fails identically on411a58bd2ewith none of these commits applied.Open question for the reviewer
Moving the hand-off to 29000 surfaces an asymmetry that was previously invisible: the event store now accepts appends up to 29000, but the event bus stops delivering at 20000. An event appended from a gap phase such as
SAGA_WRITEwould therefore be stored but never reach subscribers.With Bug 2 fixed that case now throws instead of diverging silently, which I believe is the right default. But if appending events from
SAGA_WRITEis something a saga should be able to do, the bus's delivery hook needs to move up as well. I would rather agree that than decide it unilaterally.