Metrics: close the id lookup race and bounds gaps left by the lock revert - #13583
Open
cmcfarlen wants to merge 5 commits into
Open
Metrics: close the id lookup race and bounds gaps left by the lock revert#13583cmcfarlen wants to merge 5 commits into
cmcfarlen wants to merge 5 commits into
Conversation
valid(), lookup(IdType), name() and rename() each carried their own copy of the same range test, and the copies had drifted. valid() rejected an offset past MAX_SIZE; the other three did not. Since _splitID passes the low 16 bits of an id through unmasked and the offset check only applied when the id named the current blob, an id such as 0x0000FFFF indexed well past the end of a blob's 1024 entry arrays once a second blob existed. Ids reaching these accessors come from plugins through the TSStat* API, so they are untrusted. All four now go through Storage::_is_allocated(), which rejects a negative id, an offset no _makeId could have produced, an unallocated blob, and a slot at or past the allocation point. That last comparison also fixes an off-by-one: create() returns the id and then advances, so _cur_off is the next free slot, and the old <= / > tests accepted it. An increment there landed on the slot create() would hand out next, and since create() writes only the name and never the value, the next plugin to call TSStatCreate() received a metric already carrying someone else's count. Nothing depended on the loose bound: end() builds an id at the allocation point that is compared but never dereferenced, iterator::next() keeps the offset in range, and find() returns end() on a miss.
The lock removal in apache#13567 left the reader path reading _cur_blob, _cur_off and _blobs while a concurrent create() advances them, which is the data race apache#13310 took the mutex to close. Close it without the mutex instead. Making each counter atomic does not make the pair update atomically, and it does not need to. _cur_blob and _cur_off are publication points: each is written last, with a release store, after whatever it makes visible -- the blob pointer and the reset offset for _cur_blob, the slot's name for _cur_off. A reader acquires _cur_blob first, so observing a value for it also observes everything addBlob() wrote before releasing it. The torn pair a reader could otherwise see, a new blob index with the previous blob's stale offset, is unreachable rather than merely unlikely, so neither a packed word nor per-blob counters are needed. _blobs stays non-atomic. It is only read at an index no greater than _cur_blob, and that write is sequenced before the release store the reader acquired, so there is no race to close. Writers all hold the mutex and load relaxed. What remains is that a reader can observe an older _cur_blob with an already reset _cur_off and reject an id naming the previous blob, which drops an increment rather than misattributing one. Verified with a TSAN harness running eight readers validating and resolving ids across the whole space while a writer creates 2600 metrics across several blob boundaries: three reported races before this change, none after.
Add a test that resolves ids from several threads while another registers metrics across a few blob boundaries. Nothing single threaded exercises the publication order the previous commit relies on; under the tsan preset, making either allocation counter non-atomic again reports a data race here. The test cannot catch a downgrade of the release/acquire pairs to relaxed -- atomics are race free at any ordering -- and says so, so the memory orders are not mistaken for tested. _extractType shifted a signed IdType, so _extractType(NOT_FOUND) sign extended to -4, a MetricType outside its enumeration, returned by Metrics::type(). Shifting unsigned is not enough on its own: the sign bit sits above the type field, so NOT_FOUND still yields 4. Mask to the single bit _makeId writes, which makes the function total for any input.
Nothing in tree measured the metric read paths, which is why a global mutex on the hottest one went unnoticed until it showed up in a production profile. Four cases, scaled by thread count: increment(id) what TSStatIntIncrement does, the path that regressed increment(ptr) what core and cripts do, the floor lookup(id) the lock free id resolution alone lookup(name) the same resolution through the mutex guarded name map lookup(name) is deliberately included as a positive control. It still takes the lock, so it must degrade with thread count; if it ever stops doing so, the harness is not loading the machine and the other three numbers mean nothing. Built only with ENABLE_BENCHMARKS, as with the rest of tools/benchmark.
State what holds rather than how it came to hold. Drops the explanations of which write order a comparison compensates for, what a reader would have seen otherwise, and what each benchmark case is meant to prove. Also shortens the createSpan boundary test's preamble, which describes the bug it covers at more length than the assertion needs.
Contributor
There was a problem hiding this comment.
Pull request overview
This PR hardens ts::Metrics::Storage against races and out-of-bounds id accesses (notably from untrusted plugin-provided TSStat* ids) while keeping the hot id-based read paths lock-free. It also adds targeted concurrency/bounds tests and a new Catch2 micro-benchmark to measure the affected metrics access patterns.
Changes:
- Introduce atomic publication for
_cur_blob/_cur_off(release stores) and unify id validation viaStorage::_is_allocated()acrossvalid(),lookup(id),name(), andrename(). - Fix
_extractType()to be total for allIdTypeinputs (including negative sentinel values likeNOT_FOUND). - Add a new concurrent-creation safety test and a new
benchmark_Metricsexecutable to measure lookup/increment paths at different thread counts.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| tools/benchmark/CMakeLists.txt | Adds the benchmark_Metrics target and links it against ts::tsutil and Catch2. |
| tools/benchmark/benchmark_Metrics.cc | New Catch2 benchmarking harness for increment(ptr), increment(id), lookup(id), and lookup(name) under configurable thread/op counts. |
| src/tsutil/unit_tests/test_Metrics.cc | Adds tests for malformed id offsets and concurrent id lookup during metric creation. |
| src/tsutil/Metrics.cc | Implements lock-free safe id lookup/name resolution via _is_allocated() and publishes allocation progress with atomic release stores. |
| include/tsutil/Metrics.h | Introduces atomic allocation counters, adds _is_allocated() gate, and fixes _extractType() masking. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
+680
to
+695
| for (Metrics::IdType id = 0; id < N_CREATE; ++id) { | ||
| if (!h.valid(id)) { | ||
| continue; | ||
| } | ||
|
|
||
| // valid() said this id names an allocated slot, so lookup() must agree and hand back a | ||
| // real metric rather than clamping to the reserved bad_id slot. A publication ordering | ||
| // mistake shows up here as a name that is still empty. | ||
| std::string_view name; | ||
| Metrics::MetricType type; | ||
| auto *m = h.lookup(id, &name, &type); | ||
|
|
||
| if (m == nullptr || (id != 0 && name.empty())) { | ||
| mismatches.fetch_add(1, std::memory_order_relaxed); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-ups to #13567, which reverted the locking that #13310 had added to the
ts::Metrics::Storageread paths. That revert restored the performance but left the data race #13310 was closing, plus
some pre-existing bounds problems in the same functions. This closes the race without a lock, and
fixes the bounds.
One gate for id validation
valid(),lookup(IdType),name()andrename()each carried their own copy of the same rangetest, and the copies disagreed.
valid()rejected an offset pastMAX_SIZE; the other three didnot.
_splitIDpasses the low 16 bits of an id through unmasked and the offset check only appliedwhen the id named the current blob, so an id such as
0x0000FFFFindexed well past the end of ablob's 1024 entry arrays once a second blob existed. Ids reaching these accessors come from plugins
through the
TSStat*API, so they are untrusted.All four now go through
Storage::_is_allocated(). It also fixes an off-by-one:create()returnsthe id and then advances, so
_cur_offis the next free slot, and the old<=/>tests acceptedit. An increment there landed on the slot
create()would hand out next, and sincecreate()writes only the name and never the value, the next plugin to call
TSStatCreate()received a metricalready carrying someone else's count.
Nothing depended on the loose bound:
end()builds an id at the allocation point that is comparedbut never dereferenced,
iterator::next()keeps the offset in range, andfind()returnsend()on a miss.
Publication, without a lock
_cur_bloband_cur_offbecome atomics, but the point is not that each is atomic — that alonewould not make the pair update atomically. They are publication points: each is release stored last,
after whatever it makes visible (the blob pointer and the reset offset for
_cur_blob, the slot'sname for
_cur_off), and a reader acquire loads_cur_blobfirst. Observing a value for it alsoobserves everything written before it was released, so the torn pair a reader could otherwise see —
a new blob index with the previous blob's stale offset — is unreachable rather than merely unlikely.
Neither a packed word nor per-blob counters are needed.
addBlob()also writes the blob pointer and resets_cur_offbefore advancing_cur_blob, so_cur_blobalone publishes the blob. The blob comparison in_is_allocatedis<=/<ratherthan a test for "not the current blob" to match, since a blob past
_cur_blobcan be allocated butunpublished.
_blobsneeds no atomic: it is only read at an index no greater than_cur_blob, andthat write is sequenced before the release store the reader acquired.
What remains is that a reader can observe an older
_cur_blobwith an already reset_cur_offandreject an id naming the previous blob, which drops an increment rather than misattributing one.
_extractTypeon a negative idIt shifted a signed
IdType, so_extractType(NOT_FOUND)sign extended to-4— aMetricTypeoutside its enumeration, returned by
Metrics::type(). Shifting unsigned is not sufficient on itsown: the sign bit sits above the type field, so
NOT_FOUNDstill yields4. Masking to the singlebit
_makeIdwrites makes the function total for any input.Testing
A new test resolves ids from several threads while another registers metrics across a few blob
boundaries. Under the
tsanpreset, making either allocation counter non-atomic again reports a datarace there. It does not catch a downgrade of the release/acquire pairs to relaxed — atomics are
race free at any ordering, so TSAN stays quiet and the assertions still hold. The memory orders are
reviewed, not tested, and the test says so.
tools/benchmark/benchmark_Metrics.ccis new; nothing in tree measured these paths, which is how aglobal mutex on the hottest one went unnoticed. Four cases scaled by thread count, on a 10 core
machine at 20k ops/thread:
increment(ptr)increment(id)lookup(id)lookup(name)lookup(name)is a deliberate control: it still takes the mutex, so it must degrade with threadcount. It goes 2.3 ms to 317 ms while
lookup(id)goes 1.05 to 6.13 ms, which is the evidence thatthe harness loads the machine rather than the lock free numbers being flat for want of load. Above
10 threads the machine is oversubscribed, so treat the shape as meaningful and the magnitudes as
not.
Comparing a build with and without the atomics commit put every case within noise, the only
consistent signal being 4-8% on
lookup(id)— twoldaprhrather than twoldrhon ARM64, andplain loads on x86. Set against what the mutex costs, it is not a trade worth considering.
Provenance
The bounds and memory-order findings came out of a review of this code prompted by a production
perfprofile, in which the#13310locking accounted for roughly half of all CPU in futexcontention. I do not have a public link for that review to cite. The parts of it this PR does not
implement — deleting the
sdk_assertfrom theTSStat*entry points, an opaque handle API forplugins, restructuring
Storagearound per-blob published counts, and removingrename()— wereeither out of proportion to the measured benefit or need an upstream decision first.
createSpan's blob boundary andaddBlob's bound assert were part of the same review and landedearlier in #13505.