mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-10 16:40:46 +02:00
* 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>
233 lines
7.7 KiB
Go
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
|
|
}
|