Files
seaweedfs/weed/filer/persisted_log_cache_test.go
Chris Lu 594fc667d5 Cut per-subscriber replay decode and widen replay concurrency (#9917)
* Filter metadata events before unmarshaling them per subscriber

Every subscriber unmarshaled every log entry into a full event just to
run the path filter, and entries carry complete chunk lists, so a fleet
of path-filtered subscribers spends almost all replay CPU materializing
events it then discards. A shallow wire scan now extracts just the
directory, entry names and rename destination into a skeleton event,
feeds the same matcher, and skips the decode for entries the subscriber
cannot match. Any scan surprise (malformed bytes, merged duplicate
message fields) falls back to the full decode, and the unsynced-events
heartbeat keeps firing for skipped entries.

* Raise the legacy replay cap

The cap was sized when every replay pinned a private chunk reader per
source filer. Replays now share decoded chunks, so sixteen needlessly
serializes subscriber catch-up; the expensive part stays bounded by the
cache's load gate.

* Weight concurrent log-chunk loads by size

The flat eight-load gate let eight tiny chunks through as reluctantly as
eight full ones. Charge each load's chunk size against a 128MB in-flight
budget instead: small chunks decode wide open while full-size ones still
serialize enough to cap the transient peak. Oversized weights clamp to
the budget so they can always acquire.

* Propagate heartbeat send failures and reset the skip counter

A failed heartbeat send means the stream is gone, so end the replay
instead of scanning on. A delivered event also resets the skip counter,
keeping the heartbeat cadence relative to the last thing the client
actually received.

* Share the unsynced-events counter across the prefilter and delivery

Two independent counters could starve the heartbeat: alternating drops
reset each side before either reached its threshold. One shared counter
increments on every dropped entry, prefiltered or not, and only an
actual delivery resets it, restoring the original cadence exactly.

* Tighten comments

* Benchmark the subscription match paths

For a thousand-chunk event that the subscriber filters out, the shallow
scan matches in 10us and 9 allocations against 175us and 4031
allocations for the full decode.
2026-06-10 13:08:34 -07:00

428 lines
14 KiB
Go

package filer
import (
"bytes"
"fmt"
"io"
"sync"
"sync/atomic"
"testing"
"time"
"google.golang.org/protobuf/proto"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/util"
"github.com/seaweedfs/seaweedfs/weed/wdclient"
)
func logEntriesAt(tsNs ...int64) []*filer_pb.LogEntry {
out := make([]*filer_pb.LogEntry, 0, len(tsNs))
for _, ts := range tsNs {
out = append(out, &filer_pb.LogEntry{TsNs: ts, Data: []byte("x")})
}
return out
}
func encodeLogRecords(t *testing.T, entries []*filer_pb.LogEntry) []byte {
t.Helper()
var buf bytes.Buffer
sizeBuf := make([]byte, 4)
for _, e := range entries {
data, err := proto.Marshal(e)
if err != nil {
t.Fatal(err)
}
util.Uint32toBytes(sizeBuf, uint32(len(data)))
buf.Write(sizeBuf)
buf.Write(data)
}
return buf.Bytes()
}
func TestPersistedLogCacheHitMiss(t *testing.T) {
c := newPersistedLogCache(persistedLogCacheMaxBytes)
var loads int32
load := func() ([]*filer_pb.LogEntry, bool, error) {
atomic.AddInt32(&loads, 1)
return logEntriesAt(1, 2, 3), true, nil
}
e1, err := c.getOrLoad("3,01", 1, load)
if err != nil || len(e1) != 3 {
t.Fatalf("first getOrLoad: err=%v len=%d", err, len(e1))
}
e2, err := c.getOrLoad("3,01", 1, load)
if err != nil {
t.Fatal(err)
}
if n := atomic.LoadInt32(&loads); n != 1 {
t.Fatalf("expected loader called once, got %d", n)
}
if &e1[0] != &e2[0] {
t.Fatal("cache should return the same shared slice")
}
}
func TestPersistedLogCacheNotCachedWhenUncacheable(t *testing.T) {
c := newPersistedLogCache(persistedLogCacheMaxBytes)
var loads int32
// cacheable=false models a chunk-not-found stop: deliver the prefix this
// round but never pin it, so a transient outage is re-probed next replay.
load := func() ([]*filer_pb.LogEntry, bool, error) {
atomic.AddInt32(&loads, 1)
return logEntriesAt(1, 2), false, nil
}
if e, err := c.getOrLoad("3,01", 1, load); err != nil || len(e) != 2 {
t.Fatalf("first: err=%v len=%d", err, len(e))
}
if e, err := c.getOrLoad("3,01", 1, load); err != nil || len(e) != 2 {
t.Fatalf("second: err=%v len=%d", err, len(e))
}
if n := atomic.LoadInt32(&loads); n != 2 {
t.Fatalf("uncacheable result must re-load every time, got %d loads", n)
}
}
func TestPersistedLogCacheSingleFlight(t *testing.T) {
c := newPersistedLogCache(persistedLogCacheMaxBytes)
var loads int32
release := make(chan struct{})
started := make(chan struct{})
load := func() ([]*filer_pb.LogEntry, bool, error) {
if atomic.AddInt32(&loads, 1) == 1 {
close(started)
}
<-release // hold the flight open so concurrent callers coalesce
return logEntriesAt(1), true, nil
}
const n = 20
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if _, err := c.getOrLoad("3,01", 1, load); err != nil {
t.Error(err)
}
}()
}
<-started // the flight is provably open before anyone is released
time.Sleep(10 * time.Millisecond)
close(release)
wg.Wait()
if got := atomic.LoadInt32(&loads); got != 1 {
t.Fatalf("expected 1 coalesced load across %d callers, got %d", n, got)
}
}
func TestPersistedLogCacheEviction(t *testing.T) {
// One entry estimates to 128 + 100 + 16 = 244 bytes; budget holds one, not two.
c := newPersistedLogCache(300)
mk := func(ts int64) func() ([]*filer_pb.LogEntry, bool, error) {
return func() ([]*filer_pb.LogEntry, bool, error) {
return []*filer_pb.LogEntry{{TsNs: ts, Data: make([]byte, 100)}}, true, nil
}
}
if _, err := c.getOrLoad("a", 1, mk(1)); err != nil {
t.Fatal(err)
}
if _, err := c.getOrLoad("b", 1, mk(2)); err != nil { // pushes over budget, evicts LRU "a"
t.Fatal(err)
}
c.mu.Lock()
_, hasA := c.index["a"]
_, hasB := c.index["b"]
c.mu.Unlock()
if hasA {
t.Error("least-recently-used entry a should have been evicted")
}
if !hasB {
t.Error("most-recently-used entry b should remain")
}
}
func TestDecodeLogRecords(t *testing.T) {
want := logEntriesAt(10, 20, 30)
entries, cacheable, err := decodeLogRecords(encodeLogRecords(t, want))
if err != nil || !cacheable {
t.Fatalf("clean chunk: err=%v cacheable=%v", err, cacheable)
}
if len(entries) != 3 || entries[0].TsNs != 10 || entries[2].TsNs != 30 {
t.Fatalf("decoded %v", entries)
}
if entries, cacheable, err := decodeLogRecords(nil); err != nil || !cacheable || len(entries) != 0 {
t.Fatalf("empty chunk: entries=%v cacheable=%v err=%v", entries, cacheable, err)
}
}
func TestDecodeLogRecordsIncomplete(t *testing.T) {
full := encodeLogRecords(t, logEntriesAt(10, 20))
rec1 := len(encodeLogRecords(t, logEntriesAt(10)))
// stops mid-record: the second record's bytes are cut short
entries, cacheable, err := decodeLogRecords(full[:len(full)-2])
if err != errLogChunkIncomplete || cacheable {
t.Fatalf("mid-record: err=%v cacheable=%v", err, cacheable)
}
if len(entries) != 1 || entries[0].TsNs != 10 {
t.Fatalf("mid-record prefix: %v", entries)
}
// stops mid-size-prefix
if _, _, err := decodeLogRecords(full[:rec1+2]); err != errLogChunkIncomplete {
t.Fatalf("mid-prefix: err=%v", err)
}
// garbage size prefix, e.g. a chunk that starts mid-record
garbage := make([]byte, 8)
util.Uint32toBytes(garbage, uint32(maxLogEntrySize)+1)
if _, _, err := decodeLogRecords(garbage); err != errLogChunkIncomplete {
t.Fatalf("garbage size: err=%v", err)
}
}
func logFileEntry(chunks ...*filer_pb.FileChunk) *Entry {
return &Entry{
FullPath: "/topics/.system/log/2026-06-10/00-01.f1",
Attr: Attr{Mode: 0644},
Chunks: chunks,
}
}
func stubChunkLoader(t *testing.T, chunkData map[string][]byte) *int32 {
t.Helper()
var loads int32
prev := loadLogFileEntriesFn
loadLogFileEntriesFn = func(_ *wdclient.MasterClient, chunk *filer_pb.FileChunk) ([]*filer_pb.LogEntry, bool, error) {
atomic.AddInt32(&loads, 1)
data, ok := chunkData[chunk.GetFileIdString()]
if !ok {
t.Errorf("unexpected load of chunk %s", chunk.GetFileIdString())
return nil, false, fmt.Errorf("unexpected chunk %s", chunk.GetFileIdString())
}
return decodeLogRecords(data)
}
t.Cleanup(func() { loadLogFileEntriesFn = prev })
return &loads
}
func collectTs(t *testing.T, iter *LogFileIterator) (got []int64) {
t.Helper()
for {
e, err := iter.getNext()
if err == io.EOF {
return
}
if err != nil {
t.Fatal(err)
}
got = append(got, e.TsNs)
}
}
func TestLogFileIteratorChunkedFiltering(t *testing.T) {
stubChunkLoader(t, map[string][]byte{
"3,01": encodeLogRecords(t, logEntriesAt(10, 20)),
"3,02": encodeLogRecords(t, logEntriesAt(30, 40)),
})
entry := logFileEntry(
&filer_pb.FileChunk{FileId: "3,01", Offset: 0, Size: 100, ModifiedTsNs: 25},
&filer_pb.FileChunk{FileId: "3,02", Offset: 100, Size: 100, ModifiedTsNs: 45},
)
iter := newLogFileIterator(nil, newPersistedLogCache(persistedLogCacheMaxBytes), entry, 15, 35)
got := collectTs(t, iter)
want := []int64{20, 30} // 10 filtered by startTsNs, 40 cut off by stopTsNs
if fmt.Sprint(got) != fmt.Sprint(want) {
t.Fatalf("chunked filtering: got %v, want %v", got, want)
}
}
func TestLogFileIteratorSkipsColdChunks(t *testing.T) {
// chunk "3,01" is not in the stub map: loading it would fail the test. Its
// flush time (ModifiedTsNs) is a full flush interval before startTsNs, so it
// must be skipped without being read.
start := int64(20) + int64(LogFlushInterval)
stubChunkLoader(t, map[string][]byte{
"3,02": encodeLogRecords(t, logEntriesAt(start+10, start+20)),
})
entry := logFileEntry(
&filer_pb.FileChunk{FileId: "3,01", Offset: 0, Size: 100, ModifiedTsNs: 20},
&filer_pb.FileChunk{FileId: "3,02", Offset: 100, Size: 100, ModifiedTsNs: start + 25},
)
iter := newLogFileIterator(nil, newPersistedLogCache(persistedLogCacheMaxBytes), entry, start, 0)
got := collectTs(t, iter)
if fmt.Sprint(got) != fmt.Sprint([]int64{start + 10, start + 20}) {
t.Fatalf("cold-chunk skip: got %v", got)
}
}
func TestLogFileIteratorSharesDecodedChunks(t *testing.T) {
loads := stubChunkLoader(t, map[string][]byte{
"3,01": encodeLogRecords(t, logEntriesAt(10, 20)),
})
entry := logFileEntry(&filer_pb.FileChunk{FileId: "3,01", Offset: 0, Size: 100, ModifiedTsNs: 25})
cache := newPersistedLogCache(persistedLogCacheMaxBytes)
for i := 0; i < 3; i++ { // three replays of the same chunk
iter := newLogFileIterator(nil, cache, entry, 0, 0)
if got := collectTs(t, iter); fmt.Sprint(got) != fmt.Sprint([]int64{10, 20}) {
t.Fatalf("replay %d: got %v", i, got)
}
}
if n := atomic.LoadInt32(loads); n != 1 {
t.Fatalf("expected one shared decode across replays, got %d", n)
}
}
func TestLogFileIteratorStreamsWhenRecordsSpanChunks(t *testing.T) {
// One file of four records, flushed in a way that splits the third record
// across two chunks. The first chunk decodes records 10 and 20 cleanly; the
// second starts mid-record. The iterator must yield every record exactly
// once by falling back to a whole-file byte stream.
all := logEntriesAt(10, 20, 30, 40)
full := encodeLogRecords(t, all)
cut := len(encodeLogRecords(t, all[:2])) + 3 // 3 bytes into record 30
stubChunkLoader(t, map[string][]byte{
"3,01": full[:cut],
"3,02": full[cut:],
})
prev := newLogFileStreamReader
newLogFileStreamReader = func(_ *wdclient.MasterClient, _ []*filer_pb.FileChunk) io.Reader {
return bytes.NewReader(full)
}
t.Cleanup(func() { newLogFileStreamReader = prev })
entry := logFileEntry(
&filer_pb.FileChunk{FileId: "3,01", Offset: 0, Size: uint64(cut), ModifiedTsNs: 45},
&filer_pb.FileChunk{FileId: "3,02", Offset: int64(cut), Size: uint64(len(full) - cut), ModifiedTsNs: 45},
)
iter := newLogFileIterator(nil, newPersistedLogCache(persistedLogCacheMaxBytes), entry, 0, 0)
got := collectTs(t, iter)
if fmt.Sprint(got) != fmt.Sprint([]int64{10, 20, 30, 40}) {
t.Fatalf("spanning fallback: got %v", got)
}
}
func TestLogFileIteratorStreamFallbackResumesAfterYielded(t *testing.T) {
// The first chunk decodes standalone and its records are yielded before the
// second chunk turns out to be incomplete. The stream fallback re-reads the
// file from the start, so the already-yielded records must be skipped.
all := logEntriesAt(10, 20, 30, 40)
full := encodeLogRecords(t, all)
chunk1 := len(encodeLogRecords(t, all[:2]))
stubChunkLoader(t, map[string][]byte{
"3,01": full[:chunk1],
"3,02": full[chunk1 : len(full)-2], // truncated mid-record
})
prev := newLogFileStreamReader
newLogFileStreamReader = func(_ *wdclient.MasterClient, _ []*filer_pb.FileChunk) io.Reader {
return bytes.NewReader(full)
}
t.Cleanup(func() { newLogFileStreamReader = prev })
entry := logFileEntry(
&filer_pb.FileChunk{FileId: "3,01", Offset: 0, Size: uint64(chunk1), ModifiedTsNs: 45},
&filer_pb.FileChunk{FileId: "3,02", Offset: int64(chunk1), Size: uint64(len(full) - chunk1), ModifiedTsNs: 45},
)
iter := newLogFileIterator(nil, newPersistedLogCache(persistedLogCacheMaxBytes), entry, 0, 0)
got := collectTs(t, iter)
if fmt.Sprint(got) != fmt.Sprint([]int64{10, 20, 30, 40}) {
t.Fatalf("fallback resume: got %v", got)
}
}
func TestLogFileIteratorLoadErrorPropagates(t *testing.T) {
boom := fmt.Errorf("boom")
prev := loadLogFileEntriesFn
loadLogFileEntriesFn = func(_ *wdclient.MasterClient, _ *filer_pb.FileChunk) ([]*filer_pb.LogEntry, bool, error) {
return nil, false, boom
}
t.Cleanup(func() { loadLogFileEntriesFn = prev })
entry := logFileEntry(&filer_pb.FileChunk{FileId: "3,01", Offset: 0, Size: 100, ModifiedTsNs: 45})
iter := newLogFileIterator(nil, newPersistedLogCache(persistedLogCacheMaxBytes), entry, 0, 0)
if _, err := iter.getNext(); err == nil || err.Error() != "boom" {
t.Fatalf("expected load error surfaced, got %v", err)
}
}
func TestPersistedLogCacheIdleEviction(t *testing.T) {
c := newPersistedLogCache(persistedLogCacheMaxBytes)
load := func() ([]*filer_pb.LogEntry, bool, error) {
return logEntriesAt(1), true, nil
}
if _, err := c.getOrLoad("idle", 1, load); err != nil {
t.Fatal(err)
}
if _, err := c.getOrLoad("hot", 1, load); err != nil {
t.Fatal(err)
}
c.mu.Lock()
c.index["idle"].Value.(*logCacheItem).lastUsed = time.Now().Add(-2 * persistedLogCacheIdleTTL)
c.mu.Unlock()
c.evictIdle(time.Now().Add(-persistedLogCacheIdleTTL))
c.mu.Lock()
_, hasIdle := c.index["idle"]
_, hasHot := c.index["hot"]
bytesLeft := c.curBytes
c.mu.Unlock()
if hasIdle {
t.Error("idle entry should have been evicted")
}
if !hasHot {
t.Error("recently used entry should remain")
}
if want := estimateEntriesBytes(logEntriesAt(1)); bytesLeft != want {
t.Errorf("curBytes=%d, want %d", bytesLeft, want)
}
}
func TestDecodeLogRecordsRejectsImplausibleRecords(t *testing.T) {
// zeroed region: parses as endless empty records without the size guard
if _, _, err := decodeLogRecords(make([]byte, 16)); err != errLogChunkIncomplete {
t.Fatalf("zeroed data: err=%v", err)
}
// the writer never produces a zero timestamp
if _, _, err := decodeLogRecords(encodeLogRecords(t, logEntriesAt(0))); err != errLogChunkIncomplete {
t.Fatalf("zero ts: err=%v", err)
}
// timestamps are strictly increasing within one flushed buffer
entries, cacheable, err := decodeLogRecords(encodeLogRecords(t, logEntriesAt(10, 10)))
if err != errLogChunkIncomplete || cacheable {
t.Fatalf("non-increasing ts: err=%v cacheable=%v", err, cacheable)
}
if len(entries) != 1 || entries[0].TsNs != 10 {
t.Fatalf("non-increasing prefix: %v", entries)
}
}
func TestPersistedLogCacheLoadLargerThanBudget(t *testing.T) {
// a load weight above the semaphore size could never be acquired; it must
// clamp and still run
c := newPersistedLogCache(persistedLogCacheMaxBytes)
e, err := c.getOrLoad("3,01", persistedLogCacheLoadBudget*4, func() ([]*filer_pb.LogEntry, bool, error) {
return logEntriesAt(1), true, nil
})
if err != nil || len(e) != 1 {
t.Fatalf("oversized load: err=%v len=%d", err, len(e))
}
}