mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-18 04:20:53 +02:00
* feat(s3/lifecycle): bootstrap re-walk cadence + operator hooks (Phase 8) scan_only actions only fire from the bootstrap walk: the engine classifies a rule as scan_only when its retention horizon exceeds the meta-log retention, so event-driven routing can't be trusted. Today each bucket walks once per process, so a long-running worker never revisits — scan_only retention only catches up when the worker restarts. Replace BucketBootstrapper.known (set) with BucketBootstrapper.lastWalk (name -> completion time). KickOffNew now re-walks a bucket whose last walk completed more than BootstrapInterval ago. Zero interval preserves the legacy walk-once-per-process behavior so existing deployments don't change cadence by default. walkBucket re-stamps on success and clears the stamp on failure (via MarkDirty), so the next KickOffNew picks failed walks back up. Add MarkDirty / MarkAllDirty operator hooks for forced re-walks, and a Now func() for testable time travel. weed shell run-shard grows --bootstrap-interval (cadence knob) and --force-bootstrap (drop in-memory state at startup so every bucket walks again immediately, useful when a config change should take effect without a restart). Tests: cadence respected (skip inside interval, re-walk past it); zero interval keeps once-per-process; MarkDirty forces re-walk under a 24h interval; MarkAllDirty resets every record. The fakeClock helper guards the test clock with a mutex so race-detector runs are clean. * fix(s3/lifecycle): split walk state, thread BootstrapInterval through worker, drop dead flag Three issues with the Phase 8 cadence work as it landed: 1. lastWalk did double duty as both completed-walk timestamp and in-flight debounce. A walk that took longer than BootstrapInterval would have a fresh KickOffNew start a duplicate goroutine on the next refresh tick because the stamp from KickOffNew looked stale against the interval. Split into lastCompleted (set on success) and inFlight (set on dispatch, cleared after the walk goroutine returns success or failure). KickOffNew skips inFlight buckets regardless of cadence. 2. The cadence knob existed on `weed shell` but not on the production path: scheduler.Scheduler constructed BucketBootstrapper without BootstrapInterval, and weed/worker/tasks/s3_lifecycle/Config had no field for it. Add Scheduler.BootstrapInterval, parse `bootstrap_interval_minutes` in ParseConfig (zero = legacy walk- once-per-process; negative clamps to zero), and forward it from the handler. Tests cover default, override, clamp, and explicit-zero. 3. --force-bootstrap was a no-op: BucketBootstrapper is freshly allocated at command start, so MarkAllDirty on empty state does nothing, and the flag couldn't influence an already-running process anyway. Remove it; a real runtime trigger (SIGHUP, control RPC) is a separate change. In-flight regression: a blockingInjector pins the first walk in progress while the test advances the clock past the interval. The second KickOffNew is a no-op (inFlight check). After release, the post-completion KickOffNew within the interval is also a no-op. * test(s3/lifecycle): wait for lastCompleted stamp before advancing fake clock The cadence test polled listedN to know "the walk happened" — but that fires once both list passes are issued, while the success-stamp lands later, after walkBucketDir returns. A clock.Advance(30m) between those two events would record the stamp at clock+30m instead of T0; the next assertion would then see now.Sub(last) < 1h and skip the expected re-walk. Tight in practice but exposed under -race / load. Add a waitForCompleted helper that polls b.lastCompleted directly, and use it before each clock advance in both the cadence and zero- interval tests. * fix(s3/lifecycle): expose bootstrap interval in worker UI; honor MarkDirty during walks Two follow-ups on Phase 8. The worker config descriptor had no bootstrap_interval_minutes field, so the production operator UI couldn't enable the cadence — only the internal ParseConfig + Scheduler wiring knew about it. Add the field to the cadence section (MinValue=0 since 0 is the legacy default) and include the default in DefaultValues so existing deployments see the knob with the right preset. MarkDirty / MarkAllDirty silently lost their effect when a walk was in flight: the methods cleared lastCompleted, but the walk's success path then wrote a fresh timestamp, hiding the operator's invalidation. Track a pendingDirty set; the walk goroutine consumes the flag on exit and skips the success stamp, so the next KickOffNew picks the bucket up immediately. Regression: pin a walk in progress with a blockingInjector, MarkDirty the bucket, release the walk, and assert lastCompleted stayed empty plus the next KickOffNew triggers a new walk inside the BootstrapInterval window. * refactor(s3/lifecycle): drop unused MarkDirty / MarkAllDirty + pendingDirty These methods were the operator-hook half of Phase 8, but the only caller (--force-bootstrap on the shell command) was removed when it turned out to be a no-op against a freshly-allocated bootstrapper. Nothing in production calls them anymore. Strip the dead surface: MarkDirty, MarkAllDirty, the pendingDirty set, the dirty-suppression branch in walkBucket, and the three tests that only exercised those methods. BootstrapInterval-driven re-bootstrap is the live mechanism. A real runtime trigger (SIGHUP, control RPC) is a separate change with a real call site.
484 lines
18 KiB
Go
484 lines
18 KiB
Go
package scheduler
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/glog"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle/reader"
|
|
"github.com/seaweedfs/seaweedfs/weed/util"
|
|
)
|
|
|
|
// EventInjector is the bootstrap-side hook into the dispatcher pipeline.
|
|
// One implementation routes events to the right per-shard pipeline; the
|
|
// shell's single-pipeline path passes pipeline.InjectEvent directly.
|
|
type EventInjector interface {
|
|
InjectEvent(ctx context.Context, ev *reader.Event) error
|
|
}
|
|
|
|
// listPageSize is the page size for paginated directory listings during
|
|
// the bucket walk. The filer caps SeaweedList(..., limit=0) at
|
|
// DirListingLimit (1000 by default) per call, so a single-page list
|
|
// would silently truncate large directories — a correctness bug for
|
|
// noncurrent retention since older versions past the page boundary
|
|
// would never reach the rank/sort math. Atomic so tests can shrink it
|
|
// without racing the async bootstrap goroutines other tests leave
|
|
// behind (KickOffNew dispatches walks via `go b.walkBucket(...)`,
|
|
// and a fresh test's Cleanup might land before those goroutines exit).
|
|
var listPageSize atomic.Uint32
|
|
|
|
func init() {
|
|
listPageSize.Store(1024)
|
|
}
|
|
|
|
// listAll issues paginated SeaweedList calls until the listing is
|
|
// exhausted, invoking fn for every entry. Pagination uses
|
|
// startFrom = lastEntryName (exclusive) to advance.
|
|
func listAll(ctx context.Context, client filer_pb.SeaweedFilerClient, dir string, fn func(*filer_pb.Entry) error) error {
|
|
pageSize := listPageSize.Load()
|
|
startFrom := ""
|
|
for {
|
|
var pageCount uint32
|
|
var lastName string
|
|
if err := filer_pb.SeaweedList(ctx, client, dir, "", func(e *filer_pb.Entry, _ bool) error {
|
|
pageCount++
|
|
if e != nil {
|
|
lastName = e.Name
|
|
}
|
|
return fn(e)
|
|
}, startFrom, false, pageSize); err != nil {
|
|
return err
|
|
}
|
|
if pageCount < pageSize {
|
|
return nil
|
|
}
|
|
startFrom = lastName
|
|
}
|
|
}
|
|
|
|
// BucketBootstrapper backfills already-existing entries when a freshly-PUT
|
|
// rule's bucket appears in the engine. The reader-driven path only sees
|
|
// meta-log events created after the rule lands; without this walk,
|
|
// objects PUT before the rule would never expire.
|
|
//
|
|
// Per bucket: one one-shot goroutine that lists every entry under
|
|
// /buckets/<bucket> and synthesizes a *reader.Event for each one. The
|
|
// pipeline's existing router.Route + Schedule machinery handles the rest:
|
|
// currently-due matches fire on the next dispatch tick, and not-yet-due
|
|
// matches sit in the per-shard schedule until their DueTime arrives.
|
|
//
|
|
// Synthesized events carry TsNs=0 so dispatcher.advance is a no-op for
|
|
// them — the reader still resumes from its persisted cursor on restart.
|
|
//
|
|
// Walk state is tracked in-memory per process via two maps:
|
|
// - lastCompleted: time of the last successful walk, drives the
|
|
// BootstrapInterval cadence; zero interval means "walk once per
|
|
// process".
|
|
// - inFlight: buckets whose walk goroutines are still running.
|
|
// Skipped regardless of cadence so a walk that takes longer than
|
|
// BootstrapInterval can't trigger a duplicate goroutine on the
|
|
// next refresh.
|
|
type BucketBootstrapper struct {
|
|
FilerClient filer_pb.SeaweedFilerClient
|
|
BucketsPath string
|
|
Injector EventInjector
|
|
|
|
// BootstrapInterval gates re-walks. Zero means "walk once per
|
|
// process". Non-zero means "walk again once it's been at least
|
|
// this long since the last completed walk" — the cadence scan_only
|
|
// actions rely on, since they can only fire from bootstrap.
|
|
BootstrapInterval time.Duration
|
|
|
|
// Now overrides time.Now for tests.
|
|
Now func() time.Time
|
|
|
|
mu sync.Mutex
|
|
lastCompleted map[string]time.Time
|
|
inFlight map[string]bool
|
|
}
|
|
|
|
func (b *BucketBootstrapper) now() time.Time {
|
|
if b.Now != nil {
|
|
return b.Now()
|
|
}
|
|
return time.Now()
|
|
}
|
|
|
|
// KickOffNew launches a one-shot walker goroutine for every bucket
|
|
// that's not currently in flight and either has never completed a walk
|
|
// or whose last successful walk finished more than BootstrapInterval ago.
|
|
func (b *BucketBootstrapper) KickOffNew(ctx context.Context, buckets []string) {
|
|
if b.Injector == nil {
|
|
return
|
|
}
|
|
now := b.now()
|
|
b.mu.Lock()
|
|
if b.lastCompleted == nil {
|
|
b.lastCompleted = map[string]time.Time{}
|
|
}
|
|
if b.inFlight == nil {
|
|
b.inFlight = map[string]bool{}
|
|
}
|
|
fresh := make([]string, 0, len(buckets))
|
|
for _, bucket := range buckets {
|
|
if b.inFlight[bucket] {
|
|
// Walk still running from an earlier KickOffNew — never
|
|
// double up regardless of cadence. A large bucket that
|
|
// takes longer than BootstrapInterval would otherwise
|
|
// have a fresh goroutine fire on every refresh tick.
|
|
continue
|
|
}
|
|
if last, ok := b.lastCompleted[bucket]; ok {
|
|
if b.BootstrapInterval <= 0 || now.Sub(last) < b.BootstrapInterval {
|
|
continue
|
|
}
|
|
}
|
|
b.inFlight[bucket] = true
|
|
fresh = append(fresh, bucket)
|
|
}
|
|
b.mu.Unlock()
|
|
|
|
for _, bucket := range fresh {
|
|
bucket := bucket
|
|
go b.walkBucket(ctx, bucket)
|
|
}
|
|
}
|
|
|
|
func (b *BucketBootstrapper) walkBucket(ctx context.Context, bucket string) {
|
|
root := strings.TrimSuffix(b.BucketsPath, "/") + "/" + bucket
|
|
glog.V(0).Infof("lifecycle bootstrap: starting walk for bucket %s (root=%s)", bucket, root)
|
|
count := 0
|
|
// skipBare records bucket-relative bare-key paths that
|
|
// expandVersionsDir already routed as the null version. Without it
|
|
// the walker's regular emission would also fire for the bare entry
|
|
// — in a versioned bucket buildObjectInfo classifies it as
|
|
// IsLatest=true, NumVersions=0, and ExpirationDays would create a
|
|
// stray delete marker that hides the real latest.
|
|
skipBare := map[string]bool{}
|
|
var cb func(entry *filer_pb.Entry, key string) error
|
|
cb = func(entry *filer_pb.Entry, key string) error {
|
|
if isVersionsDir(entry) {
|
|
n, err := b.expandVersionsDir(ctx, bucket, root, key, entry, cb, skipBare)
|
|
count += n
|
|
return err
|
|
}
|
|
if !entry.IsDirectory && skipBare[key] {
|
|
return nil
|
|
}
|
|
if entry.IsDirectoryKeyObject() && skipBare[key] {
|
|
return nil
|
|
}
|
|
ev := &reader.Event{
|
|
// TsNs=0 sentinel: dispatcher.advance treats <=0 as no-op,
|
|
// so the reader's persisted cursor isn't ratcheted forward
|
|
// past meta-log events that haven't been processed yet.
|
|
TsNs: 0,
|
|
Bucket: bucket,
|
|
Key: key,
|
|
ShardID: s3lifecycle.ShardID(bucket, key),
|
|
NewEntry: entry,
|
|
}
|
|
count++
|
|
return b.Injector.InjectEvent(ctx, ev)
|
|
}
|
|
walkErr := walkBucketDir(ctx, b.FilerClient, root, root, cb)
|
|
b.mu.Lock()
|
|
delete(b.inFlight, bucket)
|
|
if walkErr == nil {
|
|
// Stamp completion so BootstrapInterval cadence measures from
|
|
// end-of-walk. Failures leave lastCompleted alone, so the next
|
|
// KickOffNew sees no record and walks the bucket again.
|
|
if b.lastCompleted == nil {
|
|
b.lastCompleted = map[string]time.Time{}
|
|
}
|
|
b.lastCompleted[bucket] = b.now()
|
|
}
|
|
b.mu.Unlock()
|
|
if walkErr != nil {
|
|
if ctx.Err() == nil {
|
|
glog.V(0).Infof("lifecycle bootstrap %s: %v", bucket, walkErr)
|
|
}
|
|
return
|
|
}
|
|
glog.V(0).Infof("lifecycle bootstrap: bucket %s injected %d entries", bucket, count)
|
|
}
|
|
|
|
// versionItem is the per-sibling state expandVersionsDir builds: the
|
|
// filer entry plus its version_id (or "null" for the bare null version
|
|
// living outside .versions/). isExplicitNull marks bare entries the
|
|
// suspended-versioning write path tagged with ExtVersionIdKey="null"
|
|
// (s3api_object_handlers_put.go); we trust those as latest when the
|
|
// .versions/ pointer is missing. A pre-versioning bare object has no
|
|
// such marker, so a missing pointer there is a race window with a new
|
|
// version write and we keep the newest-sibling fallback.
|
|
type versionItem struct {
|
|
entry *filer_pb.Entry
|
|
versionID string
|
|
bareKey string // bucket-relative path; non-empty only for the null version
|
|
isExplicitNull bool
|
|
}
|
|
|
|
// expandVersionsDir lists <root>/<key>/ and, when the children look like
|
|
// SeaweedFS version files, injects one reader.Event per version with
|
|
// BootstrapVersion populated. The bare logical key (the "null" version,
|
|
// living outside .versions/) is included as a sibling so:
|
|
// - pre-versioning objects with a newer .versions/ history fire
|
|
// NoncurrentDays as id="null"
|
|
// - suspended-bucket writes (which clear the .versions/ latest pointer)
|
|
// correctly classify null as the current version while every
|
|
// .versions/ child becomes noncurrent
|
|
//
|
|
// versionsKey is the bucket-relative path of the .versions/ directory
|
|
// (e.g. "logs/foo.versions"). When the bare null version is included,
|
|
// its bucket-relative path is added to skipBare so the walker's regular
|
|
// emission for the same entry is suppressed.
|
|
//
|
|
// When no child has ExtVersionIdKey the directory is a coincidentally-
|
|
// named user folder; recurse via fallback (the bucket walk's own cb).
|
|
func (b *BucketBootstrapper) expandVersionsDir(ctx context.Context, bucket, root, versionsKey string, versionsEntry *filer_pb.Entry, fallback func(*filer_pb.Entry, string) error, skipBare map[string]bool) (int, error) {
|
|
logical := strings.TrimSuffix(versionsKey, s3_constants.VersionsFolder)
|
|
if logical == "" {
|
|
return 0, nil
|
|
}
|
|
versionsDir := strings.TrimSuffix(b.BucketsPath, "/") + "/" + bucket + "/" + versionsKey
|
|
// Collect file children only. Subdirectories under .versions/ would
|
|
// corrupt sort/rank math; the disambiguation pass below also wants
|
|
// to see only file-shaped children. Paginate so a hot key with
|
|
// thousands of versions doesn't truncate at DirListingLimit.
|
|
var children []*filer_pb.Entry
|
|
if err := listAll(ctx, b.FilerClient, versionsDir, func(e *filer_pb.Entry) error {
|
|
if e != nil && e.Attributes != nil && !e.IsDirectory {
|
|
children = append(children, e)
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
return 0, fmt.Errorf("list %s: %w", versionsDir, err)
|
|
}
|
|
items := make([]versionItem, 0, len(children)+1)
|
|
for _, e := range children {
|
|
if id, ok := e.Extended[s3_constants.ExtVersionIdKey]; ok && len(id) > 0 {
|
|
items = append(items, versionItem{entry: e, versionID: string(id)})
|
|
}
|
|
}
|
|
if len(items) == 0 {
|
|
// Coincidentally-named user folder (or an empty .versions
|
|
// container). fallback is the bucket walk's own cb so nested
|
|
// .versions/ entries inside still expand.
|
|
if fallback == nil {
|
|
return 0, nil
|
|
}
|
|
if err := walkBucketDir(ctx, b.FilerClient, versionsDir, root, fallback); err != nil {
|
|
return 0, err
|
|
}
|
|
return 0, nil
|
|
}
|
|
// Look up the bare null version. SeaweedFS keeps it at the logical
|
|
// path for pre-versioning objects and for suspended-bucket writes.
|
|
// Both shapes count: regular file (PUT'd object) and explicit S3
|
|
// directory-key marker (object name ends in /).
|
|
if nullEntry, nullKey, explicit, ok := b.lookupNullVersion(ctx, bucket, logical); ok {
|
|
items = append(items, versionItem{
|
|
entry: nullEntry,
|
|
versionID: "null",
|
|
bareKey: nullKey,
|
|
isExplicitNull: explicit,
|
|
})
|
|
}
|
|
// Sort newest-first: primary by mtime ns, fallback by version_id
|
|
// (CompareVersionIds returns <0 when first arg is newer). PUTs only
|
|
// set second-level Mtime, so collisions in the same second are
|
|
// resolved by the canonical version-id ordering used elsewhere.
|
|
sort.SliceStable(items, func(i, j int) bool {
|
|
mi := items[i].entry.Attributes.Mtime*int64(1e9) + int64(items[i].entry.Attributes.MtimeNs)
|
|
mj := items[j].entry.Attributes.Mtime*int64(1e9) + int64(items[j].entry.Attributes.MtimeNs)
|
|
if mi != mj {
|
|
return mi > mj
|
|
}
|
|
return s3lifecycle.CompareVersionIds(items[i].versionID, items[j].versionID) < 0
|
|
})
|
|
// Resolve latest position.
|
|
// 1. Pointer names a real id -> that wins (in-order or backdated).
|
|
// 2. Pointer absent + items[0] is an EXPLICIT null (suspended write
|
|
// cleared the pointer and tagged the bare object as null, AND
|
|
// the bare object is newest by mtime) -> null is latest.
|
|
// 3. Pointer absent in any other shape: fall back to newest
|
|
// sibling. Catches the post-suspended re-enable race window —
|
|
// a fresh .versions/<v1> write whose pointer update hasn't
|
|
// landed yet outranks the older suspended-null bare object.
|
|
latestID := string(versionsEntry.Extended[s3_constants.ExtLatestVersionIdKey])
|
|
latestPos := 0
|
|
if latestID != "" {
|
|
for i, it := range items {
|
|
if it.versionID == latestID {
|
|
latestPos = i
|
|
break
|
|
}
|
|
}
|
|
} else if len(items) > 0 && items[0].versionID == "null" && items[0].isExplicitNull {
|
|
latestPos = 0
|
|
}
|
|
count := 0
|
|
for i, it := range items {
|
|
var successor time.Time
|
|
if i > 0 {
|
|
prev := items[i-1].entry.Attributes
|
|
successor = time.Unix(prev.Mtime, int64(prev.MtimeNs))
|
|
}
|
|
bv := &reader.BootstrapVersion{
|
|
LogicalKey: logical,
|
|
VersionID: it.versionID,
|
|
IsLatest: i == latestPos,
|
|
IsDeleteMarker: string(it.entry.Extended[s3_constants.ExtDeleteMarkerKey]) == "true",
|
|
NumVersions: len(items),
|
|
SuccessorModTime: successor,
|
|
}
|
|
if !bv.IsLatest {
|
|
rank := i
|
|
if i > latestPos {
|
|
rank = i - 1
|
|
}
|
|
bv.NoncurrentIndex = rank
|
|
}
|
|
// Event Key for bookkeeping: real version files keep the
|
|
// .versions/<file> path; the null version uses its bare path
|
|
// so the dispatcher's identity check resolves to the same
|
|
// entry the walker would have emitted.
|
|
evKey := versionsKey + "/" + it.entry.Name
|
|
if it.versionID == "null" {
|
|
evKey = it.bareKey
|
|
}
|
|
ev := &reader.Event{
|
|
TsNs: 0,
|
|
Bucket: bucket,
|
|
Key: evKey,
|
|
ShardID: s3lifecycle.ShardID(bucket, logical),
|
|
NewEntry: it.entry,
|
|
BootstrapVersion: bv,
|
|
}
|
|
if err := b.Injector.InjectEvent(ctx, ev); err != nil {
|
|
return count, err
|
|
}
|
|
if it.versionID == "null" && skipBare != nil {
|
|
skipBare[it.bareKey] = true
|
|
}
|
|
count++
|
|
}
|
|
return count, nil
|
|
}
|
|
|
|
// lookupNullVersion returns the bare-key entry that represents the null
|
|
// version of logical, if any. Both regular files and S3 directory-key
|
|
// markers (an empty directory entry with Mime set) qualify. The
|
|
// explicit return reports whether the entry's Extended map carries
|
|
// ExtVersionIdKey == "null" — the marker the suspended-versioning
|
|
// write path applies (s3api_object_handlers_put.go). bucketRelKey is
|
|
// the bucket-relative path the walker would otherwise emit, so the
|
|
// caller can suppress the duplicate.
|
|
func (b *BucketBootstrapper) lookupNullVersion(ctx context.Context, bucket, logical string) (entry *filer_pb.Entry, bucketRelKey string, explicit bool, ok bool) {
|
|
bucketPath := strings.TrimSuffix(b.BucketsPath, "/") + "/" + bucket
|
|
parent, name := util.NewFullPath(bucketPath, logical).DirAndName()
|
|
resp, err := filer_pb.LookupEntry(ctx, b.FilerClient, &filer_pb.LookupDirectoryEntryRequest{
|
|
Directory: parent,
|
|
Name: name,
|
|
})
|
|
if err != nil || resp == nil || resp.Entry == nil {
|
|
return nil, "", false, false
|
|
}
|
|
e := resp.Entry
|
|
if e.IsDirectory && !e.IsDirectoryKeyObject() {
|
|
return nil, "", false, false
|
|
}
|
|
if id, hasID := e.Extended[s3_constants.ExtVersionIdKey]; hasID && string(id) == "null" {
|
|
explicit = true
|
|
}
|
|
return e, strings.TrimPrefix(parent+"/"+name, bucketPath+"/"), explicit, true
|
|
}
|
|
|
|
// walkBucketDir streams entries under dir and invokes cb. Two kinds of
|
|
// directories are emitted whole rather than recursed into:
|
|
// - .uploads/<id> MPU init dirs (router fires ABORT_MPU off the dir entry)
|
|
// - <key>.versions/ directories (caller expands them into per-version
|
|
// events; recursing here would emit individual version files without
|
|
// the sibling state needed for NoncurrentDays / NewerNoncurrent)
|
|
//
|
|
// .versions/ dirs are processed before everything else at each level so
|
|
// the cb's expandVersionsDir call can record the bare null-version key
|
|
// in the walk-shared skip set before the same level emits the bare entry.
|
|
// Two streaming passes (rather than buffering the whole directory) trade
|
|
// a second listing for bounded memory on flat buckets with millions of
|
|
// entries.
|
|
func walkBucketDir(ctx context.Context, client filer_pb.SeaweedFilerClient, dir, bucketRoot string, cb func(entry *filer_pb.Entry, key string) error) error {
|
|
// Pass 1: .versions/ dirs only.
|
|
if err := listAll(ctx, client, dir, func(e *filer_pb.Entry) error {
|
|
if e == nil || e.Attributes == nil {
|
|
return nil
|
|
}
|
|
if !e.IsDirectory || !isVersionsDir(e) {
|
|
return nil
|
|
}
|
|
full := dir + "/" + e.Name
|
|
key := strings.TrimPrefix(full, bucketRoot+"/")
|
|
return cb(e, key)
|
|
}); err != nil {
|
|
return fmt.Errorf("list %s: %w", dir, err)
|
|
}
|
|
// Pass 2: everything else. Bare entries whose name was claimed by
|
|
// a sibling .versions/ expansion are dropped by the cb's skip-set.
|
|
return listAll(ctx, client, dir, func(e *filer_pb.Entry) error {
|
|
if e == nil || e.Attributes == nil {
|
|
return nil
|
|
}
|
|
if e.IsDirectory && isVersionsDir(e) {
|
|
return nil
|
|
}
|
|
full := dir + "/" + e.Name
|
|
key := strings.TrimPrefix(full, bucketRoot+"/")
|
|
if e.IsDirectory {
|
|
if isMPUInitDir(key, e) {
|
|
return cb(e, key)
|
|
}
|
|
return walkBucketDir(ctx, client, full, bucketRoot, cb)
|
|
}
|
|
return cb(e, key)
|
|
})
|
|
}
|
|
|
|
// isMPUInitDir mirrors router.mpuInitInfo: a directory at .uploads/<id>
|
|
// carrying the destination key in Extended is the MPU init record. The
|
|
// router helper is package-private so this is duplicated rather than
|
|
// adding a public extraction API just for this caller.
|
|
func isMPUInitDir(key string, entry *filer_pb.Entry) bool {
|
|
uploadsPrefix := s3_constants.MultipartUploadsFolder + "/"
|
|
if !strings.HasPrefix(key, uploadsPrefix) {
|
|
return false
|
|
}
|
|
rest := key[len(uploadsPrefix):]
|
|
if rest == "" || strings.ContainsRune(rest, '/') {
|
|
return false
|
|
}
|
|
v, ok := entry.Extended[s3_constants.ExtMultipartObjectKey]
|
|
return ok && len(v) > 0
|
|
}
|
|
|
|
// isVersionsDir matches `<x>.versions/` by name suffix. We can't gate on
|
|
// ExtLatestVersionIdKey here: createDeleteMarker writes the version file
|
|
// before updating the parent's Extended pointer, so a walk that races
|
|
// with that update would see the directory without the pointer and
|
|
// recurse into raw version files, losing the sibling state needed for
|
|
// noncurrent rules. expandVersionsDir handles disambiguation by
|
|
// inspecting children for ExtVersionIdKey; coincidentally-named
|
|
// directories that aren't real .versions storage fall through to a
|
|
// regular recursion.
|
|
func isVersionsDir(entry *filer_pb.Entry) bool {
|
|
return entry.IsDirectory && strings.HasSuffix(entry.Name, s3_constants.VersionsFolder)
|
|
}
|
|
|