Files
seaweedfs/weed/filer/reader_pattern_test.go
Chris Lu 7e13340dcb filer: tolerance-window read pattern detection for concurrent readahead (#9983)
* filer: tolerance-window read pattern detection for concurrent readahead

ReaderPattern detected sequential access via strict contiguity (lastReadStopOffset
== offset). Under concurrent/reordered ReadAt (parallel readahead, which ReadAt
already supports via RLock + atomics), that exact match frequently misses even for
a genuinely sequential stream, drifting toward random mode and disabling prefetch.

Track a read frontier (max offset+size, advanced with a lock-free CAS loop) and
treat a read as sequential when its start is within SeqTolerance (8 MiB) of the
frontier. The window absorbs reordered/concurrent readahead while still rejecting
far random jumps; the existing ModeChangeLimit hysteresis is unchanged. API
(NewReaderPattern/MonitorReadAt/IsRandomMode) is unchanged, so reader_at is
unaffected. Adds reader_pattern_test.go.

* filer: read the read frontier inside the CAS loop

Capture the frontier from the CAS loop's own load rather than a separate
up-front snapshot, so the sequentiality diff is judged against the freshest
pre-image even if a concurrent readahead advances the frontier while we loop.
Also drops the now-redundant load. Mirrors the same fix on the write-side
ReaderPattern twin (review feedback on the companion PR).

* filer: add frontier/boundary/recovery guard tests for ReaderPattern

Mirror the regression guards added to the write-side WriterPattern: assert
the max-frontier never regresses on a backward read (the CAS invariant), pin
both sides of the inclusive SeqTolerance boundary, and verify sustained near
reads recover sequential mode out of the negative floor. Each guards an
invariant the original four tests left uncovered.
2026-06-16 09:15:44 -07:00

105 lines
3.8 KiB
Go

package filer
import (
"sync/atomic"
"testing"
)
const mb = 1 << 20
func TestReaderPatternSequentialAndHysteresis(t *testing.T) {
rp := NewReaderPattern()
rp.MonitorReadAt(0, mb) // near(0 vs frontier 0) -> +1
rp.MonitorReadAt(mb, mb) // +2
rp.MonitorReadAt(2*mb, mb) // +3 (capped)
if rp.IsRandomMode() {
t.Fatal("a stream from offset 0 must not be random")
}
// a single far outlier does not flip to random (hysteresis): +3 -> +2
rp.MonitorReadAt(900*mb, mb)
if rp.IsRandomMode() {
t.Fatal("a single outlier read must not flip to random mode")
}
}
func TestReaderPatternFarFirstReadIsRandom(t *testing.T) {
rp := NewReaderPattern()
rp.MonitorReadAt(500*mb, mb) // far from frontier 0 -> -1
if !rp.IsRandomMode() {
t.Fatal("a far first read should be random")
}
}
func TestReaderPatternToleranceAbsorbsReorder(t *testing.T) {
rp := NewReaderPattern()
rp.MonitorReadAt(0, mb) // +1, frontier 1MB
rp.MonitorReadAt(6*mb, mb) // |6-1|=5MB <= 8MB tolerance -> near, +2, frontier 7MB
rp.MonitorReadAt(3*mb, mb) // |3-7|=4MB <= 8MB -> near, +3
if rp.IsRandomMode() {
t.Fatal("reordered reads within tolerance must stay sequential")
}
}
func TestReaderPatternRandomRecoveryNeedsHysteresis(t *testing.T) {
rp := NewReaderPattern()
rp.MonitorReadAt(100*mb, mb) // far -> -1
rp.MonitorReadAt(300*mb, mb) // far -> -2
rp.MonitorReadAt(500*mb, mb) // far -> -3 (capped), frontier 501MB
// a single near read must not immediately flip back to sequential: -3 -> -2
rp.MonitorReadAt(501*mb, mb)
if !rp.IsRandomMode() {
t.Fatal("one near read must not flip back from deep random mode")
}
}
// The max-frontier (vs the old atomic.Swap of the last read's stop offset) is the
// load-bearing difference of this detector: the frontier must never move backward,
// or a backward read would lower the baseline and make later far reads look near.
func TestReaderPatternFrontierNeverRegresses(t *testing.T) {
rp := NewReaderPattern()
rp.MonitorReadAt(500*mb, mb) // far first read -> -1, frontier 501MB
rp.MonitorReadAt(0, mb) // a backward read must not pull the frontier back
if got := atomic.LoadInt64(&rp.readFrontier); got != 501*mb {
t.Fatalf("frontier regressed to %d; the max-frontier must never move backward", got)
}
// Behavioral consequence: reads far below the preserved frontier stay random.
// Had the frontier regressed to ~1MB, this would wrongly read as sequential.
if !rp.IsRandomMode() {
t.Fatal("reads far below the preserved frontier must remain random")
}
}
// SeqTolerance is the central new tuning knob; pin both sides of the inclusive
// boundary so an off-by-one or a '<' vs '<=' change can't slip through silently.
func TestReaderPatternToleranceBoundary(t *testing.T) {
atBoundary := NewReaderPattern()
atBoundary.MonitorReadAt(SeqTolerance, 0) // diff == SeqTolerance -> near (inclusive), +1
if atBoundary.IsRandomMode() {
t.Fatal("a read exactly at frontier+SeqTolerance must count as sequential")
}
pastBoundary := NewReaderPattern()
pastBoundary.MonitorReadAt(SeqTolerance+1, 0) // diff == SeqTolerance+1 -> far, -1
if !pastBoundary.IsRandomMode() {
t.Fatal("a read one byte past frontier+SeqTolerance must count as random")
}
}
// The escape from random mode: sustained near reads must climb the counter back
// out of the negative floor and re-enter sequential (whole-chunk cache) mode.
func TestReaderPatternRecoversFromRandom(t *testing.T) {
rp := NewReaderPattern()
rp.MonitorReadAt(100*mb, mb) // far -> -1
rp.MonitorReadAt(300*mb, mb) // far -> -2
rp.MonitorReadAt(500*mb, mb) // far -> -3 (capped), frontier 501MB
if !rp.IsRandomMode() {
t.Fatal("three far reads must be random")
}
// contiguous near reads: -3 -> -2 -> -1 -> 0 -> +1, back out of random mode
for i := int64(0); i < 4; i++ {
rp.MonitorReadAt(501*mb+i*mb, mb)
}
if rp.IsRandomMode() {
t.Fatal("sustained near reads must recover sequential mode")
}
}