package meta_cache import ( "context" "errors" "fmt" "time" "golang.org/x/sync/errgroup" "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/util" ) // DirectoryTooLargeError reports a directory the mount refuses to cache // locally. Its listings read through to the filer instead. type DirectoryTooLargeError struct { Path util.FullPath } func (e *DirectoryTooLargeError) Error() string { return fmt.Sprintf("directory %s is too large to cache locally", e.Path) } // maxCacheableEntries is the directory size above which a build gives up, or 0 // to cache everything. func EnsureVisited(mc *MetaCache, client filer_pb.FilerClient, dirPath util.FullPath, maxCacheableEntries int) error { // Collect all uncached paths from target directory up to root var uncachedPaths []util.FullPath currentPath := dirPath for { // If this path is cached, all ancestors are also cached if mc.isCachedFn(currentPath) { break } if mc.isOversized(currentPath) { // The directory itself reads through; an ancestor is stepped over, // or it would wedge every listing beneath it forever. if currentPath == dirPath { return &DirectoryTooLargeError{Path: currentPath} } } else { uncachedPaths = append(uncachedPaths, currentPath) } // Continue to parent directory if currentPath != mc.root { parent, _ := currentPath.DirAndName() currentPath = util.FullPath(parent) } else { break } } if len(uncachedPaths) == 0 { return nil } // Fetch all uncached directories in parallel with context for cancellation // If one fetch fails, cancel the others to avoid unnecessary work g, ctx := errgroup.WithContext(context.Background()) for _, p := range uncachedPaths { path := p // capture for closure g.Go(func() error { err := doEnsureVisited(ctx, mc, client, path, maxCacheableEntries) var tooLarge *DirectoryTooLargeError if errors.As(err, &tooLarge) && path != dirPath { // An ancestor found oversized just reads through; failing the // group here would cancel the builds of its cacheable // descendants, and the caller would treat the refusal as the // listed directory's own. return nil } return err }) } return g.Wait() } // batchInsertSize is the number of entries to accumulate before flushing to LevelDB. // 100 provides a balance between memory usage (~100 Entry pointers) and write efficiency // (fewer disk syncs). Larger values reduce I/O overhead but increase memory and latency. const batchInsertSize = 100 const ( emptyRebuildConfirmations = 2 emptyRebuildConfirmDelay = 50 * time.Millisecond ) func doEnsureVisited(ctx context.Context, mc *MetaCache, client filer_pb.FilerClient, path util.FullPath, maxCacheableEntries int) error { // Use singleflight to deduplicate concurrent requests for the same path _, err, _ := mc.visitGroup.Do(string(path), func() (interface{}, error) { // Check for cancellation before starting if ctx.Err() != nil { return nil, ctx.Err() } // Double-check if already cached (another goroutine may have completed) if mc.isCachedFn(path) { return nil, nil } glog.V(4).Infof("ReadDirAllEntries %s ...", path) // Use context.Background() for build lifecycle calls so that // errgroup cancellation of ctx doesn't cause enqueueAndWait to // return early, which would trigger cleanupBuild while the // operation is still queued. if err := mc.BeginDirectoryBuild(context.Background(), path); err != nil { return nil, fmt.Errorf("begin build %s: %w", path, err) } cleanupDone := false cleanupBuild := func(reason string) { if cleanupDone { return } cleanupDone = true if deleteErr := mc.deleteFolderChildrenForRebuild(context.Background(), path); deleteErr != nil { glog.V(2).Infof("clear %s build %s: %v", reason, path, deleteErr) } if abortErr := mc.AbortDirectoryBuild(context.Background(), path); abortErr != nil { glog.V(2).Infof("abort %s build %s: %v", reason, path, abortErr) } } defer func() { if !cleanupDone && ctx.Err() != nil { cleanupBuild("canceled") } }() // reloadFromFiler wipes the cached children and reloads them from the filer. reloadFromFiler := func() (entryCount int, snapshotTsNs int64, sections sectionBoundsCollector, err error) { err = util.Retry("ReadDirAllEntries", func() error { entryCount = 0 sections = sectionBoundsCollector{} var batch []*filer.Entry // reset on retry, allow GC of previous entries if err := mc.deleteFolderChildrenForRebuild(ctx, path); err != nil { return fmt.Errorf("clear existing entries for %s: %w", path, err) } var listErr error snapshotTsNs, listErr = filer_pb.ReadDirAllEntriesWithSnapshot(ctx, client, path, "", func(pbEntry *filer_pb.Entry, isLast bool) error { entry := filer.FromPbEntry(string(path), pbEntry) if !mc.includeSystemEntries && IsHiddenSystemEntry(string(path), entry.Name()) { return nil } if maxCacheableEntries > 0 && entryCount >= maxCacheableEntries { return &DirectoryTooLargeError{Path: path} } sections.note(entry.Name()) batch = append(batch, entry) entryCount++ // flush by size, not isLast: hidden entries can return early if len(batch) >= batchInsertSize { if err := mc.doBatchInsertEntries(ctx, batch); err != nil { return fmt.Errorf("batch insert for %s: %w", path, err) } batch = make([]*filer.Entry, 0, batchInsertSize) } return nil }) if listErr != nil { return listErr } if len(batch) > 0 { if err := mc.doBatchInsertEntries(ctx, batch); err != nil { return fmt.Errorf("batch insert remaining for %s: %w", path, err) } } return nil }) return entryCount, snapshotTsNs, sections, err } entryCount, snapshotTsNs, sections, fetchErr := reloadFromFiler() if fetchErr != nil { var tooLarge *DirectoryTooLargeError if errors.As(fetchErr, &tooLarge) { // Remember the refusal so the next visit fails fast instead of // streaming up to the limit again to rediscover it. mc.markOversized(path) glog.V(0).Infof("directory %s exceeds %d entries, reading it through instead of caching", path, maxCacheableEntries) cleanupBuild("oversized") return nil, fetchErr } cleanupBuild("failed") return nil, fmt.Errorf("list %s: %w", path, fetchErr) } // A transient empty listing would strand a populated directory cached over // an empty store; re-read to confirm before trusting it. First re-read is // immediate (a clean-EOF stream glitch clears at once), later ones space out. // On cancellation the deferred cleanup aborts the build. for attempt := 0; entryCount == 0 && attempt < emptyRebuildConfirmations; attempt++ { if ctx.Err() != nil { return nil, ctx.Err() } if attempt > 0 { select { case <-time.After(emptyRebuildConfirmDelay): case <-ctx.Done(): return nil, ctx.Err() } } if entryCount, snapshotTsNs, sections, fetchErr = reloadFromFiler(); fetchErr != nil { cleanupBuild("failed") return nil, fmt.Errorf("confirm empty list %s: %w", path, fetchErr) } if entryCount > 0 { glog.Warningf("rebuild of %s saw a transient empty listing, recovered %d entries on confirmation", path, entryCount) } } if err := mc.CompleteDirectoryBuild(context.Background(), path, snapshotTsNs, sections.bounds); err != nil { cleanupBuild("unreplayed") return nil, fmt.Errorf("complete build for %s: %w", path, err) } cleanupDone = true // Prevent deferred cleanup after successful publish return nil, nil }) return err } func IsHiddenSystemEntry(dir, name string) bool { return dir == "/" && (name == "topics" || name == "etc") }