Skip to content

feat: require TransactionRunner in okapi-spring-boot autoconfig (KOJAK-67)#49

Open
endrju19 wants to merge 2 commits into
mainfrom
kojak-67-require-tx-runner
Open

feat: require TransactionRunner in okapi-spring-boot autoconfig (KOJAK-67)#49
endrju19 wants to merge 2 commits into
mainfrom
kojak-67-require-tx-runner

Conversation

@endrju19
Copy link
Copy Markdown
Collaborator

Summary

Closes the silent JDBC auto-commit fallback in OutboxAutoConfiguration that allowed duplicate delivery across processor instances when no PlatformTransactionManager was on the classpath. Replaces it with a fail-fast bean factory plus a structured safety net for the multi-DataSource / multi-PTM / proxy edge cases discovered during review.

Why this matters

Under the prior code path, scheduler ticks ran in JDBC auto-commit when no PTM was registered. FOR UPDATE SKIP LOCKED released its row lock as soon as the SELECT statement returned — before updateAfterProcessing finished — so a second processor instance could claim the same entry and deliver it again. Zero error, zero log, invisible until horizontal scaling.

What changed

Production (okapi-spring-boot):

  • New @Bean okapiTransactionRunner factory — derives SpringTransactionRunner from PlatformTransactionManager (qualifier > getIfUnique > distinct error); honours a user-supplied @Bean TransactionTemplate when present; gated by @ConditionalOnExpression so publish-only deployments (both schedulers disabled) start without requiring a PTM.
  • New okapi.transaction-manager-qualifier property (mirror of okapi.datasource-qualifier), including spring-configuration-metadata.json entry for IDE autocomplete.
  • validatePtmDataSourceMatch — fails fast when an RTM-based PTM is bound to a different DataSource than okapi's outbox; logs an actionable WARN (with the actual resourceFactory class) for PTMs that don't expose a JDBC DataSource (JPA's EntityManagerFactory, Hibernate's SessionFactory, Exposed bridge, JTA); emits an INFO breadcrumb in single-DataSource non-RTM setups so future multi-DS migrations have a grep target; rewraps BeanNotOfRequiredTypeException from a mis-typed qualifier with okapi-specific context.
  • unwrapDataSource — iterative with visited-set; safely unwraps TransactionAwareDataSourceProxy / LazyConnectionDataSourceProxy chains before reference comparison; cannot hang on a self-referencing DelegatingDataSource.
  • OutboxProcessorScheduler / OutboxPurgerScheduler constructors take a non-null TransactionRunner (was nullable TransactionTemplate).

Tests:

  • OutboxAutoConfigurationTransactionRunnerTest — 19 slice tests (multi-PTM, qualifier resolution, RTM/non-RTM/JPA-RTM validation, property binding, publish-only mode, user-template honoured, etc.) + 6 unit tests for unwrapDataSource (cycle / null target / nested chain).
  • SpringObjectProviderSemanticsAssumptionsTest — pins Spring 7 ObjectProvider.getIfUnique / ResourceTransactionManager contract so framework upgrades surface loudly.
  • ExposedSpringBridgeEndToEndTest — Postgres testcontainer + Exposed SpringTransactionManager bridge, including a multi-instance amplification test (5 concurrent processors × 50 entries, mutation-verified to fail under the auto-commit regression) and a purger E2E pod the bridged PTM.
  • Existing tests updated to register a no-op TransactionRunner where they ran without a PTM, plus content-asserted WARN/INFO log assertions.

Build / infrastructure:

  • okapi-spring-boot: opt-in PIT mutation testing via -PenableMutationTesting=true.
  • okapi-integration-tests: spring7-transaction + assertj + spring-boot-test for the Exposed bridge end-to-end coverage.

Docs:

  • README migration note in the okapi-spring-boot section.

Test plan

  • ./gradlew check passes (Postgres + MySQL + Kafka testcontainers, all modules)
  • Multi-instance amplification test reliably catches the auto-commit regression (mutation-verified by replacing the autoconfig-built TR with a no-op TR — 50 entries delivered 4-5× each)
  • BUG C1 / BUG C2 demonstration tests written RED first, prove the underlying bugs existed before the fix, now serve as permanent regression guards
  • Property binding tests pin the okapi.transaction-manager-qualifier YAML contract
  • Liquibase E2E tests (issue Use dedicated okapi_databasechangelog tables to isolate library migration state #37 / KOJAK-80) still pass post-rebase
  • Branch rebased onto latest origin/main (currently at d9aa3be)

Out of scope (separate follow-up tickets)

These pre-existing concerns surfaced during review but are NOT part of this PR's scope — they live in okapi-core or predate this change:

  • Core OutboxScheduler.transactionRunner: TransactionRunner? = null is still nullable (Ktor / direct-core users can still hit the original auto-commit bug). Closing this is a breaking API change in core and warrants its own design discussion.
  • OutboxScheduler.tick() / OutboxPurger.tick() swallow-and-retry-forever — log+retry on every exception with no escalation or listener callback for consecutive failures.
  • OutboxStatus.from() throws on a corrupt status string, halting the entire pipeline.
  • SpringTransactionRunner.runInTransaction uses !! — NPE risk for nullable generic types (currently unreachable, future-fragile).

Each of the above is a self-contained problem worth its own ticket and dedicated test coverage.

endrju19 added 2 commits May 17, 2026 10:39
…K-67)

