Skip to content

feat: OutboxPurger v2 — configurability, reliability, batched delete (KOJAK-56)#11

Merged
endrju19 merged 15 commits intomainfrom
feat102
Apr 1, 2026
Merged

feat: OutboxPurger v2 — configurability, reliability, batched delete (KOJAK-56)#11
endrju19 merged 15 commits intomainfrom
feat102

Conversation

@endrju19
Copy link
Copy Markdown
Collaborator

Summary

Makes OutboxPurger a production-ready, configurable component — establishing a pattern to replicate for OutboxScheduler/ProcessorScheduler.

  • Batched delete with FOR UPDATE SKIP LOCKED (Postgres subquery, MySQL derived table wrapper) — prevents full table scans and lock contention on large tables
  • Error handlingtry/catch in tick() prevents ScheduledExecutorService from silently dying on exceptions
  • Configurability@ConfigurationProperties binding from application.yml (okapi.purger.retention-days, interval-minutes, batch-size, enabled)
  • SmartLifecycle — replaces SmartInitializingSingleton + DisposableBean for phase-ordered startup/shutdown
  • Database index(status, last_attempt) composite index for efficient purge queries
  • SLF4J logging — INFO on start/stop, DEBUG per tick, ERROR on failures

Breaking change

OutboxStore.removeDeliveredBefore(Instant)removeDeliveredBefore(Instant, Int): Int — accepts batch limit, returns count deleted. Pre-1.0, no external consumers.

Key design decisions

Decision Rationale
FOR UPDATE SKIP LOCKED in purge Multi-instance safety without distributed locks
Manual spring-configuration-metadata.json KAPT maintenance mode, KSP unsupported by Spring (2026)
implementation for SLF4J Types don't leak to public API, consistent with Exposed
MAX_BATCHES_PER_TICK = 10 Safety guard: max 1000 entries per tick, prevents DB monopolization
check(!scheduler.isShutdown) Prevents cryptic RejectedExecutionException on restart after stop

JIRA

Test plan

  • 11 unit tests in OutboxPurgerTest (batch loop, error recovery, double-start, restart guard, validation)
  • 8 Spring integration tests in OutboxPurgerAutoConfigurationTest (property binding, conditional beans, lifecycle, stop callback)
  • MySQL removeDeliveredBefore with limit test (Testcontainers)
  • E2E tests pass (Postgres + WireMock, MySQL + WireMock)
  • ktlint clean

endrju19 added 14 commits March 26, 2026 14:28
…and return count

Breaking change: removeDeliveredBefore(Instant) -> removeDeliveredBefore(Instant, Int): Int
Store implementations temporarily ignore limit parameter.
…nding and conditional beans

Also fix nested PostgresStoreConfiguration to use proxyBeanMethods=false
(Kotlin classes are final; CGLIB proxying requires non-final classes)
and add assertj-core to version catalog (required by spring-boot-test's
ApplicationContextRunner).
The enabled flag is handled by @ConditionalOnProperty on the bean factory.
Having it also in the properties class was redundant — no code read it.
…, KDoc, test coverage

- OutboxPurger.start(): check(!scheduler.isShutdown) prevents restart after stop
- OutboxPurgerScheduler.stop(callback): try-finally guarantees callback invocation
- OutboxStore.removeDeliveredBefore: verbose KDoc with @param/@return contract
- OutboxAutoConfiguration.outboxStore(): restore concrete PostgresOutboxStore return type
- OutboxPurgerTest: assert batchSize is forwarded as limit, test start-after-stop
…ies, improve test assertions

- Rename OkapiPurgerProperties -> OutboxPurgerProperties (consistent with Outbox* naming convention)
- Add latch.await() shouldBe true assertions for better timeout diagnostics
- Add stop callback invocation test for SmartLifecycle contract

