fix(metricstore): make stats read path non-mutating to avoid data race

recomputeStats() wrote statSum/statSamples/statMin/statMax/statsValid from
inside the stats() fast path, which runs under only a shared RLock via
MemoryStore.Stats -> Level.findBuffers. Two concurrent queries on the same
invalid buffer could both enter the fast path and race on those fields.

Require statsValid in the fast-path guard instead of recomputing inline;
invalid buffers now fall through to the existing point-by-point scan, which
only reads b.data. recomputeStats() is unchanged and still used by
checkpoint loadFile at single-threaded load time.

Updated stats_test.go: overwritten buffers now stay statsValid=false after a
query (documenting non-mutating reads); TestStatsMultiBufferChain and
TestStatsFastPathThenPartialTail now recompute stats after building bare
buffers so the fast-path cache fold is still covered; added
TestStatsConcurrentQueriesNoRace (race-clean under -race for both valid and
post-overwrite buffers) and TestStatsGappedChain (real inter-buffer gap
correctly excluded from Samples/Min/Max/Avg).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aditya Ujeniya
2026-07-20 11:03:16 +02:00
parent e37b2e71a6
commit f83f8afa86
2 changed files with 139 additions and 9 deletions
+1 -4
View File
@@ -74,10 +74,7 @@ func (b *buffer) stats(from, to int64) (Stats, int64, int64, error) {
// past the buffer's real data instead of scanning each point. Any slots
// between len(data) and cap are handled as gaps by the normal loop after
// t advances, so the returned `to` matches the scan semantics.
if len(b.data) > 0 && idx <= 0 && t <= b.firstWrite() && b.end() <= to {
if !b.statsValid {
b.recomputeStats()
}
if len(b.data) > 0 && b.statsValid && idx <= 0 && t <= b.firstWrite() && b.end() <= to {
if b.statSamples > 0 {
sum += b.statSum
samples += b.statSamples