Repository navigation
fix(core)!: emit OutboxProcessorListener events after the transaction commits (#137) - #147
Conversation
rawo
left a comment
There was a problem hiding this comment.
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:
SpringTransactionRunnerusesTransactionTemplate.execute. On theoutbox-processorthread and the worker threads there is no ambient transaction, soREQUIRED,REQUIRES_NEWandNESTEDall start a new transaction and commit beforeexecutereturns.SUPPORTS,NOT_SUPPORTEDandNEVERrun in auto-commit, so writes are durable before return too. If a participant marks the transaction global rollback-only, the commit throwsUnexpectedRollbackException, sopublishEventsis skipped. A silent rollback only happens with localsetRollbackOnly(), which okapi never calls. - Exposed 1.6:
transaction(db)with no outer transaction callsinTopLevelTransaction, which commits inside the loop before it returns. It retries the whole block on anySQLException, commit failures included, up todefaultMaxAttempts = 3. Each attempt makes a freshProcessedBatch. 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 anduseNestedTransactions = falsethere is no commit at return. The scheduler never hits that case, but a hand-rolled caller could (see the inline comment onOutboxScheduler.kt). - Concurrency > 1: each worker runs
processBatch()→publishEventson its own thread, andProcessedBatchis 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
processBatchthrows (claim, delivery orupdateAfterProcessingBatch), no events fire, which matches the old behaviour. Listener exceptions are still swallowed per callback insidepublishEvents. If the commit fails, the scheduler logs the tick error and emits nothing.
Cross-cutting (no line in the diff to attach these to)
- 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. - 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.
onBatchFailedor anokapi.batch.failedcounter) so operators can alert on commit failures? Not blocking for this PR. - nit (stale refs):
CompositeMessageDeliverer.kt:62still says the scheduler wraps "the wholeOutboxProcessor.processNextcycle". It isprocessBatchnow. The test nameOutboxSchedulerTest"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. |
There was a problem hiding this comment.
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())) |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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() |
There was a problem hiding this comment.
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>()) |
There was a problem hiding this comment.
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.
| | `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 | |
There was a problem hiding this comment.
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)".
| 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. |
There was a problem hiding this comment.
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.
|
Addressed the review in 3bca3eb:
|
d292cba to
00e161e
Compare
… 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>
00e161e to
235f53f
Compare
Closes #137. Part of the 2.0.0 breaking-change set.
Problem
OutboxProcessor.processNextcalledlistener.onEntryProcessed/onBatchProcessedright afterstore.updateAfterProcessingBatch, inside the scheduler's transaction, before it commits. When the commit then failed (connection loss, serialization failure, timeout):PENDING.okapi.entries.deliveredandokapi.entries.failedhad already been incremented, and they were incremented again on redelivery.Fix
OutboxProcessor.processBatch(limit)claims, delivers and persists, then returns aProcessedBatchwithout notifying anyone.OutboxProcessor.publishEvents(batch)emits the entry events and then the batch event.OutboxSchedulerrunsprocessBatchinsiderunInTransactionandpublishEventsafter 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 thereforeokapi.batch.duration, now runs until the commit.OutboxProcessor,OutboxProcessorListenerandOutboxScheduler; the README metrics table and listener paragraph; a CHANGELOG entry under Changed (BREAKING); an UPGRADING section.Breaking
okapi.batch.durationincludes the commit.Tests
ListenerAfterCommitTest(unit):processBatchdoesn't notify, andpublishEventsdoes;processNextstill notifies at once;ListenerAfterCommitEndToEndTest(Postgres): the listener queries the row's status from a separate connection insideonEntryProcessedand must seeDELIVERED, which proves the commit already happened.expected:<DELIVERED> but was:<PENDING>../gradlew ktlintCheck testpasses locally.🤖 Generated with Claude Code