Skip to content

Commit 5a3a77e

Browse files
authored
feat: config unification — Duration migration + OutboxPurgerConfig (KOJAK-61) (#16)
## Summary - Migrate all time-based config from `Long` with units in names (`intervalMs`, `retentionDays`, `intervalMinutes`) to `java.time.Duration` without units (`interval`, `retention`) — follows Spring Boot conventions - Create `OutboxPurgerConfig` value object in okapi-core (matching existing `OutboxSchedulerConfig` pattern) - Migrate `OutboxPurger` constructor from raw parameters to config object - Add `maxRetries` to `OutboxProcessorProperties` (was hardcoded to 5) - Replace `@Validated`/`@Min` with `require()` in `init` blocks everywhere - Update `spring-configuration-metadata.json` with new Duration-typed keys ### Breaking changes (pre-1.0) | Before | After | |--------|-------| | `OutboxSchedulerConfig(intervalMs = 1000)` | `OutboxSchedulerConfig(interval = Duration.ofSeconds(1))` | | `OutboxPurger(store, Duration.ofDays(7), 3600000, 100, clock)` | `OutboxPurger(store, OutboxPurgerConfig(...), clock)` | | `okapi.processor.interval-ms=1000` | `okapi.processor.interval=1s` | | `okapi.purger.retention-days=7` | `okapi.purger.retention=7d` | | `okapi.purger.interval-minutes=60` | `okapi.purger.interval=1h` | ## Test plan - [x] All okapi-core unit tests pass - [x] All okapi-spring-boot unit tests pass - [x] All integration tests pass - [x] `./gradlew clean ktlintFormat check` — BUILD SUCCESSFUL - [x] No leftover `intervalMs`/`retentionDays`/`intervalMinutes`/`@Validated`/`@Min` references
1 parent c5523d4 commit 5a3a77e

15 files changed

Lines changed: 248 additions & 119 deletions

File tree

okapi-core/src/main/kotlin/com/softwaremill/okapi/core/OutboxPurger.kt

Lines changed: 10 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,13 @@ package com.softwaremill.okapi.core
22

33
import org.slf4j.LoggerFactory
44
import java.time.Clock
5-
import java.time.Duration
65
import java.util.concurrent.Executors
76
import java.util.concurrent.ScheduledExecutorService
87
import java.util.concurrent.TimeUnit
98
import java.util.concurrent.atomic.AtomicBoolean
109

1110
/**
12-
* Periodically removes DELIVERED outbox entries older than [retentionDuration].
11+
* Periodically removes DELIVERED outbox entries older than [OutboxPurgerConfig.retention].
1312
*
1413
* Runs on a single daemon thread with explicit [start]/[stop] lifecycle.
1514
* [start] and [stop] are single-use -- the internal executor cannot be restarted after shutdown.
@@ -19,17 +18,9 @@ import java.util.concurrent.atomic.AtomicBoolean
1918
*/
2019
class OutboxPurger(
2120
private val outboxStore: OutboxStore,
22-
private val retentionDuration: Duration = Duration.ofDays(7),
23-
private val intervalMs: Long = 3_600_000L,
24-
private val batchSize: Int = 100,
21+
private val config: OutboxPurgerConfig = OutboxPurgerConfig(),
2522
private val clock: Clock = Clock.systemUTC(),
2623
) {
27-
init {
28-
require(retentionDuration > Duration.ZERO) { "retentionDuration must be positive, got: $retentionDuration" }
29-
require(intervalMs > 0) { "intervalMs must be positive, got: $intervalMs" }
30-
require(batchSize > 0) { "batchSize must be positive, got: $batchSize" }
31-
}
32-
3324
private val running = AtomicBoolean(false)
3425

3526
private val scheduler: ScheduledExecutorService =
@@ -41,11 +32,12 @@ class OutboxPurger(
4132
check(!scheduler.isShutdown) { "OutboxPurger cannot be restarted after stop()" }
4233
if (!running.compareAndSet(false, true)) return
4334
logger.info(
44-
"Outbox purger started [retention={}, interval={}ms, batchSize={}]",
45-
retentionDuration,
46-
intervalMs,
47-
batchSize,
35+
"Outbox purger started [retention={}, interval={}, batchSize={}]",
36+
config.retention,
37+
config.interval,
38+
config.batchSize,
4839
)
40+
val intervalMs = config.interval.toMillis()
4941
scheduler.scheduleWithFixedDelay(::tick, intervalMs, intervalMs, TimeUnit.MILLISECONDS)
5042
}
5143

@@ -62,14 +54,14 @@ class OutboxPurger(
6254

6355
private fun tick() {
6456
try {
65-
val cutoff = clock.instant().minus(retentionDuration)
57+
val cutoff = clock.instant().minus(config.retention)
6658
var totalDeleted = 0
6759
var batches = 0
6860
do {
69-
val deleted = outboxStore.removeDeliveredBefore(cutoff, batchSize)
61+
val deleted = outboxStore.removeDeliveredBefore(cutoff, config.batchSize)
7062
totalDeleted += deleted
7163
batches++
72-
} while (deleted == batchSize && batches < MAX_BATCHES_PER_TICK)
64+
} while (deleted == config.batchSize && batches < MAX_BATCHES_PER_TICK)
7365

7466
if (totalDeleted > 0) {
7567
logger.debug("Purged {} delivered entries in {} batches", totalDeleted, batches)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.softwaremill.okapi.core
2+
3+
import java.time.Duration
4+
5+
data class OutboxPurgerConfig(
6+
val retention: Duration = Duration.ofDays(7),
7+
val interval: Duration = Duration.ofHours(1),
8+
val batchSize: Int = 100,
9+
) {
10+
init {
11+
require(!retention.isZero && !retention.isNegative) { "retention must be positive, got: $retention" }
12+
require(!interval.isNegative && interval.toMillis() > 0) { "interval must be at least 1ms, got: $interval" }
13+
require(batchSize > 0) { "batchSize must be positive, got: $batchSize" }
14+
}
15+
}

okapi-core/src/main/kotlin/com/softwaremill/okapi/core/OutboxScheduler.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@ class OutboxScheduler(
3333
fun start() {
3434
check(!scheduler.isShutdown) { "OutboxScheduler cannot be restarted after stop()" }
3535
if (!running.compareAndSet(false, true)) return
36-
logger.info("Outbox processor started [interval={}ms, batchSize={}]", config.intervalMs, config.batchSize)
37-
scheduler.scheduleWithFixedDelay(::tick, 0L, config.intervalMs, TimeUnit.MILLISECONDS)
36+
logger.info("Outbox processor started [interval={}, batchSize={}]", config.interval, config.batchSize)
37+
scheduler.scheduleWithFixedDelay(::tick, 0L, config.interval.toMillis(), TimeUnit.MILLISECONDS)
3838
}
3939

4040
fun stop() {
Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
package com.softwaremill.okapi.core
22

3+
import java.time.Duration
4+
35
data class OutboxSchedulerConfig(
4-
val intervalMs: Long = 1_000L,
6+
val interval: Duration = Duration.ofSeconds(1),
57
val batchSize: Int = 10,
68
) {
79
init {
8-
require(intervalMs > 0) { "intervalMs must be positive, got: $intervalMs" }
10+
require(!interval.isNegative && interval.toMillis() > 0) { "interval must be at least 1ms, got: $interval" }
911
require(batchSize > 0) { "batchSize must be positive, got: $batchSize" }
1012
}
1113
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package com.softwaremill.okapi.core
2+
3+
import io.kotest.assertions.throwables.shouldThrow
4+
import io.kotest.core.spec.style.FunSpec
5+
import io.kotest.matchers.shouldBe
6+
import java.time.Duration
7+
import java.time.Duration.ofDays
8+
import java.time.Duration.ofHours
9+
import java.time.Duration.ofMinutes
10+
import java.time.Duration.ofNanos
11+
import java.time.Duration.ofSeconds
12+
13+
class OutboxPurgerConfigTest : FunSpec({
14+
15+
test("default config has valid values") {
16+
val config = OutboxPurgerConfig()
17+
config.retention shouldBe ofDays(7)
18+
config.interval shouldBe ofHours(1)
19+
config.batchSize shouldBe 100
20+
}
21+
22+
test("accepts custom valid values") {
23+
val config = OutboxPurgerConfig(
24+
retention = ofHours(12),
25+
interval = ofSeconds(30),
26+
batchSize = 50,
27+
)
28+
config.retention shouldBe ofHours(12)
29+
config.interval shouldBe ofSeconds(30)
30+
config.batchSize shouldBe 50
31+
}
32+
33+
test("rejects zero retention") {
34+
shouldThrow<IllegalArgumentException> {
35+
OutboxPurgerConfig(retention = Duration.ZERO)
36+
}
37+
}
38+
39+
test("rejects negative retention") {
40+
shouldThrow<IllegalArgumentException> {
41+
OutboxPurgerConfig(retention = ofDays(-1))
42+
}
43+
}
44+
45+
test("rejects zero interval") {
46+
shouldThrow<IllegalArgumentException> {
47+
OutboxPurgerConfig(interval = Duration.ZERO)
48+
}
49+
}
50+
51+
test("rejects negative interval") {
52+
shouldThrow<IllegalArgumentException> {
53+
OutboxPurgerConfig(interval = ofMinutes(-5))
54+
}
55+
}
56+
57+
test("rejects zero batchSize") {
58+
shouldThrow<IllegalArgumentException> {
59+
OutboxPurgerConfig(batchSize = 0)
60+
}
61+
}
62+
63+
test("rejects negative batchSize") {
64+
shouldThrow<IllegalArgumentException> {
65+
OutboxPurgerConfig(batchSize = -10)
66+
}
67+
}
68+
69+
test("rejects sub-millisecond interval") {
70+
shouldThrow<IllegalArgumentException> {
71+
OutboxPurgerConfig(interval = ofNanos(1))
72+
}
73+
}
74+
})

okapi-core/src/test/kotlin/com/softwaremill/okapi/core/OutboxPurgerTest.kt

Lines changed: 43 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import io.kotest.assertions.throwables.shouldThrow
44
import io.kotest.core.spec.style.FunSpec
55
import io.kotest.matchers.shouldBe
66
import java.time.Clock
7-
import java.time.Duration
7+
import java.time.Duration.ofDays
8+
import java.time.Duration.ofMillis
9+
import java.time.Duration.ofMinutes
810
import java.time.Instant
911
import java.time.ZoneOffset
1012
import java.util.concurrent.CountDownLatch
@@ -29,17 +31,19 @@ class OutboxPurgerTest : FunSpec({
2931

3032
val purger = OutboxPurger(
3133
outboxStore = store,
32-
retentionDuration = Duration.ofDays(7),
33-
intervalMs = 50,
34-
batchSize = 100,
34+
config = OutboxPurgerConfig(
35+
retention = ofDays(7),
36+
interval = ofMillis(50),
37+
batchSize = 100,
38+
),
3539
clock = fixedClock,
3640
)
3741

3842
purger.start()
3943
latch.await(2, TimeUnit.SECONDS) shouldBe true
4044
purger.stop()
4145

42-
capturedCutoff shouldBe fixedNow.minus(Duration.ofDays(7))
46+
capturedCutoff shouldBe fixedNow.minus(ofDays(7))
4347
capturedLimit shouldBe 100
4448
}
4549

@@ -49,14 +53,18 @@ class OutboxPurgerTest : FunSpec({
4953
val store = stubStore(onRemove = { _, _ ->
5054
val count = callCount.incrementAndGet()
5155
if (count == 1) {
52-
100 // first batch: full
56+
100
5357
} else {
5458
latch.countDown()
55-
42 // second batch: partial, loop stops
59+
42
5660
}
5761
})
5862

59-
val purger = OutboxPurger(store, intervalMs = 50, batchSize = 100, clock = fixedClock)
63+
val purger = OutboxPurger(
64+
outboxStore = store,
65+
config = OutboxPurgerConfig(interval = ofMillis(50), batchSize = 100),
66+
clock = fixedClock,
67+
)
6068
purger.start()
6169
latch.await(2, TimeUnit.SECONDS) shouldBe true
6270
purger.stop()
@@ -70,10 +78,14 @@ class OutboxPurgerTest : FunSpec({
7078
val store = stubStore(onRemove = { _, _ ->
7179
val count = callCount.incrementAndGet()
7280
if (count >= 10) latch.countDown()
73-
100 // always full, would loop forever without guard
81+
100
7482
})
7583

76-
val purger = OutboxPurger(store, intervalMs = 50, batchSize = 100, clock = fixedClock)
84+
val purger = OutboxPurger(
85+
outboxStore = store,
86+
config = OutboxPurgerConfig(interval = ofMillis(50), batchSize = 100),
87+
clock = fixedClock,
88+
)
7789
purger.start()
7890
latch.await(2, TimeUnit.SECONDS) shouldBe true
7991
purger.stop()
@@ -91,7 +103,11 @@ class OutboxPurgerTest : FunSpec({
91103
0
92104
})
93105

94-
val purger = OutboxPurger(store, intervalMs = 50, batchSize = 100, clock = fixedClock)
106+
val purger = OutboxPurger(
107+
outboxStore = store,
108+
config = OutboxPurgerConfig(interval = ofMillis(50), batchSize = 100),
109+
clock = fixedClock,
110+
)
95111
purger.start()
96112
latch.await(2, TimeUnit.SECONDS) shouldBe true
97113
purger.stop()
@@ -108,9 +124,13 @@ class OutboxPurgerTest : FunSpec({
108124
0
109125
})
110126

111-
val purger = OutboxPurger(store, intervalMs = 50, batchSize = 100, clock = fixedClock)
127+
val purger = OutboxPurger(
128+
outboxStore = store,
129+
config = OutboxPurgerConfig(interval = ofMillis(50), batchSize = 100),
130+
clock = fixedClock,
131+
)
132+
purger.start()
112133
purger.start()
113-
purger.start() // second start should be ignored
114134
latch.await(2, TimeUnit.SECONDS) shouldBe true
115135
purger.stop()
116136

@@ -119,7 +139,11 @@ class OutboxPurgerTest : FunSpec({
119139

120140
test("isRunning transitions") {
121141
val store = stubStore(onRemove = { _, _ -> 0 })
122-
val purger = OutboxPurger(store, intervalMs = 60_000, batchSize = 100, clock = fixedClock)
142+
val purger = OutboxPurger(
143+
outboxStore = store,
144+
config = OutboxPurgerConfig(interval = ofMinutes(1), batchSize = 100),
145+
clock = fixedClock,
146+
)
123147

124148
purger.isRunning() shouldBe false
125149
purger.start()
@@ -128,26 +152,12 @@ class OutboxPurgerTest : FunSpec({
128152
purger.isRunning() shouldBe false
129153
}
130154

131-
test("constructor rejects invalid batchSize") {
132-
shouldThrow<IllegalArgumentException> {
133-
OutboxPurger(stubStore(), batchSize = 0, clock = fixedClock)
134-
}
135-
}
136-
137-
test("constructor rejects zero retentionDuration") {
138-
shouldThrow<IllegalArgumentException> {
139-
OutboxPurger(stubStore(), retentionDuration = Duration.ZERO, clock = fixedClock)
140-
}
141-
}
142-
143-
test("constructor rejects negative intervalMs") {
144-
shouldThrow<IllegalArgumentException> {
145-
OutboxPurger(stubStore(), intervalMs = -1, clock = fixedClock)
146-
}
147-
}
148-
149155
test("start after stop throws") {
150-
val purger = OutboxPurger(stubStore(), intervalMs = 60_000, batchSize = 100, clock = fixedClock)
156+
val purger = OutboxPurger(
157+
outboxStore = stubStore(),
158+
config = OutboxPurgerConfig(interval = ofMinutes(1), batchSize = 100),
159+
clock = fixedClock,
160+
)
151161

152162
purger.start()
153163
purger.stop()

okapi-core/src/test/kotlin/com/softwaremill/okapi/core/OutboxSchedulerConfigTest.kt

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,30 +3,34 @@ package com.softwaremill.okapi.core
33
import io.kotest.assertions.throwables.shouldThrow
44
import io.kotest.core.spec.style.FunSpec
55
import io.kotest.matchers.shouldBe
6+
import java.time.Duration
7+
import java.time.Duration.ofMillis
8+
import java.time.Duration.ofNanos
9+
import java.time.Duration.ofSeconds
610

711
class OutboxSchedulerConfigTest : FunSpec({
812

913
test("default config has valid values") {
1014
val config = OutboxSchedulerConfig()
11-
config.intervalMs shouldBe 1_000L
15+
config.interval shouldBe ofSeconds(1)
1216
config.batchSize shouldBe 10
1317
}
1418

1519
test("accepts custom valid values") {
16-
val config = OutboxSchedulerConfig(intervalMs = 500, batchSize = 50)
17-
config.intervalMs shouldBe 500L
20+
val config = OutboxSchedulerConfig(interval = ofMillis(500), batchSize = 50)
21+
config.interval shouldBe ofMillis(500)
1822
config.batchSize shouldBe 50
1923
}
2024

21-
test("rejects zero intervalMs") {
25+
test("rejects zero interval") {
2226
shouldThrow<IllegalArgumentException> {
23-
OutboxSchedulerConfig(intervalMs = 0)
27+
OutboxSchedulerConfig(interval = Duration.ZERO)
2428
}
2529
}
2630

27-
test("rejects negative intervalMs") {
31+
test("rejects negative interval") {
2832
shouldThrow<IllegalArgumentException> {
29-
OutboxSchedulerConfig(intervalMs = -1)
33+
OutboxSchedulerConfig(interval = ofMillis(-1))
3034
}
3135
}
3236

@@ -41,4 +45,10 @@ class OutboxSchedulerConfigTest : FunSpec({
4145
OutboxSchedulerConfig(batchSize = -5)
4246
}
4347
}
48+
49+
test("rejects sub-millisecond interval") {
50+
shouldThrow<IllegalArgumentException> {
51+
OutboxSchedulerConfig(interval = ofNanos(1))
52+
}
53+
}
4454
})

0 commit comments

Comments
 (0)