mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-20 13:30:46 +02:00
perf(filer): share decoded log entries across metadata replays Concurrent SubscribeMetadata replays of the same persisted log history each opened a chunk reader per source filer and re-decoded the same files, so a reconnect storm multiplied into many GB of buffers. Cache the decoded entries of completed log files in a bounded LRU, coalescing concurrent loads with single-flight and bounding concurrent decodes. Each hit is validated against the file's current chunk set, so a file that received a late append is reloaded rather than served stale; reads that stop on an unreachable chunk are delivered but not cached so a transient outage re-probes on the next replay.
243 lines
9.1 KiB
Go
243 lines
9.1 KiB
Go
package filer
|
|
|
|
import (
|
|
"container/list"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"sync"
|
|
"time"
|
|
|
|
"golang.org/x/sync/singleflight"
|
|
"google.golang.org/protobuf/proto"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/glog"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/util"
|
|
"github.com/seaweedfs/seaweedfs/weed/wdclient"
|
|
)
|
|
|
|
const (
|
|
// persistedLogCacheMaxBytes bounds retained entries regardless of subscriber
|
|
// count.
|
|
persistedLogCacheMaxBytes = 256 << 20
|
|
// persistedLogCacheMinAge keeps the actively-written current file out of the
|
|
// cache to avoid reload churn. It is a heuristic, not a correctness gate:
|
|
// every hit is validated against the file's current chunk set (a fingerprint),
|
|
// so a file that turns out to have grown is reloaded rather than served stale.
|
|
persistedLogCacheMinAge = 2 * LogFlushInterval
|
|
// persistedLogCacheMaxLoads bounds how many distinct files decode at once, so
|
|
// the transient peak (an 8MB chunk reader plus the decoded slice per load)
|
|
// stays bounded no matter how many positions the subscriber fleet spans.
|
|
persistedLogCacheMaxLoads = 8
|
|
// maxLogEntrySize guards the per-entry allocation against a corrupt size
|
|
// prefix. The write path caps a single entry well under this, so it never
|
|
// rejects a legitimate event.
|
|
maxLogEntrySize = 1 << 30
|
|
)
|
|
|
|
// persistedLogCache shares the decoded entries of completed metadata log files
|
|
// across concurrent SubscribeMetadata replays, so N replays of the same history
|
|
// read and decode each file once instead of N times. 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 chan struct{}
|
|
}
|
|
|
|
type logCacheItem struct {
|
|
key string
|
|
// fingerprint identifies the file's chunk set when it was decoded. A cached
|
|
// item is only served when the caller's fingerprint still matches; an
|
|
// appended-to file (e.g. a delayed flush landed after the file was first read)
|
|
// produces a new fingerprint and is reloaded.
|
|
fingerprint string
|
|
entries []*filer_pb.LogEntry
|
|
bytes int64
|
|
}
|
|
|
|
func newPersistedLogCache(maxBytes int64) *persistedLogCache {
|
|
return &persistedLogCache{
|
|
ll: list.New(),
|
|
index: make(map[string]*list.Element),
|
|
maxBytes: maxBytes,
|
|
loadSem: make(chan struct{}, persistedLogCacheMaxLoads),
|
|
}
|
|
}
|
|
|
|
// logFileIsCacheable reports whether a log file identified by its minute
|
|
// timestamp is old enough that caching it is worthwhile (it is unlikely to still
|
|
// be receiving appends). Correctness does not depend on this being exact.
|
|
func logFileIsCacheable(fileTsNs int64) bool {
|
|
return time.Since(time.Unix(0, fileTsNs)) > persistedLogCacheMinAge
|
|
}
|
|
|
|
// chunksFingerprint summarizes a log file's append-only chunk set; it changes
|
|
// whenever the file grows.
|
|
func chunksFingerprint(chunks []*filer_pb.FileChunk) string {
|
|
if len(chunks) == 0 {
|
|
return "empty"
|
|
}
|
|
var total uint64
|
|
for _, c := range chunks {
|
|
total += c.Size
|
|
}
|
|
return fmt.Sprintf("%d/%d/%s", len(chunks), total, chunks[len(chunks)-1].GetFileIdString())
|
|
}
|
|
|
|
// getOrLoad returns the decoded entries for key, loading them once on miss and
|
|
// coalescing concurrent misses for the same (key, fingerprint). A cached item
|
|
// whose fingerprint no longer matches is treated as a miss and replaced. The
|
|
// returned slice is shared and must be treated as read-only.
|
|
type logLoadResult struct {
|
|
entries []*filer_pb.LogEntry
|
|
err error
|
|
}
|
|
|
|
// load returns the decoded entries, whether the result is stable enough to
|
|
// cache, and any error. A read that stopped on a missing chunk is delivered to
|
|
// this caller but not cached: the truncation point depends on transient volume
|
|
// availability, not on the (cached) fingerprint, so it must be re-probed on
|
|
// later replays. On a genuine error the partially-read entries are still
|
|
// returned alongside the error so the caller can deliver them before failing,
|
|
// matching the streaming path; only a clean, complete read is cached.
|
|
func (c *persistedLogCache) getOrLoad(key, fingerprint string, load func() ([]*filer_pb.LogEntry, bool, error)) ([]*filer_pb.LogEntry, error) {
|
|
if entries, ok := c.lookup(key, fingerprint); ok {
|
|
return entries, nil
|
|
}
|
|
v, _, _ := c.sf.Do(key+"\x00"+fingerprint, func() (interface{}, error) {
|
|
if entries, ok := c.lookup(key, fingerprint); ok {
|
|
return logLoadResult{entries: entries}, nil
|
|
}
|
|
entries, cacheable, loadErr := c.loadGuarded(load)
|
|
if loadErr == nil && cacheable {
|
|
c.store(key, fingerprint, entries)
|
|
}
|
|
return logLoadResult{entries: entries, err: loadErr}, nil
|
|
})
|
|
res := v.(logLoadResult)
|
|
return res.entries, res.err
|
|
}
|
|
|
|
// loadGuarded runs load under the concurrent-load semaphore, releasing the slot
|
|
// via defer so an abnormal exit cannot leak slots and wedge future loads.
|
|
func (c *persistedLogCache) loadGuarded(load func() ([]*filer_pb.LogEntry, bool, error)) ([]*filer_pb.LogEntry, bool, error) {
|
|
c.loadSem <- struct{}{}
|
|
defer func() { <-c.loadSem }()
|
|
return load()
|
|
}
|
|
|
|
func (c *persistedLogCache) lookup(key, fingerprint string) ([]*filer_pb.LogEntry, bool) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
el, ok := c.index[key]
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
item := el.Value.(*logCacheItem)
|
|
if item.fingerprint != fingerprint {
|
|
// The file changed (grew) since it was cached; drop the stale snapshot so
|
|
// the caller reloads and stores the current version.
|
|
c.removeElement(el)
|
|
return nil, false
|
|
}
|
|
c.ll.MoveToFront(el)
|
|
return item.entries, true
|
|
}
|
|
|
|
func (c *persistedLogCache) store(key, fingerprint string, entries []*filer_pb.LogEntry) {
|
|
bytes := estimateEntriesBytes(entries)
|
|
if bytes > c.maxBytes {
|
|
// A single file larger than the whole budget would evict everything else
|
|
// and still not fit; serve it this round but do not retain it.
|
|
return
|
|
}
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if el, ok := c.index[key]; ok {
|
|
c.removeElement(el) // replace any older version of this file
|
|
}
|
|
el := c.ll.PushFront(&logCacheItem{key: key, fingerprint: fingerprint, entries: entries, bytes: bytes})
|
|
c.index[key] = el
|
|
c.curBytes += bytes
|
|
// Evict least-recently-used until under budget, but always keep what we just
|
|
// inserted (a single file larger than the budget still serves this round).
|
|
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
|
|
}
|
|
|
|
func estimateEntriesBytes(entries []*filer_pb.LogEntry) int64 {
|
|
// LogEntry struct (~112B) + slice slot (8B) + a little slack, plus the
|
|
// payload backing arrays. Deliberately generous so curBytes does not run
|
|
// under the real retained heap.
|
|
total := int64(len(entries)) * 128
|
|
for _, e := range entries {
|
|
total += int64(len(e.Data)+len(e.Key)) + 16
|
|
}
|
|
return total
|
|
}
|
|
|
|
// loadLogFileEntries reads a whole persisted log file from volume servers and
|
|
// decodes every LogEntry. The second result reports whether the read reached a
|
|
// clean end (cacheable). A chunk-not-found stop returns the readable prefix with
|
|
// cacheable=false: the file's chunk list (the cache fingerprint) is unchanged by
|
|
// a momentarily-unreachable volume, so caching the prefix would pin a truncation
|
|
// that a transient outage should self-heal. Such reads mirror the streaming
|
|
// path for this round but are re-probed on the next replay instead.
|
|
func loadLogFileEntries(masterClient *wdclient.MasterClient, filePath string, chunks []*filer_pb.FileChunk) (entries []*filer_pb.LogEntry, cacheable bool, err error) {
|
|
r := NewChunkStreamReaderFromFiler(context.Background(), masterClient, chunks)
|
|
defer r.Close()
|
|
|
|
sizeBuf := make([]byte, 4)
|
|
for {
|
|
if _, readErr := io.ReadFull(r, sizeBuf); readErr != nil {
|
|
if readErr == io.EOF {
|
|
// clean end exactly at a record boundary: every record was read
|
|
return entries, true, nil
|
|
}
|
|
if readErr == io.ErrUnexpectedEOF {
|
|
// stopped mid-frame (a partial size prefix): ambiguous, possibly a
|
|
// transient short read, so deliver the prefix but do not cache it
|
|
glog.V(1).Infof("log file %s ends mid-record, not caching", filePath)
|
|
return entries, false, nil
|
|
}
|
|
if isChunkNotFoundError(readErr) {
|
|
glog.V(1).Infof("log file %s has an unreadable chunk, not caching: %v", filePath, readErr)
|
|
return entries, false, nil
|
|
}
|
|
return entries, false, readErr
|
|
}
|
|
size := util.BytesToUint32(sizeBuf)
|
|
if size > maxLogEntrySize {
|
|
return entries, false, fmt.Errorf("log file %s entry size %d exceeds %d", filePath, size, maxLogEntrySize)
|
|
}
|
|
entryData := make([]byte, size)
|
|
if _, readErr := io.ReadFull(r, entryData); readErr != nil {
|
|
if isChunkNotFoundError(readErr) {
|
|
glog.V(1).Infof("log file %s has an unreadable chunk, not caching: %v", filePath, readErr)
|
|
return entries, false, nil
|
|
}
|
|
return entries, false, readErr
|
|
}
|
|
logEntry := &filer_pb.LogEntry{}
|
|
if unmarshalErr := proto.Unmarshal(entryData, logEntry); unmarshalErr != nil {
|
|
return entries, false, unmarshalErr
|
|
}
|
|
entries = append(entries, logEntry)
|
|
}
|
|
}
|