Files
seaweedfs/weed/filer/filechunk_manifest_cache.go
T
8db41d0217 [Mount] Cache Chunk Manifest Resolution for Repeated File Opens (#11266)
* cache resolved chunk manifests for Mount

* Address PR review: per-mount cache, singleflight, reuse ResolveOneChunkManifest

- Own the manifest cache per WFS mount instead of a process-global
  variable, so manifests from one filer backend are never served to
  another (Devin/CodeRabbit major bug).
- Coalesce concurrent cold misses via singleflight so only one fetch
  runs during a cold burst (Greptile P2).
- Copy cached data after releasing the mutex so a large copy does not
  block concurrent hits, inserts, and evictions (CodeRabbit nitpick).
- Reuse the existing ResolveOneChunkManifest function name instead of
  introducing a new resolveOneChunkManifest wrapper.
- Validate (unmarshal) manifest bytes before caching so malformed
  manifests do not poison the cache.
- Add TestChunkGroupManifestResolutionCoalescesColdMisses covering
  the singleflight cold-miss path.

* Address round 2 review: coalesced-miss cancellation, test overlap

- Use singleflight.DoChan in fetchOrLoad and select on ctx.Done() so a
  caller whose context is canceled while waiting for an in-flight fetch
  returns ctx.Err() promptly instead of blocking for the leader's
  result (Devin BUG).
- Add TestResolveOneChunkManifestCanceledWaiterReturnsDuringCoalescedMiss
  covering the canceled-waiter path.
- Delay the cold-miss fixture response so the leader's fetch is still
  in flight when concurrent opens join the singleflight, making the
  one-fetch assertions reliable (CodeRabbit Minor).

* Address review: keep ResolveOneChunkManifest four-argument

Restore the exported ResolveOneChunkManifest to its original
four-argument signature so external callers keep compiling. Move the
cache-aware resolution into an unexported resolveOneChunkManifest
helper that accepts the per-mount ChunkManifestCache. The exported
function delegates to the helper with a nil cache, preserving the
historical uncached behavior for every non-Mount caller. The Mount
path (ChunkGroup.SetChunks) now calls the unexported helper with the
mount-owned cache. Tests and benchmarks that exercise the cache path
call the unexported helper directly.

---------

Co-authored-by: Chris Lu <chris.lu@gmail.com>
Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com>
2026-09-12 20:06:25 -07:00

147 lines
3.7 KiB
Go

package filer
import (
"container/list"
"context"
"fmt"
"sync"
"golang.org/x/sync/singleflight"
)
const (
MaxMountChunkManifestCacheEntries = 256
MaxMountChunkManifestCacheBytes = 64 << 20
)
type chunkManifestCacheKey struct {
fileID string
cipherKey string
isCompressed bool
}
func (k chunkManifestCacheKey) flightKey() string {
return fmt.Sprintf("%s\x00%s\x00%t", k.fileID, k.cipherKey, k.isCompressed)
}
type chunkManifestCacheEntry struct {
key chunkManifestCacheKey
data []byte
}
// ChunkManifestCache is a bounded, thread-safe LRU cache for chunk manifest
// bytes. Each mount (WFS) owns its own instance so manifests fetched through
// one filer backend are never served to another. Concurrent cold misses for
// the same key are coalesced via singleflight so only one fetch runs.
type ChunkManifestCache struct {
mu sync.Mutex
maxEntries int
maxBytes int64
bytes int64
entries map[chunkManifestCacheKey]*list.Element
lru *list.List
flight singleflight.Group
}
// NewChunkManifestCache creates a bounded LRU cache for chunk manifest bytes.
func NewChunkManifestCache(maxEntries int, maxBytes int64) *ChunkManifestCache {
return &ChunkManifestCache{
maxEntries: maxEntries,
maxBytes: maxBytes,
entries: make(map[chunkManifestCacheKey]*list.Element),
lru: list.New(),
}
}
func (c *ChunkManifestCache) get(key chunkManifestCacheKey) ([]byte, bool) {
c.mu.Lock()
element, found := c.entries[key]
if !found {
c.mu.Unlock()
return nil, false
}
c.lru.MoveToFront(element)
// entry.data is immutable after insertion; copy outside the lock so a
// large copy does not block concurrent hits, inserts, and evictions.
data := element.Value.(*chunkManifestCacheEntry).data
c.mu.Unlock()
return append([]byte(nil), data...), true
}
func (c *ChunkManifestCache) put(key chunkManifestCacheKey, data []byte) {
if c.maxEntries <= 0 || int64(len(data)) > c.maxBytes {
return
}
entry := &chunkManifestCacheEntry{
key: key,
data: append([]byte(nil), data...),
}
c.mu.Lock()
defer c.mu.Unlock()
if old, found := c.entries[key]; found {
c.removeElement(old)
}
element := c.lru.PushFront(entry)
c.entries[key] = element
c.bytes += int64(len(entry.data))
for len(c.entries) > c.maxEntries || c.bytes > c.maxBytes {
c.removeElement(c.lru.Back())
}
}
// fetchOrLoad returns cached manifest bytes for key, or invokes fetch and
// caches the result. Concurrent calls for the same key are coalesced via
// singleflight so only one fetch runs during a cold burst. A caller whose
// context is canceled while waiting for the in-flight fetch returns
// ctx.Err() immediately rather than blocking for the leader's result.
func (c *ChunkManifestCache) fetchOrLoad(ctx context.Context, key chunkManifestCacheKey, fetch func() ([]byte, error)) ([]byte, error) {
if data, ok := c.get(key); ok {
return data, nil
}
ch := c.flight.DoChan(key.flightKey(), func() (interface{}, error) {
// Re-check under the flight: another flight may have just populated
// the cache for this key.
if data, ok := c.get(key); ok {
return data, nil
}
data, err := fetch()
if err != nil {
return nil, err
}
c.put(key, data)
return data, nil
})
select {
case <-ctx.Done():
return nil, ctx.Err()
case res := <-ch:
if res.Err != nil {
return nil, res.Err
}
return res.Val.([]byte), nil
}
}
func (c *ChunkManifestCache) clear() {
c.mu.Lock()
defer c.mu.Unlock()
c.entries = make(map[chunkManifestCacheKey]*list.Element)
c.lru.Init()
c.bytes = 0
}
func (c *ChunkManifestCache) removeElement(element *list.Element) {
if element == nil {
return
}
c.lru.Remove(element)
entry := element.Value.(*chunkManifestCacheEntry)
delete(c.entries, entry.key)
c.bytes -= int64(len(entry.data))
}