adapter/statsclient: fix stale symlink refresh and dir epoch check, add ListSymlinks - #369
adapter/statsclient: fix stale symlink refresh and dir epoch check, add ListSymlinks#369otroan wants to merge 3 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 |
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>
5fc83b7 to
11cd47d
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. Reading the vector once is far cheaper than resolving thousands of symlinks - but then the item names are recoverable only from the symlinks, and an item's position is not derivable from anything a caller can compute, because vlib_register_errors allocates it from a heap and reuses freed holes. VPP exports /sys/node/names, but nothing equivalent for error reasons, and StatEntry.Index for a symlink is its own directory index, not the item it aliases. ListSymlinks reports that mapping: for each symlink, the entry it aliases (index and name) and the item within it. Like ListStats it walks names and indexes only and copies no counter data, so it is cheap enough to rebuild whenever the epoch changes - which is the only time the mapping can change. The (target, item) pair stays off StatEntry: a caller wanting labels does not want the per-symlink data copy that DumpStats does, and a caller wanting data has no use for the indexes. The internal statSegment accessor reports ok=false for non-symlink entries and for v1, which has no symlink target encoding, so neither can be mistaken for a symlink with target 0 item 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
11cd47d to
209ec2e
Compare
Three changes to the stats adapter, one per commit. The first two are bug fixes that stand on their own; the third is the API change.
1.
UpdateDirleft symlink entries stale (31edb1f)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.Symlinks are now re-resolved through
CopyEntryData. That allocates, where the non-symlink path updates in place, because a resolved item has no stable backing slice to write into — called out in a comment, with a pointer toListSymlinksfor callers refreshing large numbers of symlinks per tick.2.
UpdateDircompared the prepared dir against a separately read epoch (0442495)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.
ListSymlinks(209ec2e)VPP exposes some counters only as one vector plus a set of symlinks naming its items.
/node/errorsis the case that motivates this: it is a single counter vector, and every/err/<node>/<reason>is a symlink into one item of it. Reading the vector once is far cheaper than resolving thousands of symlinks — but then the item names are recoverable only from the symlinks, and an item's position is not derivable from anything a caller can compute, becausevlib_register_errorsallocates it from a heap and reuses freed holes. VPP exports/sys/node/names, but nothing equivalent for error reasons, andStatEntry.Indexfor a symlink is its own directory index, not the item it aliases.returns, per symlink, the entry it aliases (index and name) plus the item index within it. Like
ListStatsit walks names and indexes only and copies no counter data, so it is cheap enough to rebuild whenever the epoch changes — which is the only time the mapping can change.What changed since the first version of this PR
Following review, the shape of the API change is different:
SymlinkTarget/SymlinkItemare no longer fields onStatEntry. A caller that wants labels does not want the per-symlink data copyDumpStatsdoes, and a caller that wants data has no use for the indexes. Putting the mapping on the identifier path serves the actual use case and keepsStatEntryas it was.(*StatsClient).Epoch()is dropped.PrepareDiralready returnsStatDir.Epoch— which is howexamples/stats-clientreads it — and as noted in review, a standalone accessor could not provide the staleness guarantee its doc comment implied.ok=falsefor non-symlink entries and for v1, which has no symlink target encoding, so neither can be mistaken for a symlink to target 0 / item 0.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: change the backing counter, call
UpdateDir, assert the value seen through the symlink moved. Reverting the fix in commit 1 givesBuilding 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.The integration test drops the earlier
Data != nilassertion, which was vacuous —PrepareDiralready populates it — and instead checks that each symlink's reported(target, item)yields the same value as resolving that symlink individually. That is the property a caller depends on when it reads a backing vector directly and labels its items fromListSymlinks.🤖 Generated with Claude Code