Skip to content

Use ConcurrentHashtable for telemetry log deduplication - #12367

Draft
bric3 wants to merge 44 commits into
masterfrom
perf/log-collector-concurrent-hashtable
Draft

Use ConcurrentHashtable for telemetry log deduplication#12367
bric3 wants to merge 44 commits into
masterfrom
perf/log-collector-concurrent-hashtable

Conversation

@bric3

@bric3 bric3 commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

What Does This Do

Replaces LogCollector's ConcurrentHashMap dedup storage with a bounded ConcurrentHashtable entry that carries its own atomic count. Duplicate lookups no longer construct RawLogMessage.

Migrates the direct tests to JUnit 5 and strengthens concurrency, capacity, and drain coverage.

Motivation

Telemetry logging can be reached repeatedly from error paths. The previous duplicate-hit path allocated a RawLogMessage on every call and retained a separate AtomicInteger per distinct message.

Additional Notes

Stacked on #11675.

The eight-thread duplicate-hit benchmark reduced normalized allocation from 48.000 B/op to approximately 0 B/op (about 1e-4 B/op measured).

Contributor Checklist

  • Format the title according to the contribution guidelines
  • Assign the type: and comp: or inst: labels in addition to any other useful labels
  • Avoid using close, fix, or linking keywords when referencing an issue; use solves instead
  • Update CODEOWNERS on source file addition, migration, or deletion
  • Update public documentation with any new configuration flags or behaviors
  • Once approved, use merge queue to merge the PR

dougqh and others added 30 commits June 18, 2026 11:44
…y tables

Mirrors Hashtable's D1/D2 API with concurrent access guarantees: lock-free
get via AtomicReferenceArray volatile reads, synchronized getOrCreate with
double-checked re-read on miss. Eliminates composite key object allocation
on hot read paths — the same structural advantage Hashtable.D2 has over
HashMap<Pair<K1,K2>,V>, but thread-safe.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…t class; add D2 benchmark

Extract bucketIndex and forEach into ConcurrentHashtable.Support, mirroring the
Hashtable.Support pattern. Add ConcurrentHashtableD2Benchmark comparing get and
getOrCreate throughput against ConcurrentHashMap and ConcurrentSkipListMap.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… ConcurrentHashtable

Two gaps filled per-dimension (D1 and D2):
- Chain collision: force multiple entries into the same bucket (CollidingKey
  with fixed hashCode for D1; pigeonhole via 2-bucket table for D2) and verify
  all entries are reachable after concurrent inserts.
- Concurrent distinct keys: 16 threads each insert a unique key simultaneously,
  verifying final size and that every key is retrievable — exercises concurrent
  inserts to different buckets, which the single-shared-key test does not cover.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…htable

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ThreadSafeCounterBenchmarks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…enchmark

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ectly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…adSafeMapD2Benchmark

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaced by the ThreadSafeMap{D1,D2,Counter}Benchmark split.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Give ConcurrentHashtable its own entry hierarchy (Entry / D1.Entry / D2.Entry)
with a volatile next pointer, independent of the single-threaded Hashtable. The
volatile chain pointer lets a chain splice under the write lock be observed by
lock-free readers, which makes removal safe:

  - remove(key)        unlink a single entry
  - removeIf(predicate) sweep the whole table under one lock
  - drain(sink)        read-and-reset: remove every entry, handing each to a
                       caller-supplied accumulator (Consumer + context-passing
                       BiConsumer overload) -- the flush/publish primitive
  - clear()            empty the table

Removed entries keep their own next pointer intact so an in-flight reader can
still traverse forward. Migrates the ThreadSafeMap* benchmarks to the new
entry base. Adds single-threaded and concurrent removal tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lookups reuse the interned KEYS/SOURCE_* instances used to populate the table,
so they exercise the == identity fast path — deliberate and realistic for the
tracer (keys are typically interned tag-name constants), not an oversight.
Clarifies so it isn't misread against the equals()-path numbers elsewhere.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… shape

Flatten the nested Support class onto the ConcurrentHashtable namespace
(static fns over a caller-owned AtomicReferenceArray, mirroring FlatHashtable)
and type the bucket arrays AtomicReferenceArray<TEntry> so the unchecked casts
on the bucket read paths disappear.

