Skip to content

Commit b9d1b57

Browse files
dougqhdevflow.devflow-routing-intake
andauthored
Fix lookup-then-read race in GenerationalUtf8Cache.getUtf8 (#11917)
Fix lookup-then-read race in GenerationalUtf8Cache.getUtf8 getUtf8() looks up a matching slot via lookupEntryIndex() and then reads that slot from the array in a second step. Between the two, another thread can mutate the slot: recalibrate()/eviction can null it, and promotion nulls the eden slot after promoting into tenured. The array read therefore returned either null (-> NPE on the following hit()) or, worse, a *different* value's entry whose bytes were then returned as if they were the requested value's -- silent payload corruption. This never manifests today because trace serialization runs on a single thread (TraceProcessingWorker), but the cache is built to allow concurrent access, so the race is a latent bug against that contract. CacheEntry identity is immutable (adjHash/value/valueUtf8 are final), so the fix re-validates the loaded reference against the request (entry != null && entry.matches(adjHash, value)) on both the tenured and eden read paths; a null-or-mismatched slot is treated as a miss. The residual races on hit()'s score/lastUsedMs writes are benign -- they only nudge LRU/eviction bookkeeping and never affect returned bytes. Adds a concurrent regression test that fails (NPE / wrong bytes) without the fix and passes with it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Mark Utf8 caches @threadsafe and add multi-threaded cache benchmarks Follow-on cleanup to the getUtf8 race fix: - Mark both `GenerationalUtf8Cache` and `SimpleUtf8Cache` `@ThreadSafe` (`javax.annotation.concurrent.ThreadSafe`, the annotation already used across the codebase), making the intended concurrency contract explicit. (`SimpleUtf8Cache` was already correct — its lookup returns a validated entry reference rather than re-reading the slot by index.) - Split the UTF8 cache benchmarks into a single-threaded `Utf8Benchmark` and a multi-threaded `Utf8ConcurrentBenchmark`, sharing `Utf8Workload`. The single-threaded variant reflects how the caches are driven today (serialization is single-threaded) and drives recalibrate inline; the concurrent variant uses @group to model the intended concurrent drive pattern (a dedicated recalibrate thread + worker lookup threads on the shared cache) and doubles as a concurrency guardrail. A @threads>1 benchmark like this would have hit the NPE and surfaced the race sooner. Measured cost of the matches() re-validation (single-thread, -f3, with vs without fix, `simple` as unchanged control): allocation flat (-0.03%) and throughput within run-to-run noise (changed benchmark moved less than the untouched control), i.e. no measurable cost. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Merge branch 'master' into dougqh/utf8-cache-concurrency-fix Co-authored-by: devflow.devflow-routing-intake <devflow.devflow-routing-intake@kubernetes.us1.ddbuild.io>
1 parent 58f636d commit b9d1b57

6 files changed

Lines changed: 255 additions & 73 deletions

File tree

communication/src/main/java/datadog/communication/serialization/GenerationalUtf8Cache.java

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
44
import java.nio.charset.StandardCharsets;
5+
import javax.annotation.concurrent.ThreadSafe;
56

67
/**
78
* 2-level generational cache of UTF8 values - primarily intended to be used for tag values
@@ -62,6 +63,7 @@
6263
* calling ValueUtf8Cache#reclibrate will adjust promotion thresholds to
6364
* provide better cache utilization.
6465
*/
66+
@ThreadSafe
6567
@SuppressFBWarnings(
6668
value = "IS2_INCONSISTENT_SYNC",
6769
justification =
@@ -219,32 +221,42 @@ public final byte[] getUtf8(String value, long accessTimeMs) {
219221
CacheEntry[] tenuredEntries = this.tenuredEntries;
220222
int matchingTenuredIndex = lookupEntryIndex(tenuredEntries, MAX_TENURED_PROBES, adjHash, value);
221223
if (matchingTenuredIndex != -1) {
224+
// The slot can be mutated concurrently between the lookup and this read: nulled (recalibrate
225+
// purge / eviction) or reassigned to a *different* value. CacheEntry identity is immutable
226+
// (adjHash/value/valueUtf8 are final), so re-validate the loaded reference against the
227+
// request; anything but a match means the slot moved out from under us, so fall through and
228+
// treat it as a miss rather than NPE'ing (null) or returning another value's bytes
229+
// (reassigned).
222230
CacheEntry tenuredEntry = tenuredEntries[matchingTenuredIndex];
231+
if (tenuredEntry != null && tenuredEntry.matches(adjHash, value)) {
232+
tenuredEntry.hit(accessTimeMs);
223233

224-
tenuredEntry.hit(accessTimeMs);
225-
226-
this.tenuredHits += 1;
227-
return tenuredEntry.utf8();
234+
this.tenuredHits += 1;
235+
return tenuredEntry.utf8();
236+
}
228237
}
229238

230239
CacheEntry[] edenEntries = this.edenEntries;
231240
int matchingEdenIndex = lookupEntryIndex(edenEntries, MAX_EDEN_PROBES, adjHash, value);
232241
if (matchingEdenIndex != -1) {
242+
// Same lookup-then-read race as tenured, plus concurrent promotion nulls the slot (line
243+
// below); re-validate the loaded reference and treat null-or-mismatch as a miss.
233244
CacheEntry edenEntry = edenEntries[matchingEdenIndex];
245+
if (edenEntry != null && edenEntry.matches(adjHash, value)) {
246+
double hits = edenEntry.hit(accessTimeMs);
247+
if (hits > this.promotionThreshold) {
248+
// mark promoted first - to avoid racy insertions
249+
this.promotions += 1;
234250

235-
double hits = edenEntry.hit(accessTimeMs);
236-
if (hits > this.promotionThreshold) {
237-
// mark promoted first - to avoid racy insertions
238-
this.promotions += 1;
251+
boolean evicted = lruInsert(this.tenuredEntries, MAX_TENURED_PROBES, edenEntry);
252+
if (evicted) this.tenuredEvictions += 1;
239253

240-
boolean evicted = lruInsert(this.tenuredEntries, MAX_TENURED_PROBES, edenEntry);
241-
if (evicted) this.tenuredEvictions += 1;
254+
edenEntries[matchingEdenIndex] = null;
255+
}
242256

243-
edenEntries[matchingEdenIndex] = null;
257+
this.edenHits += 1;
258+
return edenEntry.utf8();
244259
}
245-
246-
this.edenHits += 1;
247-
return edenEntry.utf8();
248260
}
249261

250262
boolean wasMarked = Caching.mark(this.edenMarkers, adjHash);

communication/src/main/java/datadog/communication/serialization/SimpleUtf8Cache.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package datadog.communication.serialization;
22

33
import java.nio.charset.StandardCharsets;
4+
import javax.annotation.concurrent.ThreadSafe;
45

56
/**
67
* A simple UTF8 cache - primarily intended for tag names
@@ -41,6 +42,7 @@
4142
* If there are no available slots in entries for a newly created CacheEntry,
4243
* a LFU: least frequently used eviction policy is used to free up a slot.
4344
*/
45+
@ThreadSafe
4446
public final class SimpleUtf8Cache implements EncodingCache {
4547
static final int MAX_CAPACITY = 1024;
4648

communication/src/test/java/datadog/communication/serialization/GenerationalUtf8CacheTest.java

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,12 @@
77
import static org.junit.jupiter.api.Assertions.assertSame;
88

99
import java.nio.charset.StandardCharsets;
10+
import java.util.Arrays;
1011
import java.util.Random;
12+
import java.util.concurrent.CountDownLatch;
1113
import java.util.concurrent.ThreadLocalRandom;
14+
import java.util.concurrent.atomic.AtomicInteger;
15+
import java.util.concurrent.atomic.AtomicReference;
1216
import org.junit.jupiter.api.Test;
1317
import org.junit.jupiter.params.ParameterizedTest;
1418
import org.junit.jupiter.params.provider.ValueSource;
@@ -175,6 +179,88 @@ public void fuzz() {
175179
assertNotEquals(0, promotedHits);
176180
}
177181

182+
@Test
183+
public void concurrentAccess_neverThrowsOrReturnsWrongBytes() throws InterruptedException {
184+
// Regression test for a lookup-then-read race in getUtf8(): a slot can be nulled (recalibrate
185+
// purge / eviction) or reassigned to a *different* value between lookupEntryIndex() and the
186+
// array read. Before the fix this either NPE'd (null slot) or, worse, silently returned another
187+
// value's bytes (reassigned slot). Serialization is single-threaded today, but the cache is
188+
// built to allow concurrent use, so this exercises that contract.
189+
final GenerationalUtf8Cache cache = create();
190+
191+
// More distinct values than the cache can hold, so promotions/evictions churn slots hard.
192+
final String[] values = new String[256];
193+
for (int i = 0; i < values.length; ++i) {
194+
values[i] = "value-" + i;
195+
}
196+
197+
final int threadCount = 8;
198+
final int iterationsPerThread = 200_000;
199+
final CountDownLatch start = new CountDownLatch(1);
200+
final AtomicReference<Throwable> failure = new AtomicReference<>();
201+
final AtomicInteger readersRunning = new AtomicInteger(threadCount);
202+
203+
Thread[] readers = new Thread[threadCount];
204+
for (int t = 0; t < threadCount; ++t) {
205+
readers[t] =
206+
new Thread(
207+
() -> {
208+
try {
209+
start.await();
210+
ThreadLocalRandom random = ThreadLocalRandom.current();
211+
for (int i = 0; i < iterationsPerThread && failure.get() == null; ++i) {
212+
String value = values[random.nextInt(values.length)];
213+
byte[] result = cache.getUtf8(value);
214+
if (!Arrays.equals(value.getBytes(StandardCharsets.UTF_8), result)) {
215+
failure.compareAndSet(
216+
null,
217+
new AssertionError(
218+
"getUtf8(\""
219+
+ value
220+
+ "\") returned bytes for \""
221+
+ new String(result, StandardCharsets.UTF_8)
222+
+ "\""));
223+
return;
224+
}
225+
}
226+
} catch (Throwable e) {
227+
failure.compareAndSet(null, e);
228+
} finally {
229+
readersRunning.decrementAndGet();
230+
}
231+
});
232+
}
233+
234+
// Recalibrate in a tight loop for the duration, nulling decayed slots concurrently with reads.
235+
Thread recalibrator =
236+
new Thread(
237+
() -> {
238+
try {
239+
start.await();
240+
while (readersRunning.get() > 0 && failure.get() == null) {
241+
cache.recalibrate();
242+
}
243+
} catch (Throwable e) {
244+
failure.compareAndSet(null, e);
245+
}
246+
});
247+
248+
for (Thread reader : readers) {
249+
reader.start();
250+
}
251+
recalibrator.start();
252+
start.countDown();
253+
254+
for (Thread reader : readers) {
255+
reader.join();
256+
}
257+
recalibrator.join();
258+
259+
if (failure.get() != null) {
260+
throw new AssertionError("concurrent getUtf8() failed", failure.get());
261+
}
262+
}
263+
178264
@Test
179265
public void bigString_dont_cache() {
180266
String lorem = "Lorem ipsum dolor sit amet";

dd-trace-core/src/jmh/java/datadog/trace/common/writer/ddagent/Utf8Benchmark.java

Lines changed: 15 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,75 +1,31 @@
11
package datadog.trace.common.writer.ddagent;
22

3+
import static datadog.trace.common.writer.ddagent.Utf8Workload.NUM_LOOKUPS;
4+
import static datadog.trace.common.writer.ddagent.Utf8Workload.nextTag;
5+
import static datadog.trace.common.writer.ddagent.Utf8Workload.nextValue;
6+
37
import datadog.communication.serialization.GenerationalUtf8Cache;
48
import datadog.communication.serialization.SimpleUtf8Cache;
59
import java.nio.charset.StandardCharsets;
6-
import java.util.concurrent.ThreadLocalRandom;
710
import org.openjdk.jmh.annotations.Benchmark;
811
import org.openjdk.jmh.annotations.BenchmarkMode;
912
import org.openjdk.jmh.annotations.Mode;
1013
import org.openjdk.jmh.infra.Blackhole;
1114

1215
/**
13-
* This benchmark isn't really intended to used to measure throughput, but rather to be used with
14-
* "-prof gc" to check bytes / op.
16+
* Single-threaded UTF8 cache benchmark. This reflects how the caches are actually driven today:
17+
* trace serialization runs on a single thread, so one thread performs the lookups and drives {@link
18+
* GenerationalUtf8Cache#recalibrate()} inline at a natural transaction boundary (here, once per
19+
* op). This is the representative allocation/throughput number. See {@link Utf8ConcurrentBenchmark}
20+
* for the multi-threaded contract/guardrail variant.
1521
*
16-
* <p>Since {@link String#getBytes(java.nio.charset.Charset)} is intrinsified the caches typically
17-
* perform worse throughput wise, the benefit of the caches is to reduce allocation. Intention of
18-
* this benchmark is to create data that roughly resembles what might be seen in a trace payload.
19-
* Tag names are quite static, tag values are mostly low cardinality, but some tag values have
20-
* infinite cardinality.
22+
* <p>This benchmark isn't really intended to measure throughput, but rather to be used with "-prof
23+
* gc" to check bytes / op. Since {@link String#getBytes(java.nio.charset.Charset)} is intrinsified
24+
* the caches typically perform worse throughput wise; the benefit of the caches is to reduce
25+
* allocation.
2126
*/
2227
@BenchmarkMode(Mode.Throughput)
2328
public class Utf8Benchmark {
24-
static final int NUM_LOOKUPS = 10_000;
25-
26-
static final String[] TAGS = {
27-
"_dd.asm.keep",
28-
"ci.provider",
29-
"language",
30-
"db.statement",
31-
"ci.job.url",
32-
"ci.pipeline.url",
33-
"db.pool",
34-
"http.forwarder",
35-
"db.warehouse",
36-
"custom"
37-
};
38-
39-
static int pos = 0;
40-
static int standardVal = 0;
41-
42-
static final String nextTag() {
43-
if (pos == TAGS.length - 1) {
44-
pos = 0;
45-
} else {
46-
pos += 1;
47-
}
48-
return TAGS[pos];
49-
}
50-
51-
static final String nextValue(String tag) {
52-
if (tag.equals("custom")) {
53-
return nextCustomValue(tag);
54-
} else {
55-
return nextStandardValue(tag);
56-
}
57-
}
58-
59-
/*
60-
* Produces a high cardinality value - > thousands of distinct values per tag - many 1-time values
61-
*/
62-
static final String nextCustomValue(String tag) {
63-
return tag + ThreadLocalRandom.current().nextInt();
64-
}
65-
66-
/*
67-
* Produces a moderate cardinality value - tens of distinct values per tag
68-
*/
69-
static final String nextStandardValue(String tag) {
70-
return tag + ThreadLocalRandom.current().nextInt(20);
71-
}
72-
7329
@Benchmark
7430
public static final String tagUtf8_baseline() {
7531
return nextTag();
@@ -109,7 +65,7 @@ public static final void valueUtf8_baseline(Blackhole bh) {
10965
@Benchmark
11066
public static final void valueUtf8_cache_generational(Blackhole bh) {
11167
GenerationalUtf8Cache valueCache = VALUE_CACHE;
112-
valueCache.recalibrate();
68+
valueCache.recalibrate(); // single thread drives recalibrate inline, at a transaction boundary
11369

11470
for (int i = 0; i < NUM_LOOKUPS; ++i) {
11571
String tag = nextTag();
@@ -125,7 +81,7 @@ public static final void valueUtf8_cache_generational(Blackhole bh) {
12581
@Benchmark
12682
public static final void valueUtf8_cache_simple(Blackhole bh) {
12783
SimpleUtf8Cache valueCache = SIMPLE_VALUE_CACHE;
128-
valueCache.recalibrate();
84+
valueCache.recalibrate(); // single thread drives recalibrate inline, at a transaction boundary
12985

13086
for (int i = 0; i < NUM_LOOKUPS; ++i) {
13187
String tag = nextTag();
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package datadog.trace.common.writer.ddagent;
2+
3+
import static datadog.trace.common.writer.ddagent.Utf8Workload.NUM_LOOKUPS;
4+
import static datadog.trace.common.writer.ddagent.Utf8Workload.nextTag;
5+
import static datadog.trace.common.writer.ddagent.Utf8Workload.nextValue;
6+
7+
import datadog.communication.serialization.GenerationalUtf8Cache;
8+
import datadog.communication.serialization.SimpleUtf8Cache;
9+
import org.openjdk.jmh.annotations.Benchmark;
10+
import org.openjdk.jmh.annotations.BenchmarkMode;
11+
import org.openjdk.jmh.annotations.Group;
12+
import org.openjdk.jmh.annotations.GroupThreads;
13+
import org.openjdk.jmh.annotations.Mode;
14+
import org.openjdk.jmh.annotations.Scope;
15+
import org.openjdk.jmh.annotations.State;
16+
import org.openjdk.jmh.infra.Blackhole;
17+
18+
/**
19+
* Multi-threaded companion to {@link Utf8Benchmark}, exercising the caches' intended thread-safety
20+
* contract against the shared caches.
21+
*
22+
* <p>The point of this variant is to illustrate how {@code recalibrate()} is driven under
23+
* concurrency. Unlike the single-threaded case — where the lone serializer thread recalibrates
24+
* inline — you would <em>not</em> have every worker recalibrate (that needlessly churns the shared
25+
* cache and widens the lookup-then-read race window). Instead a single dedicated thread drives
26+
* {@code recalibrate()} while the remaining threads perform lookups. JMH's {@code @Group} /
27+
* {@code @GroupThreads} expresses exactly that: 7 lookup threads + 1 recalibrate thread on the same
28+
* cache.
29+
*
30+
* <p>The recalibrate thread runs continuously, which is deliberately more aggressive than a real
31+
* periodic cadence — it maximizes contention so this doubles as a concurrency guardrail.
32+
*/
33+
@BenchmarkMode(Mode.Throughput)
34+
@State(Scope.Group)
35+
public class Utf8ConcurrentBenchmark {
36+
static final GenerationalUtf8Cache VALUE_CACHE = new GenerationalUtf8Cache(64, 128);
37+
static final SimpleUtf8Cache SIMPLE_VALUE_CACHE = new SimpleUtf8Cache(128);
38+
39+
@Benchmark
40+
@Group("generational")
41+
@GroupThreads(7)
42+
public void generational_lookup(Blackhole bh) {
43+
for (int i = 0; i < NUM_LOOKUPS; ++i) {
44+
String tag = nextTag();
45+
bh.consume(VALUE_CACHE.getUtf8(nextValue(tag)));
46+
}
47+
}
48+
49+
@Benchmark
50+
@Group("generational")
51+
@GroupThreads(1)
52+
public void generational_recalibrate() {
53+
VALUE_CACHE.recalibrate();
54+
}
55+
56+
@Benchmark
57+
@Group("simple")
58+
@GroupThreads(7)
59+
public void simple_lookup(Blackhole bh) {
60+
for (int i = 0; i < NUM_LOOKUPS; ++i) {
61+
String tag = nextTag();
62+
bh.consume(SIMPLE_VALUE_CACHE.getUtf8(nextValue(tag)));
63+
}
64+
}
65+
66+
@Benchmark
67+
@Group("simple")
68+
@GroupThreads(1)
69+
public void simple_recalibrate() {
70+
SIMPLE_VALUE_CACHE.recalibrate();
71+
}
72+
}

0 commit comments

Comments
 (0)