Skip to content

core: require non-null TransactionRunner in OutboxScheduler / OutboxPurger (breaking) #51

@endrju19

Description

@endrju19

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

  • OutboxScheduler.kt: drop nullable default and ?: fallback; constructor parameter required.
  • OutboxPurger.kt: same.
  • Adapter wrappers (OutboxProcessorScheduler / OutboxPurgerScheduler in okapi-spring-boot) already pass a non-null TransactionRunner after feat: require TransactionRunner in okapi-spring-boot autoconfig (KOJAK-67) #49 — no further changes needed there.
  • Update Ktor adapter (when it lands) and any standalone examples to construct a real TransactionRunner.
  • Update okapi-core unit 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-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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions