ReactiveMessageService.dispatch() has existed for a while with a sign above it saying “full enforcement coming in #193.” That day arrived today.

The issue wasn’t laziness. Reactive integration tests in Quarkus need a native async driver — H2’s Hibernate Reactive support is synchronous underneath, which means it doesn’t properly integrate with the Vert.x context management that Panache.withTransaction() depends on. Once Podman was configured with 4 GB and postgres:17-alpine was available, the blocker was gone and the real work could begin.

What the spec got wrong the first time

I wrote the design spec, then handed it to a reviewer. Two critical findings came back before a line of code was written.

The first was the commitment consistency gap. I’d proposed creating the commitment (OPEN state) in a separate transaction after the message insert committed. The reviewer pointed out the failure mode: message persists with a non-null commitmentId UUID, then the commitment creation fails. No FK constraint fires. CommitmentService.findByCorrelationId() returns empty; the obligation is silently lost. The fix was simple in the reactive path — there’s no REQUIRES_NEW constraint in Hibernate Reactive anyway. Fold the commitment save into Phase 2’s withTransaction("qhorus", ...) alongside the message insert. Atomic by default.

The second was the LAST_WRITE early-exit problem. My spec said “return early” from the reactive chain when LAST_WRITE detects the same sender and updates in place. In a Mutiny .flatMap().map() pipeline, “return early” isn’t a statement — it’s a design question. I chose a sealed interface:

sealed interface TransactResult permits OverwriteResult, FullResult {}
record OverwriteResult(DispatchResult result) implements TransactResult {}
record FullResult(DispatchContext ctx) implements TransactResult {}

The withTransaction block returns Uni<TransactResult>. Pattern-matching on the result drives the rest of the chain. OverwriteResult short-circuits; FullResult continues through commitment state transitions and side effects. Boolean flags or Optional<DispatchContext> would have worked but this is cleaner to read.

The spec also missed the ACL pre-fetch optimisation. The original proposal called reactiveInstanceService.findCapabilityTagsForInstance(sender) unconditionally on every dispatch. For open channels — the common case — this is a DB round-trip for nothing. The actual guard: skip the fetch entirely if allowedWriters is null or blank or the message is EVENT. Same semantics as the blocking path’s lazy supplier.

What the implementation found

Claude started on the first task (store seam additions) and worked upward. The ReactiveJpaChannelStore.updateLastActivity() implementation was the first surprise. I’d spec’d it as a direct repo.update("lastActivityAt = ?1 WHERE id = ?2", ...) — targeted, no entity load. Claude discovered that PanacheRepositoryBase.update() in a named-PU reactive context throws “Mutiny.SessionFactory bean not found” when only the named datasource has reactive=true. The default SessionFactory it reaches for doesn’t exist. The workaround: findById() then mutate the field and rely on Hibernate’s dirty checking. Javadoc updated to say what actually happens rather than what I intended.

The nested transaction semantics were worth documenting. ReactiveLedgerWriteService.record() wraps its body in Panache.withTransaction("qhorus", ...). When called from inside ReactiveMessageService.dispatch()’s outer withTransaction, the inner call joins the enclosing transaction — it doesn’t start a new one. The blocking service uses JTA REQUIRES_NEW so the ledger entry survives outer failures. The reactive path is actually stricter: message insert and ledger entry commit together or not at all. I documented this as an intentional divergence from the blocking semantics, not a bug.

The PostgreSQL FK constraint caught the contract tests. The existing MessageServiceContractTest created messages with random UUID.randomUUID() as channel IDs. With H2 drop-and-create, no FK constraint fires and the tests pass. With Flyway-managed PostgreSQL, the fk_message_channel constraint rejects every one of them. Claude fixed the contract test setup to use real persisted channels via QuarkusTransaction.requiringNew().run(() -> channelService.create(...)). This became the standing pattern for reactive test entity setup: JTA (requiringNew) from test threads, not Panache.withTransaction() which needs a Vert.x context. It’s now a protocol.

The final reviewer flag was minor: two @Inject fields for the same ReactiveChannelStore bean — channelStore for the channel load and reactiveChannelStore for updateLastActivity. Both resolve to the same bean instance; it’s not a bug, but it’s confusing. Consolidated before the commit.

What it looks like now

The reactive dispatch() enforces the same eight concerns as the blocking gate, in the same order: paused check, writer ACL (guarded tag fetch, sync check), rate limit, trust gate (one ManagedExecutor hop for the JPA query), type policy, LAST_WRITE semantics, commitment open (inline in the write transaction), and ledger write returning a populated LedgerWriteOutcome. State transitions (acknowledge, fulfill, decline, fail, delegate) go through ReactiveCommitmentService after the transaction commits. Observer dispatch moved post-commit — it was inside withTransaction before, which meant observers could query state that hadn’t committed yet.

All 11 tests pass. Full build at 278 tests green. Two casehub-ledger issues filed for the remaining deferred items: reactive LedgerAttestation persistence and a Uni<Boolean> variant for TrustGateService.meetsThreshold().

The @Disabled annotations are gone.


<
Previous Post
Worker carries a definition, not an outcome
>
Next Post
The Audit Entry That Couldn't Exist