Fix lookup-then-read race in GenerationalUtf8Cache.getUtf8#11917
Fix lookup-then-read race in GenerationalUtf8Cache.getUtf8#11917dougqh wants to merge 2 commits into
Conversation
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>
|
🎯 Code Coverage (details) 🔗 Commit SHA: 8d60959 | Docs | Datadog PR Page | Give us feedback! |
🟢 Java Benchmark SLOs — All performance SLOs passed
PR vs. master results
Commit: Load and DaCapo benchmarks can be triggered manually in the GitLab pipeline. Results will appear in the Benchmarking Platform UI after completion. |
There was a problem hiding this comment.
More details
PR successfully fixes a latent lookup-then-read race in GenerationalUtf8Cache that could cause NPE or silent payload corruption under concurrent access. The fix adds post-read validation against the original request using final fields, and the comprehensive concurrent test with 8 reader threads + recalibrate loop confirms it handles all three race scenarios (null slot, slot reassignment, promotion nulling). No issues found.
🤖 Datadog Autotest · Commit 2961bb5 · What is Autotest? · Any feedback? Reach out in #autotest
| @Documented | ||
| @Retention(RetentionPolicy.CLASS) | ||
| @Target(ElementType.TYPE) | ||
| public @interface ThreadSafe {} |
There was a problem hiding this comment.
I think adding this should be brought to a larger audience. We already use @NotThreadSafe (parts of spotbug that can be used for static analysis) so that perhaps consider everything threadsafe and mark exceptions with that?
There was a problem hiding this comment.
Good catch, that's definitely not what I intended either.
We certainly don't need our own annotation for ThreadSafe.
I'll make sure it gets fixed.
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>
2961bb5 to
8d60959
Compare
amarziali
left a comment
There was a problem hiding this comment.
Thanks for having fixed this concurrency issue. It sounds ok to me. I left some minor comment
| // mark promoted first - to avoid racy insertions | ||
| this.promotions += 1; | ||
| boolean evicted = lruInsert(this.tenuredEntries, MAX_TENURED_PROBES, edenEntry); | ||
| if (evicted) this.tenuredEvictions += 1; |
There was a problem hiding this comment.
Not my preference, but I can if you wish.
There was a problem hiding this comment.
I was pretty sure we had somewhere written to prefer using brackets on if but I cannot find anymore so perhaps I imagined it 🤣 do as you prefer!
|
|
||
| boolean evicted = lruInsert(this.tenuredEntries, MAX_TENURED_PROBES, edenEntry); | ||
| if (evicted) this.tenuredEvictions += 1; | ||
| edenEntries[matchingEdenIndex] = null; |
There was a problem hiding this comment.
this looks also fragile to me and lack of a cas that would solve the case if also another thread possibly replaces that slot with a different new entry?
There was a problem hiding this comment.
The code is deliberately designed not to use CAS to avoid the overhead.
The safety of the code comes from the atomicity of the reference assignment and safety of final fields in Java.
But that does put the burden on the caller to double-check that the Entry matches the expectations -- which is admittedly, how the bug happened in the first place.
| @SuppressFBWarnings( | ||
| value = "IS2_INCONSISTENT_SYNC", | ||
| justification = | ||
| "stat updates are deliberately racy - sync is only used to prevent simultaneous bulk updates") |
There was a problem hiding this comment.
Is that concerning the issue we're fixing? If yes it can be removed (actually that class should have had a @NotThreadSafe but we seem having silenced this issue
There was a problem hiding this comment.
No, not exactly. There are still some deliberately racy elements inside the cache, but not issues that jeopardize correctness for the callers.
|
/merge |
|
View all feedbacks in Devflow UI.
The expected merge time in
The merge request has been interrupted because the build 5400882391554179487 took longer than expected. The current limit for the base branch 'master' is 120 minutes. |
What This Does
Fixes a latent lookup-then-read data race in
GenerationalUtf8Cache.getUtf8().In current releases, this bug could not be triggered because the caches are only used in a single thread. However, the caches were intended to be thread-safe, so I'm making them thread-safe.
Motivation
Avoid a future bug by making Utf8Caches adhere to the intended contract.
From Claude...
The method finds a matching slot with
lookupEntryIndex(), then reads that slot from the array in a separate step. Between the two, another thread can mutate the slot:recalibrate()(purge) and eviction can null it → NPE on the subsequenthit().getUtf8()returns that value's bytes for the requested string — silent payload corruption.Additional Notes
From Claude...
CacheEntryidentity is immutable (adjHash/value/valueUtf8arefinal), so the read paths now re-validate the loaded reference against the request:on both the tenured and eden paths. A null-or-mismatched slot falls through and is treated as a cache miss (re-encode). The residual races on
hit()'sscore/lastUsedMswrites are intentionally left as-is — they only nudge LRU/eviction bookkeeping and never affect the returned bytes.Test
Adds
concurrentAccess_neverThrowsOrReturnsWrongBytes— 8 reader threads hammeringgetUtf8()over a value set larger than capacity, with a concurrentrecalibrate()loop, asserting every result equals the requested value's UTF-8. Verified it fails (NPE / wrong bytes) without the fix and passes with it.🤖 Generated with Claude Code
Additional Changes
Made the intended contract explicit and gave it teeth:
@ThreadSafeannotation (datadog.trace.api.annotation) and applied it to bothGenerationalUtf8CacheandSimpleUtf8Cache. (SimpleUtf8Cachewas already correct — its lookup returns a validated entry reference instead of re-reading the slot by index.)Utf8Benchmark) and multi-threaded (Utf8ConcurrentBenchmark, sharedUtf8Workload). The single-threaded variant reflects how the caches are driven today and recalibrates inline; the concurrent variant uses JMH@Groupto model the intended concurrent drive (a dedicated recalibrate thread + worker lookup threads) and doubles as a guardrail. A multi-threaded benchmark like this would have hit the NPE and surfaced the race sooner.From Claude: measured cost of the
matches()re-validation (single-thread,-f3, with vs without fix,simplecache as unchanged control): allocation flat (−0.03%) and throughput within run-to-run noise — the changed benchmark moved less than the untouched control. No measurable cost.