Skip to content

Commit b46dfa1

Browse files
authored
build: enable build cache and parallel execution, and harden async timing tests (#103)
PR: #103
1 parent ffea10a commit b46dfa1

7 files changed

Lines changed: 121 additions & 31 deletions

File tree

CLAUDE.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,9 +85,12 @@ Layered, from the bottom up:
8585

8686
- **Java 8 bytecode everywhere except** `sdk-transport-jdkhttp` (11) and `sdk-async-virtualthreads` (21).
8787
Avoid `InputStream.transferTo` (9+), `Thread.threadId()` (19+), records, sealed `permits` in Java-8
88-
modules. A module that genuinely needs a newer JDK must override **both** `jvmToolchain(N)` **and**
88+
modules. A module that genuinely needs a newer JDK must override **all three** of `jvmToolchain(N)`,
89+
the `java { sourceCompatibility / targetCompatibility = VERSION_N + toolchain }` block, and
8990
`compilerOptions { jvmTarget.set(JvmTarget.JVM_N) }` in its own build script — overriding only the
90-
toolchain produces Java-8-format bytecode referencing newer stdlib symbols (`NoSuchMethodError` on JDK 8).
91+
toolchain produces Java-8-format bytecode referencing newer stdlib symbols (`NoSuchMethodError` on JDK 8),
92+
and omitting the `java {}` block trips Gradle's `compileJava`/`compileKotlin` JVM-target validation. See
93+
`docs/architecture.md` (Cross-Compile Toolchain Discipline).
9194
- **MIT license header in every source file.** Each `.kt`, `.java`, and `.kts` file starts with the 6-line
9295
`Copyright (c) 2026 dexpace and Omar Aljarrah` / `SPDX-License-Identifier: MIT` block — copy it from any
9396
existing file when creating new ones. Nothing enforces this automatically; it is a review convention.

docs/architecture.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ concerns.
2626
- [Cross-Cutting Design Decisions](#cross-cutting-design-decisions)
2727
- [Zero Dependencies](#zero-dependencies)
2828
- [JDK 8 Compatibility](#jdk-8-compatibility)
29+
- [Cross-Compile Toolchain Discipline](#cross-compile-toolchain-discipline)
2930
- [Immutability and Builders](#immutability-and-builders)
3031
- [Virtual Thread Safety](#virtual-thread-safety)
3132
- [Internal Visibility](#internal-visibility)
@@ -512,6 +513,58 @@ All code targets Java 8 bytecode (`jvmTarget = "1.8"`). Specific implications:
512513
- `ReentrantLock` (Java 5+) replaces `synchronized` for future-proofing with virtual threads
513514
- No `java.net.http.HttpClient` (Java 11+); the `HttpClient` interface is transport-agnostic
514515

516+
### Cross-Compile Toolchain Discipline
517+
518+
Most modules compile against Java 8 bytecode, but two need a newer JDK: `sdk-transport-jdkhttp`
519+
targets 11 (`java.net.http.HttpClient` was finalised in JEP 321) and `sdk-async-virtualthreads`
520+
targets 21 (virtual threads). Each of those modules raises its target by overriding **three**
521+
things in its own build script:
522+
523+
```kotlin
524+
kotlin {
525+
jvmToolchain(21) // which JDK compiles the module
526+
}
527+
528+
java {
529+
sourceCompatibility = JavaVersion.VERSION_21 // Java-source level
530+
targetCompatibility = JavaVersion.VERSION_21 // bytecode version `compileJava` emits
531+
toolchain {
532+
languageVersion.set(JavaLanguageVersion.of(21))
533+
}
534+
}
535+
536+
tasks.withType<KotlinCompile>().configureEach {
537+
compilerOptions {
538+
jvmTarget.set(JvmTarget.JVM_21) // bytecode version `compileKotlin` emits
539+
}
540+
}
541+
```
542+
543+
(`sdk-transport-jdkhttp` does the same with `11`/`VERSION_11`/`JVM_11`.) **All three** overrides
544+
are mandatory. The `java {}` block governs `compileJava` and keeps Gradle's JVM-target validation
545+
between `compileJava` and `compileKotlin` happy; a module that sets only the Kotlin toolchain and
546+
`jvmTarget` but omits the `java {}` block will trip that validation or compile its Java sources at
547+
the wrong level.
548+
549+
The root build registers a `plugins.withId("org.jetbrains.kotlin.jvm")` callback that sets
550+
`jvmTarget` to `JVM_1_8` for every Kotlin module by default. A module that bumps only the
551+
toolchain — say to JDK 21 — but leaves `jvmTarget` at the inherited `1.8` will compile *against*
552+
the JDK 21 standard library while *emitting* Java-8-format class files. The result links fine on
553+
the build machine but references methods that do not exist on a Java 8 runtime, so a downstream
554+
Java 8 consumer fails at call time with `NoSuchMethodError`. Setting `jvmTarget` to match the
555+
toolchain makes the Kotlin compiler reject newer-than-target stdlib references at compile time
556+
instead, turning that runtime failure into a build error.
557+
558+
This per-module override is the current, deliberately safe arrangement. The discipline matters
559+
under a hypothetical future consolidation onto a single newer toolchain (for build speed, or to
560+
sidestep the detekt-1.23.x crash on JDK 25+). If every module were compiled by, say, JDK 17 while
561+
the Java-8-target modules kept `jvmTarget = JVM_1_8`, those modules would again be compiling
562+
against a newer stdlib than they emit bytecode for. Guarding that arrangement requires a
563+
`--release 8` / `-Xjdk-release=8` flag on the Java-8-target modules so the compiler bounds the
564+
*visible* API to Java 8, not just the bytecode version. As long as each module that needs a newer
565+
runtime carries its own matched `jvmToolchain` + `jvmTarget` pair, no `--release` guard is needed;
566+
adopt one only if the toolchain is ever unified.
567+
515568
### Immutability and Builders
516569

517570
All HTTP model classes follow the same pattern:

gradle.properties

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,6 @@
11
kotlin.code.style=official
2+
3+
# Reuse task outputs across builds and run independent module tasks concurrently to cut build time.
4+
# (org.gradle.configuration-cache is intentionally left out and tracked separately.)
5+
org.gradle.caching=true
6+
org.gradle.parallel=true

sdk-async-coroutines/src/test/kotlin/org/dexpace/sdk/async/coroutines/CoroutinesTest.kt

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,13 @@ import kotlin.test.assertEquals
3939
import kotlin.test.assertFails
4040
import kotlin.test.assertTrue
4141

42+
// Failsafe deadline for awaiting an operation that is expected to complete promptly. It exists only
43+
// to stop a genuinely-stuck test from hanging forever, so it is kept generous: a healthy test
44+
// returns the instant the awaited work finishes and never approaches this bound, even on a loaded
45+
// CI host running modules in parallel.
46+
private const val FAILSAFE_TIMEOUT_SECONDS = 30L
47+
private const val FAILSAFE_TIMEOUT_MILLIS = FAILSAFE_TIMEOUT_SECONDS * 1000L
48+
4249
class CoroutinesTest {
4350
@Test
4451
fun `suspend execute awaits the underlying future`() =
@@ -105,7 +112,7 @@ class CoroutinesTest {
105112
job.cancel()
106113
// `cancelled` is completed by the future's listener after kotlinx-coroutines-jdk8's
107114
// `await()` calls `cancel(true)` on the underlying CompletableFuture.
108-
cancelled.get(2, TimeUnit.SECONDS)
115+
cancelled.get(FAILSAFE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
109116
scope.cancel()
110117
}
111118

@@ -121,7 +128,7 @@ class CoroutinesTest {
121128
delay(5)
122129
42
123130
}
124-
assertEquals(42, future.get(2, TimeUnit.SECONDS))
131+
assertEquals(42, future.get(FAILSAFE_TIMEOUT_SECONDS, TimeUnit.SECONDS))
125132
} finally {
126133
scope.cancel()
127134
}
@@ -134,7 +141,7 @@ class CoroutinesTest {
134141
try {
135142
val syncClient = HttpClient { request -> mockResponse(request, 201) }
136143
val asyncClient = syncClient.asAsyncCoroutines(scope)
137-
val response = withTimeout(2000) { asyncClient.execute(getRequest()) }
144+
val response = withTimeout(FAILSAFE_TIMEOUT_MILLIS) { asyncClient.execute(getRequest()) }
138145
assertEquals(201, response.status.code)
139146
} finally {
140147
scope.cancel()
@@ -171,7 +178,7 @@ class CoroutinesTest {
171178
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
172179
try {
173180
val async = sync.asAsyncCoroutines(scope)
174-
async.executeAsync(getRequest()).get(2, TimeUnit.SECONDS)
181+
async.executeAsync(getRequest()).get(FAILSAFE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
175182
assertEquals("coroutines-test", seenTraceId.get())
176183
} finally {
177184
scope.cancel()
@@ -195,7 +202,7 @@ class CoroutinesTest {
195202
delay(5)
196203
MDC.get("trace.id") ?: "<missing>"
197204
}
198-
assertEquals("cf-of-test", future.get(2, TimeUnit.SECONDS))
205+
assertEquals("cf-of-test", future.get(FAILSAFE_TIMEOUT_SECONDS, TimeUnit.SECONDS))
199206
} finally {
200207
scope.cancel()
201208
}

sdk-async-netty/src/test/kotlin/org/dexpace/sdk/async/netty/NettyTest.kt

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,18 @@ import kotlin.test.Test
2828
import kotlin.test.assertEquals
2929
import kotlin.test.assertTrue
3030

31+
// Failsafe deadline for awaiting an operation that is expected to complete promptly. It exists only
32+
// to stop a genuinely-stuck test from hanging forever, so it is kept generous: a healthy test
33+
// returns the instant the awaited work finishes and never approaches this bound, even on a loaded
34+
// CI host running modules in parallel.
35+
private const val FAILSAFE_TIMEOUT_SECONDS = 30L
36+
3137
class NettyTest {
3238
private val executor = DefaultEventExecutor()
3339

3440
@AfterTest
3541
fun shutdown() {
36-
executor.shutdownGracefully(0, 0, TimeUnit.SECONDS).await(2, TimeUnit.SECONDS)
42+
executor.shutdownGracefully(0, 0, TimeUnit.SECONDS).await(FAILSAFE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
3743
}
3844

3945
@Test
@@ -43,7 +49,7 @@ class NettyTest {
4349
CompletableFuture.completedFuture(mockResponse(request, 200))
4450
}
4551
val future = client.executeNetty(getRequest(), executor)
46-
assertTrue(future.await(2, TimeUnit.SECONDS))
52+
assertTrue(future.await(FAILSAFE_TIMEOUT_SECONDS, TimeUnit.SECONDS))
4753
assertTrue(future.isSuccess)
4854
assertEquals(200, future.now.status.code)
4955
}
@@ -56,7 +62,7 @@ class NettyTest {
5662
CompletableFuture<Response>().apply { completeExceptionally(sentinel) }
5763
}
5864
val future = client.executeNetty(getRequest(), executor)
59-
assertTrue(future.await(2, TimeUnit.SECONDS))
65+
assertTrue(future.await(FAILSAFE_TIMEOUT_SECONDS, TimeUnit.SECONDS))
6066
assertEquals(false, future.isSuccess)
6167
// Netty's `cause()` returns the failure passed to `setFailure(...)` — should be the
6268
// original IOException, not a CompletionException wrapper.
@@ -70,7 +76,7 @@ class NettyTest {
7076
AsyncHttpClient { request -> CompletableFuture.completedFuture(mockResponse(request, 201)) },
7177
).build()
7278
val future = pipeline.sendNetty(getRequest(), executor)
73-
assertTrue(future.await(2, TimeUnit.SECONDS))
79+
assertTrue(future.await(FAILSAFE_TIMEOUT_SECONDS, TimeUnit.SECONDS))
7480
assertEquals(201, future.now.status.code)
7581
}
7682

@@ -86,7 +92,7 @@ class NettyTest {
8692
// Cancel via Netty's API.
8793
nettyFuture.cancel(true)
8894
// Wait deterministically for the cancel to propagate to the source future.
89-
cancelLatch.get(2, TimeUnit.SECONDS)
95+
cancelLatch.get(FAILSAFE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
9096
assertTrue(sourceFuture.isCancelled, "cancelling the Netty promise should cancel the source CompletableFuture")
9197
}
9298

@@ -117,7 +123,7 @@ class NettyTest {
117123
it.start()
118124
}
119125

120-
completionLatch.get(2, TimeUnit.SECONDS)
126+
completionLatch.get(FAILSAFE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
121127
val err = completionErrorRef.get()
122128
if (err != null) throw AssertionError("trySuccess threw unexpectedly after cancel: $err", err)
123129
// Promise remains cancelled.
@@ -137,7 +143,7 @@ class NettyTest {
137143
CompletableFuture.completedFuture(mockResponse(request, 200))
138144
}
139145
val nettyFuture = async.executeNetty(getRequest(), executor)
140-
nettyFuture.get(2, TimeUnit.SECONDS)
146+
nettyFuture.get(FAILSAFE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
141147
assertEquals("netty-transport-test", seenTraceId.get())
142148
} finally {
143149
MDC.clear()
@@ -158,7 +164,7 @@ class NettyTest {
158164
val f = CompletableFuture<Response>()
159165
// Complete on a separate thread to ensure whenComplete fires off-caller-thread.
160166
Thread {
161-
gate.get(2, TimeUnit.SECONDS)
167+
gate.get(FAILSAFE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
162168
f.complete(mockResponse(request, 200))
163169
}.also {
164170
it.isDaemon = true
@@ -174,7 +180,7 @@ class NettyTest {
174180
mdcLatch.complete(Unit)
175181
}
176182
gate.complete(Unit)
177-
mdcLatch.get(2, TimeUnit.SECONDS)
183+
mdcLatch.get(FAILSAFE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
178184
assertEquals("netty-whencomplete-mdc", seenTraceId.get())
179185
} finally {
180186
MDC.clear()
@@ -193,7 +199,7 @@ class NettyTest {
193199
CompletableFuture.completedFuture(mockResponse(request, 200))
194200
}
195201
async.executeNetty(getRequest(), executor)
196-
.get(2, TimeUnit.SECONDS)
202+
.get(FAILSAFE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
197203
assertEquals("netty-caller-preserve", MDC.get("trace.id"))
198204
} finally {
199205
MDC.clear()

sdk-async-reactor/src/test/kotlin/org/dexpace/sdk/async/reactor/ReactorTest.kt

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,13 @@ import kotlin.test.Test
3434
import kotlin.test.assertEquals
3535
import kotlin.test.assertTrue
3636

37+
// Failsafe deadline for awaiting an operation that is expected to complete promptly. It exists only
38+
// to stop a genuinely-stuck test from blocking forever, so it is kept generous: a healthy test
39+
// returns the instant the awaited work finishes and never approaches this bound, even on a loaded
40+
// CI host running modules in parallel.
41+
private const val FAILSAFE_TIMEOUT_SECONDS = 30L
42+
private val FAILSAFE_TIMEOUT: Duration = Duration.ofSeconds(FAILSAFE_TIMEOUT_SECONDS)
43+
3744
class ReactorTest {
3845
@BeforeTest
3946
fun installIo() {
@@ -134,7 +141,7 @@ class ReactorTest {
134141
seenTraceId.set(MDC.get("trace.id")?.hashCode() ?: -1)
135142
CompletableFuture.completedFuture(mockResponse(request, 200))
136143
}
137-
client.executeMono(getRequest()).block(java.time.Duration.ofSeconds(2))
144+
client.executeMono(getRequest()).block(FAILSAFE_TIMEOUT)
138145
// The supplier is called synchronously on the subscribe thread, so MDC is already present.
139146
// We just verify that the supplier sees it (baseline regression).
140147
assertTrue(seenTraceId.get() != 0, "Supplier should see the trace.id")
@@ -154,7 +161,7 @@ class ReactorTest {
154161
AsyncHttpClient { request ->
155162
CompletableFuture.completedFuture(mockResponse(request, 200))
156163
}
157-
client.executeMono(getRequest()).block(java.time.Duration.ofSeconds(2))
164+
client.executeMono(getRequest()).block(FAILSAFE_TIMEOUT)
158165
// After block() returns, the caller's MDC should still be intact — withMdc inside the
159166
// adapter's hooks restores the previous (= caller's) MDC on exit.
160167
assertEquals("reactor-caller-preserve", MDC.get("trace.id"))
@@ -177,9 +184,9 @@ class ReactorTest {
177184
// StepVerifier creates subscription then cancels it.
178185
StepVerifier.create(mono)
179186
.thenCancel()
180-
.verify(Duration.ofSeconds(2))
187+
.verify(FAILSAFE_TIMEOUT)
181188
// Retrieve the underlying future that was created during subscription.
182-
val underlying = futureLatch.get(2, TimeUnit.SECONDS)
189+
val underlying = futureLatch.get(FAILSAFE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
183190
assertTrue(
184191
underlying.isCancelled,
185192
"Disposing the Mono subscription should cancel the underlying CompletableFuture",
@@ -202,7 +209,7 @@ class ReactorTest {
202209
// Reactor scheduler thread — the mdc.withMdc { ... } wrap must re-apply MDC there.
203210
client.executeMono(getRequest())
204211
.subscribeOn(Schedulers.boundedElastic())
205-
.block(Duration.ofSeconds(2))
212+
.block(FAILSAFE_TIMEOUT)
206213
assertEquals(
207214
"reactor-supplier-mdc",
208215
seenTraceId.get(),
@@ -234,7 +241,7 @@ class ReactorTest {
234241
// supplier would observe "assembly-time"; with Mono.defer the capture happens per
235242
// subscription, so it must observe the subscriber's value instead.
236243
MDC.put("trace.id", "subscribe-time")
237-
mono.block(Duration.ofSeconds(2))
244+
mono.block(FAILSAFE_TIMEOUT)
238245

239246
assertEquals(
240247
"subscribe-time",
@@ -261,11 +268,11 @@ class ReactorTest {
261268

262269
MDC.put("trace.id", "first")
263270
val mono = client.executeMono(getRequest())
264-
mono.block(Duration.ofSeconds(2))
271+
mono.block(FAILSAFE_TIMEOUT)
265272

266273
// Re-subscribe under a different MDC; deferred capture must reflect the new value.
267274
MDC.put("trace.id", "second")
268-
mono.block(Duration.ofSeconds(2))
275+
mono.block(FAILSAFE_TIMEOUT)
269276

270277
assertEquals(listOf("first", "second"), seenTraceIds.toList())
271278
} finally {
@@ -292,7 +299,7 @@ class ReactorTest {
292299
val mono = pipeline.sendMono(getRequest())
293300

294301
MDC.put("trace.id", "subscribe-time")
295-
mono.block(Duration.ofSeconds(2))
302+
mono.block(FAILSAFE_TIMEOUT)
296303

297304
assertEquals(
298305
"subscribe-time",

sdk-async-virtualthreads/src/test/kotlin/org/dexpace/sdk/async/virtualthreads/VirtualThreadsTest.kt

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ import kotlin.test.Test
2828
import kotlin.test.assertEquals
2929
import kotlin.test.assertTrue
3030

31+
// Failsafe deadline for awaiting an operation that is expected to complete promptly. It exists only
32+
// to stop a genuinely-stuck test from hanging forever, so it is kept generous: a healthy test
33+
// returns the instant the awaited work finishes and never approaches this bound, even on a loaded
34+
// CI host running modules in parallel.
35+
private const val FAILSAFE_TIMEOUT_SECONDS = 30L
36+
3137
class VirtualThreadsTest {
3238
@Test
3339
fun `asAsyncVirtualThreads runs the call on a virtual thread`() {
@@ -42,7 +48,7 @@ class VirtualThreadsTest {
4248
}
4349

4450
syncClient.asAsyncVirtualThreads().use { vtClient ->
45-
val response = vtClient.executeAsync(getRequest()).get(2, TimeUnit.SECONDS)
51+
val response = vtClient.executeAsync(getRequest()).get(FAILSAFE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
4652
assertEquals(200, response.status.code)
4753
}
4854
assertTrue(
@@ -55,10 +61,13 @@ class VirtualThreadsTest {
5561
fun `close shuts the virtual-thread executor down`() {
5662
val vt = HttpClient { request -> mockResponse(request, 200) }.asAsyncVirtualThreads()
5763
// Drive one request to ensure the executor is live.
58-
vt.executeAsync(getRequest()).get(2, TimeUnit.SECONDS)
64+
vt.executeAsync(getRequest()).get(FAILSAFE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
5965
vt.close()
6066
// After close, the executor service has terminated; submitting again would throw.
61-
val thrown = runCatching { vt.executeAsync(getRequest()).get(2, TimeUnit.SECONDS) }.exceptionOrNull()
67+
val thrown =
68+
runCatching {
69+
vt.executeAsync(getRequest()).get(FAILSAFE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
70+
}.exceptionOrNull()
6271
assertTrue(thrown != null, "expected closed virtual-thread executor to reject new tasks")
6372
}
6473

@@ -99,7 +108,7 @@ class VirtualThreadsTest {
99108
}
100109
syncClient.asAsyncVirtualThreads().use { vt ->
101110
val futures = (1..100).map { vt.executeAsync(getRequest()) }
102-
futures.forEach { it.get(5, TimeUnit.SECONDS) }
111+
futures.forEach { it.get(FAILSAFE_TIMEOUT_SECONDS, TimeUnit.SECONDS) }
103112
}
104113
assertEquals(100, executions.get())
105114
}
@@ -128,7 +137,7 @@ class VirtualThreadsTest {
128137
mockResponse(request, 200)
129138
}
130139
sync.asAsyncVirtualThreads().use { async ->
131-
async.executeAsync(getRequest()).get(2, java.util.concurrent.TimeUnit.SECONDS)
140+
async.executeAsync(getRequest()).get(FAILSAFE_TIMEOUT_SECONDS, java.util.concurrent.TimeUnit.SECONDS)
132141
}
133142
assertEquals("vt-transport-test", seenTraceId.get())
134143
} finally {

0 commit comments

Comments
 (0)