feat(scheduler): Add multithreaded scheduler#73
Conversation
…d optional virtual threads
There was a problem hiding this comment.
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) andworkerExecutorFactorywith platform/virtual thread pool helpers. - Updates
OutboxSchedulerto 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.
…d optional virtual threads
…duler' into feat/kojak-77-multithreaded-scheduler # Conflicts: # okapi-benchmarks/src/jmh/kotlin/com/softwaremill/okapi/benchmarks/OutboxSchedulerConcurrencyBenchmark.kt # okapi-core/src/main/kotlin/com/softwaremill/okapi/core/OutboxScheduler.kt # okapi-core/src/main/kotlin/com/softwaremill/okapi/core/OutboxSchedulerConfig.kt
Signed-off-by: Rafał Wokacz <rafal.wokacz@gmail.com>
…urency benchmarks tests
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Rafał Wokacz <rafal.wokacz@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Rafał Wokacz <rafal.wokacz@gmail.com>
…pted Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Rafał Wokacz <rafal.wokacz@gmail.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
okapi-core/src/main/kotlin/com/softwaremill/okapi/core/OutboxScheduler.kt:101
- The warning/error text in shutdownAndAwait() is misleading:
- The timeout warning says "worker threads" even though this helper is used for both the scheduler executor and the worker executor.
- The InterruptedException branch logs "Executor terminated" but the executor wasn't terminated; the shutdown wait was interrupted.
Consider updating these messages to be accurate and include the executor type for troubleshooting.
logger.warn("Executor did not terminate after shutdownNow(); some worker threads may still be running")
}
} catch (e: InterruptedException) {
Thread.currentThread().interrupt()
logger.warn("Interrupted while awaiting executor termination; forcing shutdownNow()", e)
…ption thrown at shutdown Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Rafał Wokacz <rafal.wokacz@gmail.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
okapi-core/src/main/kotlin/com/softwaremill/okapi/core/OutboxScheduler.kt:165
awaitWorkerswallows interrupts during normal operation to avoid overlapping ticks, but it currently clears the thread's interrupt status permanently. This can hide cancellation signals and makes the scheduler thread violate the common contract of preserving interrupts. Record the interrupt and restore it after the worker completes (while still continuing to wait).
} catch (e: InterruptedException) {
if (!running.get()) {
Thread.currentThread().interrupt()
logger.warn("Interrupted while awaiting outbox worker during shutdown", e)
return
Summary
Implements KOJAK-77: adds a
concurrencyknob toOutboxSchedulerConfig, 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 existingFOR UPDATE SKIP LOCKED, so no app-level coordination is needed — cross-worker and cross-instance safety is free.OutboxSchedulerConfig: newconcurrency: Int(1-256, validated) andworkerExecutorFactory: (Int) -> ExecutorService, withdefaultPlatformPool(namedoutbox-worker-Ndaemon threads) andvirtualThreadPoolcompanion factory shortcuts.OutboxScheduler: fans out each tick toconcurrencyworkers 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.concurrencyproperty wired through with the same validation/startup-failure behavior as the existing processor properties.Benchmark & key finding
New
OutboxSchedulerConcurrencyBenchmark(JMH) measuresexecutorType ∈ {platform, virtual}×concurrency ∈ {1, 4, 16, 64}against Kafka'sdeliverBatchtransport (HTTPdeliverBatchisn't implemented yet — KOJAK-74 — so this ticket, formally blocked on it, benchmarks against Kafka instead).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
OutboxSchedulerConfigTest(concurrency validation 1-256,defaultPlatformPool/virtualThreadPoolfactory behavior),OutboxSchedulerTest(fan-out dispatches exactlyconcurrencyworkers per tick,concurrency=1never touchesworkerExecutorFactory, one worker's exception doesn't block the others)ConcurrentClaimTestsextended with a realOutboxScheduler(concurrency=4)against both Postgres and MySQL — disjoint claims across workers and ticks, zero delivery amplificationokapi-spring-boot: property binding + invalid-value startup-failure tests./gradlew ktlintCheckclean, full./gradlew buildgreenNot done / deliberately out of scope
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)