Skip to content

[#3728] Port the Axon Framework 4 saga repositories into axon-legacy - #5009

Merged
smcvb merged 15 commits into
mainfrom
feat/saga-repository
Sep 4, 2026
Merged

[#3728] Port the Axon Framework 4 saga repositories into axon-legacy#5009
smcvb merged 15 commits into
mainfrom
feat/saga-repository

Conversation

@MateuszNaKodach

@MateuszNaKodach MateuszNaKodach commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Moves SagaRepository, LockingSagaRepository and AnnotatedSagaRepository out of stash/legacy and into axon-legacy. This is the layer between the two slices that already landed: #4992 brought the SagaStore and its implementations, #4997 brought Saga, AnnotatedSaga, the SagaLifecycle replacement, the annotations and the metamodel. Neither is usable without this one: nothing turned a stored saga into an AnnotatedSaga, wrote it back at the end of a unit of work, or kept two threads off the same saga.

The governing rule for this module is unchanged: a deviation from Axon Framework 4 ships only if Axon Framework 5 forces it. Not if it would be an improvement, not if it matches how AF5 does things elsewhere. Every edit here is traceable to a compile error or to a framework mechanism that no longer exists, and the odd inherited behaviours are pinned with tests rather than tidied up.

What moved

Only what the two repositories need: SagaRepository, ResourceInjector, NoResourceInjector, SagaCreationException, LockingSagaRepository, AnnotatedSagaRepository, and the two Axon Framework 4 tests that cover them. AbstractResourceInjector, SimpleResourceInjector, AssociationValueMap and the saga managers stay in stash, since nothing in this closure reaches them.

The one API change: ProcessingContext on SagaRepository

Set<String> find(AssociationValue associationValue, ProcessingContext context);

@Nullable
Saga<T> load(String sagaIdentifier, ProcessingContext context);

Saga<T> createInstance(String sagaIdentifier, Supplier<T> factoryMethod, ProcessingContext context);

Axon Framework 4 read the ambient unit of work from CurrentUnitOfWork, a thread local that Axon Framework 5 deliberately does not have. Unlike SagaStore, this layer genuinely needs the lifecycle: it registers the store write on a phase of the caller's context, which is what puts that write in the caller's transaction, and it releases the saga's lock when processing completes. This is where #4992 concluded the parameter belongs, having removed it from the storage SPI for the opposite reason.

Substitutions in the two implementations

Axon Framework 4 axon-legacy
CurrentUnitOfWork.get() / .root() the ProcessingContext parameter
unitOfWork.root().onCleanup(...) context.doFinally(...)
unitOfWork.onPrepareCommit(...) context.runOn(AnnotatedSagaRepository.SAGA_WRITE, ...), see below
getOrComputeResource("Repository[X]/UnsavedSagas", ...) context.computeResourceIfAbsent(key, HashSet::new), key built per repository instance
managedSagas instance field unchanged

The unitOfWork / processRoot distinction collapses: Axon Framework 4 needed it because a nested unit of work had its own phase sequence while resources and cleanup belonged to the outermost one, and in Axon Framework 5 a branched context forwards every lifecycle registration and every non-overridden resource to the root anyway. loadedFromBranchedContextAfterCreate proves that: a branch shares the managed saga instance and the unsaved-saga set, so the saga is still written exactly once.

managedSagas stays a repository instance field, JVM-wide as it was. That is what makes find return sagas another thread has not committed yet, which the Axon Framework 4 suite pins and which moving the map into the context would have silently dropped.

Choices worth explaining

The saga managers are out of scope.

Saga event handlers must complete on the invoking thread. AnnotatedSaga.handle now fails with a SagaExecutionException if a handler returns a result that is not done yet. This is not a new restriction -- it is the Axon Framework 4 contract being kept:

An incomplete result from a handler AF4 AF5, before this PR AF5, after
dropped, never awaited awaited, and its work escapes the transaction unnoticed rejected loudly

Axon Framework 4 invoked the handler through EventMessageHandler#handleSync and ignored its return value, so an asynchronous result never ran inside a transaction. EventHandlingComponent#handle can express one and the unit of work awaits it, which silently voids #4992's safety argument: the stores join the surrounding transaction through a thread-bound provider, which only holds if the store call happens on the thread that opened the transaction. An already completed CompletableFuture is accepted, being indistinguishable from a synchronous return. Two limitations are documented on the class rather than papered over -- it detects rather than prevents, and a void handler that hands work to an executor is undetectable, as it was in Axon Framework 4.

A saga is written in a phase of its own, which is what makes it work behind a SubscribingEventProcessor.

What was broken. A saga behind a SubscribingEventProcessor could not be stored at all. Its first event failed the whole processing context with Failed to register handler in phase PREPARE_COMMIT (20000). ProcessingContext is already in phase PREPARE_COMMIT (20000). and, because that same context carried the command handler that published the event, the command failed with it.

Why the subscribing processor was problematic The repository needs its store write to land in the caller's transaction, and its only lever is a lifecycle registration on the caller's ProcessingContext. A UnitOfWork rejects any registration for a phase at or below the phase it is currently running. A PooledStreamingEventProcessor creates a unit of work per batch and invokes its components during INVOCATION, so PREPARE_COMMIT was still ahead and the registration was accepted. A SubscribingEventProcessor creates none: handed a non-null context it calls processInGivenContext and handles the events in the publisher's context. SimpleEventBus, for events published with a context, queues them and drains that queue from a PREPARE_COMMIT action, passing the same context to its subscribers. So the saga's handler ran inside PREPARE_COMMIT and asked for PREPARE_COMMIT. Nothing about the saga was wrong; the phase it was reached from was the phase it needed.

Why Axon Framework 4 did not have this problem, which is not the reason it might appear. Axon Framework 4 was not more permissive about phases in general: DefaultUnitOfWork#addHandler asserted !phase.isBefore(phase()) and threw for a strictly earlier phase exactly as Axon Framework 5 does. It differed only on the equal case, and its saga path did not use that either. AnnotatedSagaRepository opened a nested DefaultUnitOfWork: started while another unit of work sat on the CurrentUnitOfWork thread local, it adopted that one as parent but kept its own handler map at phase NOT_STARTED and its own resources. Registration on the child was therefore legal whatever the parent was doing, and commitAsNested ran the child's PREPARE_COMMIT and COMMIT immediately, deferring only AFTER_COMMIT up the ancestor chain and wiring the child's rollback to the parent's. Axon Framework 4 sidestepped the restriction by giving each nested scope a fresh phase timeline, not by registering into a running phase. Axon Framework 5 has no nesting: a branched ProcessingContext forwards every registration and every resource to the root. The mechanism is what is missing, not the permission.

What was ported instead. The position that mechanism produced: after the handler mutated the saga, before the caller's transaction commits. ProcessingLifecycle.Phase is an interface over an arbitrary order and DefaultPhases leaves 10000 between its entries, so that position can simply be named:

public static final ProcessingLifecycle.Phase SAGA_WRITE =
        () -> ProcessingLifecycle.DefaultPhases.PREPARE_COMMIT.order() + 5_000;

Both doLoad and doCreateInstance register there. That removes the dependency on where the repository was called from, which is what makes the two processors equivalent again: INVOCATION and PREPARE_COMMIT are both below SAGA_WRITE. The write stays transactional because TransactionManager#attachToProcessingLifecycle starts the transaction at PRE_INVOCATION, commits it with runOnCommit and rolls back onError; and it stays serialised because LockingSagaRepository releases its lock through doFinally, which runs after every phase. The constant is public so surrounding code can order its own actions against the write. Note that a phase declaring the same order does not run before or after the write but alongside it, since actions sharing an order may be invoked in parallel.

SagaRepositoryEventProcessorIT now asserts the saga is stored behind both processors, that the write falls between a recording TransactionManager's start and its commit, and that a failure elsewhere in the context leaves the saga unwritten.

Out of scope

  • AbstractSagaManager / AnnotatedSagaManager as an EventHandlingComponent, and with them AnnotatedSaga's two UnsupportedOperationException("TODO") methods.
  • The follow-up above.
  • Spring Boot auto-configuration for the saga repository.
  • Reference documentation, until a saga manager can be wired.

@MateuszNaKodach
MateuszNaKodach requested a review from a team as a code owner September 3, 2026 11:10
@MateuszNaKodach
MateuszNaKodach requested review from hatzlj, jangalinski and zambrovski and removed request for a team September 3, 2026 11:10
@MateuszNaKodach
MateuszNaKodach marked this pull request as draft September 3, 2026 11:13
@MateuszNaKodach MateuszNaKodach added this to the Release 5.4.0 milestone Sep 3, 2026
@MateuszNaKodach MateuszNaKodach self-assigned this Sep 3, 2026
@MateuszNaKodach MateuszNaKodach added Priority 1: Must Highest priority. A release cannot be made if this issue isn’t resolved. Type: Feature Use to signal an issue is completely new to the project. labels Sep 3, 2026
The saga store landed in axon-legacy without the layer that drives it. A
SagaStore on its own cannot be used: something has to turn a stored saga into an
AnnotatedSaga, write it back at the end of a unit of work, and keep two threads
off the same saga. That layer is SagaRepository and its two implementations, and
none of it is in a built artifact today.

This commit moves the part of it that needs no decisions. SagaRepository,
ResourceInjector, NoResourceInjector and SagaCreationException are the four
classes in that closure which never touched the Axon Framework 4 unit of work,
so they compile unchanged and can be carried across as a pure rename. Keeping
them separate means the next two commits, which do have to replace
CurrentUnitOfWork with a ProcessingContext, show only that rewiring rather than
burying it in a large move.

ResourceInjector and NoResourceInjector come along because
AnnotatedSagaRepository.Builder takes an injector and defaults to the no-op one.
AbstractResourceInjector and SimpleResourceInjector deliberately stay behind:
nothing in the repository closure reaches them, and this module only takes what
it needs.

No content changes, so git log --follow still reaches the Axon Framework 4
history of all four files.
Axon Framework 4 found the ambient unit of work through the CurrentUnitOfWork
thread local, which is why none of these three methods needed a parameter for
it. Axon Framework 5 deliberately has no such global, and this layer genuinely
needs the lifecycle: it registers the store write on prepare-commit and releases
the saga lock on completion. So the context has to be passed in. This is the
only place in the saga stack where that is true, and it is where the SagaStore
pull request concluded the parameter belongs, having removed it from the storage
SPI for exactly the reason that the storage layer does not need a lifecycle at
all.

The parameter is added to find as well, even though the implementation ignores
it. A partially context-aware SPI is worse than either alternative: an
implementor cannot tell from the signature whether the omission is a statement
about find or an oversight, and a custom repository may well want the context
there to consult uncommitted state. Uniformity costs one unused parameter and
removes that question.

load's return is now explicitly @nullable. The package is @NullMarked, and the
javadoc has always promised null for a saga that no longer exists, so without
the annotation the contract and the code would disagree.

Doing this before the implementations move keeps the change reviewable: no call
sites exist in the reactor yet, so this commit is the signature decision on its
own.
This is the class that keeps two threads off the same saga, so it has to come
across before anything can safely use the store. A pure rename was not possible:
it is one of only two classes in the closure that read CurrentUnitOfWork, and
that class is not being moved.

The rewiring is as small as the mechanism allows. lockSagaAccess no longer looks
up the ambient unit of work; it takes the ProcessingContext that is now on the
SPI. unitOfWork.root().onCleanup(...) becomes context.doFinally(...), which is
onError plus whenComplete, and the unit of work runs exactly one of the two, so
the lock is still released precisely once on both commit and rollback. The
abstract doLoad and doCreateInstance take the context too, because
AnnotatedSagaRepository needs it to register its store write. Nothing else
changes: the Builder, the PessimisticLockFactory default and the order of
lock-then-load are untouched, so the guarantee that one processing context at a
time operates on a saga is the Axon Framework 4 one.

The ported test comes with it, since the class cannot be green without it. Its
mocked LockFactory and Lock stay, and so do its assertions: the only edits are a
real UnitOfWork from SimpleUnitOfWorkFactory in place of
LegacyDefaultUnitOfWork, and the context argument on the verify calls. Mockito
here is deliberate rather than inherited laziness: the assertion is that the
repository does not touch the lock before the unit of work completes, which is
about interactions and cannot be phrased as state. A third case is added for
rollback, which Axon Framework 4 did not cover but doFinally now makes worth
stating.

Two behaviours are covered that Axon Framework 4 left to inspection. The first
is serialisation itself, with two real units of work on two threads against a
real PessimisticLockFactory: the second one is observed not to get in until the
first completes. The Axon Framework 4 test only checked that a lock had been
obtained and released, which says nothing about mutual exclusion.

The second is a hazard that Axon Framework 4 could not have: its unit of work
lived in a thread local, so the thread that released the lock was always the
thread that took it. In Axon Framework 5 the release runs wherever the unit of
work runs its completion handlers, and unless forceSyncProcessing is on, that is
whichever thread completed the last phase. The tests pin both directions --
off-thread release with the default configuration, same-thread release under
forcedSameThreadInvocation, which is what a TransactionManager answering true to
requiresSameThreadInvocations arranges. Since PessimisticLockFactory hands out a
thread-owned lock, the off-thread case means unlock throws
IllegalMonitorStateException, the unit of work swallows it as a warning, and the
saga stays locked for the life of the JVM. That is documented on the class as a
configuration requirement rather than guarded against, because a guard would be
new behaviour and the real answer is the transaction manager the module already
requires for the stores.

Writing that test first was worth it: the first attempt handed the work to
another thread with supplyAsync and passed for the wrong reason, because the
result completed before the unit of work chained onto it, leaving the release on
the invoking thread. The test now completes the handler's result only after
execute() has attached those handlers, and the comment records why -- it is also
the reason this hazard is intermittent in production rather than reliable.
The last of the three classes, and the one that actually persists a saga. Like
LockingSagaRepository it read CurrentUnitOfWork, so a pure rename was not
possible; everything else about it is unchanged.

Three substitutions, all forced. CurrentUnitOfWork.get() and its .root() become
the ProcessingContext parameter: Axon Framework 4 needed the distinction because
a nested unit of work had its own phase sequence while resources and cleanup
belonged to the outermost one, and Axon Framework 5 collapses it, since a
branched context forwards every lifecycle registration and every non-overridden
resource to the root. onCleanup becomes doFinally, onPrepareCommit becomes
runOnPrepareCommit, and the unsaved-sagas resource moves from a string key on
the unit of work to a Context.ResourceKey built per repository instance, keeping
the Axon Framework 4 label so it still identifies itself when debugging. The
managedSagas map stays a repository instance field, JVM-wide as it was, because
that is what makes find return sagas another thread has not committed yet -- a
property the Axon Framework 4 suite pins and which moving the map into the
context would have silently dropped.

Everything else keeps its Axon Framework 4 shape and visibility: the Builder,
the protected commit, deleteSaga, updateSaga, storeSaga, doLoadSaga and
unsavedSagaResource hooks, and the TreeSet merge in find. Subclasses in user
code may depend on all of it.

The ported test comes with it. Its spy on InMemorySagaStore and its
verifications stay, because they assert which store calls happen and in which
order, which is not expressible as state. What changed is only the unit of work:
work that Axon Framework 4 did against an ambient unit of work now runs inside
an invocation action of a real UnitOfWork, and assertions that were made before
committing are made inside that action.

Two of the ported tests need explaining.

loadedFromNestedUnitOfWorkAfterCreate nested a unit of work during normal
invocation. Its Axon Framework 5 equivalent is a branched ProcessingContext,
which is the same thing the nested unit of work was: a deeper scope within one
processing session. Renamed to say so, assertions untouched, and it passes --
which is the useful part, since it shows a branch shares the managed saga
instance and the unsaved-saga set with its parent.

loadedFromNestedUnitOfWorkAfterCreateAndStore loads a saga from within
prepare-commit, and is @disabled because it cannot pass yet. It fails with
"Failed to register handler in phase PREPARE_COMMIT (20000). ProcessingContext
is already in phase PREPARE_COMMIT (20000)." This is not a synthetic scenario:
SimpleEventBus publishes events that were published with a context during
PREPARE_COMMIT, and SubscribingEventProcessor handles them in that same context,
so a saga on a subscribing processor meets this on its first event. Axon
Framework 4 saved the saga through a nested unit of work whose prepare-commit
ran immediately.

The repository cannot fix that, and the alternatives are worth recording so they
are not tried again. Saving inline at load time is wrong: Axon Framework 4
registered at load and saved after the handler had mutated the saga, so an
inline save would persist the saga without the associations that handler adds,
and would insert a saga the handler ended. Registering on COMMIT instead is
wrong: the transaction manager registers its own commit on that phase back at
PRE_INVOCATION, and phase actions run in registration order, so the write would
land after the transaction closed. Relaxing the framework's phase guard would
work, and the machinery already supports it, but that guard is a deliberate
framework-wide invariant and this is not the change to relax it in. The fix
belongs to the component that invokes the saga, because that is the only layer
that knows when handling finished. The class javadoc says so, and the test stays
in its Axon Framework 4 form rather than being inverted to assert the exception,
so that it simply turns green when that component lands.

Writing the test before the move earned its keep twice. It first passed for the
wrong reason, because registering the prepare-commit action outside the
invocation put it ahead of the repository's own action and inverted the Axon
Framework 4 order. Fixing that produced the failure above.
The Axon Framework 4 saga repository has a handful of behaviours that look like
oversights and are not. Nothing in the ported suite stated them, so the next
person to read this code has no way to tell "deliberate" from "nobody noticed",
and the obvious reaction to several of them is to tidy them up. Under this
module's rule that a deviation ships only if Axon Framework 5 forces it, tidying
them up is exactly what must not happen, so each one now has a test that says
so.

What is pinned, and why each is worth stating:

A new saga that ends before its context commits is never written, because
doCreateInstance guards the insert with saga.isActive(). A loaded saga that ends
is deleted. Those two are asymmetric on purpose and it is easy to "fix" one into
the other.

An ended new saga keeps its identifier in the context's unsaved-saga set for the
rest of that context, because doCreateInstance removes it inside the isActive
branch while doLoad removes it unconditionally. Harmless, but the asymmetry is
exactly the kind of thing a later reader straightens out without realising it is
inherited.

Loading an identifier that does not exist asks the store again on every call,
since computeIfAbsent does not store a null. That is a real repeated query, not
a caching bug to be fixed here.

Deleting a saga passes the associations it still has together with the ones it
lost during the context, so a removed association cannot outlive the saga it
pointed at.

find reports a saga that is both managed and stored once rather than twice,
because the two sources are merged into one set.

A failing saga factory surfaces as SagaCreationException with the original
exception as its cause, since doCreateInstance wraps its whole body.

The locking repository takes the lock for an identifier even when no saga
exists, and holds it for the rest of the context.

These are characterisation tests: they pass on the first run by design. Their
value is the opposite of a red-then-green cycle -- they fail if the move to
ProcessingContext quietly changed one of these behaviours, which is precisely
the risk this port carries.

Ending a saga goes through the AnnotatedSaga instance rather than the static
SagaLifecycle the Axon Framework 4 tests used, since that static API is gone.
The lifecycle-parameter route a user's saga takes is exercised where it belongs,
in the tests that drive a saga through its event handlers.
The SagaStore pull request dropped two Axon Framework 4 tests because both drive
AnnotatedSagaRepository rather than a store, and recorded them as deferred
rather than lost, to return when the repository moved into axon-legacy. It has,
so they return.

JpaSagaStoreTest.addingAnInactiveSagaDoesntStoreIt asserts that a saga created
and ended within one processing context leaves the database untouched. The
decision is the repository's, not the store's -- the store writes what it is
told -- which is why it could not live in the shared store suite. It is
equivalent to the in-memory case pinned on the repository itself, but against a
real database and asserting on rows, which is the form that catches a store that
writes anyway.

CachingSagaStoreTest.canHandleConcurrentReadsAndWritesThroughAnnotatedSagaRepository
runs 32 concurrent create-find-load cycles through a repository over the caching
store. Its value is that it exercises the caches under a real access pattern:
the repository interleaves finds with inserts across threads, which the
store-level concurrency test cannot produce because it never holds a saga across
calls. The concurrency shape, the operation count and the timeout are the Axon
Framework 4 ones; only the units of work are new, since the Axon Framework 4
version drove CurrentUnitOfWork by hand.

Ending the saga goes through the AnnotatedSaga instance, because the static
SagaLifecycle that the Axon Framework 4 test called from inside the saga's own
method no longer exists.
Sending a command is the thing a saga is for, and the Axon Framework 4 property
worth caring about is that a failure does not leave a half-finished saga behind.
Axon Framework 4 delivered that by nesting the command's unit of work inside the
saga's. That mechanism is gone, so the property had to be tested rather than
assumed.

The test drives the real path: a saga created through the repository over the JPA
store, handling an event, sending a command through a CommandGateway it holds in
a field -- which is how an Axon Framework 4 saga received one from a
ResourceInjector -- and handing it the ProcessingContext its handler was invoked
with. On success both the saga and the command's write are in the database; on
failure the saga is not.

Two things it deliberately does not claim.

It does not treat "a command sent from a saga gets its own unit of work" as a
regression. CommandDispatcher and CommandGateway do carry the context through to
CommandBus#dispatch, and SimpleCommandBus then creates its own unit of work
anyway, so the context reaches interceptors and resources but not the lifecycle.
That is correct: it is the same shape a distributed command bus has, where the
handler runs in another JVM and could not share the sender's unit of work at
all. Axon Framework 4 gave the local SimpleCommandBus a guarantee that
disappeared the moment the bus was distributed; Axon Framework 5 is consistent
between the two, and a saga reacts to the command's result rather than relying on
shared atomicity.

It also does not assert that the command's write is rolled back by the saga's
failure. In this configuration it is, because EntityManagerTransactionManager
returns a no-op Transaction when one is already active and the whole thing runs
on one EntityManager. But that is the transaction manager's propagation, not
something the saga repository provides, and pinning it here would turn a
property of the wiring into an apparent saga guarantee.

Getting the failing case right needed care, and the first attempt was wrong in a
way worth recording. Failing the unit of work from runOnAfterCommit left the
saga in the database, because that phase runs after the transaction has already
been committed. The failure has to be registered from within the invocation, so
it lands behind the repository's own prepare-commit action: after the saga was
written, while the transaction is still open. That the saga then disappears is
the actual evidence that the repository writes inside the caller's transaction.
…uired

This is not a new restriction, it is the Axon Framework 4 contract being kept.
Axon Framework 4 invoked a saga's handler through EventMessageHandler#handleSync
and ignored its return value, so a handler returning a CompletableFuture had it
dropped on the floor: whatever it did later ran outside every unit of work and
every transaction, unobserved.

Saga now extends EventHandlingComponent, which can express an asynchronous
result, and the unit of work awaits it. That is worse than what Axon Framework 4
did, because it makes asynchronous saga handling look supported while quietly
voiding the guarantee the store half of this work relies on: JdbcSagaStore and
JpaSagaStore join the surrounding transaction through a thread-bound provider,
which only holds if the store call happens on the thread that opened the
transaction. A handler completing elsewhere breaks that premise, and the
JdbcSagaStore integration tests already show a write escaping the transaction
and surviving a rollback with nothing signalling a problem.

So AnnotatedSaga now checks whether the handler's result is done before handing
it back, and fails handling with a SagaExecutionException naming the handler and
the saga if it is not. An already completed future is accepted, since it is
indistinguishable from a synchronous return.

Two limitations are documented on the class rather than papered over. It detects
rather than prevents: by the time the check runs, the handler has already
started whatever it started, and only the transactional part can still be rolled
back -- which is still strictly better than committing as if nothing happened. A
handler that hands work to an executor and returns void is not detectable at
all, in Axon Framework 5 as much as in Axon Framework 4.

SagaExecutionException gains a message-only constructor. The alternative was
passing a null cause, which reads as an oversight.

Written test-first, and the first run was informative: the pending-result case
failed on a timeout rather than on a rejection, which is precisely the "awaited,
not rejected" behaviour this commit removes.
A migrating user meets this layer through its signatures, and two of the
consequences of the port are things they can hit at runtime without any compiler
error to warn them. The class and method tables carry the moves and the added
ProcessingContext parameters; the processing-context page carries the two
behavioural notes, next to the SagaStore section that explains why the storage
SPI went the other way.

The first note is that nested units of work are gone. That is mostly invisible,
because a branched ProcessingContext produces the same outcome the nesting
existed for, but it does mean loading a saga from within PREPARE_COMMIT now
fails, and it says plainly which configuration reaches that -- a saga on a
subscribing event processor fed by events published with a context -- rather
than leaving someone to discover it.

The second is that a saga event handler must complete on the invoking thread,
framed as the Axon Framework 4 rule now being enforced rather than as a new
restriction, since Axon Framework 4 dropped an asynchronous result unawaited and
never ran it in a transaction either. The lock's matching thread requirement is
recorded with it.
The unit-level disabled test says the repository cannot be called from inside
PREPARE_COMMIT. It does not say which configuration puts it there, and that is
the part someone wiring this up needs to know. This integration test states it
in terms of the two event processors.

Behind a PooledStreamingEventProcessor the saga is stored. The processor creates
a unit of work per batch and invokes its components during INVOCATION, which
leaves PREPARE_COMMIT free for the repository to write in.

Behind a SubscribingEventProcessor it depends on how the event was published.
Published without a context, the processor opens a unit of work of its own and
everything works. Published with one, as a command handler would, SimpleEventBus
queues the event and delivers it during PREPARE_COMMIT of that same context, and
the repository is then asked to register for the phase that is already running.
Handling fails and the publishing unit of work fails with it.

The stand-in for the future saga event handling component does the one thing
that component must do -- reach the repository with the context it was invoked
in -- so the test is about the repository's phase requirement rather than about
any particular component design.

Worth noting from writing it: the phase rejection reaches the caller as a
SagaCreationException, because doCreateInstance wraps anything its body throws.
That is inherited Axon Framework 4 behaviour, pinned separately, and it means
the underlying IllegalStateException is a cause rather than the thrown type.
A saga lock released on the wrong thread cannot be recovered: the default
PessimisticLockFactory hands out a ReentrantLock owned by the thread that took
it, so unlock elsewhere throws and the lock stays held for the life of the JVM.
Documenting the configuration requirement, as the previous commit did, tells
someone how to avoid it. It does not help the person who has already hit it, and
that person gets the worst possible diagnostic: the processing lifecycle catches
whatever a completion handler throws and logs "A Completion handler threw an
exception", which names no saga, no identifier and no thread. The symptom they
actually see is one saga that silently stops being loadable.

So the release now goes through a method that catches the failure and logs an
error naming the saga identifier, the thread that acquired the lock, the thread
that tried to release it, and the configuration that fixes it. Nothing here can
release the lock, and nothing pretends to; this turns a silent hang into
something a log search can find.

Deliberately not done two other ways. There is no fail-fast check, because a
repository cannot ask a ProcessingContext whether its completion handlers will
run on the invoking thread -- the context exposes isStarted, isError,
isCommitted and isCompleted, and forceSyncProcessing is private to the unit of
work. That query is the piece that would turn this documentation into a real
guard, and it belongs in the framework, where a thread-bound transaction or JDBC
connection has the same problem. And the reporting is driven by the release
actually failing rather than by comparing threads, so a LockFactory whose locks
are not thread-owned releases on any thread without being accused of anything.

Two options were rejected outright. Swapping the default for a lock that is not
thread-owned would deadlock: lockSagaAccess runs on every load, so loading one
saga twice in a context acquires the same lock twice and relies on reentrancy,
which loadedFromUnitOfWorkAfterPreviousLoad exercises. Releasing after
invocation rather than at context completion guarantees the right thread and
breaks what the lock is for, since the saga is written at prepare-commit and an
early release would let another thread write the same saga concurrently.

The test asserts on the log, which is unusual and is the point: the log is the
entire behaviour being added. Its sibling asserts that an ordinary same-thread
unit of work logs nothing, so the check cannot start crying wolf unnoticed.
The test showed a saga sending a command through a CommandGateway held in a
field, which is what an Axon Framework 4 saga received from a ResourceInjector
and therefore what a migrated saga looks like. But CommandDispatcher is the
documented preferred way to send a command from inside a message handler, and a
project migrating its sagas is likely to reach for it, so leaving it untested
left the more interesting question unanswered.

It works, and there is a condition worth knowing about. The resolver for a
CommandDispatcher parameter is contributed by a ConfigurationEnhancer rather
than registered through META-INF/services, so the ClasspathParameterResolverFactory
that AnnotationSagaMetaModelFactory uses by default does not include it. A saga
declaring that parameter therefore does not resolve unless the repository is
built with a factory that has it, which an application configured through
MessagingConfigurer has to hand over via
AnnotatedSagaRepository.Builder#parameterResolverFactory. CommandDispatcher.forContext
also resolves the gateway from the context's application context, so an empty
one is not enough.

Both routes are now covered, since a migrating project may be on either, and the
condition is recorded in api-changes next to the rest of the repository's wiring
notes rather than left for someone to rediscover.
The saga repository has to write the saga inside the caller's transaction, and
the only lever it has for that is a lifecycle registration on the caller's
ProcessingContext. Whether that lever exists depends on a framework rule that
was, until now, asserted nowhere near the place it matters and documented
nowhere at all: UnitOfWork#on rejects any phase whose order is at or below the
order of the phase currently running.

That rule collides with how events published with a context are delivered.
SimpleEventBus queues them and drains the queue during PREPARE_COMMIT, and a
SubscribingEventProcessor hands them to its components in that same context. A
subscriber therefore runs inside PREPARE_COMMIT, which is exactly the phase the
saga repository wants. Before changing anything in the repository, this needs to
be established as framework behaviour rather than as a saga anecdote, because
the repository's design follows from it and has to keep following from it.

Two things are pinned here, and neither changes existing behaviour:

Phase is an interface over an arbitrary int order, and DefaultPhases leaves
10000 between each of its entries. So a handler that cannot register for its own
phase can still register for a custom phase in the gap above it, which runs once
every action of the current phase is done and before the next default phase
begins. ProcessingLifecycleTest covered custom phases only when registered
before the lifecycle started, and covered mid-phase registration only into later
default phases; the combination the repository is about to rely on was
uncovered. It is asserted on order, not on mere execution, since the whole point
is where the work lands relative to PREPARE_COMMIT and COMMIT.

The rejection itself was covered for a strictly earlier phase
(registeringHandlersInPastPhasesCausesHandlerToFail) but not for the equal case,
which is the one the saga hits. Axon Framework 4 differed on precisely that
case: DefaultUnitOfWork#addHandler asserted !phase.isBefore(phase()) with a
strict comparison, so registering for the running phase was allowed and executed
via a consuming drain over the live handler deque. Recording the equal case
separately keeps that difference visible instead of leaving it implied by a
message assertion about a different phase.

The SimpleEventBus tests deliberately use a real UnitOfWork rather than
StubProcessingContext. The stub assigns its currentPhase after running a phase's
actions, so during a PREPARE_COMMIT action it still reports the earlier phase
and accepts registrations production rejects; it also throws a different
exception type. Any test written against the stub would have passed regardless
of the rule it claims to exercise.
Solves: a saga behind a SubscribingEventProcessor could not be stored at all. Its
first event failed the whole processing context with "Failed to register handler
in phase PREPARE_COMMIT (20000). ProcessingContext is already in phase
PREPARE_COMMIT (20000)." Since the same context carried the command handler that
published the event, the command failed with it. That was pinned as a known gap
by a disabled unit test and by an integration test asserting the failure, and
called out as unresolved in api-changes.

Why the subscribing processor specifically, and not the pooled one. The
repository needs its store write to land in the caller's transaction, and its
only lever for that is a lifecycle registration on the caller's
ProcessingContext, which it made for PREPARE_COMMIT. A UnitOfWork rejects any
registration for a phase at or below the phase it is currently running. A
PooledStreamingEventProcessor creates a unit of work per batch and invokes its
components during INVOCATION, so PREPARE_COMMIT was still ahead and the
registration was accepted. A SubscribingEventProcessor does not create one: when
handed a non-null context it calls processInGivenContext and handles the events
in the publisher's context. SimpleEventBus, for events published with a context,
queues them and drains that queue from a PREPARE_COMMIT action, passing the same
context through to its subscribers. So the saga's handler ran inside
PREPARE_COMMIT and asked for PREPARE_COMMIT. Nothing about the saga was wrong;
the phase it was reached from was the phase it needed.

Why Axon Framework 4 did not have this problem, which is not the reason it might
appear. AF4 was not more permissive about the phase in general:
DefaultUnitOfWork#addHandler asserted !phase.isBefore(phase()) and threw for a
strictly earlier phase exactly as AF5 does. It differed only on the equal case,
which its saga path did not use. AnnotatedSagaRepository opened a nested
DefaultUnitOfWork: started while another unit of work sat on the
CurrentUnitOfWork thread-local, it adopted that one as parent but kept its own
handler map at phase NOT_STARTED and its own resources. Registration on the
child was therefore legal whatever the parent was doing, and commitAsNested ran
the child's PREPARE_COMMIT and COMMIT immediately, deferring only AFTER_COMMIT up
the ancestor chain and wiring the child's rollback to the parent's. AF4 sidestepped
the restriction by giving each nested scope a fresh phase timeline, not by
registering into a running phase. AF5 has no nesting: a branched
ProcessingContext forwards every lifecycle registration and every resource to the
root. So the mechanism cannot be ported, and it is worth being precise that the
mechanism is what is missing, not permission.

What can be ported is the position that mechanism produced: after the handler
mutated the saga, and before the caller's transaction commits.
ProcessingLifecycle.Phase is an interface over an arbitrary order and
DefaultPhases leaves 10000 between its entries, so that position can simply be
named. SAGA_WRITE sits at PREPARE_COMMIT + 5000, and both doLoad and
doCreateInstance register there. That also removes the dependency on where the
repository was called from, which is what makes the two processors equivalent
again: INVOCATION and PREPARE_COMMIT are both below SAGA_WRITE. The write stays
transactional because TransactionManager#attachToProcessingLifecycle starts the
transaction at PRE_INVOCATION, commits it with runOnCommit and rolls back
onError; and it stays serialised because LockingSagaRepository releases its lock
through doFinally, which runs after every phase.

The trade-off: one write where Axon Framework 4 made two. AF4's nested unit of
work had two properties that mattered here, and both are gone. It had its own
resources, so the unsaved-saga bookkeeping the repository uses to write each saga
once per session started empty in the child; looking at the same saga again
therefore looked like a first sighting and scheduled a second write. And its
prepare-commit ran immediately, so that second write happened there and then. AF4
consequently did insertSaga with whatever associations the saga had at that
moment, then updateSaga with the rest. AF5 shares the bookkeeping with the root,
so the second look schedules nothing; and the single write it did schedule is
ordered after every PREPARE_COMMIT action, so by the time it runs the saga
already carries everything. One insert instead of insert-then-update, and the
same stored saga either way -- the difference is the number of round trips to the
store, not the outcome.

This is the only deviation from Axon Framework 4 in the port, and it is not one
AF5 forces, so it needed a reason to be preferred rather than merely tolerated.
The faithful alternative was implemented and measured: keep registering for
PREPARE_COMMIT and fall back to SAGA_WRITE on the IllegalStateException. It works,
and it turns the AF4 test green with its assertions untouched, because the second
load then does come from inside PREPARE_COMMIT and does produce AF4's update. It
was dropped for two reasons. It makes an exception part of normal control flow,
with no way to avoid it -- a ProcessingContext exposes isStarted, isError,
isCommitted and isCompleted, but not its phase, so the repository cannot ask
before trying. More importantly it makes the write's phase, and therefore its
order against the event store append and the token store update which both sit in
PREPARE_COMMIT, depend on which processor happens to front the saga. One phase
everywhere is worth more than fidelity in a scenario that stores the same result
either way.

The AF4 test is kept, renamed to what it now asserts, and its javadoc explains
what a nested unit of work was, which of its two properties produced the second
write, and why neither survives -- so the next reader meets the reasoning rather
than a quietly adjusted assertion.

Two tests were added for the position rather than for the outcome, since the
outcome would still hold if the write drifted into the wrong phase: one at unit
level asserting the write falls between the last PREPARE_COMMIT action and
COMMIT, and one in the integration test asserting it falls between a recording
transaction manager's start and its commit. A third asserts that a failure
elsewhere in the context leaves the saga unwritten, which is the property the
phase choice exists to preserve. The integration test's subscribing case now
asserts the saga is stored instead of asserting the exception.

@smcvb smcvb left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Bunch of nits, nothing you can't resolve. Hence, preemptively approving this PR.

- Use correct awaitility import
- Timeout thread.join
- Rename constant for clarity

#3728

@smcvb smcvb left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

My concerns have been addressed, hence I'm approving this pull request.

@smcvb
smcvb enabled auto-merge September 4, 2026 10:56
@smcvb
smcvb merged commit 94555a0 into main Sep 4, 2026
7 checks passed
@smcvb
smcvb deleted the feat/saga-repository branch September 4, 2026 11:05
MateuszNaKodach added a commit that referenced this pull request Sep 4, 2026
Axon Framework 4 asked every loaded Saga instance whether it took the event and
counted it as invoked only when it said yes. That answer is what a
SagaCreationPolicy.IF_NONE_FOUND policy consults: no Saga of this type took the
event, so start one. The port asked a single type-level question instead and
counted every loaded instance as invoked, so a Saga that declines still
suppresses creation.

The two answers diverge whenever the store index still lists an association
value the live instance no longer holds. A handler that calls
SagaLifecycle.removeAssociationWith earlier in the same unit of work produces
exactly that, because the store write only lands at
AnnotatedSagaRepository.SAGA_WRITE; so does AnnotatedSagaRepository.managedSagas
handing back an instance another thread has mutated but not committed, which
#5009 deliberately preserved. In both, Axon Framework 4 starts a fresh Saga and
the port silently does not.

Only IF_NONE_FOUND is affected, which is worth stating because it bounds the
change: shouldCreateSaga never creates under NONE whatever the flag says, and
short-circuits on ALWAYS before reading it. The rest of the creation-policy
chain was already faithful.

The filter itself never disappeared. AnnotatedSaga#handle still narrows its
handlers to the ones whose association value the instance holds, so a declining
Saga never runs its handler and nothing is written. What disappeared is the
ability to observe that decision, because handle returns a stream with no
entries by construction and a Saga that declined is indistinguishable from one
that handled the event without producing anything. The manager therefore has to
ask a second question, as Axon Framework 4 did, and the predicate is extracted
rather than written: canHandle and handle now share one lookup instead of
duplicating it, as Axon Framework 4 duplicated it.

Axon Framework 4 reached that question through MessageHandler#canHandle, a
public default method that Saga inherited by way of EventMessageHandler. Axon
Framework 5 reduced MessageHandler to an empty marker, so the declaration moves
onto Saga itself. This preserves the Axon Framework 4 surface rather than
widening it: saga.canHandle(event) was callable on any Axon Framework 4 Saga.
Narrowing it is not available either, since interface members are implicitly
public and a package-private abstract method cannot be declared. Computing the
predicate inside AnnotatedSagaManager was the alternative, and it was rejected
because the manager's SagaModel and the repository's are separate instances, so
a hand-wired setup can have them configured differently and the manager would
consult the wrong one; it would also drop the branch under which a handler that
resolves no association value at all counts as a match.

The manager keeps its own check, which is the other, unrelated Axon Framework 4
canHandle: does this Saga type declare a handler for this event. It moves to an
early return because that is where Axon Framework 4 asked it, from the event
processor before handle was entered. It stays worth asking even though Axon
Framework 5 gates on supports(QualifiedName) first, since that compares a name
where this compares payload assignability and resolvable parameters, and is
therefore the coarser of the two.

Finally, startNewSaga now asks the new Saga before invoking it, as Axon
Framework 4 did by routing creation through the same invocation path. For an
AnnotatedSaga the answer is always yes, since the instance carries exactly the
association value the starting handler resolved; it matters only for a custom
Saga implementation, and it costs nothing now that the question exists.

#3728
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: Feature Use to signal an issue is completely new to the project.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants