Files
seaweedfs/weed/filer/persisted_log_cache.go
T
Chris Luandbruce-zzz 0f05957bc4 filer: self-heal chunk manifest reads when volume locations go stale (#11107)
* filer: self-heal fetchWholeChunk on stale volume locations

Upstream #10156/#10800 wired cache invalidation into the buffer-based
read paths, but manifest resolution still goes through fetchWholeChunk,
which returns the raw error on failure. When cached volume locations
are stale (volume tiered to remote storage, server rolled), resolving
a large multipart file fails permanently even though other locations
are healthy.

Thread the ChunkGroup's cacheInvalidator through ResolveChunkManifest /
ResolveOneChunkManifest / fetchWholeChunk, and on failure invalidate,
re-lookup and retry once via the existing retryFetchWithFreshLocations
helper. The streaming bytesBuffer is reset before the retry so partial
bytes from the failed attempt cannot corrupt the manifest
proto.Unmarshal. Non-mount callers pass nil and keep their semantics.

* filer: move the manifest self-heal tests in with the other manifest tests

Also make the stale server stream a prefix and then abort mid-body, which is
what actually leaves partial bytes in the buffer: an HTTP error status returns
before ReadUrlAsStream ever calls the writer, so a 500 never exercised the
Reset the tests claimed to cover.

Claude-Session: https://claude.ai/code/session_01FK3oGC5ZVeJYvNBWgb9JUD

* filer: keep the cached volume locations when a manifest read is cancelled

A cancelled or timed-out read says nothing about where the volume lives, so
dropping the location and going back to the master only costs the next reader
a round trip. PrepareStreamContentWithThrottler already guards its self-heal
this way. The guard also goes inside retryFetchWithFreshLocations, since the
caller can be cancelled between its own check and the invalidation, and that
covers the reader cache and prefetch paths too.

fetchWholeChunk returns the context error rather than the stream failure it
provoked, and ResolveOneChunkManifest wraps with %w so errors.Is still sees it.
That matters even where no invalidator is passed: volume.fsck resolves
manifests with nil and tells its own abort from a corrupt manifest that way,
so the cancellation check sits ahead of the nil-invalidator return.

Claude-Session: https://claude.ai/code/session_01FK3oGC5ZVeJYvNBWgb9JUD

* filer: self-heal manifest reads on the filer and s3 paths too

Every caller that already holds the location cache backing its lookup function
can hand it over: the filer's read, copy and deletion paths and the log cache
have the MasterClient right there, and s3api has the FilerClient. MinusChunks
takes one for the same reason, since the deletion path resolves manifests
through it. Only the shell tools and the replication sinks, whose lookup
functions cache privately with nothing to invalidate, keep passing nil.

Claude-Session: https://claude.ai/code/session_01FK3oGC5ZVeJYvNBWgb9JUD

---------

Co-authored-by: bruce-zzz <bruce.zou@hhy-data.com>
2026-09-02 17:43:46 -07:00

233 lines
7.7 KiB
Go

package filer
import (
"bytes"
"container/list"
"context"
"errors"
"sync"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/util"
"github.com/seaweedfs/seaweedfs/weed/wdclient"
"golang.org/x/sync/semaphore"
"golang.org/x/sync/singleflight"
)
const (
// persistedLogCacheMaxBytes bounds retained entries regardless of subscriber count.
persistedLogCacheMaxBytes = 256 << 20
// persistedLogCacheLoadBudget bounds in-flight fetch+decode bytes, charged
// by chunk size: small chunks load wide, full-size ones cap the peak.
persistedLogCacheLoadBudget = 128 << 20
// persistedLogCacheIdleTTL frees entries no replay has touched recently, so
// the cache holds memory only while subscribers actually replay.
persistedLogCacheIdleTTL = 5 * time.Minute
// maxLogEntrySize guards the per-entry allocation against a corrupt size prefix.
maxLogEntrySize = 1 << 30
)
// errLogChunkIncomplete reports a chunk that does not start and end on record
// boundaries; the file is then only readable as a whole byte stream.
var errLogChunkIncomplete = errors.New("log chunk does not hold whole records")
// persistedLogCache shares decoded metadata-log chunks across concurrent
// SubscribeMetadata replays. Chunks are immutable (each log flush uploads one
// whole buffer of complete records as a new chunk), so even the actively
// written current file shares its flushed chunks. Cached entries are shared
// read-only; callers must not mutate them.
type persistedLogCache struct {
mu sync.Mutex
ll *list.List // front = most recently used; values are *logCacheItem
index map[string]*list.Element
curBytes int64
maxBytes int64
sf singleflight.Group
loadSem *semaphore.Weighted
}
type logCacheItem struct {
key string // chunk file id
entries []*filer_pb.LogEntry
bytes int64
lastUsed time.Time
}
func newPersistedLogCache(maxBytes int64) *persistedLogCache {
c := &persistedLogCache{
ll: list.New(),
index: make(map[string]*list.Element),
maxBytes: maxBytes,
loadSem: semaphore.NewWeighted(persistedLogCacheLoadBudget),
}
// the filer's cache lives for the process lifetime
go c.loopEvictIdle()
return c
}
func (c *persistedLogCache) loopEvictIdle() {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for range ticker.C {
c.evictIdle(time.Now().Add(-persistedLogCacheIdleTTL))
}
}
// evictIdle drops every entry last used at or before cutoff. Recency order
// makes the idle entries exactly the tail of the LRU list.
func (c *persistedLogCache) evictIdle(cutoff time.Time) {
c.mu.Lock()
defer c.mu.Unlock()
for c.ll.Len() > 0 {
el := c.ll.Back()
if el.Value.(*logCacheItem).lastUsed.After(cutoff) {
break
}
c.removeElement(el)
}
}
type logLoadResult struct {
entries []*filer_pb.LogEntry
err error
}
// getOrLoad returns the decoded entries for a chunk, loading once on miss and
// coalescing concurrent misses. Only a clean, complete decode is cached: a
// chunk-not-found read must be re-probed on later replays, and an incomplete
// chunk stays with the streaming fallback.
func (c *persistedLogCache) getOrLoad(fileId string, loadBytes int64, load func() ([]*filer_pb.LogEntry, bool, error)) ([]*filer_pb.LogEntry, error) {
if entries, ok := c.lookup(fileId); ok {
return entries, nil
}
v, _, _ := c.sf.Do(fileId, func() (interface{}, error) {
if entries, ok := c.lookup(fileId); ok {
return logLoadResult{entries: entries}, nil
}
entries, cacheable, loadErr := c.loadGuarded(loadBytes, load)
if loadErr == nil && cacheable {
c.store(fileId, entries)
}
return logLoadResult{entries: entries, err: loadErr}, nil
})
res := v.(logLoadResult)
return res.entries, res.err
}
func (c *persistedLogCache) loadGuarded(loadBytes int64, load func() ([]*filer_pb.LogEntry, bool, error)) ([]*filer_pb.LogEntry, bool, error) {
weight := loadBytes
if weight < 1 {
weight = 1
}
if weight > persistedLogCacheLoadBudget {
// never exceeds the semaphore size, or the acquire could not succeed
weight = persistedLogCacheLoadBudget
}
if err := c.loadSem.Acquire(context.Background(), weight); err != nil {
return nil, false, err
}
defer c.loadSem.Release(weight)
return load()
}
func (c *persistedLogCache) lookup(fileId string) ([]*filer_pb.LogEntry, bool) {
c.mu.Lock()
defer c.mu.Unlock()
el, ok := c.index[fileId]
if !ok {
return nil, false
}
c.ll.MoveToFront(el)
item := el.Value.(*logCacheItem)
item.lastUsed = time.Now()
return item.entries, true
}
func (c *persistedLogCache) store(fileId string, entries []*filer_pb.LogEntry) {
bytes := estimateEntriesBytes(entries)
if bytes > c.maxBytes {
// would evict everything else and still not fit; serve unretained
return
}
c.mu.Lock()
defer c.mu.Unlock()
if el, ok := c.index[fileId]; ok {
c.removeElement(el)
}
el := c.ll.PushFront(&logCacheItem{key: fileId, entries: entries, bytes: bytes, lastUsed: time.Now()})
c.index[fileId] = el
c.curBytes += bytes
for c.curBytes > c.maxBytes && c.ll.Len() > 1 {
c.removeElement(c.ll.Back())
}
}
// removeElement drops an element from both the list and the index. Caller holds mu.
func (c *persistedLogCache) removeElement(el *list.Element) {
item := el.Value.(*logCacheItem)
c.ll.Remove(el)
delete(c.index, item.key)
c.curBytes -= item.bytes
}
// estimateEntriesBytes is deliberately generous so curBytes does not run under
// the real retained heap.
func estimateEntriesBytes(entries []*filer_pb.LogEntry) int64 {
total := int64(len(entries)) * 128
for _, e := range entries {
total += int64(len(e.Data)+len(e.Key)) + 16
}
return total
}
// loadLogFileEntries reads one log file chunk from volume servers and decodes
// its records. fetchWholeChunk handles lookup, retries, cipher and gzip.
func loadLogFileEntries(masterClient *wdclient.MasterClient, chunk *filer_pb.FileChunk) (entries []*filer_pb.LogEntry, cacheable bool, err error) {
bytesBuffer := bytesBufferPool.Get().(*bytes.Buffer)
bytesBuffer.Reset()
defer bytesBufferPool.Put(bytesBuffer)
lookupFileIdFn := func(ctx context.Context, fileId string) (targetUrls []string, err error) {
return masterClient.LookupFileId(ctx, fileId)
}
if fetchErr := fetchWholeChunk(context.Background(), bytesBuffer, lookupFileIdFn, chunk.GetFileIdString(), chunk.CipherKey, chunk.IsCompressed, masterClient); fetchErr != nil {
return nil, false, fetchErr
}
return decodeLogRecords(bytesBuffer.Bytes())
}
// decodeLogRecords parses size-prefixed LogEntry records. A buffer that stops
// mid-record, or whose size prefix is garbage (also the symptom of starting
// mid-record), reports errLogChunkIncomplete with the cleanly decoded prefix.
// Since UnmarshalVT is permissive enough to accept misaligned bytes,
// records must also satisfy the writer's invariants: never empty, a positive
// timestamp, and strictly increasing within one flushed buffer.
// UnmarshalVT copies all bytes, so the entries do not alias data.
func decodeLogRecords(data []byte) (entries []*filer_pb.LogEntry, cacheable bool, err error) {
var lastTsNs int64
for pos := 0; pos < len(data); {
if pos+4 > len(data) {
return entries, false, errLogChunkIncomplete
}
size32 := util.BytesToUint32(data[pos : pos+4])
if size32 == 0 || size32 > maxLogEntrySize {
return entries, false, errLogChunkIncomplete
}
size := int(size32)
if pos+4+size > len(data) {
return entries, false, errLogChunkIncomplete
}
logEntry := &filer_pb.LogEntry{}
if unmarshalErr := logEntry.UnmarshalVT(data[pos+4 : pos+4+size]); unmarshalErr != nil {
return entries, false, errLogChunkIncomplete
}
if logEntry.TsNs <= lastTsNs {
return entries, false, errLogChunkIncomplete
}
lastTsNs = logEntry.TsNs
entries = append(entries, logEntry)
pos += 4 + size
}
return entries, true, nil
}