Files
seaweedFS/weed/mount/meta_cache/meta_cache.go
Chris Lu 3f946fc0c0 mount: make metadata cache rebuilds snapshot-consistent (#8531)
* filer: expose metadata events and list snapshots

* mount: invalidate hot directory caches

* mount: read hot directories directly from filer

* mount: add sequenced metadata cache applier

* mount: apply metadata responses through cache applier

* mount: replay snapshot-consistent directory builds

* mount: dedupe self metadata events

* mount: factor directory build cleanup

* mount: replace proto marshal dedup with composite key and ring buffer

The dedup logic was doing a full deterministic proto.Marshal on every
metadata event just to produce a dedup key. Replace with a cheap
composite string key (TsNs|Directory|OldName|NewName).

Also replace the sliding-window slice (which leaked the backing array
unboundedly) with a fixed-size ring buffer that reuses the same array.

* filer: remove mutex and proto.Clone from request-scoped MetadataEventSink

MetadataEventSink is created per-request and only accessed by the
goroutine handling the gRPC call. The mutex and double proto.Clone
(once in Record, once in Last) were unnecessary overhead on every
filer write operation. Store the pointer directly instead.

* mount: skip proto.Clone for caller-owned metadata events

Add ApplyMetadataResponseOwned that takes ownership of the response
without cloning. Local metadata events (mkdir, create, flush, etc.)
are freshly constructed and never shared, so the clone is unnecessary.

* filer: only populate MetadataEvent on successful DeleteEntry

Avoid calling eventSink.Last() on error paths where the sink may
contain a partial event from an intermediate child deletion during
recursive deletes.

* mount: avoid map allocation in collectDirectoryNotifications

Replace the map with a fixed-size array and linear dedup. There are
at most 3 directories to notify (old parent, new parent, new child
if directory), so a 3-element array avoids the heap allocation on
every metadata event.

* mount: fix potential deadlock in enqueueApplyRequest

Release applyStateMu before the blocking channel send. Previously,
if the channel was full (cap 128), the send would block while holding
the mutex, preventing Shutdown from acquiring it to set applyClosed.

* mount: restore signature-based self-event filtering as fast path

Re-add the signature check that was removed when content-based dedup
was introduced. Checking signatures is O(1) on a small slice and
avoids enqueuing and processing events that originated from this
mount instance. The content-based dedup remains as a fallback.

* filer: send snapshotTsNs only in first ListEntries response

The snapshot timestamp is identical for every entry in a single
ListEntries stream. Sending it in every response message wastes
wire bandwidth for large directories. The client already reads
it only from the first response.

* mount: exit read-through mode after successful full directory listing

MarkDirectoryRefreshed was defined but never called, so directories
that entered read-through mode (hot invalidation threshold) stayed
there permanently, hitting the filer on every readdir even when cold.
Call it after a complete read-through listing finishes.

* mount: include event shape and full paths in dedup key

The previous dedup key only used Names, which could collapse distinct
rename targets. Include the event shape (C/D/U/R), source directory,
new parent path, and both entry names so structurally different events
are never treated as duplicates.

* mount: drain pending requests on shutdown in runApplyLoop

After receiving the shutdown sentinel, drain any remaining requests
from applyCh non-blockingly and signal each with errMetaCacheClosed
so callers waiting on req.done are released.

* mount: include IsDirectory in synthetic delete events

metadataDeleteEvent now accepts an isDirectory parameter so the
applier can distinguish directory deletes from file deletes. Rmdir
passes true, Unlink passes false.

* mount: fall back to synthetic event when MetadataEvent is nil

In mknod and mkdir, if the filer response omits MetadataEvent (e.g.
older filer without the field), synthesize an equivalent local
metadata event so the cache is always updated.

* mount: make Flush metadata apply best-effort after successful commit

After filer_pb.CreateEntryWithResponse succeeds, the entry is
persisted. Don't fail the Flush syscall if the local metadata cache
apply fails — log and invalidate the directory cache instead.
Also fall back to a synthetic event when MetadataEvent is nil.

* mount: make Rename metadata apply best-effort

The rename has already succeeded on the filer by the time we apply
the local metadata event. Log failures instead of returning errors
that would be dropped by the caller anyway.

* mount: make saveEntry metadata apply best-effort with fallback

After UpdateEntryWithResponse succeeds, treat local metadata apply
as non-fatal. Log and invalidate the directory cache on failure.
Also fall back to a synthetic event when MetadataEvent is nil.

* filer_pb: preserve snapshotTsNs on error in ReadDirAllEntriesWithSnapshot

Return the snapshot timestamp even when the first page fails, so
callers receive the snapshot boundary when partial data was received.

* filer: send snapshot token for empty directory listings

When no entries are streamed, send a final ListEntriesResponse with
only SnapshotTsNs so clients always receive the snapshot boundary.

* mount: distinguish not-found vs transient errors in lookupEntry

Return fuse.EIO for non-not-found filer errors instead of
unconditionally returning ENOENT, so transient failures don't
masquerade as missing entries.

* mount: make CacheRemoteObject metadata apply best-effort

The file content has already been cached successfully. Don't fail
the read if the local metadata cache update fails.

* mount: use consistent snapshot for readdir in direct mode

Capture the SnapshotTsNs from the first loadDirectoryEntriesDirect
call and store it on the DirectoryHandle. Subsequent batch loads
pass this stored timestamp so all batches use the same snapshot.

Also export DoSeaweedListWithSnapshot so mount can use it directly
with snapshot passthrough.

* filer_pb: fix test fake to send SnapshotTsNs only on first response

Match the server behavior: only the first ListEntriesResponse in a
page carries the snapshot timestamp, subsequent entries leave it zero.

* Fix nil pointer dereference in ListEntries stream consumers

Remove the empty-directory snapshot-only response from ListEntries
that sent a ListEntriesResponse with Entry==nil, which crashed every
raw stream consumer that assumed resp.Entry is always non-nil.

Also add defensive nil checks for resp.Entry in all raw ListEntries
stream consumers across: S3 listing, broker topic lookup, broker
topic config, admin dashboard, topic retention, hybrid message
scanner, Kafka integration, and consumer offset storage.

* Add nil guards for resp.Entry in remaining ListEntries stream consumers

Covers: S3 object lock check, MQ management dashboard (version/
partition/offset loops), and topic retention version loop.

* Make applyLocalMetadataEvent best-effort in Link and Symlink

The filer operations already succeeded; failing the syscall because
the local cache apply failed is wrong. Log a warning and invalidate
the parent directory cache instead.

* Make applyLocalMetadataEvent best-effort in Mkdir/Rmdir/Mknod/Unlink

The filer RPC already committed; don't fail the syscall when the
local metadata cache apply fails. Log a warning and invalidate the
parent directory cache to force a re-fetch on next access.

* flushFileMetadata: add nil-fallback for metadata event and best-effort apply

Synthesize a metadata event when resp.GetMetadataEvent() is nil
(matching doFlush), and make the apply best-effort with cache
invalidation on failure.

* Prevent double-invocation of cleanupBuild in doEnsureVisited

Add a cleanupDone guard so the deferred cleanup and inline error-path
cleanup don't both call DeleteFolderChildren/AbortDirectoryBuild.

* Fix comment: signature check is O(n) not O(1)

* Prevent deferred cleanup after successful CompleteDirectoryBuild

Set cleanupDone before returning from the success path so the
deferred context-cancellation check cannot undo a published build.

* Invalidate parent directory caches on rename metadata apply failure

When applyLocalMetadataEvent fails during rename, invalidate the
source and destination parent directory caches so subsequent accesses
trigger a re-fetch from the filer.

* Add event nil-fallback and cache invalidation to Link and Symlink

Synthesize metadata events when the server doesn't return one, and
invalidate parent directory caches on apply failure.

* Match requested partition when scanning partition directories

Parse the partition range format (NNNN-NNNN) and match against the
requested partition parameter instead of using the first directory.

* Preserve snapshot timestamp across empty directory listings

Initialize actualSnapshotTsNs from the caller-requested value so it
isn't lost when the server returns no entries. Re-add the server-side
snapshot-only response for empty directories (all raw stream consumers
now have nil guards for Entry).

* Fix CreateEntry error wrapping to support errors.Is/errors.As

Use errors.New + %w instead of %v for resp.Error so callers can
unwrap the underlying error.

* Fix object lock pagination: only advance on non-nil entries

Move entriesReceived inside the nil check so nil entries don't
cause repeated ListEntries calls with the same lastFileName.

* Guard Attributes nil check before accessing Mtime in MQ management

* Do not send nil-Entry response for empty directory listings

The snapshot-only ListEntriesResponse (with Entry == nil) for empty
directories breaks consumers that treat any received response as an
entry (Java FilerClient, S3 listing). The Go client-side
DoSeaweedListWithSnapshot already preserves the caller-requested
snapshot via actualSnapshotTsNs initialization, so the server-side
send is unnecessary.

* Fix review findings: subscriber dedup, invalidation normalization, nil guards, shutdown race

- Remove self-signature early-return in processEventFn so all events
  flow through the applier (directory-build buffering sees self-originated
  events that arrive after a snapshot)
- Normalize NewParentPath in collectEntryInvalidations to avoid duplicate
  invalidations when NewParentPath is empty (same-directory update)
- Guard resp.Entry.Attributes for nil in admin_server.go and
  topic_retention.go to prevent panics on entries without attributes
- Fix enqueueApplyRequest race with shutdown by using select on both
  applyCh and applyDone, preventing sends after the apply loop exits
- Add cleanupDone check to deferred cleanup in meta_cache_init.go for
  clarity alongside the existing guard in cleanupBuild
- Add empty directory test case for snapshot consistency

* Propagate authoritative metadata event from CacheRemoteObjectToLocalCluster and generate client-side snapshot for empty directories

- Add metadata_event field to CacheRemoteObjectToLocalClusterResponse
  proto so the filer-emitted event is available to callers
- Use WithMetadataEventSink in the server handler to capture the event
  from NotifyUpdateEvent and return it on the response
- Update filehandle_read.go to prefer the RPC's metadata event over
  a locally fabricated one, falling back to metadataUpdateEvent when
  the server doesn't provide one (e.g., older filers)
- Generate a client-side snapshot cutoff in DoSeaweedListWithSnapshot
  when the server sends no snapshot (empty directory), so callers like
  CompleteDirectoryBuild get a meaningful boundary for filtering
  buffered events

* Skip directory notifications for dirs being built to prevent mid-build cache wipe

When a metadata event is buffered during a directory build,
applyMetadataSideEffects was still firing noteDirectoryUpdate for the
building directory. If the directory accumulated enough updates to
become "hot", markDirectoryReadThrough would call DeleteFolderChildren,
wiping entries that EnsureVisited had already inserted. The build would
then complete and mark the directory cached with incomplete data.

Fix by using applyMetadataSideEffectsSkippingBuildingDirs for buffered
events, which suppresses directory notifications for dirs currently in
buildingDirs while still applying entry invalidations.

* Add test for directory notification suppression during active build

TestDirectoryNotificationsSuppressedDuringBuild verifies that metadata
events targeting a directory under active EnsureVisited build do NOT
fire onDirectoryUpdate for that directory. In production, this prevents
markDirectoryReadThrough from calling DeleteFolderChildren mid-build,
which would wipe entries already inserted by the listing.

The test inserts an entry during a build, sends multiple metadata events
for the building directory, asserts no notifications fired for it,
verifies the entry survives, and confirms buffered events are replayed
after CompleteDirectoryBuild.

* Fix create invalidations, build guard, event shape, context, and snapshot error path

- collectEntryInvalidations: invalidate FUSE kernel cache on pure
  create events (OldEntry==nil && NewEntry!=nil), not just updates
  and deletes
- completeDirectoryBuildNow: only call markCachedFn when an active
  build existed (state != nil), preventing an unpopulated directory
  from being marked as cached
- Add metadataCreateEvent helper that produces a create-shaped event
  (NewEntry only, no OldEntry) and use it in mkdir, mknod, symlink,
  and hardlink create fallback paths instead of metadataUpdateEvent
  which incorrectly set both OldEntry and NewEntry
- applyMetadataResponseEnqueue: use context.Background() for the
  queued mutation so a cancelled caller context cannot abort the
  apply loop mid-write
- DoSeaweedListWithSnapshot: move snapshot initialization before
  ListEntries call so the error path returns the preserved snapshot
  instead of 0

* Fix review findings: test loop, cache race, context safety, snapshot consistency

- Fix build test loop starting at i=1 instead of i=0, missing new-0.txt verification
- Re-check IsDirectoryCached after cache miss to avoid ENOENT race with markDirectoryReadThrough
- Use context.Background() in enqueueAndWait so caller cancellation can't abort build/complete mid-way
- Pass dh.snapshotTsNs in skip-batch loadDirectoryEntriesDirect for snapshot consistency
- Prefer resp.MetadataEvent over fallback in Unlink event derivation
- Add comment on MetadataEventSink.Record single-event assumption

* Fix empty-directory snapshot clock skew and build cancellation race

Empty-directory snapshot: Remove client-side time.Now() synthesis when
the server returns no entries. Instead return snapshotTsNs=0, and in
completeDirectoryBuildNow replay ALL buffered events when snapshot is 0.
This eliminates the clock-skew bug where a client ahead of the filer
would filter out legitimate post-list events.

Build cancellation: Use context.Background() for BeginDirectoryBuild
and CompleteDirectoryBuild calls in doEnsureVisited, so errgroup
cancellation doesn't cause enqueueAndWait to return early and trigger
cleanupBuild while the operation is still queued.

* Add tests for empty-directory build replay and cancellation resilience

TestEmptyDirectoryBuildReplaysAllBufferedEvents: verifies that when
CompleteDirectoryBuild receives snapshotTsNs=0 (empty directory, no
server snapshot), ALL buffered events are replayed regardless of their
TsNs values — no clock-skew-sensitive filtering occurs.

TestBuildCompletionSurvivesCallerCancellation: verifies that once
CompleteDirectoryBuild is enqueued, a cancelled caller context does not
prevent the build from completing. The apply loop runs with
context.Background(), so the directory becomes cached and buffered
events are replayed even when the caller gives up waiting.

* Fix directory subtree cleanup, Link rollback, test robustness

- applyMetadataResponseLocked: when a directory entry is deleted or
  moved, call DeleteFolderChildren on the old path so cached descendants
  don't leak as stale entries.

- Link: save original HardLinkId/Counter before mutation. If
  CreateEntryWithResponse fails after the source was already updated,
  rollback the source entry to its original state via UpdateEntry.

- TestBuildCompletionSurvivesCallerCancellation: replace fixed
  time.Sleep(50ms) with a deadline-based poll that checks
  IsDirectoryCached in a loop, failing only after 2s timeout.

- TestReadDirAllEntriesWithSnapshotEmptyDirectory: assert that
  ListEntries was actually invoked on the mock client so the test
  exercises the RPC path.

- newMetadataEvent: add early return when both oldEntry and newEntry are
  nil to avoid emitting events with empty Directory.

---------

Co-authored-by: Copilot <copilot@github.com>
2026-03-07 09:19:40 -08:00

841 lines
25 KiB
Go

package meta_cache
import (
"context"
"errors"
"os"
"sync"
"time"
"golang.org/x/sync/singleflight"
"fmt"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/filer/leveldb"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/util"
"google.golang.org/protobuf/proto"
)
// need to have logic similar to FilerStoreWrapper
// e.g. fill fileId field for chunks
type MetaCache struct {
root util.FullPath
localStore filer.VirtualFilerStore
leveldbStore *leveldb.LevelDBStore // direct reference for batch operations
sync.RWMutex
uidGidMapper *UidGidMapper
markCachedFn func(fullpath util.FullPath)
isCachedFn func(fullpath util.FullPath) bool
invalidateFunc func(fullpath util.FullPath, entry *filer_pb.Entry)
onDirectoryUpdate func(dir util.FullPath)
visitGroup singleflight.Group // deduplicates concurrent EnsureVisited calls for the same path
applyCh chan metadataApplyRequest
applyDone chan struct{}
applyStateMu sync.Mutex
applyClosed bool
buildingDirs map[util.FullPath]*directoryBuildState
dedupRing dedupRingBuffer
}
var errMetaCacheClosed = errors.New("metadata cache is shut down")
type MetadataResponseApplyOptions struct {
NotifyDirectories bool
InvalidateEntries bool
}
var (
LocalMetadataResponseApplyOptions = MetadataResponseApplyOptions{
NotifyDirectories: true,
}
SubscriberMetadataResponseApplyOptions = MetadataResponseApplyOptions{
NotifyDirectories: true,
InvalidateEntries: true,
}
)
type directoryBuildState struct {
bufferedEvents []*filer_pb.SubscribeMetadataResponse
}
const recentEventDedupWindow = 4096
type metadataApplyRequestKind int
const (
metadataApplyEvent metadataApplyRequestKind = iota
metadataBeginBuild
metadataCompleteBuild
metadataAbortBuild
metadataShutdown
)
type metadataApplyRequest struct {
ctx context.Context
kind metadataApplyRequestKind
resp *filer_pb.SubscribeMetadataResponse
options MetadataResponseApplyOptions
buildPath util.FullPath
snapshotTsNs int64
done chan error
}
func NewMetaCache(dbFolder string, uidGidMapper *UidGidMapper, root util.FullPath,
markCachedFn func(path util.FullPath), isCachedFn func(path util.FullPath) bool, invalidateFunc func(util.FullPath, *filer_pb.Entry), onDirectoryUpdate func(dir util.FullPath)) *MetaCache {
leveldbStore, virtualStore := openMetaStore(dbFolder)
mc := &MetaCache{
root: root,
localStore: virtualStore,
leveldbStore: leveldbStore,
markCachedFn: markCachedFn,
isCachedFn: isCachedFn,
uidGidMapper: uidGidMapper,
onDirectoryUpdate: onDirectoryUpdate,
invalidateFunc: func(fullpath util.FullPath, entry *filer_pb.Entry) {
invalidateFunc(fullpath, entry)
},
applyCh: make(chan metadataApplyRequest, 128),
applyDone: make(chan struct{}),
buildingDirs: make(map[util.FullPath]*directoryBuildState),
dedupRing: newDedupRingBuffer(),
}
go mc.runApplyLoop()
return mc
}
func openMetaStore(dbFolder string) (*leveldb.LevelDBStore, filer.VirtualFilerStore) {
os.RemoveAll(dbFolder)
os.MkdirAll(dbFolder, 0755)
store := &leveldb.LevelDBStore{}
config := &cacheConfig{
dir: dbFolder,
}
if err := store.Initialize(config, ""); err != nil {
glog.Fatalf("Failed to initialize metadata cache store for %s: %+v", store.GetName(), err)
}
return store, filer.NewFilerStoreWrapper(store)
}
func (mc *MetaCache) InsertEntry(ctx context.Context, entry *filer.Entry) error {
mc.Lock()
defer mc.Unlock()
return mc.doInsertEntry(ctx, entry)
}
func (mc *MetaCache) doInsertEntry(ctx context.Context, entry *filer.Entry) error {
return mc.localStore.InsertEntry(ctx, entry)
}
// doBatchInsertEntries inserts multiple entries using LevelDB's batch write.
// This is more efficient than inserting entries one by one.
func (mc *MetaCache) doBatchInsertEntries(ctx context.Context, entries []*filer.Entry) error {
return mc.leveldbStore.BatchInsertEntries(ctx, entries)
}
func (mc *MetaCache) AtomicUpdateEntryFromFiler(ctx context.Context, oldPath util.FullPath, newEntry *filer.Entry) error {
mc.Lock()
defer mc.Unlock()
return mc.atomicUpdateEntryFromFilerLocked(ctx, oldPath, newEntry, false)
}
func (mc *MetaCache) atomicUpdateEntryFromFilerLocked(ctx context.Context, oldPath util.FullPath, newEntry *filer.Entry, allowUncachedInsert bool) error {
entry, err := mc.localStore.FindEntry(ctx, oldPath)
if err != nil && err != filer_pb.ErrNotFound {
glog.Errorf("Metacache: find entry error: %v", err)
return err
}
if entry != nil {
if oldPath != "" {
if newEntry != nil && oldPath == newEntry.FullPath {
// skip the unnecessary deletion
// leave the update to the following InsertEntry operation
} else {
ctx = context.WithValue(ctx, "OP", "MV")
glog.V(3).Infof("DeleteEntry %s", oldPath)
if err := mc.localStore.DeleteEntry(ctx, oldPath); err != nil {
return err
}
}
}
} else {
// println("unknown old directory:", oldDir)
}
if newEntry != nil {
newDir, _ := newEntry.DirAndName()
if allowUncachedInsert || mc.isCachedFn(util.FullPath(newDir)) {
glog.V(3).Infof("InsertEntry %s/%s", newDir, newEntry.Name())
if err := mc.localStore.InsertEntry(ctx, newEntry); err != nil {
return err
}
}
}
return nil
}
func (mc *MetaCache) ApplyMetadataResponse(ctx context.Context, resp *filer_pb.SubscribeMetadataResponse, options MetadataResponseApplyOptions) error {
if resp == nil || resp.EventNotification == nil {
return nil
}
clonedResp := proto.Clone(resp).(*filer_pb.SubscribeMetadataResponse)
return mc.applyMetadataResponseEnqueue(ctx, clonedResp, options)
}
// ApplyMetadataResponseOwned is like ApplyMetadataResponse but takes ownership
// of resp without cloning. The caller must not use resp after this call.
func (mc *MetaCache) ApplyMetadataResponseOwned(ctx context.Context, resp *filer_pb.SubscribeMetadataResponse, options MetadataResponseApplyOptions) error {
if resp == nil || resp.EventNotification == nil {
return nil
}
return mc.applyMetadataResponseEnqueue(ctx, resp, options)
}
func (mc *MetaCache) applyMetadataResponseEnqueue(ctx context.Context, resp *filer_pb.SubscribeMetadataResponse, options MetadataResponseApplyOptions) error {
if ctx == nil {
ctx = context.Background()
}
req := metadataApplyRequest{
// Use a non-cancellable context for the queued mutation so a
// cancelled caller doesn't abort the apply loop mid-write.
ctx: context.Background(),
kind: metadataApplyEvent,
resp: resp,
options: options,
done: make(chan error, 1),
}
if err := mc.enqueueApplyRequest(req); err != nil {
return err
}
select {
case err := <-req.done:
return err
case <-ctx.Done():
return ctx.Err()
}
}
func (mc *MetaCache) BeginDirectoryBuild(ctx context.Context, dirPath util.FullPath) error {
return mc.enqueueAndWait(ctx, metadataApplyRequest{
kind: metadataBeginBuild,
buildPath: dirPath,
})
}
func (mc *MetaCache) CompleteDirectoryBuild(ctx context.Context, dirPath util.FullPath, snapshotTsNs int64) error {
return mc.enqueueAndWait(ctx, metadataApplyRequest{
kind: metadataCompleteBuild,
buildPath: dirPath,
snapshotTsNs: snapshotTsNs,
})
}
func (mc *MetaCache) AbortDirectoryBuild(ctx context.Context, dirPath util.FullPath) error {
return mc.enqueueAndWait(ctx, metadataApplyRequest{
kind: metadataAbortBuild,
buildPath: dirPath,
})
}
func (mc *MetaCache) UpdateEntry(ctx context.Context, entry *filer.Entry) error {
mc.Lock()
defer mc.Unlock()
return mc.localStore.UpdateEntry(ctx, entry)
}
func (mc *MetaCache) FindEntry(ctx context.Context, fp util.FullPath) (entry *filer.Entry, err error) {
mc.RLock()
defer mc.RUnlock()
entry, err = mc.localStore.FindEntry(ctx, fp)
if err != nil {
return nil, err
}
if entry.TtlSec > 0 && entry.Crtime.Add(time.Duration(entry.TtlSec)*time.Second).Before(time.Now()) {
return nil, filer_pb.ErrNotFound
}
mc.mapIdFromFilerToLocal(entry)
return
}
func (mc *MetaCache) DeleteEntry(ctx context.Context, fp util.FullPath) (err error) {
mc.Lock()
defer mc.Unlock()
return mc.localStore.DeleteEntry(ctx, fp)
}
func (mc *MetaCache) DeleteFolderChildren(ctx context.Context, fp util.FullPath) (err error) {
mc.Lock()
defer mc.Unlock()
return mc.localStore.DeleteFolderChildren(ctx, fp)
}
func (mc *MetaCache) ListDirectoryEntries(ctx context.Context, dirPath util.FullPath, startFileName string, includeStartFile bool, limit int64, eachEntryFunc filer.ListEachEntryFunc) error {
mc.RLock()
defer mc.RUnlock()
if !mc.isCachedFn(dirPath) {
// if this request comes after renaming, it should be fine
glog.Warningf("unsynchronized dir: %v", dirPath)
}
_, err := mc.localStore.ListDirectoryEntries(ctx, dirPath, startFileName, includeStartFile, limit, func(entry *filer.Entry) (bool, error) {
if entry.TtlSec > 0 && entry.Crtime.Add(time.Duration(entry.TtlSec)*time.Second).Before(time.Now()) {
return true, nil
}
mc.mapIdFromFilerToLocal(entry)
return eachEntryFunc(entry)
})
if err != nil {
return err
}
return err
}
func (mc *MetaCache) Shutdown() {
done := make(chan error, 1)
mc.applyStateMu.Lock()
if !mc.applyClosed {
mc.applyClosed = true
mc.applyCh <- metadataApplyRequest{
kind: metadataShutdown,
done: done,
}
}
mc.applyStateMu.Unlock()
select {
case <-done:
case <-mc.applyDone:
}
<-mc.applyDone
mc.Lock()
defer mc.Unlock()
mc.localStore.Shutdown()
}
func (mc *MetaCache) mapIdFromFilerToLocal(entry *filer.Entry) {
entry.Attr.Uid, entry.Attr.Gid = mc.uidGidMapper.FilerToLocal(entry.Attr.Uid, entry.Attr.Gid)
}
func (mc *MetaCache) Debug() {
if debuggable, ok := mc.localStore.(filer.Debuggable); ok {
println("start debugging")
debuggable.Debug(os.Stderr)
}
}
// IsDirectoryCached returns true if the directory has been fully cached
// (i.e., all entries have been loaded via EnsureVisited or ReadDir).
func (mc *MetaCache) IsDirectoryCached(dirPath util.FullPath) bool {
return mc.isCachedFn(dirPath)
}
func (mc *MetaCache) noteDirectoryUpdate(dirPath util.FullPath) {
if mc.onDirectoryUpdate != nil {
mc.onDirectoryUpdate(dirPath)
}
}
func (mc *MetaCache) enqueueAndWait(ctx context.Context, req metadataApplyRequest) error {
if ctx == nil {
ctx = context.Background()
}
// Use a non-cancellable context for the queued operation so a
// cancelled caller doesn't abort a build/complete mid-way.
req.ctx = context.Background()
req.done = make(chan error, 1)
if err := mc.enqueueApplyRequest(req); err != nil {
return err
}
select {
case err := <-req.done:
return err
case <-ctx.Done():
return ctx.Err()
}
}
func (mc *MetaCache) enqueueApplyRequest(req metadataApplyRequest) error {
mc.applyStateMu.Lock()
if mc.applyClosed {
mc.applyStateMu.Unlock()
return errMetaCacheClosed
}
// Release the mutex before the potentially-blocking channel send so that
// Shutdown can still acquire it to set applyClosed when the channel is full.
mc.applyStateMu.Unlock()
select {
case mc.applyCh <- req:
return nil
case <-mc.applyDone:
return errMetaCacheClosed
}
}
func (mc *MetaCache) runApplyLoop() {
defer close(mc.applyDone)
for req := range mc.applyCh {
req.done <- mc.handleApplyRequest(req)
close(req.done)
if req.kind == metadataShutdown {
mc.drainApplyCh()
return
}
}
}
// drainApplyCh non-blockingly drains any remaining requests from applyCh
// after a shutdown sentinel, signalling each caller so they don't block.
func (mc *MetaCache) drainApplyCh() {
for {
select {
case req := <-mc.applyCh:
req.done <- errMetaCacheClosed
close(req.done)
default:
return
}
}
}
func (mc *MetaCache) handleApplyRequest(req metadataApplyRequest) error {
switch req.kind {
case metadataApplyEvent:
return mc.applyMetadataResponseNow(req.ctx, req.resp, req.options)
case metadataBeginBuild:
return mc.beginDirectoryBuildNow(req.buildPath)
case metadataCompleteBuild:
return mc.completeDirectoryBuildNow(req.ctx, req.buildPath, req.snapshotTsNs)
case metadataAbortBuild:
return mc.abortDirectoryBuildNow(req.buildPath)
case metadataShutdown:
return nil
default:
return nil
}
}
type metadataInvalidation struct {
path util.FullPath
entry *filer_pb.Entry
}
type metadataResponseSideEffects struct {
dirsToNotify []util.FullPath
invalidations []metadataInvalidation
}
func (mc *MetaCache) applyMetadataResponseNow(ctx context.Context, resp *filer_pb.SubscribeMetadataResponse, options MetadataResponseApplyOptions) error {
if mc.shouldSkipDuplicateEvent(resp) {
return nil
}
immediateEvents, bufferedEvents := mc.routeMetadataResponse(resp)
if len(bufferedEvents) == 0 {
return mc.applyMetadataResponseDirect(ctx, resp, options, false)
}
// Apply side effects but skip directory notifications for dirs that are
// currently being built. Notifying a building dir can trigger
// markDirectoryReadThrough → DeleteFolderChildren, wiping entries that
// EnsureVisited already inserted, leaving an incomplete cache.
mc.applyMetadataSideEffectsSkippingBuildingDirs(resp, options)
for buildDir, events := range bufferedEvents {
state := mc.buildingDirs[buildDir]
if state == nil {
continue
}
state.bufferedEvents = append(state.bufferedEvents, events...)
}
for _, immediateEvent := range immediateEvents {
if err := mc.applyMetadataResponseDirect(ctx, immediateEvent, MetadataResponseApplyOptions{}, false); err != nil {
return err
}
}
return nil
}
func (mc *MetaCache) applyMetadataResponseDirect(ctx context.Context, resp *filer_pb.SubscribeMetadataResponse, options MetadataResponseApplyOptions, allowUncachedInsert bool) error {
if _, err := mc.applyMetadataResponseLocked(ctx, resp, options, allowUncachedInsert); err != nil {
return err
}
mc.applyMetadataSideEffects(resp, options)
return nil
}
func (mc *MetaCache) applyMetadataSideEffects(resp *filer_pb.SubscribeMetadataResponse, options MetadataResponseApplyOptions) {
sideEffects := metadataResponseSideEffects{}
if options.NotifyDirectories {
sideEffects.dirsToNotify = collectDirectoryNotifications(resp)
}
if options.InvalidateEntries {
sideEffects.invalidations = collectEntryInvalidations(resp)
}
for _, dirPath := range sideEffects.dirsToNotify {
mc.noteDirectoryUpdate(dirPath)
}
for _, invalidation := range sideEffects.invalidations {
mc.invalidateFunc(invalidation.path, invalidation.entry)
}
}
// applyMetadataSideEffectsSkippingBuildingDirs is like applyMetadataSideEffects
// but suppresses directory notifications for dirs currently in buildingDirs.
// This prevents markDirectoryReadThrough from wiping entries mid-build.
func (mc *MetaCache) applyMetadataSideEffectsSkippingBuildingDirs(resp *filer_pb.SubscribeMetadataResponse, options MetadataResponseApplyOptions) {
sideEffects := metadataResponseSideEffects{}
if options.NotifyDirectories {
sideEffects.dirsToNotify = collectDirectoryNotifications(resp)
}
if options.InvalidateEntries {
sideEffects.invalidations = collectEntryInvalidations(resp)
}
for _, dirPath := range sideEffects.dirsToNotify {
if _, building := mc.buildingDirs[dirPath]; !building {
mc.noteDirectoryUpdate(dirPath)
}
}
for _, invalidation := range sideEffects.invalidations {
mc.invalidateFunc(invalidation.path, invalidation.entry)
}
}
func (mc *MetaCache) applyMetadataResponseLocked(ctx context.Context, resp *filer_pb.SubscribeMetadataResponse, _ MetadataResponseApplyOptions, allowUncachedInsert bool) (metadataResponseSideEffects, error) {
message := resp.GetEventNotification()
if message == nil {
return metadataResponseSideEffects{}, nil
}
var oldPath util.FullPath
var newEntry *filer.Entry
if message.OldEntry != nil {
oldPath = util.NewFullPath(resp.Directory, message.OldEntry.Name)
}
if message.NewEntry != nil {
dir := resp.Directory
if message.NewParentPath != "" {
dir = message.NewParentPath
}
newEntry = filer.FromPbEntry(dir, message.NewEntry)
}
mc.Lock()
err := mc.atomicUpdateEntryFromFilerLocked(ctx, oldPath, newEntry, allowUncachedInsert)
// When a directory is deleted or moved, remove its cached descendants
// so stale children cannot be served from the local cache.
if err == nil && oldPath != "" && message.OldEntry != nil && message.OldEntry.IsDirectory {
isDelete := message.NewEntry == nil
isMove := message.NewEntry != nil && (message.NewParentPath != resp.Directory || message.NewEntry.Name != message.OldEntry.Name)
if isDelete || isMove {
if deleteErr := mc.localStore.DeleteFolderChildren(ctx, oldPath); deleteErr != nil {
glog.V(2).Infof("delete descendants of %s: %v", oldPath, deleteErr)
}
}
}
mc.Unlock()
if err != nil {
return metadataResponseSideEffects{}, err
}
return metadataResponseSideEffects{}, nil
}
func (mc *MetaCache) beginDirectoryBuildNow(dirPath util.FullPath) error {
if _, found := mc.buildingDirs[dirPath]; found {
return nil
}
mc.buildingDirs[dirPath] = &directoryBuildState{}
return nil
}
func (mc *MetaCache) abortDirectoryBuildNow(dirPath util.FullPath) error {
delete(mc.buildingDirs, dirPath)
return nil
}
func (mc *MetaCache) completeDirectoryBuildNow(ctx context.Context, dirPath util.FullPath, snapshotTsNs int64) error {
state := mc.buildingDirs[dirPath]
delete(mc.buildingDirs, dirPath)
if state == nil {
return nil
}
for _, event := range state.bufferedEvents {
// When the server provided a snapshot timestamp, skip events that
// the listing already included. When snapshotTsNs == 0 (empty
// directory — server returned no entries and no snapshot), replay
// ALL buffered events to avoid dropping mutations due to
// client/server clock skew.
if snapshotTsNs != 0 && event.TsNs != 0 && event.TsNs <= snapshotTsNs {
continue
}
if err := mc.applyMetadataResponseDirect(ctx, event, MetadataResponseApplyOptions{}, true); err != nil {
return err
}
}
mc.markCachedFn(dirPath)
return nil
}
func (mc *MetaCache) routeMetadataResponse(resp *filer_pb.SubscribeMetadataResponse) ([]*filer_pb.SubscribeMetadataResponse, map[util.FullPath][]*filer_pb.SubscribeMetadataResponse) {
message := resp.GetEventNotification()
if message == nil {
return []*filer_pb.SubscribeMetadataResponse{resp}, nil
}
oldDir, hasOld := metadataOldParentDir(resp)
newDir, hasNew := metadataNewParentDir(resp)
oldBuilding := hasOld && mc.isBuildingDir(oldDir)
newBuilding := hasNew && mc.isBuildingDir(newDir)
if !oldBuilding && !newBuilding {
return []*filer_pb.SubscribeMetadataResponse{resp}, nil
}
bufferedEvents := make(map[util.FullPath][]*filer_pb.SubscribeMetadataResponse)
var immediateEvents []*filer_pb.SubscribeMetadataResponse
if hasOld && hasNew && oldDir != newDir {
deleteEvent := metadataDeleteFragment(resp)
createEvent := metadataCreateFragment(resp)
if oldBuilding {
bufferedEvents[oldDir] = append(bufferedEvents[oldDir], deleteEvent)
} else {
immediateEvents = append(immediateEvents, deleteEvent)
}
if newBuilding {
bufferedEvents[newDir] = append(bufferedEvents[newDir], createEvent)
} else {
immediateEvents = append(immediateEvents, createEvent)
}
return immediateEvents, bufferedEvents
}
targetDir := newDir
if hasOld {
targetDir = oldDir
}
if mc.isBuildingDir(targetDir) {
bufferedEvents[targetDir] = append(bufferedEvents[targetDir], resp)
return nil, bufferedEvents
}
return []*filer_pb.SubscribeMetadataResponse{resp}, nil
}
func (mc *MetaCache) isBuildingDir(dirPath util.FullPath) bool {
_, found := mc.buildingDirs[dirPath]
return found
}
func metadataOldParentDir(resp *filer_pb.SubscribeMetadataResponse) (util.FullPath, bool) {
if resp.GetEventNotification() == nil || resp.EventNotification.OldEntry == nil {
return "", false
}
return util.FullPath(resp.Directory), true
}
func metadataNewParentDir(resp *filer_pb.SubscribeMetadataResponse) (util.FullPath, bool) {
if resp.GetEventNotification() == nil || resp.EventNotification.NewEntry == nil {
return "", false
}
newDir := resp.Directory
if resp.EventNotification.NewParentPath != "" {
newDir = resp.EventNotification.NewParentPath
}
return util.FullPath(newDir), true
}
func metadataDeleteFragment(resp *filer_pb.SubscribeMetadataResponse) *filer_pb.SubscribeMetadataResponse {
if resp.GetEventNotification() == nil || resp.EventNotification.OldEntry == nil {
return nil
}
return &filer_pb.SubscribeMetadataResponse{
Directory: resp.Directory,
EventNotification: &filer_pb.EventNotification{
OldEntry: proto.Clone(resp.EventNotification.OldEntry).(*filer_pb.Entry),
},
TsNs: resp.TsNs,
}
}
func metadataCreateFragment(resp *filer_pb.SubscribeMetadataResponse) *filer_pb.SubscribeMetadataResponse {
if resp.GetEventNotification() == nil || resp.EventNotification.NewEntry == nil {
return nil
}
newDir := resp.Directory
if resp.EventNotification.NewParentPath != "" {
newDir = resp.EventNotification.NewParentPath
}
return &filer_pb.SubscribeMetadataResponse{
Directory: newDir,
EventNotification: &filer_pb.EventNotification{
NewEntry: proto.Clone(resp.EventNotification.NewEntry).(*filer_pb.Entry),
NewParentPath: newDir,
},
TsNs: resp.TsNs,
}
}
func metadataEventDedupKey(resp *filer_pb.SubscribeMetadataResponse) string {
var oldName, newName, newParent string
hasOld, hasNew := false, false
if msg := resp.GetEventNotification(); msg != nil {
if msg.OldEntry != nil {
oldName = msg.OldEntry.Name
hasOld = true
}
if msg.NewEntry != nil {
newName = msg.NewEntry.Name
hasNew = true
newParent = msg.NewParentPath
}
}
// Encode event shape (create/delete/update/rename) so structurally
// different events with the same names are not collapsed.
var shape byte
switch {
case hasOld && hasNew:
if resp.Directory != newParent && newParent != "" {
shape = 'R' // rename across directories
} else {
shape = 'U' // update in place
}
case hasOld:
shape = 'D' // delete
case hasNew:
shape = 'C' // create
}
return fmt.Sprintf("%d|%c|%s|%s|%s|%s", resp.TsNs, shape, resp.Directory, oldName, newParent, newName)
}
func (mc *MetaCache) shouldSkipDuplicateEvent(resp *filer_pb.SubscribeMetadataResponse) bool {
if resp == nil || resp.TsNs == 0 {
return false
}
key := metadataEventDedupKey(resp)
return !mc.dedupRing.Add(key)
}
type dedupRingBuffer struct {
keys [recentEventDedupWindow]string
head int
size int
set map[string]struct{}
}
func newDedupRingBuffer() dedupRingBuffer {
return dedupRingBuffer{
set: make(map[string]struct{}, recentEventDedupWindow),
}
}
func (r *dedupRingBuffer) Add(key string) bool {
if _, found := r.set[key]; found {
return false // duplicate
}
if r.size == recentEventDedupWindow {
evicted := r.keys[r.head]
delete(r.set, evicted)
} else {
r.size++
}
r.keys[r.head] = key
r.set[key] = struct{}{}
r.head = (r.head + 1) % recentEventDedupWindow
return true // new entry
}
func collectDirectoryNotifications(resp *filer_pb.SubscribeMetadataResponse) []util.FullPath {
message := resp.GetEventNotification()
if message == nil {
return nil
}
// At most 3 dirs: old parent, new parent, new child (if directory).
// Use a fixed slice with linear dedup to avoid map allocation.
var dirs [3]util.FullPath
n := 0
addUnique := func(p util.FullPath) {
for i := 0; i < n; i++ {
if dirs[i] == p {
return
}
}
dirs[n] = p
n++
}
if message.OldEntry != nil {
oldPath := util.NewFullPath(resp.Directory, message.OldEntry.Name)
parent, _ := oldPath.DirAndName()
addUnique(util.FullPath(parent))
}
if message.NewEntry != nil {
newDir := resp.Directory
if message.NewParentPath != "" {
newDir = message.NewParentPath
}
newPath := util.NewFullPath(newDir, message.NewEntry.Name)
parent, _ := newPath.DirAndName()
addUnique(util.FullPath(parent))
if message.NewEntry.IsDirectory {
addUnique(newPath)
}
}
return dirs[:n]
}
func collectEntryInvalidations(resp *filer_pb.SubscribeMetadataResponse) []metadataInvalidation {
message := resp.GetEventNotification()
if message == nil {
return nil
}
var invalidations []metadataInvalidation
if message.OldEntry != nil && message.NewEntry != nil {
oldKey := util.NewFullPath(resp.Directory, message.OldEntry.Name)
invalidations = append(invalidations, metadataInvalidation{path: oldKey, entry: message.OldEntry})
// Normalize NewParentPath: empty means same directory as resp.Directory
newDir := resp.Directory
if message.NewParentPath != "" {
newDir = message.NewParentPath
}
if message.OldEntry.Name != message.NewEntry.Name || resp.Directory != newDir {
newKey := util.NewFullPath(newDir, message.NewEntry.Name)
invalidations = append(invalidations, metadataInvalidation{path: newKey, entry: message.NewEntry})
}
return invalidations
}
if filer_pb.IsCreate(resp) && message.NewEntry != nil {
newDir := resp.Directory
if message.NewParentPath != "" {
newDir = message.NewParentPath
}
newKey := util.NewFullPath(newDir, message.NewEntry.Name)
invalidations = append(invalidations, metadataInvalidation{path: newKey, entry: message.NewEntry})
}
if filer_pb.IsDelete(resp) && message.OldEntry != nil {
oldKey := util.NewFullPath(resp.Directory, message.OldEntry.Name)
invalidations = append(invalidations, metadataInvalidation{path: oldKey, entry: message.OldEntry})
}
return invalidations
}