Files
seaweedfs/weed/server/filer_server_remote_evict.go
T
Chris LuGitHubDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com>Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
757917f564 filer: evict remote-cached objects under storage pressure (#11515)
* filer: identify remote-mounted entries safe to drop under disk pressure

ListEvictableRemoteEntries walks every mounted directory directly on the
filer store (no lazy remote listing) and returns entries that hold local
chunks fully synchronized with remote, ordered oldest-cached first.

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

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

* filer: evict remote-cached chunks oldest-first and vacuum the garbage

uncacheRemoteEntry applies the same transition remote.uncache does -
cleared chunks plus a reset LastLocalSyncTsNs under the entry path lock -
and evictRemoteCachedEntries serializes passes over all mounts until a
byte target is met. Aged victims are preferred; a second pass accepts any
synchronized cached entry when aged ones cannot cover the request, since
a failed read is worse than a dropped hot object.

Cleared chunks only become disk space after compaction, so
reclaimRemoteCacheSpace pairs each pass with a rate-limited VacuumVolume
call that also picks up orphaned partial fills.

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

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

* filer: trigger remote cache eviction under storage pressure

A periodic check (30s) reads disk usage from master topology and evicts
remote-mounted cached chunks once any disk crosses
-filer.remoteCacheEvictThreshold (default 0.9; 0 disables), with a vacuum
pass to reclaim the tombstoned needles.

The cold-read cache path also kicks the same reclaim when a fill fails on
exhausted volumes - the request still falls back to streaming from the
remote, but the cache stops being permanently wedged full.

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

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

* filer: flush deletion queue before remote cache vacuum

Vacuum ran immediately after eviction while evicted file IDs still sat
in the asynchronous deletion queue, so compaction saw no garbage and the
cache stayed wedged. Flush the queue synchronously first and shorten the
vacuum cooldown so sustained pressure does not wait five minutes between
reclaim passes.

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

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

* test: cover remote cache eviction under capacity pressure

Unit tests pin the eligibility filter and oldest-first ordering; the
integration test runs a constrained two-node setup that saturates the
cache, verifies the oldest synced entry is evicted and vacuumed, and
that a later read re-caches it.

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

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

* filer: coalesce remote cache reclaim passes

A failed cache fill used to queue behind any in-flight eviction,
stacking full mount traversals during a write-failure storm. Skip the
pass when one is already running; the caller falls back to streaming
from remote regardless.

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

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

* filer: stop the remote cache janitor on shutdown

The eviction ticker kept running after Shutdown closed the metadata
store and could traverse a closed store. Give the janitor a context
cancelled from Shutdown and propagate it into its master RPCs and
traversals.

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

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

* filer: vacuum only tombstoned volumes and retry deferred passes

VacuumVolume with no volume id swept every collection, compacting
volumes unrelated to the cache fill that failed. Now the reclaim path
collects the vids of file ids actually flushed from the deletion queue
and compacts only those. Vids that land inside the vacuum cooldown stay
in a pending set the janitor retries on each tick, so chunks evicted
just after a sweep are not stranded until the next pressure event.

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

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

* filer: count only pressured disks when evicting remote cache

The janitor measured the largest excess on one disk but let bytes on
healthy disks satisfy the reclaim target. Split the topology disk view
per physical disk and count only chunk bytes whose volumes sit on an
over-threshold disk; entries contributing nothing there are skipped.

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

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

* filer: compare remote cache sync time at nanosecond precision

Second-precision mtime comparisons let a local write in the same second
as the last sync still qualify as evictable, discarding unsynced
changes. Compare LastLocalSyncTsNs against full-precision mtime
(mtime_ns round-trips through the entry codec), and apply the same fix
to remote.uncache's inline check.

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

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

* filer: invalidate remote sync stamp on local content change

A local overwrite that keeps the remote entry's LastLocalSyncTsNs looks
evictable even though the remote copy no longer matches, and some write
paths stamp mtime at second precision so a timestamp comparison cannot
catch it. UpdateEntry now clears the stamp when chunks change without a
fresh stamp, leaving replicated updates authoritative.

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

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

* filer: bound remote cache master rpcs and vacuum all evicted garbage

VolumeList and VacuumVolume now run under a 30s context so a stalled
master cannot wedge the eviction janitor. The targeted vacuum drops the
garbage threshold so volumes with under 10% deleted bytes still compact.

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

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

* test: tolerate straggler fills in remote cache eviction test

Detached fills from the concurrent wave keep racing the final checks:
live chunks legitimately fill both volumes, and a re-cached object can
be evicted again before its commit is observed.

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

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

* filer: start remote cache eviction loop after filer init

The janitor's first tick dereferences fs.filer; starting the goroutine
before NewFiler assigns it could panic when startup exceeds an interval.

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

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

* filer: keep remote cache vacuum intent across retries

Evicted entries now record their chunk volumes for vacuum directly, so
the intent survives whoever consumes the shared deletion queue first.
A pending volume keeps several vacuum attempts so tombstones that land
late are still compacted, and the janitor retries pending volumes under
the reclaim mutex instead of flushing unrelated deletes every tick.

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

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

* filer: bound each remote cache vacuum request independently

A shared 30s deadline across pending volumes let one slow compaction
cancel the rest. Each VacuumVolume now gets its own context, and pending
volumes keep more attempts since the master reports request acceptance
rather than compaction.

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

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

* test: tighten remote cache reclamation bound

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

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

* filer: treat chunk timestamp changes as content changes

chunksEqual now also compares ModifiedTsNs so an update that rewrites a
chunk record still invalidates the remote sync stamp.

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

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

* filer: run remote cache queue flush under the reclaim context

BatchDelete for flushed file ids now uses the caller's context instead of
context.Background(), so a reclaim pass bounded by shutdown or timeout
stops its deletes too. Other callers keep their existing behavior.

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

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

* filer: scope remote cache vacuum to evicted volumes

The flush no longer feeds the shared deletion queue's ids into the
pending set — only evicted chunks' volumes are tracked, so ordinary
deletions no longer pick up repeated vacuum attempts. The flush also
runs under a shutdown-immune bounded context and is skipped when no
volume is pending.

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

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

* filer: retry remote cache vacuums even after unmount

Pending volumes were only retried while a remote mount existed; removing
the last mount skipped every later pass and left evicted bytes allocated.

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

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

* filer: reclaim partial cache fills that run out of capacity

A fill that fails midway queues its written chunks for deletion, but
when no entries remain evictable the reclaim pass found no pending
volumes and skipped the flush and vacuum entirely, leaving the partial
garbage to the slow periodic vacuum while the disk stayed full. Mark
the failed fill's chunk volumes pending so the pass tombstones and
compacts them even when nothing was evicted.

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-09-29 22:05:42 +08:00

301 lines
10 KiB
Go

package weed_server
import (
"context"
"strings"
"time"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/stats"
"github.com/seaweedfs/seaweedfs/weed/storage/needle"
"github.com/seaweedfs/seaweedfs/weed/util"
"google.golang.org/protobuf/proto"
)
const (
remoteCacheEvictInterval = 30 * time.Second
remoteCacheEvictMinAge = time.Minute
remoteCacheVacuumCooldown = time.Minute
remoteCacheMasterRpcTime = 30 * time.Second
remoteCachePendingVidAttempts = 10
)
// uncacheRemoteEntry drops the local chunks of one remote-mounted entry, the
// same state transition remote.uncache applies through UpdateEntry. Cleared
// chunks go to the deletion queue and are reclaimed by the next compaction.
// When vids is set, only entries holding chunks on those volumes count toward
// the freed bytes, and entries contributing nothing are left untouched.
func (fs *FilerServer) uncacheRemoteEntry(ctx context.Context, fullPath util.FullPath, minCacheAge time.Duration, vids map[uint32]struct{}) (freedBytes int64, err error) {
pathLock := fs.entryLockTable.AcquireLock("uncacheRemoteEntry", fullPath, util.ExclusiveLock)
defer fs.entryLockTable.ReleaseLock(fullPath, pathLock)
current, err := fs.filer.FindEntry(ctx, fullPath)
if err != nil {
return 0, err
}
if !filer.IsEvictableRemoteEntry(current) {
return 0, nil
}
if time.Since(time.Unix(0, current.Remote.LastLocalSyncTsNs)) < minCacheAge {
return 0, nil
}
freedBytes = remoteEntryBytesOnVids(current, vids)
if freedBytes == 0 {
return 0, nil
}
newEntry := current.ShallowClone()
newEntry.Chunks = nil
newEntry.Remote = proto.Clone(current.Remote).(*filer_pb.RemoteEntry)
newEntry.Remote.LastLocalSyncTsNs = 0
if err := fs.filer.CreateEntry(ctx, newEntry, current, false, false, nil, true, fs.filer.MaxFilenameLength); err != nil {
return 0, err
}
fileIds := make([]string, 0, len(current.Chunks))
for _, chunk := range current.Chunks {
fileIds = append(fileIds, chunk.GetFileIdString())
}
fs.notePendingRemoteCacheVids(fileIds)
stats.RemoteCacheEvictedCounter.Inc()
glog.V(1).InfofCtx(ctx, "uncacheRemoteEntry %s freed %d bytes", fullPath, freedBytes)
return freedBytes, nil
}
// remoteEntryBytesOnVids sums the entry's chunk bytes on the given volumes; a
// nil set counts the whole object.
func remoteEntryBytesOnVids(entry *filer.Entry, vids map[uint32]struct{}) int64 {
if vids == nil {
return int64(entry.Size())
}
var bytes int64
for _, chunk := range entry.Chunks {
fid, err := needle.ParseFileIdFromString(chunk.GetFileIdString())
if err != nil {
continue
}
if _, ok := vids[uint32(fid.VolumeId)]; ok {
bytes += int64(chunk.Size)
}
}
return bytes
}
// evictRemoteCachedEntries drops local chunks of remote-mounted entries
// oldest-cached first until bytesNeeded is met or candidates run out. The
// first pass honors a minimum cache age so a just-fetched hot object is not
// dropped under a reader; when aged candidates cannot cover the request a
// second pass accepts any synchronized cached entry. When pressuredVids is
// set, only bytes on those volumes count and entries elsewhere are skipped.
func (fs *FilerServer) evictRemoteCachedEntries(ctx context.Context, bytesNeeded int64, pressuredVids map[uint32]struct{}) (freed int64) {
if fs.filer.RemoteStorage == nil {
return 0
}
mounts := fs.filer.RemoteStorage.MountedDirectories()
for _, minCacheAge := range []time.Duration{remoteCacheEvictMinAge, 0} {
for _, entry := range fs.filer.ListEvictableRemoteEntries(ctx, mounts, minCacheAge) {
if bytesNeeded > 0 && freed >= bytesNeeded {
return freed
}
n, err := fs.uncacheRemoteEntry(ctx, entry.FullPath, minCacheAge, pressuredVids)
if err != nil {
glog.WarningfCtx(ctx, "evict remote cache %s: %v", entry.FullPath, err)
continue
}
freed += n
}
}
return freed
}
// remoteCacheDiskPressure reports per-disk usage across the cluster: the total
// bytes to reclaim and the volumes hosted on disks over the eviction threshold.
func (fs *FilerServer) remoteCacheDiskPressure(ctx context.Context) (bytesToFree int64, pressuredVids map[uint32]struct{}, over bool) {
threshold := fs.option.RemoteCacheEvictThreshold
if threshold <= 0 {
return 0, nil, false
}
pressuredVids = make(map[uint32]struct{})
rpcCtx, cancel := context.WithTimeout(ctx, remoteCacheMasterRpcTime)
defer cancel()
err := fs.filer.MasterClient.WithClient(rpcCtx, false, func(client master_pb.SeaweedClient) error {
resp, err := client.VolumeList(rpcCtx, &master_pb.VolumeListRequest{})
if err != nil {
return err
}
for _, dc := range resp.TopologyInfo.DataCenterInfos {
for _, rack := range dc.RackInfos {
for _, dn := range rack.DataNodeInfos {
for _, disk := range dn.DiskInfos {
for _, pd := range disk.SplitByPhysicalDisk() {
if pd.DiskTotalBytes == 0 {
continue
}
used := pd.DiskTotalBytes - pd.DiskFreeBytes
if float64(used) < float64(pd.DiskTotalBytes)*threshold {
continue
}
over = true
bytesToFree += int64(used) - int64(float64(pd.DiskTotalBytes)*threshold*0.95)
for _, vi := range pd.VolumeInfos {
pressuredVids[vi.Id] = struct{}{}
}
}
}
}
}
}
return nil
})
if err != nil {
glog.WarningfCtx(ctx, "remote cache disk pressure check: %v", err)
return 0, nil, false
}
return bytesToFree, pressuredVids, over
}
// flushAndVacuumRemoteCacheVolumes forces the deletion queue down to the volume
// servers so fresh tombstones land, then compacts the volumes carrying them.
// The flush survives shutdown cancellation: an interrupted delete would be
// requeued into the in-memory retry queue that exits with the process,
// stranding bytes whose metadata the eviction already dropped.
func (fs *FilerServer) flushAndVacuumRemoteCacheVolumes(ctx context.Context) {
if len(fs.pendingRemoteCacheVids()) == 0 {
return
}
flushCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), remoteCacheMasterRpcTime)
fs.filer.FlushFileIdDeletionQueue(flushCtx, filer.LookupByMasterClientFn(fs.filer.MasterClient))
cancel()
fs.vacuumPendingRemoteCacheVids(ctx)
}
// vacuumPendingRemoteCacheVids compacts every volume still owed a vacuum. The
// master does not report whether a request actually compacted, so a vid keeps
// roughly ten minutes of attempts: tombstones that land late or compaction
// losses to another vacuum are retried by later passes instead of stranded.
func (fs *FilerServer) vacuumPendingRemoteCacheVids(ctx context.Context) {
pending := fs.pendingRemoteCacheVids()
if len(pending) == 0 {
return
}
if last := fs.remoteCacheLastVacuum.Load(); last != nil && time.Since(*last) < remoteCacheVacuumCooldown {
return
}
now := time.Now()
fs.remoteCacheLastVacuum.Store(&now)
if err := fs.filer.MasterClient.WithClient(ctx, false, func(client master_pb.SeaweedClient) error {
for vid := range pending {
vCtx, cancel := context.WithTimeout(ctx, remoteCacheMasterRpcTime)
_, err := client.VacuumVolume(vCtx, &master_pb.VacuumVolumeRequest{VolumeId: vid})
cancel()
if err != nil {
glog.WarningfCtx(ctx, "remote cache vacuum volume %d: %v", vid, err)
continue
}
fs.completePendingRemoteCacheVid(vid)
}
return nil
}); err != nil {
glog.WarningfCtx(ctx, "remote cache vacuum: %v", err)
}
}
func (fs *FilerServer) notePendingRemoteCacheVids(fileIds []string) {
fs.remoteCachePendingVidsMu.Lock()
defer fs.remoteCachePendingVidsMu.Unlock()
if fs.remoteCachePendingVids == nil {
fs.remoteCachePendingVids = make(map[uint32]int)
}
for _, fid := range fileIds {
if parsed, err := needle.ParseFileIdFromString(fid); err == nil {
fs.remoteCachePendingVids[uint32(parsed.VolumeId)] = remoteCachePendingVidAttempts
}
}
}
func (fs *FilerServer) pendingRemoteCacheVids() map[uint32]struct{} {
fs.remoteCachePendingVidsMu.Lock()
defer fs.remoteCachePendingVidsMu.Unlock()
out := make(map[uint32]struct{}, len(fs.remoteCachePendingVids))
for vid := range fs.remoteCachePendingVids {
out[vid] = struct{}{}
}
return out
}
func (fs *FilerServer) completePendingRemoteCacheVid(vid uint32) {
fs.remoteCachePendingVidsMu.Lock()
defer fs.remoteCachePendingVidsMu.Unlock()
if fs.remoteCachePendingVids[vid] <= 1 {
delete(fs.remoteCachePendingVids, vid)
} else {
fs.remoteCachePendingVids[vid]--
}
}
// reclaimRemoteCacheSpace evicts remote-cached content and compacts volumes to
// release disk space under capacity pressure. A pass already in flight is
// enough; callers that would queue behind it just fall back to remote reads.
func (fs *FilerServer) reclaimRemoteCacheSpace(ctx context.Context, bytesNeeded int64, pressuredVids map[uint32]struct{}) {
if !fs.remoteCacheEvictMu.TryLock() {
return
}
defer fs.remoteCacheEvictMu.Unlock()
freed := fs.evictRemoteCachedEntries(ctx, bytesNeeded, pressuredVids)
if freed > 0 {
glog.V(0).InfofCtx(ctx, "remote cache eviction freed %d bytes", freed)
}
fs.flushAndVacuumRemoteCacheVolumes(ctx)
}
func isRemoteCacheCapacityError(err error) bool {
msg := err.Error()
return strings.Contains(msg, "writable volumes") ||
strings.Contains(msg, "free volumes") ||
strings.Contains(msg, "no space left") ||
strings.Contains(msg, "out of space")
}
// runRemoteCacheEviction periodically evicts remote-cached entries once any
// disk crosses the configured usage threshold, with a vacuum pass to reclaim
// the deleted chunks.
func (fs *FilerServer) evictCtx() context.Context {
if fs.remoteCacheEvictCtx == nil {
return context.Background()
}
return fs.remoteCacheEvictCtx
}
func (fs *FilerServer) runRemoteCacheEviction() {
if fs.option.RemoteCacheEvictThreshold <= 0 || fs.remoteCacheEvictCtx == nil {
return
}
ctx := fs.remoteCacheEvictCtx
ticker := time.NewTicker(remoteCacheEvictInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
}
if fs.remoteCacheEvictMu.TryLock() {
fs.vacuumPendingRemoteCacheVids(ctx)
fs.remoteCacheEvictMu.Unlock()
}
if fs.filer.RemoteStorage == nil || len(fs.filer.RemoteStorage.MountedDirectories()) == 0 {
continue
}
bytesToFree, pressuredVids, over := fs.remoteCacheDiskPressure(ctx)
if !over {
continue
}
glog.V(0).Infof("remote cache: disk usage over %.0f%%, evicting %d bytes", fs.option.RemoteCacheEvictThreshold*100, bytesToFree)
fs.reclaimRemoteCacheSpace(ctx, bytesToFree, pressuredVids)
}
}