Skip to content

fix(core)!: emit OutboxProcessorListener events after the transaction commits (#137) - #147

Merged
rawo merged 3 commits into
mainfrom
fix/137-listener-after-commit
Nov 9, 2026
Merged

rawo merged 3 commits into
mainfrom
fix/137-listener-after-commit

Conversation

@rawo

@rawo rawo commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

Closes #137. Part of the 2.0.0 breaking-change set.

Independent of the stacked schema PRs (#144 → #145 → #146), so it is based on main. It adds the same "From 1.x to 2.0.0" heading to UPGRADING.md as #144, which will be a trivial merge conflict for whichever lands second.

Problem

OutboxProcessor.processNext called listener.onEntryProcessed / onBatchProcessed right after store.updateAfterProcessingBatch, inside the scheduler's transaction, before it commits. When the commit then failed (connection loss, serialization failure, timeout):

  • The rows rolled back to PENDING.
  • okapi.entries.delivered and okapi.entries.failed had already been incremented, and they were incremented again on redelivery.
  • A custom listener could act on state that was never persisted.

Fix

  • OutboxProcessor.processBatch(limit) claims, delivers and persists, then returns a ProcessedBatch without notifying anyone.
  • OutboxProcessor.publishEvents(batch) emits the entry events and then the batch event.
  • OutboxScheduler runs processBatch inside runInTransaction and publishEvents after it returns. Under the scheduler, events therefore describe committed state, and a batch whose commit fails emits nothing.
  • processNext(limit) keeps its signature and still notifies at once (processBatch + publishEvents), for callers that drive the processor themselves. Its KDoc says that inside a transaction those events come before the commit.
  • onBatchProcessed's duration, and therefore okapi.batch.duration, now runs until the commit.
  • Docs: the KDoc of OutboxProcessor, OutboxProcessorListener and OutboxScheduler; the README metrics table and listener paragraph; a CHANGELOG entry under Changed (BREAKING); an UPGRADING section.

Breaking

  • Listener timing: under the scheduler, callbacks run after the commit, outside the processing transaction, and are skipped when the commit fails. A listener that relied on joining that transaction can't any more.
  • Batch timer: okapi.batch.duration includes the commit.

Tests

  • ListenerAfterCommitTest (unit):
    • processBatch doesn't notify, and publishEvents does;
    • processNext still notifies at once;
    • the scheduler emits events only after the runner's "commit";
    • a runner whose commit throws leads to no events over several ticks.
  • ListenerAfterCommitEndToEndTest (Postgres): the listener queries the row's status from a separate connection inside onEntryProcessed and must see DELIVERED, which proves the commit already happened.
    • The test was checked against the fix: with the old scheduler it fails with expected:<DELIVERED> but was:<PENDING>.
  • ./gradlew ktlintCheck test passes locally.

🤖 Generated with Claude Code

@rawo rawo left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Looks right to me. I traced every shipped TransactionRunner on the scheduler's own threads and confirmed that "after runInTransaction returns" really does mean "after commit". I ran the new tests locally: ListenerAfterCommitTest (4/4) and ListenerAfterCommitEndToEndTest (1/1) pass. The comments below are mostly about documenting the contract this now relies on, and a few test and doc gaps.

What I checked

  • Spring: SpringTransactionRunner uses TransactionTemplate.execute. On the outbox-processor thread and the worker threads there is no ambient transaction, so REQUIRED, REQUIRES_NEW and NESTED all start a new transaction and commit before execute returns. SUPPORTS, NOT_SUPPORTED and NEVER run in auto-commit, so writes are durable before return too. If a participant marks the transaction global rollback-only, the commit throws UnexpectedRollbackException, so publishEvents is skipped. A silent rollback only happens with local setRollbackOnly(), which okapi never calls.
  • Exposed 1.6: transaction(db) with no outer transaction calls inTopLevelTransaction, which commits inside the loop before it returns. It retries the whole block on any SQLException, commit failures included, up to defaultMaxAttempts = 3. Each attempt makes a fresh ProcessedBatch. Only the attempt that commits is returned and published. That is correct, and better than before, when a failed attempt's events had already been emitted. With an outer Exposed transaction and useNestedTransactions = false there is no commit at return. The scheduler never hits that case, but a hand-rolled caller could (see the inline comment on OutboxScheduler.kt).
  • Concurrency > 1: each worker runs processBatch() → publishEvents on its own thread, and ProcessedBatch is not shared. Nothing new is needed for thread safety beyond the existing rule that listeners must be thread-safe. Memory is the same as before: entries were already alive until notification.
  • Exception paths: if processBatch throws (claim, delivery or updateAfterProcessingBatch), no events fire, which matches the old behaviour. Listener exceptions are still swallowed per callback inside publishEvents. If the commit fails, the scheduler logs the tick error and emits nothing.

Cross-cutting (no line in the diff to attach these to)

  1. should-fix (docs): the counters now mean committed status transitions, not transport sends. When an attempt is rolled back, its messages still went out to Kafka/HTTP. That happens on a failed commit, and up to 3 times per tick with Exposed's retry loop. Those sends are never counted, and the redelivery is counted once. I think that's the right semantics, but the README metrics table / CHANGELOG should say so, because "okapi.entries.delivered: Successfully delivered entries" will be read as sends.
  2. question: a failed commit now leaves no trace in metrics at all: no batch timer sample and no counter, only an ERROR log. Before, it was over-counted, which was wrong, but at least visible. Should there be a follow-up (e.g. onBatchFailed or an okapi.batch.failed counter) so operators can alert on commit failures? Not blocking for this PR.
  3. nit (stale refs): CompositeMessageDeliverer.kt:62 still says the scheduler wraps "the whole OutboxProcessor.processNext cycle". It is processBatch now. The test name OutboxSchedulerTest "tick calls processNext with configured batchSize" is stale too.

try {
transactionRunner.runInTransaction { outboxProcessor.processNext(config.batchSize) }
val batch = transactionRunner.runInTransaction { outboxProcessor.processBatch(config.batchSize) }
// After the commit (issue #137): a batch whose commit failed is rolled back and is not reported.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

should-fix (contract docs): the after-commit guarantee now rests on an unstated TransactionRunner contract: runInTransaction must have committed by the time it returns (it must not join an ambient transaction or defer the commit). The shipped runners meet it on the scheduler's threads (see the review body). But TransactionRunner's KDoc advertises "Standalone: user-provided lambda", and the Spring autoconfig already warns about ticks "joining an outer TX". A custom runner that participates in an outer transaction (Spring REQUIRED under an outer @Transactional, or Exposed transaction {} nested with useNestedTransactions = false) would silently bring back #137. Please spell out the requirement on TransactionRunner (and maybe in ExposedTransactionRunner/SpringTransactionRunner). It would also help to note that Exposed may retry the block, in which case only the attempt that commits is reported.

*/
fun publishEvents(batch: ProcessedBatch) {
batch.entries.forEach { updated -> notifyEntry(updated, batch.deliveryDuration) }
notifyBatch(batch.count, Duration.between(batch.startedAt, clock.instant()))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

nit: the batch duration is read after all onEntryProcessed callbacks have run, so a slow listener (e.g. a composite doing I/O) inflates okapi.batch.duration. That doesn't match the README's new "from the claim until the transaction has committed". The old code had the same ordering, but now that the semantics are being redefined it's cheap to fix: take val endedAt = clock.instant() as the first statement of publishEvents and use it here.

* then [OutboxProcessorListener.onBatchProcessed], whose duration runs from the start of the batch
* until now, so it includes the commit when called after one. Call it at most once per batch.
*/
fun publishEvents(batch: ProcessedBatch) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

nit: "Call it at most once per batch" is only documented. Calling it twice double-counts every Micrometer counter, which is exactly the class of bug this PR fixes. A one-shot guard is cheap: an AtomicBoolean in ProcessedBatch, with a second call either logged and ignored or failing via check. It would make the new public API harder to misuse.

* The outcome of [OutboxProcessor.processBatch]: [entries] in the state just persisted, whose
* listener events are held back until [OutboxProcessor.publishEvents].
*/
class ProcessedBatch internal constructor(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

nit (Java interop / API surface): internal doesn't hide these from Java. javap on the built class shows a plain public ProcessedBatch(List, Instant, Duration) constructor (Kotlin does not mangle internal constructors) and public getters getStartedAt$okapi_okapi_core() / getDeliveryDuration$okapi_okapi_core(). So Java callers can construct one, and those mangled names become de facto ABI. If the goal is "opaque token", a public class with a private constructor and a factory internal to the module (or an interface with an internal implementation) is tighter. Smaller points: no toString, so it logs as ProcessedBatch@1a2b; and entries exposes whatever list OutboxEntryProcessor returned (Java can cast and mutate it). Neither matters much, but this is new public API in a major release, so it's worth deciding now.

listener = listener,
)
val runner = object : TransactionRunner {
override fun <T> runInTransaction(block: () -> T): T = db.jdbc.withTransaction(block)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

should-fix (test proves less than claimed): this runner is hand-rolled (db.jdbc.withTransaction), so the end-to-end test never exercises the runners users actually get: SpringTransactionRunner (via TransactionTemplate + DataSourceTransactionManager) and ExposedTransactionRunner. Both are already on this module's test classpath. Parametrising the test over those two (plus this one) would back the PR's claim that the fix holds "under the scheduler / Spring Boot autoconfiguration" with evidence, not just reasoning.

conn.prepareStatement("SELECT status FROM okapi_outbox WHERE id = ?::uuid").use { stmt ->
stmt.setString(1, entry.outboxId.raw.toString())
stmt.executeQuery().use { rs ->
rs.next()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

nit: if the row isn't found, rs.next() returns false, getString throws, and notifyEntry swallows the exception as a WARN. The future then never completes, and the test fails after 10 s with a bare TimeoutException instead of a useful message. Wrapping the body in try { ... } catch (e: Throwable) { statusSeenByListener.completeExceptionally(e) } (or completing with null when !rs.next()) makes failures diagnosable.

}

test("a batch whose commit fails emits no events") {
val log = Collections.synchronizedList(mutableListOf<String>())

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

nit (coverage): a cheap extra case would pin down the Exposed retry behaviour: a runner that calls block() twice, where the first "commit" throws and the second succeeds, like inTopLevelTransaction on an SQLException. Assert that exactly one entry:Delivered / batch:1 pair is emitted. Another case could cover processBatch itself throwing (e.g. updateAfterProcessingBatch fails), asserting no events. Both behaviours are correct today but untested. The latch/timing in these tests looks sound, by the way: every log append happens before its countDown, and stop() waits for the in-flight tick.

Comment thread README.md Outdated
| `okapi.entries.retry.scheduled` | Counter | Failed attempts rescheduled for retry |
| `okapi.entries.failed` | Counter | Permanently failed entries |
| `okapi.batch.duration` | Timer | Processing time per batch |
| `okapi.batch.duration` | Timer | Processing time per batch, from the claim until the transaction has committed |

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

nit: this holds only under the scheduler. With processNext inside the caller's transaction, the duration ends before the commit. It also includes the onEntryProcessed callbacks (see the comment on OutboxProcessor.kt:98). Maybe: "…from the claim until the scheduler's transaction has committed (processNext: until the results are written)".

Comment thread UPGRADING.md Outdated
failed.

- A custom listener that wrote to the database expecting to join the processing transaction now
runs without one: open your own transaction in the callback, or make the write idempotent.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

nit: consider naming the most likely way this breaks for Ktor users explicitly. A listener that used the Exposed DSL with no transaction {} of its own, relying on the scheduler's thread-bound transaction, now gets IllegalStateException: No transaction in context on every event. OutboxProcessor swallows that as a WARN, so the listener silently stops working. In Spring, a listener calling a @Transactional(propagation = MANDATORY) method fails in the same silent way.

@rawo

rawo commented Nov 6, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the review in 3bca3eb:

  1. Undocumented commit contract. The TransactionRunner KDoc now states it: when runInTransaction returns normally, the transaction has committed. It also covers the pitfalls you listed (Spring REQUIRED inside @Transactional, nested Exposed transactions) and that a runner may retry the block, as Exposed does. UPGRADING repeats it for custom runners.
  2. End-to-end test didn't use the real runners. ListenerAfterCommitEndToEndTest now runs the same tick three ways: a plain JDBC runner, SpringTransactionRunner (TransactionTemplate + DataSourceTransactionManager, with SpringConnectionProvider), and ExposedTransactionRunner (with ExposedConnectionProvider). All three see DELIVERED from a separate connection.
  3. Counter semantics. Documented in the README, the CHANGELOG and UPGRADING: counters count committed status changes, not delivery attempts.
  4. No metric for a failed commit. Agreed this is worth having, but it's additive (an onBatchFailed callback or an okapi.batch.failed counter can land in a minor release). I'll open a follow-up issue rather than grow this breaking PR.
  5. Stale references. The CompositeMessageDeliverer KDoc now points at processBatch, and the scheduler test was renamed.
  6. Slow listener inflated the batch timer. Fixed: the batch end time is read before the callbacks run.
  7. Double publish. Fixed: publishEvents is now one-shot per batch, guarded by an AtomicBoolean, and a second call throws IllegalStateException. Tested.
  8. ProcessedBatch from Java. entries is now an unmodifiable copy and the class has a toString. The internal timing fields stay internal-mangled. Java can technically call the constructor, but the KDoc says only OutboxProcessor creates batches, and a hand-built batch has no effect beyond publishing its own events.
  9. Test timed out instead of failing. Fixed: the listener calls completeExceptionally, and a missing row fails with a message.
  10. Missing unit tests. Added: a runner that runs the block twice (rolled back, then committed) produces exactly one set of events, after the commit; a processBatch that throws publishes nothing.
  11. README batch timer wording. Qualified to cover only the scheduler and to exclude listener callbacks.
  12. Silent failure on upgrade. UPGRADING now names it: Exposed's "No transaction in context" and Spring's MANDATORY failures, which are only logged as WARN.

./gradlew ktlintCheck test passes.

@rawo rawo added the ready PR reviewed, fixes applied, tests and ktlint green; ready for maintainer merge label Nov 6, 2026
@rawo
rawo force-pushed the fix/137-listener-after-commit branch 2 times, most recently from d292cba to 00e161e Compare November 6, 2026 12:00
rawo and others added 3 commits November 9, 2026 13:08
… commits (#137)

OutboxProcessor notified the listener right after writing a batch, inside the scheduler's
transaction, so a failed commit left delivered/failed counters incremented for rows that rolled
back to PENDING and were counted again on redelivery. OutboxProcessor gains processBatch (claim,
deliver, persist, no events) and publishEvents; OutboxScheduler runs the first in the transaction
and the second after it returns, so events describe committed state and a failed commit emits none.
processNext keeps notifying at once for callers that drive the processor themselves.

BREAKING CHANGE: under the scheduler, listener callbacks run after the commit, outside the
processing transaction, and are skipped when the commit fails; okapi.batch.duration includes the
commit.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- TransactionRunner documents its contract: runInTransaction has committed when it returns, and may
  run the block more than once (Exposed retries).
- publishEvents reads the batch end time before the callbacks and is one-shot per batch;
  ProcessedBatch exposes a read-only entry list and a toString.
- Fix stale processNext references.
- Tests: the end-to-end test runs with SpringTransactionRunner and ExposedTransactionRunner as well
  as plain JDBC and fails fast if the row is missing; unit tests for a retrying runner, a failing
  processBatch and double publishing.
- Docs: counters count committed status changes; batch timer wording; how a listener that relied on
  the processing transaction now fails.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…now) from #131

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@rawo
rawo force-pushed the fix/137-listener-after-commit branch from 00e161e to 235f53f Compare November 9, 2026 12:11
@rawo
rawo merged commit 03668e4 into main Nov 9, 2026
8 checks passed
@rawo
rawo deleted the fix/137-listener-after-commit branch November 9, 2026 12:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready PR reviewed, fixes applied, tests and ktlint green; ready for maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

OutboxProcessorListener events are emitted before the transaction commits; metrics over-count on commit failure

1 participant