@ConfigurationProperties(prefix = "okapi.purger")
@Validated
data class OutboxPurgerProperties(
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we add 'enabled' here?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

here it would be a dead property - nothing really reads it. 'enabled' is handled in @ConditionalOnProperty on outboxPurgerScheduler bean

@endrju19 endrju19 merged commit 95885f7 into main Apr 1, 2026
7 checks passed
@endrju19 endrju19 deleted the feat102 branch April 1, 2026 08:09
endrju19 added a commit that referenced this pull request Apr 1, 2026
…ifecycle (KOJAK-62) (#14)

## Summary

Apply the same production-ready pattern from KOJAK-56 (OutboxPurger v2)
to OutboxScheduler and OutboxProcessorScheduler:

- **Fix silent death bug**: `OutboxScheduler.tick()` had no try/catch —
`ScheduledExecutorService` silently stops on uncaught exception. Added
`catch(Exception)` with SLF4J error logging.
- **Add lifecycle guards**: `AtomicBoolean` running flag prevents
double-start, `check(!scheduler.isShutdown)` prevents restart-after-stop
with a clear error message, `isRunning()` method.
- **Extract config value object**: `OutboxSchedulerConfig` data class
with `require()` validation in init block (make illegal states
unrepresentable).
- **Migrate to SmartLifecycle**: Replace `SmartInitializingSingleton +
DisposableBean` with `SmartLifecycle`. Phase ordering: processor
(`MAX_VALUE - 2048`) starts before purger (`MAX_VALUE - 1024`) and stops
after it. `stop(callback)` with `try-finally`.
- **Spring properties binding**: `OutboxProcessorProperties` with
`@ConfigurationProperties(prefix = "okapi.processor")`, `@Validated`,
`@field:Min(1)`. `@ConditionalOnProperty` for `enabled` toggle.
- **Database index**: `(status, created_at)` composite index for
`claimPending()` query performance (Postgres + MySQL).
- **Configuration metadata**: Processor properties added to
`spring-configuration-metadata.json`.

### Configuration

```yaml
okapi:
  processor:
    enabled: true       # default
    interval-ms: 1000   # default
    batch-size: 10      # default
```

### Commits (8)

| Commit | Scope |
|--------|-------|
| `feat(core): add OutboxSchedulerConfig value object with validation` |
okapi-core |
| `feat(core): rewrite OutboxScheduler with error handling, guards,
logging` | okapi-core |
| `feat(spring): add OutboxProcessorProperties` | okapi-spring-boot |
| `feat(spring): migrate OutboxProcessorScheduler to SmartLifecycle` |
okapi-spring-boot |
| `feat(spring): bind OutboxProcessorProperties in
OutboxAutoConfiguration` | okapi-spring-boot |
| `test(spring): add OutboxProcessorAutoConfigurationTest` |
okapi-spring-boot |
| `feat(db): add index (status, created_at) for processor claimPending
query` | okapi-postgres, okapi-mysql |
| `feat(spring): add processor properties to
spring-configuration-metadata.json` | okapi-spring-boot |

## Test plan

- [x] `OutboxSchedulerConfigTest` — 6 tests (defaults, custom values,
validation)
- [x] `OutboxSchedulerTest` — 7 tests (batchSize forwarding, error
recovery, double-start, isRunning transitions, restart-after-stop,
transactionRunner wrapping, no transactionRunner)
- [x] `OutboxProcessorAutoConfigurationTest` — 7 tests (bean creation,
disabled toggle, properties binding, defaults, SmartLifecycle isRunning,
validation, stop callback)
- [x] All existing tests pass (purger, publisher, E2E)
- [x] `./gradlew clean check` — BUILD SUCCESSFUL
- [x] `./gradlew ktlintCheck` — clean

## Related

- Depends on: #11 (KOJAK-56, OutboxPurger v2) — base branch is `feat102`
- Related: KOJAK-61 (OutboxPurgerConfig extraction — separate task, uses
`OutboxSchedulerConfig` as reference pattern)
- JIRA: [KOJAK-62](https://softwaremill.atlassian.net/browse/KOJAK-62)
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.

2 participants