Repository navigation
Never reclaim dictionary storage after a timed-out drain - #839
Conversation
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 71c668aa20
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
CI Test ResultsRun: #37782741352 | Commit:
Status Overview
Legend: ✅ passed | ❌ failed | ⚪ skipped | 🚫 cancelled Summary: Total: 32 | Passed: 32 | Failed: 0 Updated: 2026-10-08 13:48:01 UTC |
This comment has been minimized.
This comment has been minimized.
|
@jbachorik thanks for the review. All five are addressed in e30f3d0:
Re the |
StringDictionary::clearAll() waited for all RefCountGuards to clear, but on timeout the drain only logged a warning and returned. clearAll() then cleared all three buffers anyway, freeing overflow nodes and arena chunks that a guarded lookup could still be reading or writing. clearStandby() had the same problem one rotation later: it cleared its target without any drain, so a guard that outlived rotate()'s timed-out drain could still be using the buffer. Add RefCountGuard::tryWaitForRefCountsToClear(), which waits only for guards on the given resources and reports a timeout instead of swallowing it. clearAll() now drains just its own three buffers, so traffic on the other dictionaries can neither delay nor fail the reset, and on timeout leaves the dictionary entirely unchanged and returns false. clearStandby() drains its target and skips the clear on timeout. Profiler::start() logs a dictionary it could not reset and only signals a ContextValueCache reset when the context-value map was really reset. Remove waitForAllRefCountsToClear(), which no longer has callers, and update the StringDictionary design doc, which still described the removed-then-restored _accepting recheck and claimed the arena made a stale read safe. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Profiler::start() calls Counters::reset() right after resetting the dictionaries. That erased the DICTIONARY_DRAIN_TIMEOUTS increment of a reset skipped on a drain timeout, and zeroed the DICTIONARY_PAGES and DICTIONARY_BYTES gauges although the dictionaries keep their root tables and first arena chunks, and all of their storage when the reset was skipped. Freeing that storage later drove the gauges negative. Each dictionary buffer now tracks its live overflow tables and arena chunks and can re-add them to the gauges. Profiler::start() does so for all three dictionaries after Counters::reset(), and re-adds one drain timeout per skipped reset. The dictionary and counter reset steps move into resetDictionaries() and resetCounters() so a gtest can run them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
StringDictionary::clearAll() and clearStandby() now only report whether they reset; Profiler turns a skipped reset into DICTIONARY_DRAIN_TIMEOUTS and a warning, in resetRecordingState() for start() and in rotateDictsAndRun() for the standby clears. Before, clearAll() counted but did not log, clearStandby() did both, and Profiler logged and then had to re-add the count that its own Counters::reset() had erased. resetRecordingState() now covers the whole reset sequence of a fresh start(), so the count no longer travels between helpers. Remove _class_map_lock: nothing else takes it, so holding it around clearAll() synchronised nothing. Say that DICTIONARY_KEYS counts keys newly assigned since the last clearStandby(), not buffer contents. In the StringDictionary design doc, rewrite the intro that still called a missed racing reader harmless because of the arena, and qualify the "no entry lost during rotate()" invariant for a timed-out drain. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
e30f3d0 to
f9b0f28
Compare
jbachorik
left a comment
There was a problem hiding this comment.
Thanks for cleaning this up. Looking good!
What does this PR do?:
Stops
StringDictionaryfrom freeing or resetting buffer storage while a guarded accessor may still be using it.RefCountGuard::tryWaitForRefCountsToClear(targets, n). It waits only for guards on the given resources and returnsfalseon timeout instead of logging and returning normally.StringDictionary::clearAll()now returns[[nodiscard]] bool:_next_idreset, counter reset or generation bump, and it returnsfalse. A partial reset would be worse than none: restarting_next_idwithout clearing would reissue ids existing entries still use. A dictionary that wasn't reset stays consistent.clearStandby()now waits for guards on its target buffer and skips the clear on timeout. The skipped buffer becomes active on the nextrotate()with its old entries. That's harmless because ids are only reassigned byclearAll(), and the buffer is cleared the next time it is the clear target.Profiler::start()logs a warning for any dictionary it could not reset. It only signals aContextValueCachereset when the context-value map was actually reset.waitForAllRefCountsToClear(), which no longer has callers.doc/architecture/StringDictionary.md. It still described the_acceptingrecheck as removed, which it isn't, and claimed the arena made a stale read safe, which it doesn't: extra arena chunks and overflow nodes are freed.Motivation:
PROF-16136.
clearAll()disabled new accesses, calledwaitForAllRefCountsToClear()and then cleared all buffers unconditionally. On timeout (~500 ms) the drain only logged a warning, so a lookup that had already passed the_acceptingrecheck could resume into freed overflowSBTablenodes or arena chunks.Two things made this more likely than the ticket describes:
Profiler::start()resets three dictionaries back to back, and JNI callers (recordTraceRoot,registerConstant0,lookupClass) keep taking guards on the other two while one drains. So it could time out from churn alone, with no stalled thread.lookupClass()does not take_class_map_lock, so the class map is exposed too.rotate()'swaitForRefCountToClear(old_active)also proceeds on timeout in release builds.clearStandby()then cleared that buffer two rotations later with no drain at all.Additional Notes:
CallTraceStoragefollows the same "free after a drain" pattern (processTraces(),clearTableOnly(), destructor), but it is latent there, not reachable. Every production caller ofprocessTraces()/clear()holdslockAll(), everyput()runs inside a stripe lock, andlockAll()waits without a time limit, so its drain cannot time out. The destructor never runs because theProfilersingleton is never deleted. A stacked PR documents that contract and hardens the timeout path.rotate()still useswaitForRefCountToClear, which aborts on timeout in debug builds. That is now a diagnostic only, sinceclearStandby()handles a straggler safely.How to test the change?:
New gtests:
StringDictionaryReclamationTest.ClearAllKeepsStorageWhileGuardHeldandClearStandbyKeepsBufferWhileGuardHeld. A helper thread holds a realRefCountGuardand a pointer to a key in a non-first arena chunk (memoryclear()frees), then re-reads it after the reset.StringArena::reset()←clearAll()/clearStandby().StringDictionaryReclamationTest.ClearAllIgnoresGuardsOnOtherDictionaries: a guard on another dictionary neither fails nor delays the reset.RefCountGuardTryWaitTest.*(4 tests) for the new drain primitive.clearAll()'s result.Results:
gtestDebug(full suite) passes. ASan and TSan pass forstringDictionary_utandrefCountGuard_ut, andcompileFuzzersucceeds.testDebug(Java): 224 tests, 1 failure:VirtualThreadWallClockTest.samplesCarrierFramesFromBlockingVT[cstack=vmx]("no carrier-thread frames", a continuation-unwind check unrelated to dictionaries). It passed when the class was rerun on its own.For Datadog employees:
credentials of any kind, I've requested a security review (run the
dd:platform-security-reviewskill, or file a request via the PSEC review form).
bewairealso runs automatically on every PR.🤖 Generated with Claude Code