Fix native OOM from unbounded CallTraceHashTable expansion#670
Conversation
Benchmark Results (commit 75285e9)Pipeline: https://gitlab.ddbuild.io/DataDog/apm-reliability/benchmarking-platform/-/pipelines/125374352 Commit: ✅ Within expected boundariesNo significant runtime deltas (all within run-to-run noise) and no internal-counter outliers. Runtime details (per benchmark × JDK)
Internal counter details (ddprof)ddprof internal counters, latest / dev (✅ = 0, · = unavailable):
|
|
Could you rebase the PR? seems it has conflicts. |
CallTraceHashTable grows its backing LongHashTable by doubling capacity from a LinearAllocator whose chunks are a fixed 8MB. Once a table exceeds one chunk (capacity >= 512K entries), LinearAllocator::alloc() can never place it: the bump-allocator loops allocating fresh chunks that still cannot fit the oversized request, until mmap fails - a native OOM / vm.max_map_count exhaustion, not a heap-space error. This is reachable under live-heap profiling (memory=...:l) with a long, unrotated recording, where the active table grows past that threshold between processTraces() rotations. Cap expansion at the largest table that fits in one allocator chunk (LinearAllocator::maxAllocatableSize()); further growth degrades put() to a higher load factor and recovers on the next rotation. Deriving the bound from the chunk size keeps it correct if the chunk size ever changes. Also fix a per-thread native leak in LivenessTracker's subsampling RNG state: the gen/dis/skipped ThreadLocals were reclaimed only via pthread-key destructors, which never fire when a JNI-attached thread detaches on a reused OS thread. Release them explicitly from Profiler::onThreadEnd(), matching how the CPU/wall engines and ProfiledThread are already torn down. Environment: Datadog workspace Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
75285e9 to
c9d17a9
Compare
CI Test ResultsRun: #29814088134 | Commit:
Status Overview
Legend: ✅ passed | ❌ failed | ⚪ skipped | 🚫 cancelled Summary: Total: 32 | Passed: 32 | Failed: 0 Updated: 2026-07-21 09:05:25 UTC |
…abot Dependabot bumped analyze to v4.37.1 without init, causing CodeQL's "Loaded a configuration file for version X, but running version Y" error.
Benchmark Results (commit d1d5076)Pipeline: https://gitlab.ddbuild.io/DataDog/apm-reliability/benchmarking-platform/-/pipelines/125698320 Commit:
|
| Benchmark | JDK | Latest | Dev | Δ (dev vs latest) | Issues L/D |
|---|---|---|---|---|---|
| akka-uct | 21 | ✅ 10276 ms (21 iters) | ✅ 10386 ms (21 iters) | ≈ +1.1% (±10.9%) | — / — |
| akka-uct | 25 | ✅ 8869 ms (24 iters) | ✅ 8909 ms (24 iters) | ≈ +0.5% (±10.4%) | — / — |
| finagle-chirper | 21 | ✅ 5960 ms (33 iters) | ✅ 5984 ms (33 iters) | ≈ +0.4% (±25.1%) | |
| finagle-chirper | 25 | ✅ 5462 ms (36 iters) | ✅ 5542 ms (36 iters) | ≈ +1.5% (±24.9%) | |
| fj-kmeans | 21 | ✅ 2663 ms (70 iters) | ✅ 2825 ms (66 iters) | 🔴 +6.1% | — / — |
| fj-kmeans | 25 | ✅ 2834 ms (66 iters) | ✅ 2818 ms (66 iters) | ≈ -0.6% (±2.6%) | — / — |
| future-genetic | 21 | ✅ 2058 ms (90 iters) | ✅ 2044 ms (90 iters) | ≈ -0.7% (±2.5%) | — / — |
| future-genetic | 25 | ✅ 2022 ms (91 iters) | ✅ 2124 ms (87 iters) | 🔴 +5% | — / — |
| naive-bayes | 21 | ✅ 1233 ms (138 iters) | ✅ 1245 ms (137 iters) | ≈ +1% (±33.1%) | — / — |
| naive-bayes | 25 | ✅ 1009 ms (169 iters) | ✅ 1010 ms (169 iters) | ≈ +0.1% (±31.5%) | — / — |
| reactors | 21 | ✅ 16270 ms (15 iters) | ✅ 15718 ms (15 iters) | ≈ -3.4% (±6%) | — / — |
| reactors | 25 | ✅ 19058 ms (15 iters) | ✅ 18294 ms (15 iters) | ≈ -4% (±4.9%) | — / — |
Internal counter details (ddprof)
ddprof internal counters, latest / dev (✅ = 0, · = unavailable):
| Benchmark | JDK | Dropped rec | Dropped jvmti | Dropped trace | Skipped WC | AGCT fail | Unwind fail |
|---|---|---|---|---|---|---|---|
| akka-uct | 21 | ✅ / ✅ | ✅ / ✅ | 2 / 3 | 2042 / 1952 | ✅ / ✅ | ✅ / ✅ |
| akka-uct | 25 | ✅ / ✅ | ✅ / ✅ | 2 / 4 | 2251 / 2204 | ✅ / ✅ | ✅ / ✅ |
| finagle-chirper | 21 | ✅ / ✅ | ✅ / ✅ | 4 / 3 | 8718 / 8771 | ✅ / ✅ | ✅ / ✅ |
| finagle-chirper | 25 | ✅ / ✅ | ✅ / ✅ | ✅ / 2 | 8527 / 8342 | ✅ / ✅ | ✅ / ✅ |
| fj-kmeans | 21 | ✅ / ✅ | ✅ / ✅ | ✅ / ✅ | ✅ / ✅ | ✅ / ✅ | ✅ / ✅ |
| fj-kmeans | 25 | ✅ / ✅ | ✅ / ✅ | 1 / 4 | 1270 / 1292 | ✅ / ✅ | ✅ / ✅ |
| future-genetic | 21 | ✅ / ✅ | ✅ / ✅ | 2 / 1 | 3042 / 2917 | ✅ / ✅ | ✅ / ✅ |
| future-genetic | 25 | ✅ / ✅ | ✅ / ✅ | ✅ / ✅ | 2833 / 2919 | ✅ / ✅ | ✅ / ✅ |
| naive-bayes | 21 | ✅ / ✅ | ✅ / ✅ | 7 / 7 | 3462 / 3491 | ✅ / ✅ | ✅ / ✅ |
| naive-bayes | 25 | ✅ / ✅ | ✅ / ✅ | 4 / 3 | 3463 / 3490 | ✅ / ✅ | ✅ / ✅ |
| reactors | 21 | ✅ / ✅ | ✅ / ✅ | 1 / ✅ | 1696 / 1521 | ✅ / ✅ | ✅ / ✅ |
| reactors | 25 | ✅ / ✅ | ✅ / ✅ | 2 / ✅ | 1978 / 1919 | ✅ / ✅ | ✅ / ✅ |
Reliability & Chaos Results✅ All reliability & chaos checks passed Pipeline: https://gitlab.ddbuild.io/DataDog/java-profiler/-/pipelines/125698277 |
kaahos
left a comment
There was a problem hiding this comment.
apart from the missing copyrights, it looks good to me!
|
🔄 Datadog auto-retried 2 jobs - 2 passed on retry 🔗 Commit SHA: b159f4e | Docs | Datadog PR Page | Give us feedback! |
Benchmark Results (commit b159f4e)Pipeline: https://gitlab.ddbuild.io/DataDog/apm-reliability/benchmarking-platform/-/pipelines/125887870 Commit:
|
| Benchmark | JDK | Latest | Dev | Δ (dev vs latest) | Issues L/D |
|---|---|---|---|---|---|
| akka-uct | 21 | ✅ 10319 ms (21 iters) | ✅ 10341 ms (21 iters) | ≈ +0.2% (±11.1%) | — / — |
| akka-uct | 25 | ✅ 8856 ms (24 iters) | ✅ 8805 ms (24 iters) | ≈ -0.6% (±10.3%) | — / — |
| finagle-chirper | 21 | ✅ 5940 ms (33 iters) | ✅ 5938 ms (33 iters) | ≈ -0% (±25.7%) | |
| finagle-chirper | 25 | ✅ 5533 ms (35 iters) | ✅ 5496 ms (36 iters) | ≈ -0.7% (±24.5%) | |
| fj-kmeans | 21 | ✅ 2784 ms (67 iters) | ✅ 2770 ms (67 iters) | ≈ -0.5% (±2.7%) | — / — |
| fj-kmeans | 25 | ✅ 2774 ms (67 iters) | ✅ 2819 ms (66 iters) | ≈ +1.6% (±2.7%) | — / — |
| future-genetic | 21 | ✅ 2095 ms (88 iters) | ✅ 2112 ms (87 iters) | ≈ +0.8% (±2.9%) | — / — |
| future-genetic | 25 | ✅ 2074 ms (90 iters) | ✅ 2008 ms (93 iters) | 🟢 -3.2% | — / — |
| naive-bayes | 21 | ✅ 1267 ms (135 iters) | ✅ 1231 ms (139 iters) | ≈ -2.8% (±32.3%) | — / — |
| naive-bayes | 25 | ✅ 1010 ms (169 iters) | ✅ 1015 ms (168 iters) | ≈ +0.5% (±31.7%) | — / — |
| reactors | 21 | ✅ 15746 ms (15 iters) | ✅ 16228 ms (15 iters) | ≈ +3.1% (±6.1%) | — / — |
| reactors | 25 | ✅ 17941 ms (15 iters) | ✅ 18765 ms (15 iters) | ≈ +4.6% (±5.6%) | — / — |
Internal counter details (ddprof)
ddprof internal counters, latest / dev (✅ = 0, · = unavailable):
| Benchmark | JDK | Dropped rec | Dropped jvmti | Dropped trace | Skipped WC | AGCT fail | Unwind fail |
|---|---|---|---|---|---|---|---|
| akka-uct | 21 | ✅ / ✅ | ✅ / ✅ | 1 / ✅ | 1925 / 2009 | ✅ / ✅ | ✅ / ✅ |
| akka-uct | 25 | ✅ / ✅ | ✅ / ✅ | 2 / 3 | 2253 / 2239 | ✅ / ✅ | ✅ / ✅ |
| finagle-chirper | 21 | ✅ / ✅ | ✅ / ✅ | 2 / 2 | 8371 / 8786 | ✅ / ✅ | ✅ / ✅ |
| finagle-chirper | 25 | ✅ / ✅ | ✅ / ✅ | 2 / 2 | 8047 / 8448 | ✅ / ✅ | ✅ / ✅ |
| fj-kmeans | 21 | ✅ / ✅ | ✅ / ✅ | 1 / 4 | 1252 / 1245 | ✅ / ✅ | ✅ / ✅ |
| fj-kmeans | 25 | ✅ / ✅ | ✅ / ✅ | 4 / ✅ | 1284 / 1261 | ✅ / ✅ | ✅ / ✅ |
| future-genetic | 21 | ✅ / ✅ | ✅ / ✅ | 1 / ✅ | 2935 / 2848 | ✅ / ✅ | ✅ / ✅ |
| future-genetic | 25 | ✅ / ✅ | ✅ / ✅ | 3 / 1 | 2984 / 2948 | ✅ / ✅ | ✅ / ✅ |
| naive-bayes | 21 | ✅ / ✅ | ✅ / ✅ | 3 / 6 | 3512 / 3484 | ✅ / ✅ | ✅ / ✅ |
| naive-bayes | 25 | ✅ / ✅ | ✅ / ✅ | 5 / 4 | 3469 / 3505 | ✅ / ✅ | ✅ / ✅ |
| reactors | 21 | ✅ / ✅ | ✅ / ✅ | 4 / 1 | 1562 / 1503 | ✅ / ✅ | ✅ / ✅ |
| reactors | 25 | ✅ / ✅ | ✅ / ✅ | 2 / ✅ | 1806 / 1940 | ✅ / ✅ | ✅ / ✅ |
Problem
Under live-heap profiling (
memory=...:l) with a long-running, unrotated recording, the profiler can exhaust native address space and crash withos::commit_memory ... failed; error='Not enough space' (errno=12)/pthread_create failed (EAGAIN). The failure isvm.max_map_count(VMA) exhaustion, not RAM: a captured memory map showed tens of thousands of 8 MB (and 8 MB-multiple) anonymous regions.Root cause
CallTraceHashTablegrows its backingLongHashTableby doubling capacity, allocating each generation from aLinearAllocatorwhose chunks are a fixed 8 MB (CALL_TRACE_CHUNK). Once a table's byte size exceeds one chunk (capacity ≥ ~512K entries),LinearAllocator::alloc()can never place it: the bump-allocator's inner loop only fitsoffs + size <= _chunk_size, so an oversized request falls through togetNextChunk(), which allocates a fresh 8 MB chunk that also can't fit it — looping and allocating chunks untilmmapfails.The active table only grows this large between
processTraces()rotations (which reset it to the initial capacity). In a single start-to-end JFR recording no rotation happens, so under the high distinct-trace volume that live-heap sampling sustains, the table crosses the one-chunk threshold and the allocator runs away.Fix
expandTableIfNeeded()now refuses to expand when the next generation'sgetSize()would exceedLinearAllocator::maxAllocatableSize()(one chunk minus its header). Instead of an unbounded allocation loop,put()keeps working on the current table at a higher load factor; the next rotation resets it. The bound is derived from the chunk size, so it stays correct ifCALL_TRACE_CHUNKchanges. A newcalltrace_storage_expansion_skippedcounter records when the cap engages.This also includes a related per-thread native leak fix in
LivenessTracker: its subsampling RNGThreadLocals (gen/dis/skipped) were reclaimed only via pthread-key destructors, which never fire when a JNI-attached thread detaches on a reused OS thread. They are now released explicitly fromProfiler::onThreadEnd(), matching how the CPU/wall engines andProfiledThreadare already torn down.Validation
Reproducer (
renaissance akka-uct -r 20undermemory=64:l) OOM'd at ~90 s / 13 iterations before the fix. With the fix it completes all 20 iterations with peak VMA count in the hundreds (was pinned at the 65530 limit). Compiles clean; verified the change is compatible with the existingexpandTableIfNeeded/overflow-guard unit tests by inspection (the new check sits alongside the existing guards and only engages for tables that exceed one chunk).🤖 Generated with Claude Code