Evict stale per-device history series in the collector - #89
Conversation
diskHist, rxHist, and txHist previously grew without bound: fastTick created a ring buffer per mountpoint/interface on first sight but never removed one, so churning veth interfaces (container start/stop) or USB drives/autofs mounts that come and go left dead series behind forever, growing memory usage and the /api/v1/metrics/history payload. Evict a device's series once its newest sample falls outside the retained history window (FastInterval * HistoryCapacity), not merely for being absent from a single tick, since DiskCollector.Collect already skips mountpoints that transiently fail to stat and such a device should not lose its history over one missed tick. Closes #24
There was a problem hiding this comment.
Reviewed against the project's Go/security/API conventions. go build ./..., go vet ./..., go test ./... -race -cover, and golangci-lint run (0 issues) all pass on ac1c234. No new dependencies, no exec.Command or privilege changes, no /api/v1 shape change, and RingBuffer.Newest() correctly handles the wraparound and post-Fill index cases.
Three findings; the first is blocking under this repo's test policy (submitting as a comment rather than "request changes" only because GitHub does not allow that on one's own PR).
- Inline on
collector_test.go— the new test bypassesfastTickand re-implements its bookkeeping, so the actual production change is uncovered. Two mutations ofcollector.gokeep the suite green, including dropping therxHist/txHisteviction entirely. - Inline on
collector.go:369— the eviction window is computed from the unclampedFastInterval, disabling eviction in the caseRun'sclampIntervalalready defends against. docs/ARCHITECTURE.md(not in the diff): the "Snapshot + history" paragraph still describes the per-device maps as "created lazily the first time a given device is seen", with nothing about removal — that sentence is now half the lifecycle. The checklist marks ARCHITECTURE.md not applicable, but the file already documents the analogous alert-side rule at length ("Disks are pruned, not garbage-collected implicitly."), and the collector's new rule deliberately differs from it:pruneDisksdrops a mountpoint after a single absent sample, while history now waits a full window. That divergence — and its reason, which is well argued in the PR description — is exactly what the next reader will come to that file for. A sentence or two there would cover it.
…t rule - Rewrite the eviction test to drive fastTick itself instead of reimplementing its per-device bookkeeping around evictStaleSeries directly, so the real call sites (including the previously-uncovered rxHist/txHist eviction) are under test. Add a table-driven test for evictStaleSeries covering the present/within-window/at-boundary/ past-boundary cases and the zero-window no-op. - Derive the eviction window from a FastInterval clamped once at construction (Collector.fastInterval) instead of the raw config field, so a non-positive FastInterval (already defended against by Run's clampInterval for the ticker) can't silently disable eviction too. Added a regression test. - Document the eviction rule in ARCHITECTURE.md, including why it deliberately differs from the alert engine's pruneDisks (prune on first absence there vs. a full history window here).
|
Pushed
|
There was a problem hiding this comment.
Re-reviewed e5ee2a3. All three points from the previous round are addressed properly. go build ./..., go vet ./..., go test ./... -race -cover, and golangci-lint run (0 issues) pass; collector coverage 87.2% → 87.6%.
I re-ran the mutation checks against the new tests, and they now catch what they need to:
Mutation of collector.go |
Result |
|---|---|
drop the rxHist/txHist eviction calls |
TestCollector_FastTick_EvictsStaleDeviceSeries fails |
revert historyWindow to the raw c.cfg.FastInterval |
TestCollector_FastTick_EvictsWithNonPositiveFastInterval fails |
> → >= on the window comparison |
TestEvictStaleSeries fails |
| evict on first absence (drop the age check) | TestEvictStaleSeries + TestCollector_FastTick_KeepsRecentlyMissingDeviceSeries fail |
Clamping FastInterval once in New and having both Run's ticker and fastTick's window read c.fastInterval is the right shape — grep confirms no raw cfg.FastInterval use is left in the collector, and New is the only constructor. The ARCHITECTURE.md paragraph lands in the right place and the contrast it draws with pruneDisks is exactly what was missing.
One correction to my previous review: I cited "passing netKeys instead of diskKeys to the disk call stays green" as evidence of the coverage gap. That mutant is still green on e5ee2a3, but it is an equivalent mutant, not a gap — every device in current has just had a sample stamped now added in the same critical section, so the age check alone already keeps it and the current membership check can never change the outcome at these call sites. It's worth keeping as defensive intent; nothing to do. The genuine evidence was the dropped rxHist/txHist calls, which the new test does catch.
One new finding inline: TestCollector_FastTick_KeepsRecentlyMissingDeviceSeries bounds a real fastTick inside a 3s window, while that tick can block for 5s+ on vcgencmd on actual Pi hardware.
- TestCollector_FastTick_KeepsRecentlyMissingDeviceSeries stamped its seeded sample with time.Now() and compared against a 3s window, but fastTick's own now is taken after collection that can legitimately run for several seconds on real Pi hardware (vcgencmd calls, statfsWithTimeout per mountpoint). Widen the window to 1h so the test no longer depends on tick duration. - SonarCloud flagged fastTick's cognitive complexity (25, limit 15). Extract the collect-and-log-errors prologue into collectFastTickSamples (returning a fastTickSamples struct) and the per-device history/eviction bookkeeping into recordDeviceHistory, leaving fastTick as orchestration only. No behavior change.
|
Pushed
|
There was a problem hiding this comment.
Reviewed e301729. go build ./..., go vet ./..., go test ./... -race -cover, and golangci-lint run (0 issues) pass locally; all five CI checks including the SonarCloud gate are green.
The one finding is a retraction of my own previous comment — see inline. The flakiness I flagged does not exist: fastTick takes its timestamp before collection, so the test never depended on tick duration, and the 3s window it replaced was correct. The change is harmless but its new comment states something untrue about the code, so I'd revert that one line.
The fastTick split checks out as behavior-preserving. What I verified rather than eyeballed:
- Order of operations is unchanged: collect (unlocked) →
c.mu.Lock()→latestassignment → scalar history → per-device history + eviction → alert evaluation.recordDeviceHistorysits exactly where the two device loops and the threeevictStaleSeriescalls used to be. - The consolidated
errvariable is safe:load,throttled,network, anduptimeall log their error immediately and never read it again. The four errors that outlive their call site (cpuErr,tempErr,memErr,diskErr) are carried on the struct, and each still feeds the same alert validity flag as before (SwapValidstill keyed offmemErr, matching the original). - No log line was lost or reworded — the set of
c.log.Warnmessages is byte-identical toe5ee2a3. netIfacesstays nil whenNetworkEnabledis false, whichTestCollector_FastTick_NetworkDisabledstill pins.- The moved eviction call sites are still covered. Re-ran the mutation checks against the refactored code: dropping the
rxHist/txHistcalls, reverting to the rawcfg.FastInterval, flipping>to>=, and skipping therecordDeviceHistorycall each fail the expected test. Coverage holds at 87.6%.
recordDeviceHistory documenting that it runs with c.mu already held is the right call, since that's no longer visible from the function itself.
The window had been widened to 1h on the premise that the test's margin must cover the tick's duration. It does not: fastTick captures its timestamp before any collection runs (s.now is the first statement of collectFastTickSamples), so the vcgencmd and statfs timeouts all elapse after that timestamp is taken and the gap between the test's seeded sample and fastTick's now is microseconds regardless of tick duration. Revert to the 3s window, which states the intent more directly and drops a comment that asserted a property of fastTick that is not true.
|
Reverted in I confirmed the retraction independently before reverting. Reproduced your experiment on this branch (3s window restored, 4s
Also re-checked that the 3s window doesn't weaken the test: mutating
|
|



📖 Description
fastTickininternal/collector/collector.golazily creates a ring buffer per mountpoint (diskHist) and per network interface (rxHist/txHist) but never removed one. Devices that disappear (e.g.veth*interfaces from Docker/Podman container churn, USB drives, autofs mounts) kept their full ring buffer in memory forever and kept being emitted byHistory()/GET /api/v1/metrics/history, causing slow unbounded memory growth and an ever-growing history payload.This is a fix, not a breaking change: no REST API shape changes.
🎫 Issues
Closes #24
👩💻 Reviewer Notes
evictStaleSeries(internal/collector/collector.go), called fromfastTickfordiskHist,rxHist, andtxHistafter their per-deviceAddloops, still underc.mu.Lock().FastInterval * HistoryCapacity) — not merely for being absent from a single tick. This matchesDiskCollector.Collect, which already skips mountpoints that transiently fail to stat, so a one-tick miss must not lose history.RingBuffer.Newest()to read the most recently added sample (needed to check staleness) without exposing internal layout.evictStaleSeriesand its two call sites infastTick; the rest of the diff is plumbing (diskKeys/netKeyssets built alongside the existing per-device loops).📑 Test Plan
TestCollector_History_EvictsStaleDeviceSeries(internal/collector/collector_test.go): withHistoryCapacity: 3(a 3s window) and fake tick timestamps, a device missing for a single tick keeps its history, while a device still absent once its newest sample is older than the window is evicted fromHistory(); a continuously-present device is unaffected.RingBuffer.Newest()unit tests (internal/collector/ringbuffer_test.go): empty buffer, basic case, and after wraparound.go build ./...,go vet ./...,go test ./... -race -cover, andgolangci-lint runall pass locally.✅ Checklist
General
go test ./... -race -coverpasses locally).go vet ./...andgolangci-lint runare clean.ARCHITECTURE.mdif this changes a documented design decision. (not applicable — no design-decision change, just closing a memory leak within the existing history-window design)REST API / configuration / packaging
Not applicable — no REST API, configuration, or packaging change.
⏭ Next Steps
None.