Skip to content

feat(scheduler): Add multithreaded scheduler#73

Open
rawo wants to merge 1 commit into
mainfrom
feat/kojak-77-multithreaded-scheduler
Open

feat(scheduler): Add multithreaded scheduler#73
rawo wants to merge 1 commit into
mainfrom
feat/kojak-77-multithreaded-scheduler

Conversation

@rawo

@rawo rawo commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements KOJAK-77: adds a concurrency knob to OutboxSchedulerConfig, letting a single scheduler tick fan out to N parallel workers instead of processing one batch at a time. Each worker claims its own disjoint batch via the existing FOR UPDATE SKIP LOCKED, so no app-level coordination is needed — cross-worker and cross-instance safety is free.

  • OutboxSchedulerConfig: new concurrency: Int (1-256, validated) and workerExecutorFactory: (Int) -> ExecutorService, with defaultPlatformPool (named outbox-worker-N daemon threads) and virtualThreadPool companion factory shortcuts.
  • OutboxScheduler: fans out each tick to concurrency workers via the configured executor, blocking until all complete before the next interval (so ticks never overlap). concurrency=1 (default) skips the executor entirely — zero overhead, byte-for-byte the original single-worker behavior.
  • okapi-spring-boot: okapi.processor.concurrency property wired through with the same validation/startup-failure behavior as the existing processor properties.

Benchmark & key finding

New OutboxSchedulerConcurrencyBenchmark (JMH) measures executorType ∈ {platform, virtual} × concurrency ∈ {1, 4, 16, 64} against Kafka's deliverBatch transport (HTTP deliverBatch isn't implemented yet — KOJAK-74 — so this ticket, formally blocked on it, benchmarks against Kafka instead).

concurrency speedup vs. concurrency=1
4 3.6×
16 6.2×
64 6.6× (diminishing)

The ticket's virtual-thread hypothesis did not hold, and is reported as such rather than adjusted to fit: virtual threads showed no advantage over platform threads at any tested concurrency, even on JDK 25 (JEP 491, so the usual "pre-JDK-24 pinning" explanation doesn't apply) — platform was tied-or-ahead throughout. Full writeup, including why (workload never oversubscribes the platform pool at these sizes), the round-count artifact at concurrency=64, and the tuning recommendation: benchmarks/results-postopt-KOJAK-77.md.

Test plan

  • Unit: OutboxSchedulerConfigTest (concurrency validation 1-256, defaultPlatformPool/virtualThreadPool factory behavior), OutboxSchedulerTest (fan-out dispatches exactly concurrency workers per tick, concurrency=1 never touches workerExecutorFactory, one worker's exception doesn't block the others)
  • Integration: ConcurrentClaimTests extended with a real OutboxScheduler(concurrency=4) against both Postgres and MySQL — disjoint claims across workers and ticks, zero delivery amplification
  • okapi-spring-boot: property binding + invalid-value startup-failure tests
  • ./gradlew ktlintCheck clean, full ./gradlew build green

Not done / deliberately out of scope

  • HTTP-transport benchmark numbers (blocked on KOJAK-74)
  • Splitting processNext()'s single transaction to shorten lock hold time during delivery I/O — tracked separately as KOJAK-79 (decision-pending; needs actual lock-contention metrics from a high-concurrency run before deciding implement-vs-reject, which this PR's benchmark didn't specifically capture)

Refs: KOJAK-77 (blocked-by KOJAK-73 ✅ done, KOJAK-74 ⏳ To Do; blocks KOJAK-79)

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds configurable parallelism to the outbox scheduler so a single tick can execute multiple independent processNext() workers concurrently (disjoint row claims via FOR UPDATE SKIP LOCKED), and wires the knob through Spring Boot with tests/benchmarks/documentation.

Changes:

  • Introduces OutboxSchedulerConfig.concurrency (1–256) and workerExecutorFactory with platform/virtual thread pool helpers.
  • Updates OutboxScheduler to fan out work per tick and wait for all workers before the next interval.
  • Adds JMH benchmark + results writeup, updates docs, and extends integration/unit tests for concurrency behavior.

Reviewed changes

Copilot reviewed 13 out of 13 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
README.md Documents scheduler concurrency benchmark results and tuning guidance.
okapi-spring-boot/src/test/kotlin/com/softwaremill/okapi/springboot/OutboxProcessorAutoConfigurationTest.kt Adds Spring property binding coverage for okapi.processor.concurrency and invalid-value startup failure.
okapi-spring-boot/src/main/kotlin/com/softwaremill/okapi/springboot/OutboxProcessorProperties.kt Adds concurrency property with validation.
okapi-spring-boot/src/main/kotlin/com/softwaremill/okapi/springboot/OutboxAutoConfiguration.kt Wires Spring properties into OutboxSchedulerConfig(concurrency=...).
okapi-integration-tests/src/test/kotlin/com/softwaremill/okapi/test/concurrency/ConcurrentClaimTests.kt Adds integration test running real OutboxScheduler(concurrency=4) ensuring no amplification and disjoint claims.
okapi-core/src/test/kotlin/com/softwaremill/okapi/core/OutboxSchedulerTest.kt Adds unit tests for fan-out, zero-overhead concurrency=1, and worker exception isolation.
okapi-core/src/test/kotlin/com/softwaremill/okapi/core/OutboxSchedulerConfigTest.kt Adds validation tests and executor factory behavior tests.
okapi-core/src/main/kotlin/com/softwaremill/okapi/core/OutboxSchedulerConfig.kt Adds concurrency + executor factory to scheduler config and companion factories.
okapi-core/src/main/kotlin/com/softwaremill/okapi/core/OutboxScheduler.kt Implements per-tick worker fan-out and worker pool shutdown.
okapi-benchmarks/src/jmh/kotlin/com/softwaremill/okapi/benchmarks/OutboxSchedulerConcurrencyBenchmark.kt Adds JMH benchmark for scheduler fan-out using platform vs virtual executors.
benchmarks/scheduler-concurrency.json Stores raw JMH results for the new benchmark.
benchmarks/results-postopt-KOJAK-77.md Adds benchmark analysis/writeup for KOJAK-77.
benchmarks/README.md Documents the new benchmark and interpretation caveats.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 74 to +82
private fun tick() {
val pool = workers
if (pool == null) {
processBatch()
} else {
val futures = (1..config.concurrency).map { pool.submit(::processBatch) }
futures.forEach { it.get() }
}
}
Comment on lines +44 to +46
private val workers: ExecutorService? =
if (config.concurrency > 1) config.workerExecutorFactory(config.concurrency) else null

Comment on lines +18 to +22
/**
* Builds the [ExecutorService] used to run workers when [concurrency] > 1. Defaults to
* a fixed platform-thread pool ([defaultPlatformPool]); pass [virtualThreadPool] for
* high concurrency (roughly 32+) to avoid platform-thread context-switch overhead.
*/
Comment on lines +96 to +106
fun drainAll() {
var remaining = TOTAL_ENTRIES
while (remaining > 0) {
val futures = (1..concurrency).map {
executor.submit(Callable { postgres.jdbc.withTransaction { processor.processNext(BATCH_SIZE) } })
}
val processedThisRound = futures.sumOf { it.get() }
if (processedThisRound == 0) break
remaining -= processedThisRound
}
}
@endrju19 endrju19 self-assigned this Jul 17, 2026
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.

3 participants