Eliminate the silent JDBC auto-commit fallback that caused duplicate
delivery across processor instances when no PlatformTransactionManager
was on the classpath. The autoconfig now produces a non-null
TransactionRunner via a fail-fast bean factory, or skips it entirely
when both scheduler and purger are disabled (publish-only deployments).

Production changes (okapi-spring-boot):
- new @bean okapiTransactionRunner — derives SpringTransactionRunner
  from PlatformTransactionManager (qualifier > getIfUnique > distinct
  error), gated by @ConditionalOnExpression on schedulers being enabled
- new okapi.transaction-manager-qualifier property (mirror of
  okapi.datasource-qualifier)
- validatePtmDataSourceMatch — fails fast for ResourceTransactionManager
  PTMs bound to a different DataSource; logs a WARN for PTMs that don't
  expose a JDBC DataSource (JPA's EntityManagerFactory, Hibernate's
  SessionFactory, Exposed bridge, JTA); falls through gracefully when
  proxy chains terminate early (cycle, null target) with a distinct WARN
- unwrapDataSource — iterative with visited-set so a self-referencing
  DelegatingDataSource doesn't spin the startup thread
- OutboxProcessorScheduler / OutboxPurgerScheduler — non-null
  TransactionRunner parameter (was nullable TransactionTemplate)
- spring-configuration-metadata.json entry for the new property

Tests:
- OutboxAutoConfigurationTransactionRunnerTest — 13 slice tests +
  6 unit tests for unwrapDataSource (cycle / null target / nested chain /
  TADP unwrap / etc.)
- SpringObjectProviderSemanticsAssumptionsTest — pins Spring 7
  ObjectProvider.getIfUnique / ResourceTransactionManager semantics so
  framework upgrades surface loudly
- ExposedSpringBridgeEndToEndTest — Postgres testcontainer + Exposed
  SpringTransactionManager bridge, including a multi-instance amplification
  test that detects regression to the auto-commit fallback
- existing tests updated to register a no-op TransactionRunner where
  schedulers ran without a PTM (Liquibase / scheduler / qualifier tests)

Build:
- okapi-spring-boot: opt-in PIT mutation testing via
  -PenableMutationTesting=true
- okapi-integration-tests: spring7-transaction + assertj + spring-boot-test
  for the Exposed bridge end-to-end coverage

README: short migration note in the okapi-spring-boot section.
…qualifier safety

Address review-round findings on top of the KOJAK-67 baseline:

- okapiTransactionRunner factory now reads ObjectProvider<TransactionTemplate>
  and reuses a unique user-supplied bean instead of silently building a fresh
  TransactionTemplate(ptm). User intent (timeout, propagation, isolation)
  flows into scheduler ticks.
- resolvePlatformTransactionManager catches BeanNotOfRequiredTypeException
  (typo into a DataSource bean name) and rewraps with an okapi-specific
  message instead of leaking Spring's generic error.
- validatePtmDataSourceMatch now reports the actual resourceFactory class
  in the WARN message (covers custom RTM implementations) and emits an INFO
  breadcrumb in the single-DataSource case (no qualifier set, non-RTM PTM)
  so future multi-DS migrations have a grep target.

Tests:
- 4 new slice tests in OutboxAutoConfigurationTransactionRunnerTest covering:
    * property binding: okapi.transaction-manager-qualifier kebab → camelCase
    * blank-string validation via Spring Binder
    * BeanNotOfRequiredTypeException → actionable error
    * user @bean TransactionTemplate honoured (reference identity check)
    * actual resourceFactory class surfaced in WARN
    * INFO breadcrumb in single-DS non-RTM scenario
- ExposedSpringBridgeEndToEndTest gains a purger-tick assertion under the
  Exposed bridge PTM (DELIVERED rows past retention are actually removed).
- Existing non-RTM WARN test extended with ListAppender-based content
  assertions (does not implement RTM / okapi.transaction-manager-qualifier
  mention) so the diagnostic cannot rot silently.

Production code adjustments:
- SpringTransactionRunner.transactionTemplate is now internal val so tests
  can assert reference identity with the user-supplied template.
- validatePtmDataSourceMatch computes resourceFactoryDescription once and
  reuses it across the WARN (multi-DS hint) and INFO (single-DS hint)
  branches.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant