mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-08-19 13:46:58 +00:00
* Share metadata-log replays per chunk instead of per file Log file chunks are immutable: each metadata-log flush uploads one whole buffer of complete records as a new chunk, and appends only add chunks. So cache decoded entries per chunk, with no age gate and no fingerprint revalidation. The per-file cache excluded files younger than two flush intervals, which is exactly the hot tail that every tailing or reconnecting subscriber replays — each through a private chunk reader holding an 8MB buffer and decoding the whole file from byte zero. A chunk's flush time also upper-bounds every record timestamp inside it, so a tail replay now skips cold chunks without reading them at all. If a chunk does not decode standalone (records spanning chunk boundaries, or a corrupt size prefix), fall back to streaming the whole file as one byte stream, resuming after the last yielded entry. * Evict idle metadata-log cache entries The replay cache only evicted on insert, so once filled it held its full budget forever. Stamp entries on use and sweep the LRU tail every minute, dropping anything untouched for five minutes; the cache now holds memory only while subscribers actually replay. * Reject implausible records when decoding log chunks proto.Unmarshal is permissive: empty payloads and unknown-field garbage parse without error, so a chunk starting mid-record could decode by coincidence and get cached instead of falling back to the byte stream. Enforce what the writer guarantees - records are never empty and carry strictly increasing positive timestamps within one flushed buffer. * Gate the singleflight test on an open flight The sleep alone only probabilistically created concurrent misses; a started channel now proves the loader holds the flight before callers are released.
416 lines
13 KiB
Go
416 lines
13 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", load)
|
|
if err != nil || len(e1) != 3 {
|
|
t.Fatalf("first getOrLoad: err=%v len=%d", err, len(e1))
|
|
}
|
|
e2, err := c.getOrLoad("3,01", 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", load); err != nil || len(e) != 2 {
|
|
t.Fatalf("first: err=%v len=%d", err, len(e))
|
|
}
|
|
if e, err := c.getOrLoad("3,01", 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", 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", mk(1)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := c.getOrLoad("b", 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", load); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := c.getOrLoad("hot", 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)
|
|
}
|
|
}
|