- createFixedBuckets(entryClass, capacity) factories on ConcurrentHashtable
  (returns the raw spine), D1, and D2 (return a D1/D2); D1(int)/D2(int) ctors
  are now private. entryClass is a symmetry + type-inference anchor here (the
  AtomicReferenceArray spine is erased, so it isn't consumed for allocation the
  way FlatHashtable's E[] is).
- key()/key1()/key2() accessors on D1.Entry/D2.Entry to match Hashtable
  post-#12044.
- Context-passing forEach/drain overloads use <C> for the context type param.
- Double-checked-locking + lock-striping recipes moved to the class Javadoc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move D1/D2 tests and the ThreadSafeMap{Counter,D1,D2} benchmarks off the
removed public ctors / Support class onto createFixedBuckets and the flattened
ConcurrentHashtable.* static fns. The D2 benchmark's raw-array custom-entry arm
now drives a typed AtomicReferenceArray<SupportEntry>.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The bucket AtomicReferenceArray is the per-table write monitor, obtained
via getWriteLock(buckets) (opaque accessor, single source of truth) so
callers never hardcode what to synchronize on. Reads (bucket/forEach) stay
lock-free; whole-table mutators (removeIf/drain/clear) self-lock; the
single-slot write primitives (insertHeadEntry/unlink) are caller-locked and
assert Thread.holdsLock(getWriteLock(buckets)) under -ea.

insertHeadEntry mirrors Hashtable's insert helper so custom tables publish
entries without touching the chain pointer directly; Entry.setNext is
demoted to package-private accordingly while next() stays public for
lock-free chain walks.

Adapts ThreadSafeMapD2Benchmark call sites to the new API.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
matches() now invokes equals() on the lookup parameter rather than the
stored field (D1: key, D2: key1/key2). When matches() inlines into
get/getOrCreate the caller's key type is known, so the JIT can devirtualize
the equals() call; Objects.equals still short-circuits on == first, so
interned keys keep the identity fast path.

ThreadSafeMapD2Benchmark's Key2 dropped Objects.hash(...) — its varargs
Object[] allocation penalized the map baselines with an alloc the wrapper
itself doesn't need, overstating the ConcurrentHashtable advantage the
benchmark measures. Uses a plain 31*h1 + h2 hash instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The D1/D2 wrappers were well covered but the caller-owned-array path — the
static building blocks that back custom tables (primitive/higher-arity keys)
— had no direct tests. Adds ConcurrentHashtableStaticsTest, which drives a
hand-written primitive-int-key table (IntTable) through the documented
lock-free-read / locked-write recipe.

Covers sizeFor, createFixedBuckets, getWriteLock, bucketIndex, both bucket
and insertHeadEntry overloads, unlink (head/middle/tail), and the static
removeIf/drain/drain-with-context/clear/forEach primitives. Also asserts the
Thread.holdsLock guards on insertHeadEntry/unlink fire when called without
the write lock (guarded by an -ea check), plus exactly-once and lock-free
reader-safety races driven entirely through the statics.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Codex flagged that if a drain sink throws part-way, already-detached entries
are gone while size() still reports the pre-drain count. Rather than add
per-entry size bookkeeping to a path that only matters when the caller is
already in error (a throwing sink is a half-published flush with no
rollback), document that the sink must not throw — on the D1/D2 drain
wrappers and the static drain primitive.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…GuardedBy

D1 and D2 are thread-safe (lock-free reads, locked writes), so mark them
@threadsafe at the type level. The hand-written mutating building blocks
insertHeadEntry and unlink require the caller to hold the table write monitor
(they already assert Thread.holdsLock(getWriteLock(buckets))); make that
precondition static/tooling-visible with @GuardedBy("getWriteLock(buckets)").

Deliberately leave the final, individually-thread-safe buckets/size fields
unannotated: reads are lock-free by design, so @GuardedBy there would
misdescribe the contract.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An int-typed key hash calling the overloaded bucket(buckets, hash) or
insertHeadEntry(buckets, hash, entry) binds to the int-index overload
instead of widening to long, treating the raw hash as an array index.
Split into distinct bucketAt/insertHeadEntryAt (index-based) and
bucketFor/insertHeadEntryFor (hash-based) so there's no overload to
mis-resolve.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…htable

# Conflicts:
#	internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapBenchmark.java
dougqh and others added 14 commits August 28, 2026 13:04
Mirrors the same guard added to Hashtable.insertHeadEntryAt. Here it
also catches reinserting an already-unlinked entry: unlink()
deliberately leaves next intact so in-flight lock-free readers can
keep traversing, so overwriting it via a reinsert would corrupt that
traversal.
Bundles buckets + a cursor-based SizeManager into a State<TEntry>,
threaded through D1/D2 as tryGetOrCreateOrEvict(OrNull) so callers can
cap table size and evict on overflow. Renames createFixedBuckets ->
createCapped and getOrCreate -> tryGetOrCreate(OrNull) to reflect the
capacity-aware contract. Adds unit test coverage for SizeManager's
reserve/evict/reset behavior and the D1/D2 eviction paths.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Mirrors Hashtable.insertReserved: splices a fully-built entry into an
already-reserved slot (from tryReserve()/tryReserveOrEvict) without
double-counting. Not used by D1/D2, whose creator is fallible and so
increments only after a successful link; documented as the contrast.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
tryReserveOrEvict is self-locking, so pairing it with insertReserved
across two critical sections lets a drain or clear land in the gap,
reset the SizeManager while the reservation is outstanding, and leave
the insert linking an entry the count never learns about -- a capped
table then drifts silently past its cap.

Document the enclosing lock as part of the contract (class level, both
tryReserveOrEvict javadocs, and insertReserved's example), fix the test
that encoded the racy shape, and add a deterministic test that a
concurrent clear cannot interleave.

Also give evictOneInRange the @GuardedBy the other cursor writers carry,
and suppress AT_STALE_THREAD_WRITE_OF_PRIMITIVE where SpotBugs cannot
model the dynamic getWriteLock(buckets) guard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
getWriteLock(buckets) had exactly one answer, so every caller asked for
the whole table whether or not that was what it needed. That makes the
locking granularity part of the API: a striped implementation would have
no object to return.

Replace it with three accessors that name a scope -- getWriteLock(state,
keyHash), getWriteLockAt(state, bucketIndex), and getTableWriteLock(state)
-- and point every @GuardedBy, assert, and call site at the one it
actually needs. All three still return the same monitor, so behavior is
unchanged; only the question each caller asks is different.

Two consequences worth having: a caller that holds one key's monitor and
mutates another is now visibly wrong rather than accidentally right, and
every getTableWriteLock use marks a spot where striping would cost
something. The class javadoc records what those spots are -- table-wide
capacity accounting and a whole-table eviction scan -- so the analysis
does not have to be redone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SizeManager treated the entry cap as strict, and paid for it twice: a
check-then-increment reserve that needed the table write lock, and drain
and clear zeroing the count so a concurrent reservation was silently
discarded. The second of those was the P1: an undercount no later
eviction repairs, since eviction decrements too.

An approximate cap is fine here -- the bucket array is fixed-size with
load-factor headroom and never rehashes, so overshoot lengthens chains
and nothing else. Taking that latitude turns out to buy exactness where
it is cheap and delete the locking where it is not:

- tryReserve claims a slot and refunds on overshoot. Atomic on its own,
  so it needs no lock, and concurrent reservers still cannot both pass
  the cap.
- drain and clear subtract what they actually removed (release(int),
  replacing reset()), so a reservation survives a sweep landing in the
  gap between reserve and insert.

That removes the reason reserve-then-insert had to share one critical
section, so insertReserved now documents the single-bucket lock instead.
What remains lock-dependent is D1/D2's isFull-then-increment ordering,
which exists so a fallible creator cannot leak a slot and can tolerate
admitting slightly over the cap.

Counting makes clear O(entries) rather than O(buckets); clear is a rare
whole-table operation, so an honest count is worth the walk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… comment

Condenses the class/method-level Javadoc across ConcurrentHashtable and the
ThreadSafeMap* benchmarks down to the load-bearing points, and reworks the
isFull()-before-creator comment in D1/D2.tryGetOrCreateOrNull to spell out
the leaked-reservation failure mode instead of a terse arrow-notation summary
(per bric3's PR review nitpick).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Inlines the power-of-two rounding and its MAX_BUCKETS cap directly into
ConcurrentHashtable instead of delegating to Hashtable.Support.sizeFor,
which is being removed as part of the Hashtable/ConcurrentHashtable API
unification. Some duplication with Hashtable.sizeFor is accepted in
exchange for removing the cross-PR coupling.
@bric3 bric3 added type: feature Enhancements and improvements comp: telemetry Telemetry tag: performance Performance related changes tag: no release notes Changes to exclude from release notes tag: ai generated Largely based on code generated by an AI or LLM and removed type: feature Enhancements and improvements labels Sep 1, 2026
@datadog-datadog-prod-us1-2

datadog-datadog-prod-us1-2 Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

🎯 Code Coverage (details)
Patch Coverage: 81.48%
Overall Coverage: 58.59% (-0.42%)

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 1367128 | Docs | View more details | Give us feedback!

@dd-octo-sts

dd-octo-sts Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

🟢 Java Benchmark SLOs — All performance SLOs passed

Suite Status
Startup 🟢 pass

SLO thresholds are defined here based on automatically generated metrics. A warning is raised when results are within 5% of the threshold.

PR vs. master results
Scenario Candidate master Δ (95% CI of mean)
startup:insecure-bank:iast:Agent 14.68 s 14.58 s [-0.1%; +1.6%] (no difference)
startup:insecure-bank:tracing:Agent 13.58 s 13.68 s [-1.5%; +0.0%] (no difference)
startup:petclinic:appsec:Agent 17.50 s 17.32 s [+0.3%; +1.7%] (maybe worse)
startup:petclinic:iast:Agent 17.45 s 16.95 s [-1.6%; +7.4%] (no difference)
startup:petclinic:profiling:Agent 17.19 s 16.56 s [-0.5%; +8.2%] (no difference)
startup:petclinic:sca:Agent 17.33 s 17.35 s [-1.0%; +0.9%] (no difference)
startup:petclinic:tracing:Agent 16.45 s 16.62 s [-2.0%; -0.1%] (maybe better)

Commit: 13671282 · CI Pipeline · Benchmarking Platform UI


Load and DaCapo benchmarks can be triggered manually in the GitLab pipeline. Results will appear in the Benchmarking Platform UI after completion.

@bric3

bric3 commented Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 1, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-01T21:19:42.623545Z 1367128 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Bravo.

Reviewed commit: 13671282ed

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Base automatically changed from feat/concurrent-hashtable to master September 1, 2026 21:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp: telemetry Telemetry tag: ai generated Largely based on code generated by an AI or LLM tag: no release notes Changes to exclude from release notes tag: performance Performance related changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants