package filer import ( "bytes" "fmt" "io" "testing" "time" "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" "github.com/seaweedfs/seaweedfs/weed/util" "github.com/seaweedfs/seaweedfs/weed/wdclient" ) // TestPersistedLogScanStartCoversSpanningWindow pins the file-selection window. // A log file is named for the start of the window it holds, and a window spans // up to LogFlushInterval, so a cursor inside the window's later minutes must // still open the file named for its earlier one -- otherwise the read reports // "nothing on disk" for entries that are sitting in it, and the gap resolvers // take that miss as proof the range is empty. func TestPersistedLogScanStartCoversSpanningWindow(t *testing.T) { // A window sealed at 12:30:59 ending 12:31:58 is written to "12-30". sealed := time.Date(2026, 6, 29, 12, 30, 59, 0, time.UTC) fileMinute := sealed.Format("15-04") // A subscriber resuming mid-window must not sort past that file. for _, cursor := range []time.Time{ sealed.Add(11 * time.Second), // 12:31:10, the reported case sealed.Add(59 * time.Second), // 12:31:58, the window's last entry sealed, // exactly the window start } { scanMinute := persistedLogScanStart(cursor).Format("15-04") if scanMinute > fileMinute { t.Fatalf("cursor %v scans from %q, which sorts past the file %q holding it", cursor.Format("15:04:05"), scanMinute, fileMinute) } } } // A cursor just after midnight has to reach back into the previous day. func TestPersistedLogScanStartCrossesMidnight(t *testing.T) { cursor := time.Date(2026, 6, 29, 0, 0, 30, 0, time.UTC) scanFrom := persistedLogScanStart(cursor) if got, want := scanFrom.Format("2006-01-02"), "2026-06-28"; got != want { t.Fatalf("scan date = %q, want %q so the previous day's last file is listed", got, want) } } // TestSpanningLogFileIsNotSkipped pins the other half of the minute-boundary // fix, against the predicate the iterator actually calls. Widening which files // get listed is useless if the iterator then drops the spanning file, which it // did by treating the following file's name as an upper bound on this file's // contents -- it is not. func TestSpanningLogFileIsNotSkipped(t *testing.T) { // Window sealed at 12:30:59, ending 12:31:58, written to "12-30". spanning := time.Date(2026, 6, 29, 12, 30, 0, 0, time.UTC).UnixNano() // The next window starts at 12:31:59 and is written to "12-31". following := time.Date(2026, 6, 29, 12, 31, 0, 0, time.UTC).UnixNano() cursor := time.Date(2026, 6, 29, 12, 31, 10, 0, time.UTC).UnixNano() if following > cursor { t.Fatal("precondition: the following file's name sorts at or before the cursor") } if !logFileMayContainAfter(spanning, cursor) { t.Fatal("the spanning file holds entries past the cursor and must be read") } // A file that genuinely cannot reach the cursor is still skipped, so the // widening does not turn into reading the whole day. old := time.Date(2026, 6, 29, 12, 0, 0, 0, time.UTC).UnixNano() if logFileMayContainAfter(old, cursor) { t.Fatal("a file a full interval behind the cursor should still be skipped") } } // TestClampLogRefsCursorNeverRewinds pins that a chunk-ref read cannot move the // subscriber backwards. The refs carry minute-level names, so the last one // normally sorts before the requested position, and the caller assigns that // value straight to the read cursor. func TestClampLogRefsCursorNeverRewinds(t *testing.T) { cursor := time.Date(2026, 6, 29, 12, 31, 10, 0, time.UTC).UnixNano() spanningFile := time.Date(2026, 6, 29, 12, 30, 0, 0, time.UTC).UnixNano() if got := clampLogRefsCursor(spanningFile, cursor); got != cursor { t.Fatalf("cursor moved to %v, want it held at %v", time.Unix(0, got), time.Unix(0, cursor)) } // A ref genuinely ahead of the cursor still advances it. ahead := time.Date(2026, 6, 29, 12, 32, 0, 0, time.UTC).UnixNano() if got := clampLogRefsCursor(ahead, cursor); got != ahead { t.Fatalf("cursor = %v, want it to advance to %v", time.Unix(0, got), time.Unix(0, ahead)) } } // TestPersistedLogScanStartTsNsMinuteAligned pins the prune bound against the // collector's minute-granular file comparison: a cursor at 12:31:20 still // collects the 12-30 file, so the bound must not sit past 12:30:00 - an // exact-ns bound disowns the file's sent state and the next pass reships it // whole, re-creating the duplicate-refs class the state exists to prevent. func TestPersistedLogScanStartTsNsMinuteAligned(t *testing.T) { cursor := time.Date(2026, 6, 29, 12, 31, 20, 0, time.UTC) fileTsNs := time.Date(2026, 6, 29, 12, 30, 0, 0, time.UTC).UnixNano() bound := PersistedLogScanStartTsNs(cursor) if fileTsNs < bound { t.Fatalf("bound %v disowns the 12-30 file the collector still lists", time.Unix(0, bound).UTC()) } // A file a full interval plus a minute behind is genuinely out of scan range. old := time.Date(2026, 6, 29, 12, 28, 0, 0, time.UTC).UnixNano() if old >= bound { t.Fatalf("bound %v keeps state for files the scan can no longer list", time.Unix(0, bound).UTC()) } } // TestLastShippedLogEntryTsNs pins the chunk-mode cursor source against the // client's reader, which it must mirror: per file the readable prefix - the // client stops at the first unreadable chunk and never resumes within the file // - and per filer the newest file with content, because the client keeps // reading later files after skipping a bad one. A marker past what the client // applies loses events; one short only re-ships. func TestLastShippedLogEntryTsNs(t *testing.T) { f := &Filer{persistedLogCache: newPersistedLogCache(1 << 20)} entry := func(ts int64) *filer_pb.LogEntry { return &filer_pb.LogEntry{TsNs: ts} } chunk := func(id string) *filer_pb.FileChunk { return &filer_pb.FileChunk{FileId: id, Size: 10} } ref := func(fileTsNs int64, chunks ...*filer_pb.FileChunk) *filer_pb.LogFileChunkRef { return &filer_pb.LogFileChunkRef{FilerId: "a", FileTsNs: fileTsNs, Chunks: chunks} } byId := map[string]struct { entries []*filer_pb.LogEntry err error }{ "good-early": {entries: []*filer_pb.LogEntry{entry(100), entry(200)}}, "good-late": {entries: []*filer_pb.LogEntry{entry(300), entry(400)}}, "missing": {err: fmt.Errorf("volume 7 not found")}, "missing2": {err: fmt.Errorf("volume 8 not found")}, "empty": {}, } origLoad := loadLogFileEntriesFn loadLogFileEntriesFn = func(masterClient *wdclient.MasterClient, c *filer_pb.FileChunk) ([]*filer_pb.LogEntry, bool, error) { r := byId[c.FileId] return r.entries, true, r.err } origLookup := lookupLogChunkFn deadVolumes := map[string]bool{} lookupLogChunkFn = func(f *Filer, fileId string) error { if deadVolumes[fileId] { return fmt.Errorf("volume 9 not found") } return nil } defer func() { loadLogFileEntriesFn = origLoad; lookupLogChunkFn = origLookup }() t.Run("a fully readable file answers with its tail, complete", func(t *testing.T) { ts, ok, complete := f.lastShippedLogEntryTsNs([]*filer_pb.FileChunk{chunk("good-early"), chunk("good-late")}) if !ok || ts != 400 || !complete { t.Fatalf("ts=%d ok=%v complete=%v, want 400 and complete", ts, ok, complete) } }) t.Run("the prefix ends at the first missing chunk", func(t *testing.T) { // The client's reader stops there and never resumes within the file: // answering from the readable suffix would put the marker past entries // the client never applied, losing them permanently. ts, ok, complete := f.lastShippedLogEntryTsNs([]*filer_pb.FileChunk{chunk("good-early"), chunk("missing"), chunk("good-late")}) if !ok || ts != 200 { t.Fatalf("ts=%d ok=%v, want the prefix end 200, never the suffix 400", ts, ok) } if complete { t.Fatal("a prefix-limited read must report incomplete, or the unread suffix is never re-shipped") } }) t.Run("nothing readable is a clean no-answer", func(t *testing.T) { if _, ok, _ := f.lastShippedLogEntryTsNs([]*filer_pb.FileChunk{chunk("missing"), chunk("empty")}); ok { t.Fatal("want no answer") } }) t.Run("an entirely missing last file falls back to earlier files", func(t *testing.T) { // The client skips the bad file but has applied the earlier ones; // discarding their progress would rewind the marker to the start // cursor and stall or replay. ts, answeredFileTsNs, ok, _ := f.LastShippedLogEntryTsNsForFiler([]*filer_pb.LogFileChunkRef{ ref(1000, chunk("good-late")), ref(2000, chunk("missing"), chunk("missing2")), }) if !ok || ts != 400 { t.Fatalf("ts=%d ok=%v, want the previous file's 400", ts, ok) } if answeredFileTsNs != 1000 { t.Fatalf("answered file %d, want 1000: the caller rolls back sent state above it", answeredFileTsNs) } }) t.Run("a cached chunk on a dead volume ends the prefix", func(t *testing.T) { // Warm the cache, then kill the middle chunk's volume. The client // reads directly and stops there; a probe trusting its own decoded // cache would sail past to the tail and put the marker beyond entries // the client never applied. if ts, ok, _ := f.lastShippedLogEntryTsNs([]*filer_pb.FileChunk{chunk("good-early"), chunk("good-late")}); !ok || ts != 400 { t.Fatalf("warmup: ts=%d ok=%v, want 400", ts, ok) } deadVolumes["good-early"] = true defer delete(deadVolumes, "good-early") ts, ok, complete := f.lastShippedLogEntryTsNs([]*filer_pb.FileChunk{chunk("good-early"), chunk("good-late")}) if ok || ts != 0 { t.Fatalf("ts=%d ok=%v, want no answer: the first chunk's volume is gone however warm the cache", ts, ok) } if complete { t.Fatal("a lookup-limited read must report incomplete so the ref re-ships") } }) t.Run("a dead needle behind a warm cache is the accepted residual", func(t *testing.T) { // Production liveness is a volume lookup: it cannot see a needle 404 // or a stale location inside a resolvable volume, so a warm cache can // answer past a chunk the client fails to read. Accepted because // metadata log chunks die volume-at-a-time (TTL'd log volumes) and the // alternative is a real read per probe, which is what the probe exists // to avoid; the client logs the skip at V(0). This test pins the // boundary so a change to it is a decision, not an accident. if ts, ok, _ := f.lastShippedLogEntryTsNs([]*filer_pb.FileChunk{chunk("good-early"), chunk("good-late")}); !ok || ts != 400 { t.Fatalf("warmup: ts=%d ok=%v, want 400", ts, ok) } // The needle dies: loads fail, but the volume still resolves. prev := byId["good-early"] byId["good-early"] = struct { entries []*filer_pb.LogEntry err error }{err: fmt.Errorf("read: 404 Not Found: not found")} defer func() { byId["good-early"] = prev }() ts, ok, complete := f.lastShippedLogEntryTsNs([]*filer_pb.FileChunk{chunk("good-early"), chunk("good-late")}) if !ok || ts != 400 || !complete { t.Fatalf("ts=%d ok=%v complete=%v: the warm cache masks a dead needle - if this now fails, the residual was closed and this test should assert the new behavior", ts, ok, complete) } }) t.Run("spanning records stream the shipped list", func(t *testing.T) { origLoad2 := loadLogFileEntriesFn loadLogFileEntriesFn = func(masterClient *wdclient.MasterClient, c *filer_pb.FileChunk) ([]*filer_pb.LogEntry, bool, error) { return nil, false, errLogChunkIncomplete } origStream := newLogFileStreamReader newLogFileStreamReader = func(masterClient *wdclient.MasterClient, chunks []*filer_pb.FileChunk) io.Reader { var buf bytes.Buffer for _, ts := range []int64{500, 600} { data, _ := (&filer_pb.LogEntry{TsNs: ts}).MarshalVT() var sizeBuf [4]byte util.Uint32toBytes(sizeBuf[:], uint32(len(data))) buf.Write(sizeBuf[:]) buf.Write(data) } // A torn trailing size prefix, as a crashed writer leaves it: the // client reads it as a clean end, so the probe must too - failing // here would block the marker forever on data the client accepts. buf.Write([]byte{0x01, 0x02}) return &buf } defer func() { loadLogFileEntriesFn = origLoad2; newLogFileStreamReader = origStream }() ts, ok, complete := f.lastShippedLogEntryTsNs([]*filer_pb.FileChunk{chunk("incomplete")}) if !ok || ts != 600 { t.Fatalf("ts=%d ok=%v, want the streamed tail 600 past the torn prefix", ts, ok) } if !complete { t.Fatal("a torn tail is where the client's read ends too: complete, nothing to re-ship") } }) }