Skip to content

Commit d292cba

Browse files
rawoclaude
andcommitted
fix(core): address review of after-commit listener events (#137)
- 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>
1 parent a328141 commit d292cba

10 files changed

Lines changed: 145 additions & 28 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,10 @@ Until `1.0.0`, breaking changes may appear in any release and are flagged with *
113113
listener runs after the commit, outside the transaction, and not at all for a batch whose commit
114114
failed. A listener that relied on running inside the processing transaction (e.g. to write to the
115115
database atomically with the status update) no longer can. `onBatchProcessed`'s duration, and with
116-
it `okapi.batch.duration`, now includes the commit; empty polls are still reported.
116+
it `okapi.batch.duration`, now includes the commit (and excludes the listener callbacks); empty
117+
polls are still reported. Counters now count committed status changes rather than delivery
118+
attempts. `TransactionRunner` now documents that `runInTransaction` must have committed when it
119+
returns, and `publishEvents` throws if a batch is published twice.
117120
`processNext(limit)` keeps notifying at once, for callers that drive the processor themselves.
118121
([#137](https://github.com/softwaremill/okapi/issues/137))
119122
- **`OutboxProcessorProperties` gained a `transportDispatch` constructor parameter** (see the

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,7 @@ Metrics are then available at `/actuator/prometheus`.
273273
| `okapi.entries.delivered` | Counter | Successfully delivered entries |
274274
| `okapi.entries.retry.scheduled` | Counter | Failed attempts rescheduled for retry |
275275
| `okapi.entries.failed` | Counter | Permanently failed entries |
276-
| `okapi.batch.duration` | Timer | Processing time per batch, from the claim until the transaction has committed |
276+
| `okapi.batch.duration` | Timer | Processing time per batch; under the scheduler from the claim until the transaction has committed (not including listener callbacks) |
277277
| `okapi.entries.count` | Gauge | Current entry count (tag: `status`) |
278278
| `okapi.entries.lag.seconds` | Gauge | Age of the oldest entry (tag: `status`) |
279279

@@ -291,7 +291,7 @@ max by (job, status) (okapi_entries_count)
291291

292292
Without Spring Boot, construct `MicrometerOutboxListener` and `MicrometerOutboxMetrics` with your `MeterRegistry`. Refresh gauges with `OutboxMetricsRefresher` or your own scheduler.
293293

294-
For custom reactions to delivery events, implement `OutboxProcessorListener`. `OutboxProcessor` takes a single listener; combine several with a composite of your own. The scheduler calls the listener after the processing transaction has committed, outside of it, and not for a batch whose commit failed, so every event describes persisted state and counters do not over-count when a commit fails. If you drive `OutboxProcessor` yourself, call `processBatch` inside your transaction and `publishEvents` after it commits; `processNext` does both at once, so inside a transaction its events precede the commit.
294+
For custom reactions to delivery events, implement `OutboxProcessorListener`. `OutboxProcessor` takes a single listener; combine several with a composite of your own. The scheduler calls the listener after the processing transaction has committed, outside of it, and not for a batch whose commit failed, so every event describes persisted state and counters do not over-count when a commit fails. Counters therefore count committed status changes, not delivery attempts: a send whose transaction then rolled back (a failed commit, or a block Exposed retried) is not counted, though the message may have reached the consumer. If you drive `OutboxProcessor` yourself, call `processBatch` inside your transaction and `publishEvents` after it commits; `processNext` does both at once, so inside a transaction its events precede the commit.
295295

296296
## Modules
297297

‎UPGRADING.md‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,13 @@ the processing transaction has committed, outside of it, and are skipped for a b
4242
failed.
4343

4444
- A custom listener that wrote to the database expecting to join the processing transaction now
45-
runs without one: open your own transaction in the callback, or make the write idempotent.
45+
runs without one: open your own transaction in the callback, or make the write idempotent. This
46+
fails quietly: an Exposed listener gets "No transaction in context", a Spring call with
47+
`MANDATORY` propagation throws, and okapi only logs each such exception as a WARN.
48+
- Counters (`okapi.entries.delivered`, `okapi.entries.failed`, ...) now count committed status
49+
changes. A send whose transaction rolled back is no longer counted.
50+
- A custom `TransactionRunner` must have committed when `runInTransaction` returns (now documented on
51+
the interface); one that joins an outer transaction brings back pre-commit events.
4652
- `okapi.batch.duration` now includes the commit, so it reads slightly higher than before.
4753
- If you call `OutboxProcessor.processNext` inside your own transaction, events still fire before
4854
your commit. Switch to `val batch = runInTransaction { processor.processBatch(limit) }` followed by

‎okapi-core/src/main/kotlin/com/softwaremill/okapi/core/CompositeMessageDeliverer.kt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ class CompositeMessageDeliverer @JvmOverloads constructor(
5959
* satisfy that opt out via [TransportDispatch.SEQUENTIAL].
6060
*
6161
* The outbox transaction is open across delivery, and stays that way: the scheduler wraps the
62-
* whole [OutboxProcessor.processNext] cycle — claim, deliver, update — in one transaction, so
62+
* whole [OutboxProcessor.processBatch] cycle — claim, deliver, update — in one transaction, so
6363
* that `FOR UPDATE SKIP LOCKED` holds its row locks until the results are written. What this
6464
* dispatch changes is which thread the transport runs on, not that scope. The store round-trips
6565
* still run on the calling thread inside the transaction, but a group handed to a virtual

‎okapi-core/src/main/kotlin/com/softwaremill/okapi/core/OutboxProcessor.kt‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,11 +91,14 @@ class OutboxProcessor @JvmOverloads constructor(
9191
/**
9292
* Notifies the listener of [batch]: [OutboxProcessorListener.onEntryProcessed] for each entry,
9393
* then [OutboxProcessorListener.onBatchProcessed], whose duration runs from the start of the batch
94-
* until now, so it includes the commit when called after one. Call it at most once per batch.
94+
* until this call, so it includes the commit when called after one, but not the listener
95+
* callbacks. A batch's events can be published only once; a second call throws.
9596
*/
9697
fun publishEvents(batch: ProcessedBatch) {
98+
check(batch.markPublished()) { "Events of this batch were already published" }
99+
val batchDuration = Duration.between(batch.startedAt, clock.instant())
97100
batch.entries.forEach { updated -> notifyEntry(updated, batch.deliveryDuration) }
98-
notifyBatch(batch.count, Duration.between(batch.startedAt, clock.instant()))
101+
notifyBatch(batch.count, batchDuration)
99102
}
100103

101104
private fun notifyEntry(updated: OutboxEntry, duration: Duration) {

‎okapi-core/src/main/kotlin/com/softwaremill/okapi/core/ProcessedBatch.kt‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,29 @@ package com.softwaremill.okapi.core
22

33
import java.time.Duration
44
import java.time.Instant
5+
import java.util.Collections
6+
import java.util.concurrent.atomic.AtomicBoolean
57

68
/**
79
* The outcome of [OutboxProcessor.processBatch]: [entries] in the state just persisted, whose
8-
* listener events are held back until [OutboxProcessor.publishEvents].
10+
* listener events are held back until [OutboxProcessor.publishEvents]. Only [OutboxProcessor]
11+
* creates one; its constructor and timing fields are internal to okapi-core.
912
*/
1013
class ProcessedBatch internal constructor(
11-
val entries: List<OutboxEntry>,
14+
entries: List<OutboxEntry>,
1215
internal val startedAt: Instant,
1316
internal val deliveryDuration: Duration,
1417
) {
18+
/** The processed entries, in the state persisted for them. Read-only. */
19+
val entries: List<OutboxEntry> = Collections.unmodifiableList(entries.toList())
20+
1521
/** Number of entries processed (0 if the store had nothing to claim). */
1622
val count: Int get() = entries.size
23+
24+
private val published = AtomicBoolean(false)
25+
26+
/** Returns `true` the first time only. */
27+
internal fun markPublished(): Boolean = published.compareAndSet(false, true)
28+
29+
override fun toString(): String = "ProcessedBatch(count=$count, startedAt=$startedAt, deliveryDuration=$deliveryDuration)"
1730
}

‎okapi-core/src/main/kotlin/com/softwaremill/okapi/core/TransactionRunner.kt‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ package com.softwaremill.okapi.core
77
* - `okapi-spring-boot`: wraps Spring's `TransactionTemplate` via `SpringTransactionRunner`
88
* - `okapi-exposed`: wraps Exposed's `transaction {}` via `ExposedTransactionRunner`
99
* - Standalone: user-provided lambda
10+
*
11+
* Contract: when [runInTransaction] returns normally, the transaction it ran [block] in has
12+
* committed. [OutboxScheduler] relies on this to emit listener events only for committed state
13+
* (issue #137). A runner that joins an outer transaction instead (e.g. Spring's `REQUIRED` called
14+
* inside `@Transactional`, or a nested Exposed `transaction {}` without nested transactions) breaks
15+
* that. A runner may run [block] more than once, as Exposed does when a statement or the commit
16+
* throws an `SQLException`; only the result of the attempt that committed is returned.
1017
*/
1118
interface TransactionRunner {
1219
fun <T> runInTransaction(block: () -> T): T

‎okapi-core/src/test/kotlin/com/softwaremill/okapi/core/ListenerAfterCommitTest.kt‎

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.softwaremill.okapi.core
22

3+
import io.kotest.assertions.throwables.shouldThrow
34
import io.kotest.core.spec.style.FunSpec
45
import io.kotest.matchers.collections.shouldBeEmpty
56
import io.kotest.matchers.collections.shouldContainExactly
@@ -39,8 +40,8 @@ private class RecordingListener(private val log: MutableList<String>, private va
3940
}
4041
}
4142

42-
private fun processor(listener: OutboxProcessorListener) = OutboxProcessor(
43-
store = OneEntryPerClaimStore(),
43+
private fun processor(listener: OutboxProcessorListener, store: RouteAwareOutboxStore = OneEntryPerClaimStore()) = OutboxProcessor(
44+
store = store,
4445
entryProcessor = OutboxEntryProcessor(
4546
object : MessageDeliverer {
4647
override val type = "stub"
@@ -113,4 +114,61 @@ class ListenerAfterCommitTest : FunSpec({
113114

114115
log.shouldBeEmpty()
115116
}
117+
118+
test("a runner that retries the block, as Exposed does on SQLException, publishes one set of events") {
119+
val log = Collections.synchronizedList(mutableListOf<String>())
120+
val batchSeen = CountDownLatch(1)
121+
val retryingRunner = object : TransactionRunner {
122+
override fun <T> runInTransaction(block: () -> T): T {
123+
block() // first attempt: its commit fails and it is rolled back
124+
log += "rollback"
125+
return block().also { log += "commit" }
126+
}
127+
}
128+
val scheduler = OutboxScheduler(
129+
processor(RecordingListener(log) { batchSeen.countDown() }),
130+
retryingRunner,
131+
OutboxSchedulerConfig(interval = Duration.ofMinutes(1)),
132+
)
133+
134+
scheduler.start()
135+
batchSeen.await(5, TimeUnit.SECONDS) shouldBe true
136+
scheduler.stop()
137+
138+
log.take(4) shouldContainExactly listOf("rollback", "commit", "entry:Delivered", "batch:1")
139+
}
140+
141+
test("a processBatch that throws publishes nothing") {
142+
val log = Collections.synchronizedList(mutableListOf<String>())
143+
val ticks = CountDownLatch(2)
144+
val failingStore = object : RouteAwareOutboxStore by OneEntryPerClaimStore() {
145+
override fun updateAfterProcessingBatch(entries: List<OutboxEntry>): List<OutboxEntry> {
146+
ticks.countDown()
147+
throw IllegalStateException("update failed")
148+
}
149+
}
150+
val scheduler = OutboxScheduler(
151+
processor(RecordingListener(log), failingStore),
152+
object : TransactionRunner {
153+
override fun <T> runInTransaction(block: () -> T): T = block()
154+
},
155+
OutboxSchedulerConfig(interval = Duration.ofMillis(20)),
156+
)
157+
158+
scheduler.start()
159+
ticks.await(5, TimeUnit.SECONDS) shouldBe true
160+
scheduler.stop()
161+
162+
log.shouldBeEmpty()
163+
}
164+
165+
test("a batch's events can be published only once") {
166+
val log = mutableListOf<String>()
167+
val processor = processor(RecordingListener(log))
168+
val batch = processor.processBatch(10)
169+
170+
processor.publishEvents(batch)
171+
shouldThrow<IllegalStateException> { processor.publishEvents(batch) }
172+
log shouldContainExactly listOf("entry:Delivered", "batch:1")
173+
}
116174
})

‎okapi-core/src/test/kotlin/com/softwaremill/okapi/core/OutboxSchedulerTest.kt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import java.util.concurrent.atomic.AtomicReference
2323

2424
class OutboxSchedulerTest : FunSpec({
2525

26-
test("tick calls processNext with configured batchSize") {
26+
test("tick calls processBatch with configured batchSize") {
2727
var capturedLimit: Int? = null
2828
val latch = CountDownLatch(1)
2929
val processor = stubProcessor { limit ->

‎okapi-integration-tests/src/test/kotlin/com/softwaremill/okapi/test/transaction/ListenerAfterCommitEndToEndTest.kt‎

Lines changed: 43 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,20 @@ import com.softwaremill.okapi.core.OutboxProcessor
1111
import com.softwaremill.okapi.core.OutboxProcessorListener
1212
import com.softwaremill.okapi.core.OutboxScheduler
1313
import com.softwaremill.okapi.core.OutboxSchedulerConfig
14+
import com.softwaremill.okapi.core.OutboxStore
1415
import com.softwaremill.okapi.core.RetryPolicy
1516
import com.softwaremill.okapi.core.TransactionRunner
17+
import com.softwaremill.okapi.exposed.ExposedConnectionProvider
18+
import com.softwaremill.okapi.exposed.ExposedTransactionRunner
1619
import com.softwaremill.okapi.postgres.PostgresOutboxStore
20+
import com.softwaremill.okapi.springboot.SpringConnectionProvider
21+
import com.softwaremill.okapi.springboot.SpringTransactionRunner
1722
import com.softwaremill.okapi.test.support.PostgresTestSupport
1823
import io.kotest.core.spec.style.FunSpec
1924
import io.kotest.matchers.shouldBe
25+
import org.jetbrains.exposed.v1.jdbc.Database
26+
import org.springframework.jdbc.datasource.DataSourceTransactionManager
27+
import org.springframework.transaction.support.TransactionTemplate
2028
import java.time.Clock
2129
import java.time.Duration
2230
import java.util.concurrent.CompletableFuture
@@ -37,23 +45,28 @@ class ListenerAfterCommitEndToEndTest : FunSpec({
3745
beforeSpec { db.start() }
3846
afterSpec { db.stop() }
3947

40-
test("the scheduler's listener sees the delivered status already committed") {
41-
val store = PostgresOutboxStore(db.jdbc)
48+
/** Runs one scheduler tick with [runner] and [store]; returns the status the listener saw from another connection. */
49+
fun statusSeenByListener(runner: TransactionRunner, store: OutboxStore): String {
50+
db.truncate()
4251
val entry = OutboxEntry.createPending(OutboxMessage("e", "{}"), AfterCommitDeliveryInfo, Clock.systemUTC().instant())
43-
db.jdbc.withTransaction { store.persist(entry) }
52+
db.jdbc.withTransaction { PostgresOutboxStore(db.jdbc).persist(entry) }
4453

45-
val statusSeenByListener = CompletableFuture<String>()
54+
val seen = CompletableFuture<String>()
4655
val listener = object : OutboxProcessorListener {
4756
override fun onEntryProcessed(event: OutboxProcessingEvent) {
48-
// A connection of its own: it only sees committed data.
49-
db.dataSource.connection.use { conn ->
50-
conn.prepareStatement("SELECT status FROM okapi_outbox WHERE id = ?::uuid").use { stmt ->
51-
stmt.setString(1, entry.outboxId.raw.toString())
52-
stmt.executeQuery().use { rs ->
53-
rs.next()
54-
statusSeenByListener.complete(rs.getString("status"))
57+
try {
58+
// A connection of its own: it only sees committed data.
59+
db.dataSource.connection.use { conn ->
60+
conn.prepareStatement("SELECT status FROM okapi_outbox WHERE id = ?::uuid").use { stmt ->
61+
stmt.setString(1, entry.outboxId.raw.toString())
62+
stmt.executeQuery().use { rs ->
63+
check(rs.next()) { "row ${entry.outboxId} not found" }
64+
seen.complete(rs.getString("status"))
65+
}
5566
}
5667
}
68+
} catch (e: Exception) {
69+
seen.completeExceptionally(e)
5770
}
5871
}
5972
}
@@ -69,16 +82,30 @@ class ListenerAfterCommitEndToEndTest : FunSpec({
6982
),
7083
listener = listener,
7184
)
72-
val runner = object : TransactionRunner {
73-
override fun <T> runInTransaction(block: () -> T): T = db.jdbc.withTransaction(block)
74-
}
7585
val scheduler = OutboxScheduler(processor, runner, OutboxSchedulerConfig(interval = Duration.ofMinutes(1)))
76-
7786
scheduler.start()
7887
try {
79-
statusSeenByListener.get(10, TimeUnit.SECONDS) shouldBe "DELIVERED"
88+
return seen.get(10, TimeUnit.SECONDS)
8089
} finally {
8190
scheduler.stop()
8291
}
8392
}
93+
94+
test("plain JDBC runner: the listener sees the delivered status already committed") {
95+
val runner = object : TransactionRunner {
96+
override fun <T> runInTransaction(block: () -> T): T = db.jdbc.withTransaction(block)
97+
}
98+
statusSeenByListener(runner, PostgresOutboxStore(db.jdbc)) shouldBe "DELIVERED"
99+
}
100+
101+
test("SpringTransactionRunner: the listener sees the delivered status already committed") {
102+
val runner = SpringTransactionRunner(TransactionTemplate(DataSourceTransactionManager(db.dataSource)))
103+
statusSeenByListener(runner, PostgresOutboxStore(SpringConnectionProvider(db.dataSource))) shouldBe "DELIVERED"
104+
}
105+
106+
test("ExposedTransactionRunner: the listener sees the delivered status already committed") {
107+
val database = Database.connect(db.dataSource)
108+
statusSeenByListener(ExposedTransactionRunner(database), PostgresOutboxStore(ExposedConnectionProvider(database))) shouldBe
109+
"DELIVERED"
110+
}
84111
})

0 commit comments

Comments
 (0)