Problem
OutboxScheduler and OutboxPurger in okapi-core currently accept transactionRunner: TransactionRunner? = null, and tick() falls back to a non-transactional path when null:
private fun tick() {
transactionRunner?.runInTransaction { processor.processNext(batchSize) }
?: processor.processNext(batchSize) // ← silent auto-commit path
...
}
Under JDBC auto-commit, FOR UPDATE SKIP LOCKED releases its row lock immediately after the SELECT. Concurrent processor instances then see overlapping result sets and silently deliver the same entry multiple times, with no log, no exception, no metric.
#49 closes this hole in the Spring Boot autoconfig layer (require a TransactionRunner bean, fail fast on multi-DataSource ambiguity, etc.). The core API still exposes the same footgun for non-Spring consumers (Ktor, manual Spring wiring, plain Java/Kotlin). The library-level invariant — claim+deliver runs inside a transaction — is currently owned by the adapter, not the core.
Proposed design (breaking, pre-1.0 acceptable)
Make TransactionRunner a required, non-nullable constructor parameter in both schedulers. Drop the nullable default and the ?: fallback in tick(). No escape hatch / "NoTransaction" sentinel object.
class OutboxScheduler(
private val outboxProcessor: OutboxProcessor,
private val transactionRunner: TransactionRunner, // was: TransactionRunner? = null
private val config: OutboxSchedulerConfig = OutboxSchedulerConfig(),
) {
private fun tick() {
try {
transactionRunner.runInTransaction { outboxProcessor.processNext(config.batchSize) }
...
Same shape for OutboxPurger.
Why no escape hatch
The only legitimate "I manage my own transaction" case (Ktor + Exposed's transaction { }, custom store with a single atomic statement, etc.) is already covered by writing a thin TransactionRunner that delegates to the adapter's native transaction primitive — for example:
val ktorRunner = TransactionRunner { block -> transaction { block() } }
That is not "no transaction" — it is a borrowed transaction, expressed through the existing interface. A nullable default or a NoTransaction object would only re-introduce the silent footgun under a different name. Users who genuinely want no surrounding transaction can write TransactionRunner { it() } themselves; the risk then becomes explicit and grep-able rather than an accidental default.
Scope
Compatibility
Breaking change in okapi-core constructors. Pre-1.0, so permitted by the project's stated versioning policy. Spring Boot autoconfig users see no behavioral change after #49 (already pass a non-null runner). Direct constructor users (Ktor, manual Spring, plain JVM) must supply a TransactionRunner — same migration shape that #49's README/CHANGELOG already documents for the Spring scheduler wrappers.
Context
Identified during the architecture-focused code review of #49 as the deepest remaining gap once the Spring path is hardened: the safety guarantee should be owned by the core layer that defines the invariant, not layered on top by the adapter.
Problem
OutboxSchedulerandOutboxPurgerinokapi-corecurrently accepttransactionRunner: TransactionRunner? = null, andtick()falls back to a non-transactional path when null:Under JDBC auto-commit,
FOR UPDATE SKIP LOCKEDreleases its row lock immediately after the SELECT. Concurrent processor instances then see overlapping result sets and silently deliver the same entry multiple times, with no log, no exception, no metric.#49 closes this hole in the Spring Boot autoconfig layer (require a
TransactionRunnerbean, fail fast on multi-DataSource ambiguity, etc.). The core API still exposes the same footgun for non-Spring consumers (Ktor, manual Spring wiring, plain Java/Kotlin). The library-level invariant — claim+deliver runs inside a transaction — is currently owned by the adapter, not the core.Proposed design (breaking, pre-1.0 acceptable)
Make
TransactionRunnera required, non-nullable constructor parameter in both schedulers. Drop the nullable default and the?:fallback intick(). No escape hatch / "NoTransaction" sentinel object.Same shape for
OutboxPurger.Why no escape hatch
The only legitimate "I manage my own transaction" case (Ktor + Exposed's
transaction { }, custom store with a single atomic statement, etc.) is already covered by writing a thinTransactionRunnerthat delegates to the adapter's native transaction primitive — for example:That is not "no transaction" — it is a borrowed transaction, expressed through the existing interface. A nullable default or a
NoTransactionobject would only re-introduce the silent footgun under a different name. Users who genuinely want no surrounding transaction can writeTransactionRunner { it() }themselves; the risk then becomes explicit and grep-able rather than an accidental default.Scope
OutboxScheduler.kt: drop nullable default and?:fallback; constructor parameter required.OutboxPurger.kt: same.OutboxProcessorScheduler/OutboxPurgerSchedulerinokapi-spring-boot) already pass a non-nullTransactionRunnerafter feat: require TransactionRunner in okapi-spring-boot autoconfig (KOJAK-67) #49 — no further changes needed there.TransactionRunner.okapi-coreunit tests that constructed the scheduler with a null runner.CHANGELOG.md[Unreleased] ### Changed (BREAKING)entry. Link from this issue's PR (#NN), not from a JIRA key.Compatibility
Breaking change in
okapi-coreconstructors. Pre-1.0, so permitted by the project's stated versioning policy. Spring Boot autoconfig users see no behavioral change after #49 (already pass a non-null runner). Direct constructor users (Ktor, manual Spring, plain JVM) must supply aTransactionRunner— same migration shape that #49's README/CHANGELOG already documents for the Spring scheduler wrappers.Context
Identified during the architecture-focused code review of #49 as the deepest remaining gap once the Spring path is hardened: the safety guarantee should be owned by the core layer that defines the invariant, not layered on top by the adapter.