Allow a metric to be unlisted - #13616
Conversation
A metric name, once created, was published for the life of the process. Any metric whose name or publication policy depends on a runtime changeable setting could therefore never retract a name it had already published, so such a setting only ever took effect for names created after the change. Tombstoning marks a slot as not enumerated. The slot, the name and the atomic survive, so lookup by name still resolves and creating the name again resurrects it with its value intact.
There was a problem hiding this comment.
🟡 Changes recommended
The new tombstone and iterator code paths need additional defensive validation and invariant enforcement to avoid incorrect behavior or potential out-of-bounds access on manufactured/invalid IDs.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR adds a “tombstone” mechanism to ts::Metrics so a metric can be withdrawn from publication (hidden from iteration/enum-based consumers) while remaining resolvable by exact name / id and keeping its backing atomic/value.
Changes:
- Extend
ts::Metrics::Storagewith a per-slot flag array and publictombstone()/tombstoned()APIs. - Update
ts::Metricsiteration semantics to skip tombstoned slots and to use a snapshot bound captured at iterator construction. - Add unit tests covering tombstoning behavior across both
ts::Metricsand records lookup, plus documentation updates.
File summaries
| File | Description |
|---|---|
src/tsutil/Metrics.cc |
Implements tombstone flagging and iterator behavior changes (snapshot bound + skip). |
include/tsutil/Metrics.h |
Exposes the tombstone API, adds per-slot flag storage, and updates iterator semantics/contracts. |
src/tsutil/unit_tests/test_Metrics.cc |
Adds coverage for tombstone behavior, iteration skipping, resurrection, and edge cases. |
src/records/unit_tests/test_RecHiddenMetricLookup.cc |
Verifies record lookup behavior with tombstoned metrics (enumeration vs exact lookup). |
doc/developer-guide/internal-libraries/Metrics.en.rst |
Documents the tombstone feature and its interaction with find()/iteration. |
Review details
Suppressed comments (2)
src/tsutil/Metrics.cc:288
Storage::tombstoned()indexes the per-slot flag array withoffsetwithout validating thatoffset < MAX_SIZE(or that the slot is allocated in the current blob). A manufactured/invalidIdTypewith a large offset can trigger out-of-bounds access; it should safely return false for non-allocated/non-sensical IDs.
Metrics::Storage::tombstoned(Metrics::IdType id) const
{
auto [blob_ix, offset] = _splitID(id);
Metrics::NamesAndAtomics *blob = _blobs[blob_ix].get();
if (!blob) {
return false;
}
return (std::get<2>(*blob)[offset].load(MEMORY_ORDER) & TOMBSTONE) != 0;
}
src/tsutil/Metrics.cc:296
- The positional iterator ctor
iterator(const Metrics&, IdType)does not callskip_tombstoned(). That allows external callers to construct an iterator that points at a tombstoned slot (contradicting the intended "iteration never visits marked slots" invariant) and reintroduces the non-terminating range-walk risk if such an iterator is used as a bound.
Metrics::iterator::iterator(const Metrics &m, IdType pos) : _metrics(m), _it(pos), _bound(m._storage->current_id()) {}
- Files reviewed: 5/5 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| auto [blob_ix, offset] = _splitID(id); | ||
| Metrics::NamesAndAtomics *blob = _blobs[blob_ix].get(); | ||
|
|
||
| // Only slots that have actually been allocated can be marked. | ||
| if (!blob || (blob_ix == _cur_blob && offset > _cur_off)) { | ||
| return false; | ||
| } |
The bound check only covered the current blob, so an id naming an offset beyond a full blob passed it and indexed off the end of that blob's flag array. It also accepted the next free slot, whose flag a later create() does not clear, so the metric eventually allocated there would be born invisible. A test caught exactly that: marking the free slot in one section hid an unrelated metric created in a later one. The iterator constructors are now private to Metrics. A caller able to name an arbitrary position could name a tombstoned one, which iteration must never visit and which does not terminate a range walk.
|
Thanks — all three findings were real, including the two that were filed as suppressed comments. Fixed in 9ed3ee8.
bool
allocated(IdType id) const
{
auto [blob, entry] = _splitID(id);
if (id < 0 || entry >= MAX_SIZE || !_blobs[blob]) {
return false;
}
return blob < _cur_blob || (blob == _cur_blob && entry < _cur_off);
}This is deliberately stricter than the existing I left The positional iterator constructor. You are right that it let a caller rest an iterator on a tombstoned slot, which reintroduces the non-terminating range-walk. Rather than only skipping, the three constructors are now private with New tests covering each case: an offset past the end of a full blob, the next free slot, and a blob index that was never allocated. |
allocated() indexes _blobs with a blob index taken from a caller supplied id and does not range check it. That is safe because _splitID masks the index with METRIC_TYPE_MASK, which happens to be MAX_BLOBS-1, but nothing tied the two together. Every other accessor that splits an id relies on the same coincidence.
|
Follow-up on a review question: That is in fact safe, but only by coincidence. b4fee22 makes it explicit: static_assert(MAX_BLOBS == METRIC_TYPE_MASK + 1, "a masked blob index must always be a valid _blobs index");Verified it fires: dropping Also added a test for the largest possible id, which exercises the other half — the offset is not masked to the blob size, so it needs the explicit Worth noting for a possible follow-up, out of scope here: |
There was a problem hiding this comment.
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.
|
|
||
| return blob < _cur_blob || (blob == _cur_blob && entry < _cur_off); | ||
| } | ||
| }; | ||
|
|
||
| Metrics(std::shared_ptr<Storage> &str) : _storage(str) {} | ||
|
|
||
| std::shared_ptr<Storage> _storage; | ||
|
|
||
| public: | ||
| // These are sort of factory classes, using the Metrics singleton for all storage etc. |
| auto [blob_ix, offset] = _splitID(it->second); | ||
|
|
||
| if (Metrics::NamesAndAtomics *blob = _blobs[blob_ix].get(); blob != nullptr) { | ||
| std::get<2>(*blob)[offset].fetch_and(static_cast<uint8_t>(~TOMBSTONE), MEMORY_ORDER); | ||
| } | ||
|
|
A tombstone elsewhere is a record that something was deleted, including in this codebase: CacheShm marks a slot dead and reusable that way. Nothing is deleted here. The metric keeps its slot, its name and its atomic, and keeps counting; only its appearance in listings changes. An unlisted phone number is the closer analogy, and it says the useful part out loud, that the metric still answers if you know its name. The bool parameter is gone with it. unlist and relist read at the call site where tombstone(id, false) did not, and the predicate is now the positive listed().
There was a problem hiding this comment.
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.
| /** Mark @a id as not enumerated, or clear that mark. | ||
| * | ||
| * A tombstoned metric keeps its slot, its name and its atomic. It is skipped by iteration, so it | ||
| * vanishes from everything that enumerates the store, but it still resolves through @c lookup and | ||
| * its value may still be read and written. Creating the same name again clears the mark and | ||
| * returns the same id. | ||
| * | ||
| * @return @c false if @a id does not name an allocated slot. | ||
| */ | ||
| bool | ||
| tombstone(IdType id, bool set = true) | ||
| { | ||
| return _storage->tombstone(id, set); | ||
| } | ||
|
|
||
| bool | ||
| tombstoned(IdType id) const | ||
| { | ||
| return _storage->tombstoned(id); | ||
| } | ||
|
|
| // Re-creating a name is how a tombstoned metric is resurrected: same slot, same atomic, and | ||
| // whatever value it accumulated while it was hidden. | ||
| auto [blob_ix, offset] = _splitID(it->second); | ||
|
|
||
| if (Metrics::NamesAndAtomics *blob = _blobs[blob_ix].get(); blob != nullptr) { | ||
| std::get<2>(*blob)[offset].fetch_and(static_cast<uint8_t>(~TOMBSTONE), MEMORY_ORDER); | ||
| } |
|
|
||
| // Only this bit, so a flag added later is not clobbered by a tombstone or a resurrect. | ||
| if (set) { | ||
| std::get<2>(*blob)[offset].fetch_or(TOMBSTONE, MEMORY_ORDER); | ||
| } else { | ||
| std::get<2>(*blob)[offset].fetch_and(static_cast<uint8_t>(~TOMBSTONE), MEMORY_ORDER); | ||
| } |
There was a problem hiding this comment.
🔵 Needs a closer look
It changes core metrics storage/iteration semantics used broadly across ATS, and needs human validation with full CI results and concurrency/compatibility scrutiny.
Review details
- Files reviewed: 5/5 changed files
- Comments generated: 0 new
- Review effort level: Lite
|
[approve ci osx autest 1] |
|
[approve ci clang-analyzer] |
create() had its own copy of the flag clearing, so the UNLISTED mask appeared in two places and the two could drift. Route it through set_listed instead; a name found in _lookups always names an allocated slot, so the call cannot fail. current() returned a pair of int16_t built from two uint16_t members. The values are small enough that it never mattered, but the narrowing misrepresents what a blob index and an offset are, and reading it as signed is what makes the offset check in allocated() look unsound.
|
Went through all six. Two were already addressed, three are the same point and are now fixed, and one is incorrect — details below. Fixed in ef97aa6
Not a defect: the negative-offset concern
The Already addressed The The PR description mismatch was real when filed; the description now documents |
There was a problem hiding this comment.
🟡 Changes recommended
The new per-slot atomic flag storage needs explicit initialization to avoid nondeterministic “unlisted” state, and one unit-test section can dereference begin() after making the store empty (undefined behavior).
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
src/tsutil/unit_tests/test_Metrics.cc:740
- This section dereferences *m.begin() after unlisting id 0; if this TEST_CASE path runs before any other metric is created, begin()==end() and dereferencing is undefined. Create a guaranteed-listed metric first so begin() is always safe to dereference.
- Files reviewed: 5/5 changed files
- Comments generated: 1
- Review effort level: Lite
| if (it != _lookups.end()) { | ||
| // Re-creating a name is how an unlisted metric is relisted: same slot, same atomic, and | ||
| // whatever value it accumulated while it was out of the listing. A name in _lookups always | ||
| // names an allocated slot, so this cannot fail. | ||
| set_listed(it->second, true); | ||
|
|
That was true through C++17, but P0883 changed It also does not depend on that change. I would rather not argue that from the standard, so 02d30f9 asserts it instead. The blob growth test already creates Confirmed the assertion is not vacuous. Storing That matters more than the standard argument, because reading uninitialized heap frequently does return zero — fresh pages are zero-filled — so this class of bug hides well and a test that only samples a metric or two would not catch it. I did not add an explicit initialization loop. It would be dead work on every blob, and the real risk is not today's behavior but a future change to something like |
There was a problem hiding this comment.
🟡 Changes recommended
Metrics::iterator can incorrectly treat listed GAUGE metrics as “end” due to type-bit contamination in find() positional iterators when using the new _bound numeric comparison.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 5/5 changed files
- Comments generated: 1
- Review effort level: Lite
| Metrics::iterator::iterator(const Metrics &m, IdType pos) : _metrics(m), _it(pos), _bound(m._storage->current_id()) | ||
| { | ||
| // Iteration never visits an unlisted slot, so an iterator must not rest on one either: used as a | ||
| // range bound it would be stepped over and never reached. find() resolves that case to end() | ||
| // before it gets here; this keeps the invariant true for any other positional construction. | ||
| skip_unlisted(); | ||
| } |
A metric id carries its type at METRIC_TYPE_BITS, but iterator positions are compared numerically against a bound built with COUNTER type bits. find() handed the stored id straight to the positional constructor, so any GAUGE id sat above the bound and the iterator reported itself exhausted. advance() already normalized this; the constructor did not. Regression from the bound comparison introduced with unlisting. No production code calls find(), and every test for it used a counter.
02d30f9 to
7b19905
Compare
|
This one is a real bug, and mine. Fixed in 7b19905.
It is a regression from the bound comparison I introduced with unlisting — before that, Fixed as suggested, by keeping only the blob and offset: auto [blob, offset] = _metrics._splitID(pos);
_it = _makeId(blob, offset, MetricType::COUNTER);Dereferencing is unaffected: Test added first and watched fail on Separately, I have dropped the commit I added earlier about |
There was a problem hiding this comment.
🟡 Changes recommended
The new allocated()-based checks introduce unsynchronized reads of shared storage state that can be exercised by the new public APIs, creating a C++ data race risk under concurrent metric registration.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 5/5 changed files
- Comments generated: 1
- Review effort level: Lite
| if (id < 0 || entry >= MAX_SIZE || !_blobs[blob]) { | ||
| return false; | ||
| } | ||
|
|
||
| return blob < _cur_blob || (blob == _cur_blob && entry < _cur_off); |
|
Correct as stated, but deliberately out of scope here.
So this is not a race introduced by unlisting; it is the existing synchronization model of The synchronization of this class is being addressed directly in #13583, "Metrics: close the id lookup race and bounds gaps left by the lock revert". That is the right place for it — it is a property of the whole store, not of this feature, and fixing it in two PRs at once would just produce conflicts. Whichever of the two lands second should extend the fix to cover the other's accessors: if #13583 goes first, |
|
[approve ci clang-analyzer] |
|
[approve ci clang] |
|
[approve ci analyzer] |
|
[approve ci clang-analyzer] |
Problem
ts::Metrics::Storagehas no removal path.create()allocates a slot and a name and nothing ever undoes either, so a metric name lives for the life of the process.Any code that decides whether to publish a name based on a runtime changeable input therefore makes a permanent commitment the first time it publishes. The decision is latched at first creation and can never be revisited.
The case that surfaced this is the per upstream server connection metrics from #13506.
proxy.config.http.per_server.connection.metric_aggregateisRECU_DYNAMICand overridable, and at value2the per group<fqdn>.<ip>:<port>metrics are supposed to stay hidden while only the per hostname aggregates are published. On a box that ran for a while at0before being switched to2, both shapes are present intraffic_ctl metric match per_server, and no reload can remove the first set. The config change took effect correctly for everything created after it; the names created before it simply cannot be withdrawn.What this does
Lets a metric be taken out of the store's listing.
An unlisted metric:
traffic_ctl metric match, the JSONRPC record lookup andstats_over_httpwith no change in any of those consumers;lookup(), soRecLookupRecord,LogAccessfield resolution andTSStatFindNamekeep working;Derivedaggregate sourcing from it is unaffected;create()on the same name, returning the same id with its accumulated value intact.An unlisted phone number is the analogy: not in the directory, but it still rings if you know it. This is a publication policy, not a lifetime — any
IdTypeorAtomicType *a caller already holds stays valid across an unlist and relist.This PR adds the mechanism only. Nothing in the tree calls it, so every existing metric enumerates exactly as before. The
ConnectionTrackerfix is a follow up.On the naming
This started out called
tombstone, which was wrong twice over. A tombstone elsewhere is a record that something was deleted, and this codebase already uses it that way —CacheShmtombstones a slot to mark it dead and reusable. Nothing is deleted here.hide/publishwould read best in isolation but both words are already load bearing for a different mechanism in this same class: the two separate stores,hidden_instance()andcreateHiddenPtr()versus the published store. An unlisted metric in the published store would have been "published but not published".listedcollides with neither, and says the useful part out loud.Implementation notes
Storage. A parallel
FlagStoragearray in the blob, rather than a member ofNameAndId: anstd::atomicmember would make that tuple neither copyable nor movable, and the slot is written with a tuple assignment. Blobs are already built withmake_unique, which value initializes, so flags start zero with no change toaddBlob(). Reads are lock free at relaxed ordering, matching the rest of the class. Cost is 1 KiB per 1024 slots against a blob that is already about 48 KiB. The singleUNLISTEDbit is set and cleared withfetch_or/fetch_andrather than a whole word store, so a flag added later is not clobbered.Id validation.
Storage::allocated()gates both entry points. It is deliberately stricter than the existingvalid(): the offset is the low 16 bits of an id and so can name a slot pastMAX_SIZEin a blob that is full, and_cur_offis the next free slot, sovalid()accepts one slot that does not exist yet. That second case is not theoretical — a test that marked the free slot caused an unrelated metric created later in the same run to come out invisible, becausecreate()only clears the flag when it finds the name already present, not when it allocates a fresh slot.A
static_assertnow tiesMAX_BLOBSto the mask_splitIDapplies to the blob index. That relationship is what keeps every_blobs[]subscript in this class in range without an explicit check, and nothing previously enforced it.Iterator. The skip loop needs an end bound, and deriving it from
end()per element would take the storage mutex per element. Instead the iterator captures the bound once at construction andend()becomes a pure sentinel that reads no storage — strictly less locking than before, whereend()locked on every construction. Iteration is now explicitly a snapshot taken atbegin(): a metric created mid iteration is never seen rather than sometimes seen, which matches the reasoning already recorded inRecLookupRecordaboutfind()/end()racing with concurrent registration.operator==is three way. Any exhausted iterator equals the end sentinel and equals any other exhausted iterator, since two of them may have skipped a different number of unlisted slots; two live iterators still compare by position, unchanged. The hand writtenoperator!=is removed in favor of the C++20 synthesized one.The three constructors are private to
Metrics. A caller able to name an arbitrary position could name an unlisted one, which iteration must never visit and which does not terminate a range walk.begin(),end()andfind()are the only ways to obtain an iterator, andfind()returnsend()for an unlisted metric — uselookup()to read one.Tests
test_Metrics.cc: skipped by iteration, still resolvable by name and id, relisted bycreate(), unlist and relist by name,begin()skipping an unlisted first slot, an unlisted run at the end of the store, iterating to a bound that is notend()with unlisted slots inside the range,find()yieldingend(), iterator comparison, independence between the published and hidden stores, and four rejected id shapes: an unallocated blob, an offset past the end of a full blob, the next free slot, and the largest possible id.test_RecHiddenMetricLookup.cc: an unlisted metric is not enumerated byRecLookupMatchingRecords, is still found byRecLookupRecord, and returns to enumeration when relisted.Documented in
doc/developer-guide/internal-libraries/Metrics.en.rst.