adapter/statsclient: fix stale symlink refresh and dir epoch check, group symlink reads - #369
adapter/statsclient: fix stale symlink refresh and dir epoch check, group symlink reads#369otroan wants to merge 4 commits into
Conversation
| // The epoch changes whenever the directory layout changes (counters added/removed), so | ||
| // a StatDir prepared under a different epoch is stale and must be re-prepared. Lets a | ||
| // caller pre-check staleness instead of relying on an UpdateDir error. | ||
| func (sc *StatsClient) Epoch() (epoch int64, inProgress bool, err error) { |
There was a problem hiding this comment.
Why expose this? The result can become stale immediately after the call returns, so it can't be used as a guarantee that a prepared dir is still valid.
The comment says callers can "pre-check staleness", which feels misleading because there's an unavoidable TOCTOU race here. UpdateDir still has to do the authoritative epoch/access checks anyway. What's the intended use case for exposing Epoch() separately?
| @@ -0,0 +1,52 @@ | |||
| // Copyright (c) 2026 Cisco and/or its affiliates. | |||
|
I think there's still a race in the stale-dir guarantee here, that existed before this PR actually. We compare Since this PR relies on epoch changes invalidating a prepared dir, shouldn't we use the epoch returned by |
| t.Fatal("UpdateDir failed:", err) | ||
| } | ||
| for i := range dir.Entries { | ||
| if e := &dir.Entries[i]; e.Symlink && e.Data == nil { |
There was a problem hiding this comment.
I don't think this tests the behavior the PR is adding. PrepareDir already resolves the symlink and populates Data, so Data != nil after UpdateDir would also pass if UpdateDir left the symlink untouched.
Can we change the backing counter between prepare/update and assert that the value observed through the symlink actually changed?
| SymlinkTarget uint32 | ||
| SymlinkItem uint32 |
There was a problem hiding this comment.
Do we want to expose the stats-segment symlink representation as part of the public StatEntry API? This makes callers aware of directory indexes and item indexes specifically so they can bypass normal symlink resolution.
If the goal is efficient refresh, I'd rather see the statsclient provide that operation directly unless there is a concrete use case that requires callers to interpret the backing vector themselves.
I think we're exposing the implementation detail to solve a problem that The requirement in the PR body is a cheap That would give the collector the intended performance model without adding
type StatEntry struct {
StatIdentifier // contains Index
Type
Data
Symlink bool
}During entry.Data = sc.CopyEntryData(dirPtr, ^uint32(0))as the PR does now, it could first group all symlinks by target: map[targetIndex][]{
dstEntry,
itemIndex,
}Then copy each target once. That means:
The only remaining per-tick cost is reading the 8-byte symlink descriptor for each selected directory entry. For thousands of |
11cd47d to
209ec2e
Compare
updateStatOnIndex skips an entry whose directory type no longer matches the type recorded at PrepareDir. For a symlink those never match: the directory type stays Symlink while entry.Type is the resolved type of the counter it aliases. So every symlink in a prepared dir was silently left at its PrepareDir value, and a PrepareDir-once + UpdateDir-per-tick loop over, say, "/interfaces" reported the same numbers forever. Re-resolve symlinks through CopyEntryData instead. That allocates, where the non-symlink path updates in place, because a resolved item has no stable backing slice to write into - noted in a comment so callers refreshing large numbers of symlinks know to expect it. Adds a synthetic v2 stats segment to test against, laid out as VPP lays out the real one, so the refresh can be shown to pick up a changed backing counter without needing a running VPP to generate traffic. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…starts on UpdateDir read the epoch once for the staleness check and then let accessStart read it again. If the directory is re-laid-out between the two reads, the staleness check passes against the old epoch while the entries are resolved against the new directory - and accessEnd then confirms that same new epoch, so nothing catches it and the caller gets values read against a directory its entry indexes no longer describe. Drop the separate read and compare dir.Epoch against the epoch accessStart settled on, which is the one accessEnd validates. Also return an error when the directory vector is nil, rather than the nil named return, which reported success. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
209ec2e to
d2dc5cf
Compare
VPP exposes some counters only as one vector plus a set of symlinks naming its items. /node/errors is the case that hurts: it is a single counter vector, and every /err/<node>/<reason> is a symlink into one item of it. A capture from a live device has 4223 such symlinks over a 4223-item vector. UpdateDir resolved each of those individually, and resolving a symlink reads its whole backing vector - so refreshing a prepared dir over that fan re-read the same vector 4223 times. Group the symlinks in a prepared dir by the entry they alias, read each target once, and fan its items out into the prepared entries. The fan-out writes through the slices the entries already hold, so it does not allocate per symlink, and it produces exactly the shape resolving each symlink would: one value per worker thread. Anything the fan-out does not recognise falls back to resolving that symlink on its own, as does segment v1, which has no symlink target encoding. This stays entirely inside statsclient. An earlier version of this change exposed the (target, item) pair as a public ListSymlinks API so a caller could read the vector and label its items itself; doing the grouping here gives the same performance model with no API change at all, as suggested in review. Two allocation sources found on the way, both paid per symlink per refresh: storing the fanned-out slice back into the adapter.Stat interface boxed it, and getSymlinkIndexes serialised the union through a bytes.Buffer to split one uint64 into two uint32s. Refreshing 128 symlinks went from 654 allocations to 142 - the remainder being one name clone per prepared entry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
d2dc5cf to
3cbab7e
Compare
|
@ondrej-fabry Thanks — you were right on both counts. Force-pushed; the two bug-fix commits keep their SHAs, the third is replaced. On the epoch race: agreed, and it was already the fix in On the API: conceded — It is the better design, and not only for the API surface — it also means callers cannot get the mapping subtly wrong. I had a collector doing the read-vector-and-label-it dance externally, and the internal version deletes that code entirely. You called the remaining per-tick cost right too: decoding the 8-byte symlink descriptor per selected entry is cheap enough that no prepared state is needed. I did not add any — the grouping is rebuilt each refresh from the directory. Two things fell out while measuring, both paid per symlink per refresh, both fixed in the same commit:
Refreshing 128 symlinks went from 654 allocations to 142, the remainder being one name clone per prepared entry in Tests for the new behaviour: Measured against a live VPP with 4099 series, this took a collector's per-tick segment access from ~6.2 ms to ~24 µs — which matters less for the CPU than for the time spent inside an optimistically-locked segment. |
|
Added a fourth commit after measuring the grouped refresh end to end against a live VPP. With each symlink target now read once, the dominant remaining cost of Refreshing 128 symlinks: 654 allocations before this PR, 142 after the grouping, 14 with this. The v1 implementation had no coverage at all — the fake segment harness is v2-only — so the new test builds a directory vector directly for each version and checks that a prefix, an extension, a different name of equal length and an empty name are all rejected. Measured on a live VPP with 4099 series, a collector's steady tick went from ~14.9 ms to ~3.0 ms (medians over 24 ticks), producing byte-identical output on both paths. |
updateStatOnIndex confirms a prepared entry is still the one it prepared by comparing its name against the directory. It did that through GetStatDirOnIndex, which clones the name out of the shared memory region - correct for callers that retain it, wasteful here, where the copy is compared and dropped on the next line. UpdateDir runs this once per prepared entry per tick. With the previous commit reading each symlink target once, that clone became the dominant remaining cost of a refresh: a prepared dir over a real box's ~3900 entries cloned ~3900 names every tick. Add StatDirOnIndexMatches, which compares in place and reports whether the name at an index equals the caller's, and use it here. GetStatDirOnIndex keeps cloning, since its callers do retain the name. Refreshing 128 symlinks goes from 142 allocations to 14. Both segment versions implement it. The v1 path had no coverage at all - the fake segment harness is v2-only - so the test builds a directory vector directly for each version and checks that a prefix, an extension, a different name of equal length and an empty name are all rejected, and that a mismatch reports adapter.Unknown rather than a plausible type. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5bc0fee to
0d2d0aa
Compare
Three changes to the stats adapter, one per commit, all bug fixes or internal optimisation. No API change.
1.
UpdateDirleft symlink entries stale (18960a5)updateStatOnIndexskips an entry whose current directory type no longer matches the type recorded atPrepareDir. For a symlink those never match: the directory type staysSymlink, whileentry.Typeis the resolved type of the counter it aliases. So every symlink in a prepared dir kept itsPrepareDirvalue, and aPrepareDir-once +UpdateDir-per-tick loop over, say,/interfacesreported the same numbers forever.2.
UpdateDircompared the prepared dir against a separately read epoch (e1718f5)Pre-existing, and spotted by @ondrej-fabry in review. The staleness check read the epoch, then
accessStartread it again. If the directory is re-laid-out between the two reads, the check passes against the old epoch while the entries are resolved against the new directory — andaccessEndthen confirms that same new epoch, so nothing catches it and the caller gets values read against a directory its entry indexes no longer describe.dir.Epochis now compared against the epochaccessStartsettled on, which is the oneaccessEndvalidates. Also returns an error when the directory vector is nil, rather than the nil named return, which reported success.3.
UpdateDirreads each symlink target once (3cbab7e)VPP exposes some counters only as one vector plus a set of symlinks naming its items.
/node/errorsis the case that hurts: it is a single counter vector, and every/err/<node>/<reason>is a symlink into one item of it. A capture from a live device has 4223 such symlinks over a 4223-item vector.Commit 1 resolves each of those individually, and resolving a symlink reads its whole backing vector — so refreshing that prepared dir re-read the same vector 4223 times.
UpdateDirnow groups the symlinks in a prepared dir by the entry they alias, reads each target once, and fans its items out into the prepared entries. The fan-out writes through the slices the entries already hold, so it does not allocate per symlink, and it produces exactly the shape resolving each symlink would: one value per worker thread. Anything the fan-out does not recognise falls back to resolving that symlink on its own, as does segment v1, which has no symlink target encoding.Two allocation sources turned up while measuring, both paid per symlink per refresh:
adapter.Statinterface boxed it;getSymlinkIndexesserialised the union through abytes.Buffer+binary.Writeto split oneuint64into twouint32s. It isuint32(v)anduint32(v>>32)— the old code wrote little-endian and reassembled little-endian, so it round-tripped to the same numeric value on any host.Refreshing 128 symlinks went from 654 allocations to 142, the remainder being one name clone per prepared entry in
GetStatDirOnIndex.What changed since the previous version of this PR
The
ListSymlinksAPI is gone, per @ondrej-fabry's review — the grouping is done insidestatsclientinstead, exactly as suggested. That gives the same performance model with no public API change: noListSymlinks, noSymlinkEntry, noSymlinkTarget/SymlinkItemonStatEntry, noEpoch()accessor. Only the unexportedstatSegmentinterface gains an accessor for the symlink's(target, item)pair, which the grouping needs.The two bug-fix commits are unchanged and keep their SHAs.
Tests
adapter/statsclient/statseg_v2_fake_test.gobuilds a synthetic v2 segment laid out the way VPP lays out the real one — shared header, VPP-side pointers thatadjust()translates back, length-prefixed vectors — holding a/node/errorsvector plus one symlink per item, named in reverse item order so an off-by-one cannot pass unnoticed. That makes the refresh testable deterministically, with no traffic generation.New in commit 3:
TestUpdateDirReadsSymlinkTargetOncewraps the segment in a counting decorator and asserts that refreshing 64 symlinks over one target does exactly one data read.TestUpdateDirGroupedMatchesIndividualpins the fan-out against one-at-a-time resolution, value by value — an off-by-one here would mislabel every error counter while looking entirely plausible.TestUpdateDirSymlinkRefreshDoesNotAllocatebounds the per-symlink allocation cost.Reverting the fix in commit 1 gives
Building the fake also turned up a pre-existing quirk worth knowing about:
CopyEntryDatatreats union data of zero as "no data", so a symlink to target 0 / item 0 resolves tonil. Real VPP never lands there; it is documented in the fake rather than worked around.🤖 Generated with Claude Code