Files
seaweedfs/weed/filer/persisted_log_cache.go
T
Chris LuandDevin 4d1f49c638 filer.sync: sign proxied chunk I/O from the per-side security file (#11645)
* filer.sync: sign proxied chunk I/O from the per-side security file

The -a.security / -b.security files were used for gRPC TLS and the
HTTPS client but not for jwt.filer_signing, so filer-proxied chunk
reads and writes carried a token signed with the process-wide key and
failed authorization whenever the two clusters' keys differ.

LoadFilerJwtFromFile returns a FilerJwtProvider for each side's file,
which FilerSource and FilerSink now accept for proxied chunk reads and
writes. With no keys in the file or no flag, both fall back to the
process-wide jwt.filer_signing configuration as before.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* replication: use the side filer read key for manifest downloads and fall back per access level

Manifest chunk resolution still signed proxied downloads with the
process-wide read key, so a source filer requiring its own key 401'd on
manifest-bearing files. A side security file that set only one access
level also produced empty tokens for the other instead of inheriting the
process-wide key, and the side file loader ignored the WEED_ environment
overrides the filer itself honors.

ResolveChunkManifest/ResolveOneChunkManifest keep their signatures;
FilerJwt-aware variants thread the provider down to fetchWholeChunk,
which prefers it on proxy URLs. The side loader now applies the same
environment precedence and falls back to the process-wide signer per
missing access level.

* security: verify the configured filer token lifetimes

* security: reject negative filer token lifetimes

A negative expires_after_seconds reached GenJwtForFilerServer and produced
a token with no expiration claim. Also synchronize the Authorization-header
capture in the proxy test and restore the prior viper key on cleanup.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-08 22:04:06 +08: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, nil); 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
}