Skip to content

Commit 205c700

Browse files
authored
fix: make DispatchContext mint a call-unique key by default (#84)
PR: #84
1 parent fb0137d commit 205c700

2 files changed

Lines changed: 53 additions & 17 deletions

File tree

sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/context/DispatchContext.kt

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,16 @@ import java.util.concurrent.atomic.AtomicLong
2424
* under the **same** [callKey], so downstream observers see the latest snapshot and
2525
* concurrent calls — even ones sharing a trace id — never collide (see [CallContext]).
2626
*
27-
* The default [callKey] is derived from the trace and span ids (`traceId:spanId`), which is
28-
* adequate when the span id is genuinely per-call. Untraced calls share constant no-op ids,
29-
* so [default] mints a process-unique key instead; supply an explicit [callKey] to override.
27+
* The default [callKey] appends a process-unique counter to the trace/span derivation
28+
* (`traceId:spanId:n`), so it is call-unique even when the span id is not. The trace and span
29+
* ids alone are not a safe key: the no-op context shares constant ids across untraced calls,
30+
* an inbound W3C trace shares a trace id across spans, and a tracer may reuse a span id across
31+
* sibling calls — all of which would otherwise collide in [ContextStore]. Supply an explicit
32+
* [callKey] to override (e.g. to re-key onto an existing chain).
3033
*/
3134
public data class DispatchContext(
3235
override val instrumentationContext: InstrumentationContext,
33-
override val callKey: String = deriveCallKey(instrumentationContext),
36+
override val callKey: String = mintCallKey(instrumentationContext),
3437
) : CallContext {
3538
/**
3639
* Promotes this dispatch context into a [RequestContext] bound to [request] and stores
@@ -51,25 +54,29 @@ public data class DispatchContext(
5154
private val mintCounter: AtomicLong = AtomicLong()
5255

5356
/**
54-
* Derives the default store key for [instrumentationContext] from its trace and span
55-
* ids (`traceId:spanId`). Unique per call only when the span id is per-call; the
56-
* no-op context shares constant ids, so [default] does not use this derivation.
57+
* Derives the trace/span portion of a store key for [instrumentationContext]
58+
* (`traceId:spanId`). This portion is not call-unique on its own — the no-op context
59+
* shares constant ids, an inbound trace shares a trace id across spans, and a span id
60+
* may be reused across sibling calls — so [mintCallKey] appends a process-unique
61+
* counter to it for the actual key. Retained as the fallback derivation that
62+
* [RequestContext] and [ExchangeContext] use when constructed directly.
5763
*/
5864
internal fun deriveCallKey(instrumentationContext: InstrumentationContext): String =
5965
instrumentationContext.traceId.value + ":" + instrumentationContext.spanId.value
6066

6167
/**
6268
* A dispatch context with a no-op instrumentation context; used when tracing is
63-
* disabled. Mints a process-unique [callKey] because the no-op context's trace and
64-
* span ids are shared constants — two untraced calls would otherwise collide in
65-
* [ContextStore].
69+
* disabled. The primary constructor's default [callKey] already mints a process-unique
70+
* key, so this just constructs one with the no-op context.
6671
*/
67-
public fun default(): DispatchContext =
68-
DispatchContext(
69-
instrumentationContext = NoopInstrumentationContext,
70-
callKey = mintCallKey(NoopInstrumentationContext),
71-
)
72+
public fun default(): DispatchContext = DispatchContext(NoopInstrumentationContext)
7273

74+
/**
75+
* Mints a call-unique store key by appending a monotonically increasing,
76+
* process-unique counter to [deriveCallKey]'s trace/span derivation
77+
* (`traceId:spanId:n`). The counter disambiguates calls that would otherwise share a
78+
* trace/span pair, so distinct calls never collide in [ContextStore].
79+
*/
7380
private fun mintCallKey(instrumentationContext: InstrumentationContext): String =
7481
deriveCallKey(instrumentationContext) + ":" + mintCounter.incrementAndGet()
7582
}

sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/context/DispatchContextTest.kt

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,15 +60,44 @@ class DispatchContextTest {
6060
@Test
6161
fun `data class equality and copy work on instrumentation context`() {
6262
val instr = FakeInstrumentationContext(TraceId(owned("eq")))
63-
val a = DispatchContext(instr)
64-
val b = DispatchContext(instr)
63+
// Pin an explicit call key so the two instances are constructed identically: the
64+
// default key is now call-unique, so two default-keyed instances are deliberately
65+
// distinct (see the call-key uniqueness test below).
66+
val key = owned("eq-key")
67+
val a = DispatchContext(instr, key)
68+
val b = DispatchContext(instr, key)
6569
assertEquals(a, b)
6670
assertEquals(a.hashCode(), b.hashCode())
6771

6872
val copy = a.copy()
6973
assertEquals(a, copy)
7074
}
7175

76+
@Test
77+
fun `two contexts sharing a trace and span id receive distinct call keys`() {
78+
// A directly-constructed dispatch context — built from an inbound W3C trace, or by a
79+
// tracer that reuses a span id across sibling client calls — must still get a
80+
// call-unique key. FakeInstrumentationContext defaults to a fixed span id, so both
81+
// instances below share the SAME trace id AND span id: the exact collision case. The
82+
// default call key must distinguish them, or they would clobber each other in
83+
// ContextStore (which rejects duplicate keys).
84+
val sharedId = owned("collision")
85+
val a = DispatchContext(FakeInstrumentationContext(TraceId(sharedId)))
86+
val b = DispatchContext(FakeInstrumentationContext(TraceId(sharedId)))
87+
ownedIds.add(a.callKey)
88+
ownedIds.add(b.callKey)
89+
90+
assertEquals(a.instrumentationContext.traceId, b.instrumentationContext.traceId)
91+
assertEquals(a.instrumentationContext.spanId, b.instrumentationContext.spanId)
92+
assertNotEquals(a.callKey, b.callKey)
93+
94+
// Both register in the store without one rejecting or evicting the other.
95+
ContextStore.put(a.callKey, a)
96+
ContextStore.put(b.callKey, b)
97+
assertSame(a, ContextStore.get(a.callKey))
98+
assertSame(b, ContextStore.get(b.callKey))
99+
}
100+
72101
@Test
73102
fun `close evicts entry keyed by call key`() {
74103
val id = owned("close")

0 commit comments

Comments
 (0)