Files
seaweedfs/weed/pb/filer_pb_direct_read_test.go
T
Chris Lu 9b1ff91949 filer: stream offloaded metadata-log entries to fix concurrent-write OOM (#10203)
* filer: stream offloaded metadata-log entries instead of buffering whole files

The client metadata-chunks read path (ReadLogFileRefs, used by the meta
aggregator to consume peer filers and by mounts) decoded every entry of a
log file into a slice before handing it to the consumer, and prefetched the
next whole file the same way. Peak memory scaled with log-file size: under
heavy concurrent writes the per-event chunk lists grow and minute-files reach
hundreds of MB to GBs, so a filer aggregating a few peers held many GBs of
decoded entries at once (heap dominated by readLogFileEntries ->
consumeBytesNoZero) and OOMed.

Stream entries through a bounded channel: a producer decodes one entry at a
time and the next file's read overlaps processing via the channel buffer, so
peak memory is bounded by the channel depth rather than O(file size). In a
synthetic replay peak live heap dropped from ~1.3x the file size to a flat
few MB regardless of file size.

* filer: tighten offload replay tests

Share one ordered-replay assertion between the merge-order and single-filer
tests, assert the callback's own error is what propagates, and drop atomic
counters from callbacks that run on a single goroutine.

* filer: abort offloaded log replay promptly instead of joining wedged readers

Collapse the single-filer fast path into the merged reader: it was a second
copy of the producer/stop lifecycle with its own subtler synchronization, and
a one-stream merge does the same job.

On abort (processing error or a fatal read error from one filer), the
consumer used to drain channels and join every producer. A producer blocked
in an uncancellable chunk read cannot observe stop until that read returns,
so an abort could stall the caller's retry loop behind a dead volume-server
connection. Closing stop is now the only cleanup: producers check it at every
send and file boundary and exit on their own, and the merge loop's blocking
receives also escape on stop. Producers also check stop before opening each
file, so an aborted replay no longer keeps reading remaining files whose
entries never reach the channel.

A mid-file chunk-not-found still skips to the next file, but the log line now
reports how many entries were delivered first instead of pretending the whole
file was skipped; the redundant error log before setFatal is gone since the
error propagates to callers that already log it.

* filer: cap offloaded log entry allocation against corrupt size prefix

A garbage 4-byte size prefix (torn chunk or stream desync) drove
make([]byte, size) up to 4GiB per entry. Reject sizes above the same 1GiB
bound the filer-side log readers enforce.
2026-07-02 12:34:03 -07:00

452 lines
13 KiB
Go

package pb
import (
"bytes"
"errors"
"fmt"
"io"
"strings"
"sync/atomic"
"testing"
"time"
"google.golang.org/protobuf/proto"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/util"
)
// buildLogFileData creates the on-disk log file format:
// [4-byte size | protobuf LogEntry] repeated.
func buildLogFileData(events []*filer_pb.SubscribeMetadataResponse) []byte {
var buf bytes.Buffer
for _, event := range events {
eventData, _ := proto.Marshal(event)
logEntry := &filer_pb.LogEntry{
TsNs: event.TsNs,
Data: eventData,
Key: []byte(event.Directory),
}
entryData, _ := proto.Marshal(logEntry)
sizeBuf := make([]byte, 4)
util.Uint32toBytes(sizeBuf, uint32(len(entryData)))
buf.Write(sizeBuf)
buf.Write(entryData)
}
return buf.Bytes()
}
func makeSubEvent(dir, name string, tsNs int64) *filer_pb.SubscribeMetadataResponse {
return &filer_pb.SubscribeMetadataResponse{
Directory: dir,
TsNs: tsNs,
EventNotification: &filer_pb.EventNotification{
NewEntry: &filer_pb.Entry{
Name: name,
IsDirectory: false,
},
},
}
}
// delayedReader wraps data with a per-open latency to simulate volume server I/O.
type delayedReader struct {
data []byte
delay time.Duration
openedAt time.Time
}
func (r *delayedReader) Read(p []byte) (int, error) {
if r.openedAt.IsZero() {
r.openedAt = time.Now()
time.Sleep(r.delay)
}
if len(r.data) == 0 {
return 0, io.EOF
}
n := copy(p, r.data)
r.data = r.data[n:]
return n, nil
}
func (r *delayedReader) Close() error { return nil }
type testLogFiles struct {
refs []*filer_pb.LogFileChunkRef
fileData map[string][]byte // key: "filerId:fileTsNs" → raw log file bytes
fileDelay time.Duration
}
func newTestLogFiles(numFilers, filesPerFiler, eventsPerFile int, fileDelay time.Duration) *testLogFiles {
t := &testLogFiles{
fileData: make(map[string][]byte),
fileDelay: fileDelay,
}
baseTs := time.Now().Add(-time.Hour).UnixNano()
tsCounter := int64(0)
for f := 0; f < numFilers; f++ {
filerId := fmt.Sprintf("filer%02d", f)
for file := 0; file < filesPerFiler; file++ {
fileTsNs := baseTs + int64(file)*int64(time.Minute)
events := make([]*filer_pb.SubscribeMetadataResponse, eventsPerFile)
for i := 0; i < eventsPerFile; i++ {
tsCounter++
ts := baseTs + tsCounter
events[i] = makeSubEvent(
fmt.Sprintf("/data/%s/dir%02d", filerId, file),
fmt.Sprintf("file%04d.txt", i),
ts,
)
}
data := buildLogFileData(events)
key := fmt.Sprintf("%s:%d", filerId, fileTsNs)
t.fileData[key] = data
t.refs = append(t.refs, &filer_pb.LogFileChunkRef{
Chunks: []*filer_pb.FileChunk{{
FileId: key,
}},
FileTsNs: fileTsNs,
FilerId: filerId,
})
}
}
return t
}
func (t *testLogFiles) readerFn() LogFileReaderFn {
return func(chunks []*filer_pb.FileChunk) (io.ReadCloser, error) {
if len(chunks) == 0 {
return nil, fmt.Errorf("no chunks")
}
key := chunks[0].FileId
data, ok := t.fileData[key]
if !ok {
return nil, fmt.Errorf("file not found: %s", key)
}
dataCopy := make([]byte, len(data))
copy(dataCopy, data)
return &delayedReader{data: dataCopy, delay: t.fileDelay}, nil
}
}
func (t *testLogFiles) totalEvents() int {
total := 0
for _, data := range t.fileData {
pos := 0
for pos+4 <= len(data) {
size := int(util.BytesToUint32(data[pos : pos+4]))
pos += 4 + size
total++
}
}
return total
}
// assertOrderedReplay reads all refs and checks every event arrives exactly
// once, in timestamp order.
func assertOrderedReplay(t *testing.T, files *testLogFiles) {
t.Helper()
var timestamps []int64
_, err := ReadLogFileRefs(files.refs, files.readerFn(), 0, 0,
PathFilter{PathPrefix: "/"},
func(resp *filer_pb.SubscribeMetadataResponse) error {
timestamps = append(timestamps, resp.TsNs)
return nil
})
if err != nil {
t.Fatalf("ReadLogFileRefs: %v", err)
}
if got, want := len(timestamps), files.totalEvents(); got != want {
t.Fatalf("expected %d events, got %d", want, got)
}
for i := 1; i < len(timestamps); i++ {
if timestamps[i] < timestamps[i-1] {
t.Fatalf("out of order at index %d: ts[%d]=%d > ts[%d]=%d",
i, i-1, timestamps[i-1], i, timestamps[i])
}
}
}
// TestReadLogFileRefsMergeOrder verifies that entries from multiple filers are
// delivered in correct timestamp order.
func TestReadLogFileRefsMergeOrder(t *testing.T) {
assertOrderedReplay(t, newTestLogFiles(3, 2, 50, 0))
}
// TestReadLogFileRefsPathFilter verifies path filtering including system log exclusion.
func TestReadLogFileRefsPathFilter(t *testing.T) {
files := newTestLogFiles(2, 2, 50, 0)
total := files.totalEvents()
var allCount, filteredCount int64
_, err := ReadLogFileRefs(files.refs, files.readerFn(), 0, 0,
PathFilter{PathPrefix: "/"},
func(resp *filer_pb.SubscribeMetadataResponse) error {
allCount++
return nil
})
if err != nil {
t.Fatalf("ReadLogFileRefs (all): %v", err)
}
_, err = ReadLogFileRefs(files.refs, files.readerFn(), 0, 0,
PathFilter{PathPrefix: "/data/filer00/"},
func(resp *filer_pb.SubscribeMetadataResponse) error {
filteredCount++
return nil
})
if err != nil {
t.Fatalf("ReadLogFileRefs (filtered): %v", err)
}
t.Logf("Total events: %d, matching /data/filer00/: %d", allCount, filteredCount)
if allCount != int64(total) {
t.Errorf("expected %d total events, got %d", total, allCount)
}
if filteredCount >= allCount {
t.Errorf("filter should reduce events: all=%d filtered=%d", allCount, filteredCount)
}
if filteredCount == 0 {
t.Errorf("filter matched zero events")
}
}
// TestDirectReadVsServerSideThroughput compares:
// - Server-side: sequential file read → gRPC send per event
// - Client direct-read: parallel filers + streaming + no gRPC
func TestDirectReadVsServerSideThroughput(t *testing.T) {
const (
numFilers = 3
filesPerFiler = 7
eventsPerFile = 300
fileReadDelay = 2 * time.Millisecond
sendDelay = 20 * time.Microsecond
)
files := newTestLogFiles(numFilers, filesPerFiler, eventsPerFile, fileReadDelay)
var serverRate float64
t.Run("server_side_sequential", func(t *testing.T) {
var processed int64
start := time.Now()
for _, ref := range files.refs {
time.Sleep(fileReadDelay)
key := ref.Chunks[0].FileId
data := files.fileData[key]
pos := 0
for pos+4 <= len(data) {
size := int(util.BytesToUint32(data[pos : pos+4]))
pos += 4 + size
time.Sleep(sendDelay)
atomic.AddInt64(&processed, 1)
}
}
elapsed := time.Since(start)
serverRate = float64(processed) / elapsed.Seconds()
t.Logf("server-side: %d events %v %6.0f events/sec (%d files sequential + %v send/event)",
processed, elapsed.Round(time.Millisecond), serverRate,
numFilers*filesPerFiler, sendDelay)
})
var directRate float64
t.Run("client_direct_read_parallel_streaming", func(t *testing.T) {
var processed int64
start := time.Now()
_, err := ReadLogFileRefs(files.refs, files.readerFn(), 0, 0,
PathFilter{PathPrefix: "/"},
func(resp *filer_pb.SubscribeMetadataResponse) error {
atomic.AddInt64(&processed, 1)
return nil
})
if err != nil {
t.Fatalf("ReadLogFileRefs: %v", err)
}
elapsed := time.Since(start)
directRate = float64(processed) / elapsed.Seconds()
t.Logf("direct-read: %d events %v %6.0f events/sec (%d filers parallel + streaming, no gRPC)",
processed, elapsed.Round(time.Millisecond), directRate, numFilers)
})
if serverRate > 0 {
t.Logf("Speedup: %.1fx (parallel + streaming + no gRPC vs server-side sequential)", directRate/serverRate)
}
}
// failingReaderFn returns err for the given file key, delegating otherwise.
func failingReaderFn(base LogFileReaderFn, failKey string, err error) LogFileReaderFn {
return func(chunks []*filer_pb.FileChunk) (io.ReadCloser, error) {
if len(chunks) > 0 && chunks[0].FileId == failKey {
return nil, err
}
return base(chunks)
}
}
// A real (non not-found) read error must fail the whole replay, not silently
// drop the file and advance the cursor.
func TestReadLogFileRefsMultiFilerGenuineErrorAborts(t *testing.T) {
files := newTestLogFiles(3, 2, 10, 0)
failKey := files.refs[2].Chunks[0].FileId // filer01's first file
readerFn := failingReaderFn(files.readerFn(), failKey, fmt.Errorf("failed to locate %s", failKey))
var count int64
_, err := ReadLogFileRefs(files.refs, readerFn, 0, 0,
PathFilter{PathPrefix: "/"},
func(resp *filer_pb.SubscribeMetadataResponse) error {
atomic.AddInt64(&count, 1)
return nil
})
if err == nil {
t.Fatalf("expected error from genuine read failure, got nil (delivered=%d)", count)
}
}
// A chunk-not-found error skips only that file (volume gone), not the replay.
func TestReadLogFileRefsMultiFilerNotFoundSkips(t *testing.T) {
files := newTestLogFiles(3, 2, 10, 0)
skipKey := files.refs[2].Chunks[0].FileId // filer01's first file
readerFn := failingReaderFn(files.readerFn(), skipKey, fmt.Errorf("volume not found: %s", skipKey))
var count int64
_, err := ReadLogFileRefs(files.refs, readerFn, 0, 0,
PathFilter{PathPrefix: "/"},
func(resp *filer_pb.SubscribeMetadataResponse) error {
atomic.AddInt64(&count, 1)
return nil
})
if err != nil {
t.Fatalf("chunk-not-found should be skipped, got error: %v", err)
}
expected := int64(files.totalEvents() - 10) // one skipped file's events
if count != expected {
t.Fatalf("expected %d events after skipping one file, got %d", expected, count)
}
}
// TestReadLogFileRefsSingleFilerOrder covers the single-filer path: every
// entry across all files, in order.
func TestReadLogFileRefsSingleFilerOrder(t *testing.T) {
assertOrderedReplay(t, newTestLogFiles(1, 4, 50, 0))
}
// TestReadLogFileRefsSingleFilerProcessErrorStops verifies that the callback's
// own error propagates and aborts the stream promptly, mid-file.
func TestReadLogFileRefsSingleFilerProcessErrorStops(t *testing.T) {
files := newTestLogFiles(1, 3, 100, 0)
var count int
wantErr := fmt.Errorf("boom")
_, err := ReadLogFileRefs(files.refs, files.readerFn(), 0, 0,
PathFilter{PathPrefix: "/"},
func(resp *filer_pb.SubscribeMetadataResponse) error {
count++
if count == 5 {
return wantErr
}
return nil
})
if !errors.Is(err, wantErr) {
t.Fatalf("expected processing error to propagate, got: %v", err)
}
// Should stop near the failing event, not process the whole 300-event set.
if count > 20 {
t.Fatalf("expected prompt stop after error, processed %d events", count)
}
}
// A corrupt size prefix must fail the replay instead of allocating gigabytes.
func TestReadLogFileRefsCorruptSizePrefix(t *testing.T) {
data := make([]byte, 4)
util.Uint32toBytes(data, 0xFFFFFFF0)
refs := []*filer_pb.LogFileChunkRef{{
Chunks: []*filer_pb.FileChunk{{FileId: "corrupt"}},
FileTsNs: 1,
FilerId: "filer00",
}}
readerFn := func(chunks []*filer_pb.FileChunk) (io.ReadCloser, error) {
return io.NopCloser(bytes.NewReader(data)), nil
}
_, err := ReadLogFileRefs(refs, readerFn, 0, 0, PathFilter{PathPrefix: "/"},
func(*filer_pb.SubscribeMetadataResponse) error { return nil })
if err == nil || !strings.Contains(err.Error(), "exceeds") {
t.Fatalf("expected size-cap error, got: %v", err)
}
}
// blockingReader blocks in Read until released.
type blockingReader struct{ release chan struct{} }
func (r *blockingReader) Read(p []byte) (int, error) { <-r.release; return 0, io.EOF }
func (r *blockingReader) Close() error { return nil }
// An abort (fatal error on one filer) must not wait for another filer's
// in-flight chunk read: the replay returns promptly and the wedged producer
// exits on its own once its read completes.
func TestReadLogFileRefsAbortDoesNotJoinWedgedReader(t *testing.T) {
files := newTestLogFiles(2, 1, 10, 0)
release := make(chan struct{})
t.Cleanup(func() { close(release) })
wedgedKey := files.refs[0].Chunks[0].FileId // filer00 wedges mid-read
failKey := files.refs[1].Chunks[0].FileId // filer01 fails for real
base := files.readerFn()
readerFn := func(chunks []*filer_pb.FileChunk) (io.ReadCloser, error) {
switch chunks[0].FileId {
case wedgedKey:
return &blockingReader{release: release}, nil
case failKey:
return nil, fmt.Errorf("failed to locate %s", failKey)
}
return base(chunks)
}
done := make(chan error, 1)
go func() {
_, err := ReadLogFileRefs(files.refs, readerFn, 0, 0, PathFilter{PathPrefix: "/"},
func(*filer_pb.SubscribeMetadataResponse) error { return nil })
done <- err
}()
select {
case err := <-done:
if err == nil {
t.Fatalf("expected the fatal read error to propagate")
}
case <-time.After(5 * time.Second):
t.Fatalf("ReadLogFileRefs did not return while a peer reader was wedged")
}
}
// TestReadLogFileRefsSingleFilerNotFoundSkips confirms a chunk-not-found on the
// single-filer path skips just that file, not the whole replay.
func TestReadLogFileRefsSingleFilerNotFoundSkips(t *testing.T) {
files := newTestLogFiles(1, 3, 10, 0)
skipKey := files.refs[1].Chunks[0].FileId // second file
readerFn := failingReaderFn(files.readerFn(), skipKey, fmt.Errorf("volume not found: %s", skipKey))
var count int
_, err := ReadLogFileRefs(files.refs, readerFn, 0, 0,
PathFilter{PathPrefix: "/"},
func(resp *filer_pb.SubscribeMetadataResponse) error {
count++
return nil
})
if err != nil {
t.Fatalf("chunk-not-found should be skipped, got error: %v", err)
}
if want := files.totalEvents() - 10; count != want {
t.Fatalf("expected %d events after skipping one file, got %d", want, count)
}
}