Files
seaweedfs/weed/filer/meta_aggregator_replay_test.go
T
Chris Lu 0d4e11a6e5 filer: bootstrap a timestamp-less peer entry onto a missing local path (#11975)
A zero local commit time conflated 'no local state' with 'state at the
epoch', so a missing entry was skipped whenever the sender reported no
commit time and the entry mtime was at or before the epoch. Only hold
the copy off when a local entry actually exists.
2026-10-23 18:45:09 -07:00

1572 lines
53 KiB
Go

package filer
import (
"context"
"errors"
"os"
"sync"
"testing"
"time"
dto "github.com/prometheus/client_model/go"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/remote_pb"
"github.com/seaweedfs/seaweedfs/weed/stats"
"github.com/seaweedfs/seaweedfs/weed/util"
)
// newReplayTestFiler builds the minimum Filer Replay needs over a stub store.
func newReplayTestFiler(store FilerStore) *Filer {
return &Filer{
Store: NewFilerStoreWrapper(store),
EntryLockTable: util.NewLockTable[util.FullPath](),
}
}
// flakyReplayStore fails InsertEntry for its first failUntil calls, then
// delegates to the embedded stubFilerStore.
type flakyReplayStore struct {
*stubFilerStore
countMu sync.Mutex
failUntil int
err error
attempts int
}
func (s *flakyReplayStore) InsertEntry(ctx context.Context, entry *Entry) error {
s.countMu.Lock()
s.attempts++
fail := s.attempts <= s.failUntil
s.countMu.Unlock()
if fail {
return s.err
}
return s.stubFilerStore.InsertEntry(ctx, entry)
}
func (s *flakyReplayStore) attemptCount() int {
s.countMu.Lock()
defer s.countMu.Unlock()
return s.attempts
}
func replayFailureCount(t *testing.T, peer pb.ServerAddress) float64 {
t.Helper()
var m dto.Metric
if err := stats.FilerMetaAggregatorReplayFailures.WithLabelValues(string(peer)).Write(&m); err != nil {
t.Fatalf("read counter: %v", err)
}
return m.GetCounter().GetValue()
}
func quotaChangeEvent(bucket string, quota int64) *filer_pb.SubscribeMetadataResponse {
return &filer_pb.SubscribeMetadataResponse{
Directory: "/buckets",
EventNotification: &filer_pb.EventNotification{
NewEntry: &filer_pb.Entry{Name: bucket, IsDirectory: true, Quota: quota},
},
TsNs: time.Now().UnixNano(),
}
}
// The old body logged a Replay error and returned, so one transient failure
// permanently diverged the entry from the peer.
func TestReplicateMetadataChangeRetriesTransientFailure(t *testing.T) {
// int64: untyped, the value defaults to int and overflows a 32-bit build
const wantQuota int64 = 131072 << 20
store := &flakyReplayStore{
stubFilerStore: newStubFilerStore(),
failUntil: 1,
err: errors.New("i/o timeout talking to store"),
}
peer := pb.ServerAddress("peer-transient:1")
f := newReplayTestFiler(store)
if err := replicateMetadataChange(f, peer, quotaChangeEvent("my-bucket", wantQuota), nil); err != nil {
t.Fatalf("replicateMetadataChange: %v", err)
}
inserted, err := store.FindEntry(context.Background(), util.NewFullPath("/buckets", "my-bucket"))
if err != nil {
t.Fatal("expected the entry to be inserted once the transient failure is retried past")
}
if inserted.Quota != wantQuota {
t.Fatalf("quota = %d, want %d", inserted.Quota, wantQuota)
}
if got := store.attemptCount(); got < 2 {
t.Fatalf("attempts = %d, want at least 2", got)
}
}
// An event that can never replay must fail fast instead of blocking the
// subscribe stream, and must be counted so the divergence is not silent.
func TestReplicateMetadataChangeGivesUpLoudlyOnPermanentFailure(t *testing.T) {
store := &flakyReplayStore{
stubFilerStore: newStubFilerStore(),
failUntil: 1 << 30,
err: errors.New("entry checksum mismatch"),
}
peer := pb.ServerAddress("peer-permanent:1")
f := newReplayTestFiler(store)
before := replayFailureCount(t, peer)
err := replicateMetadataChange(f, peer, quotaChangeEvent("poison-bucket", 65536<<20), nil)
if err == nil {
t.Fatal("expected the exhausted replay error so the caller can pin the peer offset at the failed event")
}
if got := store.attemptCount(); got != 1 {
t.Fatalf("attempts = %d, want exactly 1: a non-transient error must fail fast, not retry", got)
}
if got := replayFailureCount(t, peer); got != before+1 {
t.Fatalf("FilerMetaAggregatorReplayFailures[%s] = %v, want %v", peer, got, before+1)
}
}
func overwriteEvent(dir, name string, tsNs int64, content string) *filer_pb.SubscribeMetadataResponse {
return &filer_pb.SubscribeMetadataResponse{
Directory: dir,
EventNotification: &filer_pb.EventNotification{
OldEntry: &filer_pb.Entry{Name: name},
NewEntry: &filer_pb.Entry{Name: name, Content: []byte(content)},
},
TsNs: tsNs,
}
}
func deleteEvent(dir, name string, tsNs int64) *filer_pb.SubscribeMetadataResponse {
return &filer_pb.SubscribeMetadataResponse{
Directory: dir,
EventNotification: &filer_pb.EventNotification{
OldEntry: &filer_pb.Entry{Name: name},
},
TsNs: tsNs,
}
}
func TestReplaySkipsEventOlderThanLocalEntry(t *testing.T) {
store := newStubFilerStore()
f := newReplayTestFiler(store)
now := time.Now()
path := util.NewFullPath("/dir", "file")
store.entries[string(path)] = &Entry{
FullPath: path,
Attr: Attr{Mtime: now},
Content: []byte("newer local write"),
}
if err := f.Replay(overwriteEvent("/dir", "file", now.Add(-time.Minute).UnixNano(), "old replayed version")); err != nil {
t.Fatalf("Replay: %v", err)
}
entry, err := store.FindEntry(context.Background(), path)
if err != nil {
t.Fatalf("FindEntry: %v", err)
}
if string(entry.Content) != "newer local write" {
t.Fatalf("content = %q, want local write kept", entry.Content)
}
}
func TestReplayAppliesEventNewerThanLocalEntry(t *testing.T) {
store := newStubFilerStore()
f := newReplayTestFiler(store)
now := time.Now()
path := util.NewFullPath("/dir", "file")
store.entries[string(path)] = &Entry{
FullPath: path,
Attr: Attr{Mtime: now.Add(-time.Minute)},
Content: []byte("old local write"),
}
if err := f.Replay(overwriteEvent("/dir", "file", now.UnixNano(), "newer peer write")); err != nil {
t.Fatalf("Replay: %v", err)
}
entry, err := store.FindEntry(context.Background(), path)
if err != nil {
t.Fatalf("FindEntry: %v", err)
}
if string(entry.Content) != "newer peer write" {
t.Fatalf("content = %q, want peer write applied", entry.Content)
}
}
func TestReplaySkipsDeleteOlderThanLocalEntry(t *testing.T) {
store := newStubFilerStore()
f := newReplayTestFiler(store)
now := time.Now()
path := util.NewFullPath("/dir", "file")
store.entries[string(path)] = &Entry{FullPath: path, Attr: Attr{Mtime: now}}
if err := f.Replay(deleteEvent("/dir", "file", now.Add(-time.Minute).UnixNano())); err != nil {
t.Fatalf("Replay: %v", err)
}
if _, err := store.FindEntry(context.Background(), path); err != nil {
t.Fatalf("newer local entry must survive a stale delete: %v", err)
}
}
func TestReplayAppliesNewerDelete(t *testing.T) {
store := newStubFilerStore()
f := newReplayTestFiler(store)
now := time.Now()
path := util.NewFullPath("/dir", "file")
store.entries[string(path)] = &Entry{FullPath: path, Attr: Attr{Mtime: now.Add(-time.Minute)}}
if err := f.Replay(deleteEvent("/dir", "file", now.UnixNano())); err != nil {
t.Fatalf("Replay: %v", err)
}
if _, err := store.FindEntry(context.Background(), path); err == nil {
t.Fatal("newer delete must remove the local entry")
}
}
// A move event touches two paths; the local check applies to each side
// independently, so a newer destination is kept while the source is removed.
func TestReplayMoveChecksEachPathSeparately(t *testing.T) {
store := newStubFilerStore()
f := newReplayTestFiler(store)
now := time.Now()
oldPath := util.NewFullPath("/a", "file")
newPath := util.NewFullPath("/b", "file")
store.entries[string(oldPath)] = &Entry{FullPath: oldPath, Attr: Attr{Mtime: now.Add(-time.Minute)}}
store.entries[string(newPath)] = &Entry{FullPath: newPath, Attr: Attr{Mtime: now}, Content: []byte("kept")}
event := &filer_pb.SubscribeMetadataResponse{
Directory: "/a",
EventNotification: &filer_pb.EventNotification{
OldEntry: &filer_pb.Entry{Name: "file"},
NewEntry: &filer_pb.Entry{Name: "file"},
NewParentPath: "/b",
},
TsNs: now.Add(-time.Minute).UnixNano(),
}
if err := f.Replay(event); err != nil {
t.Fatalf("Replay: %v", err)
}
if _, err := store.FindEntry(context.Background(), oldPath); err == nil {
t.Fatal("old source entry should be deleted")
}
entry, err := store.FindEntry(context.Background(), newPath)
if err != nil {
t.Fatalf("FindEntry: %v", err)
}
if string(entry.Content) != "kept" {
t.Fatalf("content = %q, want newer destination kept", entry.Content)
}
}
// A local delete leaves a recorded tombstone, so a replayed create older than
// the delete must not resurrect the path even though no entry remains to
// compare against.
func TestReplaySkipsCreateOlderThanLocalDelete(t *testing.T) {
store := newStubFilerStore()
f := newReplayTestFiler(store)
now := time.Now()
path := util.NewFullPath("/dir", "file")
// The delete removed the entry; its commit record survives.
f.notePathChange(path, now.UnixNano(), true)
createEvent := &filer_pb.SubscribeMetadataResponse{
Directory: "/dir",
EventNotification: &filer_pb.EventNotification{
NewEntry: &filer_pb.Entry{Name: "file", Content: []byte("stale resurrect")},
},
TsNs: now.Add(-time.Minute).UnixNano(),
}
if err := f.Replay(createEvent); err != nil {
t.Fatalf("Replay: %v", err)
}
if _, err := store.FindEntry(context.Background(), path); err == nil {
t.Fatal("stale replayed create resurrected a locally deleted entry")
}
}
// A deleted directory covers its children: a bulk drop emits no per-child
// events, so the tombstone on the ancestor is what a replayed child create
// must check.
func TestReplaySkipsCreateUnderDeletedAncestor(t *testing.T) {
store := newStubFilerStore()
f := newReplayTestFiler(store)
now := time.Now()
f.notePathChange("/dir", now.UnixNano(), true)
createEvent := &filer_pb.SubscribeMetadataResponse{
Directory: "/dir",
EventNotification: &filer_pb.EventNotification{
NewEntry: &filer_pb.Entry{Name: "child", Content: []byte("stale")},
},
TsNs: now.Add(-time.Minute).UnixNano(),
}
if err := f.Replay(createEvent); err != nil {
t.Fatalf("Replay: %v", err)
}
if _, err := store.FindEntry(context.Background(), "/dir/child"); err == nil {
t.Fatal("stale replayed create resurrected a child of a deleted directory")
}
}
// A recorded commit timestamp orders replay, not the entry's mtime: a rename
// keeps the source's mtime on the destination, and a mounted client can set
// mtime arbitrarily, so an entry whose mtime postdates the event must still
// be overwritten when its commit record is older.
func TestReplayAppliesWhenCommitRecordIsOlderThanFutureMtime(t *testing.T) {
store := newStubFilerStore()
f := newReplayTestFiler(store)
now := time.Now()
path := util.NewFullPath("/b", "x")
store.entries[string(path)] = &Entry{
FullPath: path,
Attr: Attr{Mtime: now.Add(time.Hour)}, // future mtime, e.g. client-set or kept from a rename source
Content: []byte("old destination"),
}
// The last committed change predates the event.
f.notePathChange(path, now.Add(-time.Minute).UnixNano(), false)
event := &filer_pb.SubscribeMetadataResponse{
Directory: "/a",
EventNotification: &filer_pb.EventNotification{
OldEntry: &filer_pb.Entry{Name: "x"},
NewEntry: &filer_pb.Entry{Name: "x", Content: []byte("renamed in")},
NewParentPath: "/b",
},
TsNs: now.UnixNano(),
}
if err := f.Replay(event); err != nil {
t.Fatalf("Replay: %v", err)
}
entry, err := store.FindEntry(context.Background(), path)
if err != nil {
t.Fatalf("FindEntry: %v", err)
}
if string(entry.Content) != "renamed in" {
t.Fatalf("content = %q, want the rename destination applied", entry.Content)
}
}
// A store read failure is not a not-found: the replayed change must not be
// applied on the guess that no newer local entry exists.
func TestReplayPropagatesLookupError(t *testing.T) {
store := newStubFilerStore()
store.findErr = errors.New("store read failed")
f := newReplayTestFiler(store)
err := f.Replay(overwriteEvent("/dir", "file", time.Now().UnixNano(), "v"))
if err == nil {
t.Fatal("expected Replay to propagate the FindEntry failure")
}
}
// The check and the mutation share the per-path entry lock, so a local write
// holding that lock cannot be split by a replayed change.
func TestReplaySerializesWithEntryLock(t *testing.T) {
store := newStubFilerStore()
f := newReplayTestFiler(store)
now := time.Now()
path := util.NewFullPath("/dir", "file")
store.entries[string(path)] = &Entry{FullPath: path, Attr: Attr{Mtime: now}}
// A local mutation holding the path lock; a replay started behind it
// sees the recorded commit and skips.
held := f.EntryLockTable.AcquireLock("test local write", path, util.ExclusiveLock)
f.notePathChange(path, now.UnixNano(), false)
done := make(chan error, 1)
go func() {
done <- f.Replay(overwriteEvent("/dir", "file", now.Add(-time.Minute).UnixNano(), "stale"))
}()
select {
case <-done:
t.Fatal("replay finished while the path lock was still held")
case <-time.After(50 * time.Millisecond):
}
f.EntryLockTable.ReleaseLock(path, held)
if err := <-done; err != nil {
t.Fatalf("Replay: %v", err)
}
entry, err := store.FindEntry(context.Background(), path)
if err != nil {
t.Fatalf("FindEntry: %v", err)
}
if entry.Content != nil {
t.Fatalf("content = %q, want local entry kept", entry.Content)
}
}
// A closed stop channel must abort the retry loop with
// errPeerSubscriptionStopped so the subscription tears down and the offset
// save keeps the last completed event instead of skipping this one.
func TestReplicateMetadataChangeAbortsOnStop(t *testing.T) {
store := &flakyReplayStore{
stubFilerStore: newStubFilerStore(),
failUntil: 1 << 30,
err: errors.New("i/o timeout talking to store"),
}
peer := pb.ServerAddress("peer-stopped:1")
f := newReplayTestFiler(store)
stopChan := make(chan struct{})
close(stopChan)
err := replicateMetadataChange(f, peer, quotaChangeEvent("stuck-bucket", 1<<20), stopChan)
if !errors.Is(err, errPeerSubscriptionStopped) {
t.Fatalf("err = %v, want errPeerSubscriptionStopped", err)
}
if got := store.attemptCount(); got != 0 {
t.Fatalf("attempts = %d, want 0: a stopped subscription must not replay", got)
}
}
// Commit-record keys must stay small: SQL KV stores keep the key suffix in a
// VARCHAR(766) column, so a raw path over ~578 bytes silently drops the
// record (the insert fails and the fallback update touches no row).
func TestMetaPathKeyFixedSizeForLongPaths(t *testing.T) {
path := util.NewFullPath("/", string(make([]rune, 600)))
for _, p := range []util.FullPath{"/a", path} {
if got := len(metaPathKey(metaPathTsPrefix, p)); got != len(metaPathTsPrefix)+16 {
t.Fatalf("metaPathKey(%d-byte path) = %d bytes, want %d", len(p), got, len(metaPathTsPrefix)+16)
}
}
}
// Exact-path locking alone lets a replayed child create slip between a local
// ancestor delete's tombstone check and the sweep: replay locks every
// ancestor too, so it waits for the delete's lock and then sees the
// tombstone.
func TestReplaySerializesWithAncestorLock(t *testing.T) {
store := newStubFilerStore()
f := newReplayTestFiler(store)
now := time.Now()
child := util.NewFullPath("/dir", "file")
held := f.EntryLockTable.AcquireLock("test local delete", "/dir", util.ExclusiveLock)
f.notePathChange("/dir", now.UnixNano(), true)
createEvent := &filer_pb.SubscribeMetadataResponse{
Directory: "/dir",
EventNotification: &filer_pb.EventNotification{
NewEntry: &filer_pb.Entry{Name: "file", Content: []byte("stale resurrect")},
},
TsNs: now.Add(-time.Minute).UnixNano(),
}
done := make(chan error, 1)
go func() {
done <- f.Replay(createEvent)
}()
select {
case <-done:
t.Fatal("replay finished while the ancestor lock was still held")
case <-time.After(50 * time.Millisecond):
}
f.EntryLockTable.ReleaseLock("/dir", held)
if err := <-done; err != nil {
t.Fatalf("Replay: %v", err)
}
if _, err := store.FindEntry(context.Background(), child); err == nil {
t.Fatal("stale replayed create resurrected a child of a locally deleted directory")
}
}
// Bootstrap ordering uses the sender's commit timestamp: a local commit
// newer than the traversed entry keeps its state even when the peer entry's
// mtime is far in the future.
func TestBootstrapSkipsPeerEntryBehindLocalDelete(t *testing.T) {
store := newStubFilerStore()
f := newReplayTestFiler(store)
now := time.Now()
path := util.NewFullPath("/dir", "file")
f.notePathChange(path, now.UnixNano(), true)
entry := &Entry{
FullPath: path,
Attr: Attr{Mtime: now.Add(time.Hour)}, // client-settable, must not win
Content: []byte("stale peer entry"),
}
if err := f.applyTraversedEntry(context.Background(), entry, now.Add(-time.Minute).UnixNano()); err != nil {
t.Fatalf("applyTraversedEntry: %v", err)
}
if _, err := store.FindEntry(context.Background(), path); err == nil {
t.Fatal("bootstrap resurrected a path newer local delete covered")
}
}
// The bug this guards: a peer entry committed recently but stamped with an
// old client mtime must still apply over an absent-or-older local commit.
func TestBootstrapAppliesPeerCommitNewerThanLocal(t *testing.T) {
store := newStubFilerStore()
f := newReplayTestFiler(store)
now := time.Now()
path := util.NewFullPath("/dir", "file")
entry := &Entry{
FullPath: path,
Attr: Attr{Mtime: now.Add(-time.Hour)}, // old client-set mtime
Content: []byte("fresh peer write"),
}
if err := f.applyTraversedEntry(context.Background(), entry, now.UnixNano()); err != nil {
t.Fatalf("applyTraversedEntry: %v", err)
}
got, err := store.FindEntry(context.Background(), path)
if err != nil {
t.Fatalf("peer entry with newer commit must bootstrap: %v", err)
}
if string(got.Content) != "fresh peer write" {
t.Fatalf("content = %q, want peer entry applied", got.Content)
}
}
// A bootstrap-applied entry records the sender's commit timestamp, so a
// replayed event older than it — but newer than the entry's mtime — is
// still skipped.
func TestBootstrapAppliedCommitBlocksStaleReplay(t *testing.T) {
store := newStubFilerStore()
f := newReplayTestFiler(store)
now := time.Now()
path := util.NewFullPath("/dir", "file")
entry := &Entry{
FullPath: path,
Attr: Attr{Mtime: now.Add(-time.Hour)},
Content: []byte("fresh peer write"),
}
if err := f.applyTraversedEntry(context.Background(), entry, now.UnixNano()); err != nil {
t.Fatalf("applyTraversedEntry: %v", err)
}
if err := f.Replay(overwriteEvent("/dir", "file", now.Add(-time.Minute).UnixNano(), "stale replay")); err != nil {
t.Fatalf("Replay: %v", err)
}
got, _ := store.FindEntry(context.Background(), path)
if string(got.Content) != "fresh peer write" {
t.Fatalf("content = %q, stale replay must not overwrite the bootstrap entry", got.Content)
}
}
// An existing local entry is replaced only when the peer commit is newer —
// not when its mtime happens to be.
func TestBootstrapUpdatesExistingByCommitTime(t *testing.T) {
store := newStubFilerStore()
f := newReplayTestFiler(store)
now := time.Now()
path := util.NewFullPath("/dir", "file")
store.entries[string(path)] = &Entry{
FullPath: path,
Attr: Attr{Mtime: now.Add(time.Hour)}, // future mtime, e.g. client-set
Content: []byte("old local write"),
}
f.notePathChange(path, now.Add(-time.Minute).UnixNano(), false)
entry := &Entry{
FullPath: path,
Attr: Attr{Mtime: now.Add(-time.Hour)},
Content: []byte("newer peer commit"),
}
if err := f.applyTraversedEntry(context.Background(), entry, now.UnixNano()); err != nil {
t.Fatalf("applyTraversedEntry: %v", err)
}
got, _ := store.FindEntry(context.Background(), path)
if string(got.Content) != "newer peer commit" {
t.Fatalf("content = %q, newer peer commit must update despite the future local mtime", got.Content)
}
}
// A missing local entry has no commit time to order against: the peer copy
// must apply even when both sides are timestamp-less — an epoch-dated entry
// from a peer that predates EntryTsNs is still bootstrapped, while an
// existing zero-mtime local entry keeps its place.
func TestBootstrapCopiesMissingEntryWithoutCommitTime(t *testing.T) {
store := newStubFilerStore()
f := newReplayTestFiler(store)
missing := util.NewFullPath("/dir", "missing")
existing := util.NewFullPath("/dir", "existing")
store.entries[string(existing)] = &Entry{
FullPath: existing,
Attr: Attr{Mtime: time.Unix(0, 0)},
Content: []byte("zero-mtime local entry"),
}
for _, path := range []util.FullPath{missing, existing} {
entry := &Entry{
FullPath: path,
Attr: Attr{Mtime: time.Unix(0, 0)},
Content: []byte("epoch-dated peer entry"),
}
if err := f.applyTraversedEntry(context.Background(), entry, 0); err != nil {
t.Fatalf("applyTraversedEntry %s: %v", path, err)
}
}
got, err := store.FindEntry(context.Background(), missing)
if err != nil {
t.Fatalf("missing local entry must take the peer copy: %v", err)
}
if string(got.Content) != "epoch-dated peer entry" {
t.Fatalf("content = %q, want peer entry applied", got.Content)
}
if got, _ := store.FindEntry(context.Background(), existing); string(got.Content) != "zero-mtime local entry" {
t.Fatalf("content = %q, existing local entry must be kept", got.Content)
}
}
// A delete whose commit record failed to persist still blocks a stale
// replayed create: the pending record stands in until the retry lands it.
func TestPendingDeleteRecordBlocksStaleReplay(t *testing.T) {
store := newStubFilerStore()
store.setKvPutErr(errors.New("kv store unreachable"))
f := newReplayTestFiler(store)
f.flushCtx, f.flushCancel = context.WithCancel(context.Background())
defer f.flushCancel()
now := time.Now()
path := util.NewFullPath("/dir", "file")
// The delete already committed; only its ordering record failed.
f.notePathChange(path, now.UnixNano(), true)
createEvent := &filer_pb.SubscribeMetadataResponse{
Directory: "/dir",
EventNotification: &filer_pb.EventNotification{
NewEntry: &filer_pb.Entry{Name: "file", Content: []byte("stale resurrect")},
},
TsNs: now.Add(-time.Minute).UnixNano(),
}
if err := f.Replay(createEvent); err != nil {
t.Fatalf("Replay: %v", err)
}
if _, err := store.FindEntry(context.Background(), path); err == nil {
t.Fatal("stale create resurrected a path whose delete record is still pending")
}
}
// The failed record write retries until it lands, then leaves the pending set.
func TestPendingCommitRecordRepairs(t *testing.T) {
store := newStubFilerStore()
f := newReplayTestFiler(store)
f.flushCtx, f.flushCancel = context.WithCancel(context.Background())
defer f.flushCancel()
path := util.NewFullPath("/dir", "file")
store.setKvPutErr(errors.New("kv store unreachable"))
f.notePathChange(path, 100, true)
if _, loaded := f.metaTsPending.Load(string(path)); !loaded {
t.Fatal("failed record write must stay pending")
}
store.setKvPutErr(nil)
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if _, loaded := f.metaTsPending.Load(string(path)); !loaded {
ts, found, err := f.kvReadTsNs(context.Background(), metaPathKey(metaPathDelPrefix, path))
if err != nil || !found || ts != 100 {
t.Fatalf("delete record after repair: ts=%v found=%v err=%v", ts, found, err)
}
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("pending record was not repaired")
}
// CommitTsNs reports the recorded commit time over the entry mtime, and a
// still-pending record over both.
func TestCommitTsNsPrefersRecordAndPending(t *testing.T) {
store := newStubFilerStore()
f := newReplayTestFiler(store)
path := util.NewFullPath("/dir", "file")
if got, err := f.CommitTsNs(context.Background(), path, 7); err != nil || got != 7 {
t.Fatalf("no record: CommitTsNs = %d, %v; want fallback 7", got, err)
}
f.notePathChange(path, 50, false)
if got, err := f.CommitTsNs(context.Background(), path, 7); err != nil || got != 50 {
t.Fatalf("recorded: CommitTsNs = %d, %v; want 50", got, err)
}
pending := &metaTsPendingRec{}
pending.changeTsNs.Store(80)
f.metaTsPending.Store(string(path), pending)
if got, err := f.CommitTsNs(context.Background(), path, 7); err != nil || got != 80 {
t.Fatalf("pending: CommitTsNs = %d, %v; want 80", got, err)
}
}
// A successful write discharges what the pending record owed before it, so a
// repair pass can never put an older timestamp back over it.
func TestPendingCommitRecordDischargedByNewerWrite(t *testing.T) {
store := newStubFilerStore()
f := newReplayTestFiler(store)
f.flushCtx, f.flushCancel = context.WithCancel(context.Background())
defer f.flushCancel()
path := util.NewFullPath("/dir", "file")
store.setKvPutErr(errors.New("kv store unreachable"))
f.notePathChange(path, 100, false)
store.setKvPutErr(nil)
f.notePathChange(path, 200, false)
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if _, loaded := f.metaTsPending.Load(string(path)); !loaded {
break
}
time.Sleep(10 * time.Millisecond)
}
ts, found, err := f.kvReadTsNs(context.Background(), metaPathKey(metaPathTsPrefix, path))
if err != nil || !found || ts != 200 {
t.Fatalf("MpTs after repair = %v, %v, %v; want 200", ts, found, err)
}
}
// A failed delete record keeps its own time even when a later failed change
// merges into the pending record: the repair must not stamp the delete at
// the newer change's time.
func TestPendingDeleteRecordKeepsItsTime(t *testing.T) {
store := newStubFilerStore()
store.setKvPutErr(errors.New("kv store unreachable"))
f := newReplayTestFiler(store)
f.flushCtx, f.flushCancel = context.WithCancel(context.Background())
defer f.flushCancel()
path := util.NewFullPath("/dir", "file")
f.notePathChange(path, 100, true)
f.notePathChange(path, 200, false)
store.setKvPutErr(nil)
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if _, loaded := f.metaTsPending.Load(string(path)); !loaded {
break
}
time.Sleep(10 * time.Millisecond)
}
if ts, found, err := f.kvReadTsNs(context.Background(), metaPathKey(metaPathTsPrefix, path)); err != nil || !found || ts != 200 {
t.Fatalf("MpTs = %v, %v, %v; want 200", ts, found, err)
}
if ts, found, err := f.kvReadTsNs(context.Background(), metaPathKey(metaPathDelPrefix, path)); err != nil || !found || ts != 100 {
t.Fatalf("MpDl = %v, %v, %v; want 100", ts, found, err)
}
}
// A replayed directory delete must remove the local subtree too: the origin
// only emits the directory's own key, and children the peer holds that the
// origin never listed would orphan under the missing parent.
func TestReplayDirectoryDeleteSweepsLocalChildren(t *testing.T) {
store := newStubFilerStore()
f := newReplayTestFiler(store)
ctx := context.Background()
put := func(path string, isDir bool) {
e := &Entry{FullPath: util.FullPath(path)}
if isDir {
e.Attr.Mode = os.ModeDir
}
if err := store.InsertEntry(ctx, e); err != nil {
t.Fatalf("insert %s: %v", path, err)
}
}
put("/d", true)
put("/d/a.txt", false)
put("/d/sub", true)
put("/d/sub/nested.txt", false)
event := &filer_pb.SubscribeMetadataResponse{
Directory: "/",
EventNotification: &filer_pb.EventNotification{
OldEntry: &filer_pb.Entry{Name: "d", IsDirectory: true},
DeleteChunks: false,
},
TsNs: time.Now().UnixNano(),
}
if err := f.Replay(event); err != nil {
t.Fatalf("replay: %v", err)
}
for _, path := range []string{"/d", "/d/a.txt", "/d/sub", "/d/sub/nested.txt"} {
if _, err := store.FindEntry(ctx, util.FullPath(path)); !errors.Is(err, filer_pb.ErrNotFound) {
t.Fatalf("expected %s gone after replayed directory delete, got %v", path, err)
}
}
}
// The sweep locks each child under locks Replay already holds, so it cannot
// wait on a child a local write holds — a rename could hold the child while
// waiting on one of the replayed paths. Contention fails the replay instead,
// and a retry after the local write sweeps normally.
func TestReplaySweepFailsOnChildLockContention(t *testing.T) {
store := newStubFilerStore()
f := newReplayTestFiler(store)
ctx := context.Background()
if err := store.InsertEntry(ctx, &Entry{FullPath: "/d", Attr: Attr{Mode: os.ModeDir}}); err != nil {
t.Fatal(err)
}
if err := store.InsertEntry(ctx, &Entry{FullPath: "/d/a.txt"}); err != nil {
t.Fatal(err)
}
event := &filer_pb.SubscribeMetadataResponse{
Directory: "/",
EventNotification: &filer_pb.EventNotification{
OldEntry: &filer_pb.Entry{Name: "d", IsDirectory: true},
},
TsNs: time.Now().UnixNano(),
}
held := f.EntryLockTable.AcquireLock("test local write", "/d/a.txt", util.ExclusiveLock)
if err := f.Replay(event); err == nil {
f.EntryLockTable.ReleaseLock("/d/a.txt", held)
t.Fatal("replay succeeded while a child lock was held by a local write")
}
f.EntryLockTable.ReleaseLock("/d/a.txt", held)
if err := f.Replay(event); err != nil {
t.Fatalf("replay after the local write: %v", err)
}
for _, path := range []string{"/d", "/d/a.txt"} {
if _, err := store.FindEntry(ctx, util.FullPath(path)); !errors.Is(err, filer_pb.ErrNotFound) {
t.Fatalf("expected %s gone after the retried delete, got %v", path, err)
}
}
}
func TestReplayFileDeleteKeepsSiblingEntries(t *testing.T) {
store := newStubFilerStore()
f := newReplayTestFiler(store)
ctx := context.Background()
dir := &Entry{FullPath: "/d", Attr: Attr{Mode: os.ModeDir}}
if err := store.InsertEntry(ctx, dir); err != nil {
t.Fatal(err)
}
for _, name := range []string{"gone.txt", "kept.txt"} {
if err := store.InsertEntry(ctx, &Entry{FullPath: util.NewFullPath("/d", name)}); err != nil {
t.Fatal(err)
}
}
event := &filer_pb.SubscribeMetadataResponse{
Directory: "/d",
EventNotification: &filer_pb.EventNotification{
OldEntry: &filer_pb.Entry{Name: "gone.txt"},
},
TsNs: time.Now().UnixNano(),
}
if err := f.Replay(event); err != nil {
t.Fatalf("replay: %v", err)
}
if _, err := store.FindEntry(ctx, "/d/gone.txt"); !errors.Is(err, filer_pb.ErrNotFound) {
t.Fatalf("expected deleted file gone, got %v", err)
}
if _, err := store.FindEntry(ctx, "/d/kept.txt"); err != nil {
t.Fatalf("expected sibling kept, got %v", err)
}
}
func TestReplayDirectoryDeleteKeepsNewerChildren(t *testing.T) {
store := newStubFilerStore()
f := newReplayTestFiler(store)
ctx := context.Background()
eventTs := time.Now().UnixNano()
put := func(path string, isDir bool, mtime time.Time) {
e := &Entry{FullPath: util.FullPath(path), Attr: Attr{Mtime: mtime}}
if isDir {
e.Attr.Mode = os.ModeDir
}
if err := store.InsertEntry(ctx, e); err != nil {
t.Fatalf("insert %s: %v", path, err)
}
}
old := time.Unix(1600000000, 0)
newer := time.Unix(0, eventTs).Add(time.Second)
put("/d", true, old)
put("/d/old.txt", false, old)
put("/d/sub", true, old)
put("/d/sub/newer.txt", false, newer)
event := &filer_pb.SubscribeMetadataResponse{
Directory: "/",
EventNotification: &filer_pb.EventNotification{
OldEntry: &filer_pb.Entry{Name: "d", IsDirectory: true},
DeleteChunks: false,
},
TsNs: eventTs,
}
if err := f.Replay(event); err != nil {
t.Fatalf("replay: %v", err)
}
if _, err := store.FindEntry(ctx, "/d/old.txt"); !errors.Is(err, filer_pb.ErrNotFound) {
t.Fatalf("older child should be swept, got %v", err)
}
for _, path := range []string{"/d", "/d/sub", "/d/sub/newer.txt"} {
if _, err := store.FindEntry(ctx, util.FullPath(path)); err != nil {
t.Fatalf("newer child and its ancestors should survive: %s: %v", path, err)
}
}
}
// A directory kept for newer descendants still records the delete: a stale
// create replayed later for another child must be fenced by it.
func TestReplayKeptDirectoryDeleteFencesStaleCreates(t *testing.T) {
store := newStubFilerStore()
f := newReplayTestFiler(store)
ctx := context.Background()
eventTs := time.Now().UnixNano()
old := time.Unix(1600000000, 0)
newer := time.Unix(0, eventTs).Add(time.Second)
put := func(path string, isDir bool, mtime time.Time) {
e := &Entry{FullPath: util.FullPath(path), Attr: Attr{Mtime: mtime}}
if isDir {
e.Attr.Mode = os.ModeDir
}
if err := store.InsertEntry(ctx, e); err != nil {
t.Fatalf("insert %s: %v", path, err)
}
}
put("/d", true, old)
put("/d/newer.txt", false, newer)
event := &filer_pb.SubscribeMetadataResponse{
Directory: "/",
EventNotification: &filer_pb.EventNotification{
OldEntry: &filer_pb.Entry{Name: "d", IsDirectory: true},
},
TsNs: eventTs,
}
if err := f.Replay(event); err != nil {
t.Fatalf("replay: %v", err)
}
if _, err := store.FindEntry(ctx, "/d"); err != nil {
t.Fatalf("newer child should keep the directory: %v", err)
}
stale := &filer_pb.SubscribeMetadataResponse{
Directory: "/d",
EventNotification: &filer_pb.EventNotification{
NewEntry: &filer_pb.Entry{Name: "late.txt"},
},
TsNs: eventTs - int64(time.Minute),
}
if err := f.Replay(stale); err != nil {
t.Fatalf("replay stale create: %v", err)
}
if _, err := store.FindEntry(ctx, "/d/late.txt"); !errors.Is(err, filer_pb.ErrNotFound) {
t.Fatal("stale create resurrected a child under a directory the origin deleted")
}
}
// A swept hard link drops the shared inode record with the entry. When the
// record is gone the sweep must free the chunks no sibling link can reach.
func TestReplayDirectoryDeleteFreesOrphanedHardLinkChunks(t *testing.T) {
store := newStubFilerStore()
f := newReplayTestFiler(store)
f.FilerConf = NewFilerConf()
f.FileIdDeletionQueue = util.NewUnboundedQueue()
ctx := context.Background()
hardLinkId := NewHardLinkId()
inode := &Entry{
FullPath: "/d/a.txt",
Attr: Attr{Mtime: time.Now()},
HardLinkId: hardLinkId,
HardLinkCounter: 1,
Chunks: []*filer_pb.FileChunk{{FileId: "3,01637037d6"}},
}
blob, err := inode.EncodeAttributesAndChunks()
if err != nil {
t.Fatal(err)
}
if err := store.KvPut(ctx, hardLinkId, blob); err != nil {
t.Fatal(err)
}
put := func(e *Entry) {
if err := store.InsertEntry(ctx, e); err != nil {
t.Fatal(err)
}
}
put(&Entry{FullPath: "/d", Attr: Attr{Mode: os.ModeDir}})
put(&Entry{FullPath: "/d/a.txt", Attr: Attr{Mtime: time.Now()}, HardLinkId: hardLinkId, HardLinkCounter: 1})
event := &filer_pb.SubscribeMetadataResponse{
Directory: "/",
EventNotification: &filer_pb.EventNotification{
OldEntry: &filer_pb.Entry{Name: "d", IsDirectory: true},
DeleteChunks: true,
},
TsNs: time.Now().UnixNano(),
}
if err := f.Replay(event); err != nil {
t.Fatalf("replay: %v", err)
}
if _, err := store.KvGet(ctx, hardLinkId); !errors.Is(err, ErrKvNotFound) {
t.Fatalf("hard link record should be gone, got %v", err)
}
if got := f.pendingDeletionCount(); got != 1 {
t.Fatalf("pending deletions = %d, want the orphaned shared chunks queued", got)
}
}
// A sibling link outside the swept tree keeps the inode record, so the shared
// chunks stay owned and must not be deleted.
func TestReplayDirectoryDeleteKeepsSharedHardLinkChunks(t *testing.T) {
store := newStubFilerStore()
f := newReplayTestFiler(store)
f.FilerConf = NewFilerConf()
f.FileIdDeletionQueue = util.NewUnboundedQueue()
ctx := context.Background()
hardLinkId := NewHardLinkId()
inode := &Entry{
FullPath: "/d/a.txt",
Attr: Attr{Mtime: time.Now()},
HardLinkId: hardLinkId,
HardLinkCounter: 2,
Chunks: []*filer_pb.FileChunk{{FileId: "3,01637037d6"}},
}
blob, err := inode.EncodeAttributesAndChunks()
if err != nil {
t.Fatal(err)
}
if err := store.KvPut(ctx, hardLinkId, blob); err != nil {
t.Fatal(err)
}
put := func(e *Entry) {
if err := store.InsertEntry(ctx, e); err != nil {
t.Fatal(err)
}
}
put(&Entry{FullPath: "/d", Attr: Attr{Mode: os.ModeDir}})
put(&Entry{FullPath: "/d/a.txt", Attr: Attr{Mtime: time.Now()}, HardLinkId: hardLinkId, HardLinkCounter: 2})
put(&Entry{FullPath: "/keep", Attr: Attr{Mode: os.ModeDir}})
put(&Entry{FullPath: "/keep/b.txt", Attr: Attr{Mtime: time.Now()}, HardLinkId: hardLinkId, HardLinkCounter: 2})
event := &filer_pb.SubscribeMetadataResponse{
Directory: "/",
EventNotification: &filer_pb.EventNotification{
OldEntry: &filer_pb.Entry{Name: "d", IsDirectory: true},
DeleteChunks: true,
},
TsNs: time.Now().UnixNano(),
}
if err := f.Replay(event); err != nil {
t.Fatalf("replay: %v", err)
}
if _, err := store.KvGet(ctx, hardLinkId); err != nil {
t.Fatalf("surviving link must keep the inode record: %v", err)
}
if got := f.pendingDeletionCount(); got != 0 {
t.Fatalf("pending deletions = %d, want 0 while a sibling link owns the chunks", got)
}
}
// A replayed directory delete must mirror the origin's remote cleanup: entries
// the lagging peer alone still holds lose their remote objects too, and record
// tombstones so lazy remote fetches cannot resurrect them.
func TestReplayDirectoryDeleteSweepsRemoteObjects(t *testing.T) {
const storageType = "stub_replay_remote"
stub := &stubRemoteClient{
statResult: &filer_pb.RemoteEntry{RemoteMtime: 1700000000, RemoteSize: 10},
}
defer registerStubMaker(t, storageType, stub)()
conf := &remote_pb.RemoteConf{Name: "replaystore", Type: storageType}
rs := NewFilerRemoteStorage()
rs.storageNameToConf[conf.Name] = conf
rs.mapDirectoryToRemoteStorage("/buckets/mybucket", &remote_pb.RemoteStorageLocation{
Name: "replaystore",
Bucket: "mybucket",
Path: "/",
})
store := newStubFilerStore()
f := newTestFiler(t, store, rs)
ctx := context.Background()
put := func(e *Entry) {
if err := store.InsertEntry(ctx, e); err != nil {
t.Fatal(err)
}
}
for _, dir := range []string{"/buckets", "/buckets/mybucket", "/buckets/mybucket/d"} {
put(&Entry{FullPath: util.FullPath(dir), Attr: Attr{Mode: os.ModeDir}})
}
put(&Entry{
FullPath: "/buckets/mybucket/d/f.txt",
Attr: Attr{Mtime: time.Now(), Mode: 0644},
Remote: &filer_pb.RemoteEntry{RemoteMtime: 1700000000, RemoteSize: 10},
})
event := &filer_pb.SubscribeMetadataResponse{
Directory: "/buckets/mybucket",
EventNotification: &filer_pb.EventNotification{
OldEntry: &filer_pb.Entry{Name: "d", IsDirectory: true},
DeleteChunks: false,
},
TsNs: time.Now().UnixNano(),
}
if err := f.Replay(event); err != nil {
t.Fatalf("replay: %v", err)
}
f.onMetadataChangeEvent(event)
if _, err := store.FindEntry(ctx, "/buckets/mybucket/d/f.txt"); !errors.Is(err, filer_pb.ErrNotFound) {
t.Fatalf("swept child should be gone, got %v", err)
}
if len(stub.deleteCalls) != 1 || stub.deleteCalls[0].Path != "/d/f.txt" {
t.Fatalf("expected one remote delete of /d/f.txt, got %v", stub.deleteCalls)
}
if got := f.remoteTombstones.dirs["/buckets/mybucket/d"]; got != event.TsNs {
t.Fatalf("dir tombstone stamped %d, want the event ts %d", got, event.TsNs)
}
}
// A metadata-only delete keeps remote objects: the event's marker means the
// remote tree stays authoritative, so the sweep must not touch it.
func TestReplayDirectoryDeleteKeepRemoteObjects(t *testing.T) {
const storageType = "stub_replay_remote_keep"
stub := &stubRemoteClient{}
defer registerStubMaker(t, storageType, stub)()
conf := &remote_pb.RemoteConf{Name: "keepstore", Type: storageType}
rs := NewFilerRemoteStorage()
rs.storageNameToConf[conf.Name] = conf
rs.mapDirectoryToRemoteStorage("/buckets/mybucket", &remote_pb.RemoteStorageLocation{
Name: "keepstore",
Bucket: "mybucket",
Path: "/",
})
store := newStubFilerStore()
f := newTestFiler(t, store, rs)
ctx := context.Background()
put := func(e *Entry) {
if err := store.InsertEntry(ctx, e); err != nil {
t.Fatal(err)
}
}
for _, dir := range []string{"/buckets", "/buckets/mybucket", "/buckets/mybucket/d"} {
put(&Entry{FullPath: util.FullPath(dir), Attr: Attr{Mode: os.ModeDir}})
}
put(&Entry{
FullPath: "/buckets/mybucket/d/f.txt",
Attr: Attr{Mtime: time.Now(), Mode: 0644},
Remote: &filer_pb.RemoteEntry{RemoteMtime: 1700000000, RemoteSize: 10},
})
event := &filer_pb.SubscribeMetadataResponse{
Directory: "/buckets/mybucket",
EventNotification: &filer_pb.EventNotification{
OldEntry: &filer_pb.Entry{
Name: "d",
IsDirectory: true,
Extended: map[string][]byte{ExtKeepRemoteObjectKey: []byte("true")},
},
},
TsNs: time.Now().UnixNano(),
}
if err := f.Replay(event); err != nil {
t.Fatalf("replay: %v", err)
}
f.onMetadataChangeEvent(event)
if len(stub.deleteCalls) != 0 {
t.Fatalf("metadata-only delete must not touch remote objects, got %v", stub.deleteCalls)
}
if len(f.remoteTombstones.files)+len(f.remoteTombstones.dirs) != 0 {
t.Fatalf("metadata-only delete must not record tombstones, got %v", f.remoteTombstones.dirs)
}
}
// A delayed sweep must not erase an object another writer replaced since the
// entry's stamp was recorded — the same guard the write-back daemon applies.
func TestReplayDirectoryDeleteKeepsReplacedRemoteObject(t *testing.T) {
const storageType = "stub_replay_remote_replaced"
stub := &stubRemoteClient{
statResult: &filer_pb.RemoteEntry{RemoteMtime: 1700000099, RemoteSize: 10},
}
defer registerStubMaker(t, storageType, stub)()
conf := &remote_pb.RemoteConf{Name: "replacestore", Type: storageType}
rs := NewFilerRemoteStorage()
rs.storageNameToConf[conf.Name] = conf
rs.mapDirectoryToRemoteStorage("/buckets/mybucket", &remote_pb.RemoteStorageLocation{
Name: "replacestore",
Bucket: "mybucket",
Path: "/",
})
store := newStubFilerStore()
f := newTestFiler(t, store, rs)
ctx := context.Background()
put := func(e *Entry) {
if err := store.InsertEntry(ctx, e); err != nil {
t.Fatal(err)
}
}
for _, dir := range []string{"/buckets", "/buckets/mybucket", "/buckets/mybucket/d"} {
put(&Entry{FullPath: util.FullPath(dir), Attr: Attr{Mode: os.ModeDir}})
}
put(&Entry{
FullPath: "/buckets/mybucket/d/f.txt",
Attr: Attr{Mtime: time.Now(), Mode: 0644},
Remote: &filer_pb.RemoteEntry{RemoteMtime: 1700000000, RemoteSize: 10},
})
event := &filer_pb.SubscribeMetadataResponse{
Directory: "/buckets/mybucket",
EventNotification: &filer_pb.EventNotification{
OldEntry: &filer_pb.Entry{Name: "d", IsDirectory: true},
DeleteChunks: false,
},
TsNs: time.Now().UnixNano(),
}
if err := f.Replay(event); err != nil {
t.Fatalf("replay: %v", err)
}
if len(stub.deleteCalls) != 0 {
t.Fatalf("a replaced remote object must be kept, got %v", stub.deleteCalls)
}
}
// A backend offering a conditional delete must serve the sweep: the check and
// the delete bind into one preconditioned request, so a replacement landing
// between them cannot be deleted.
func TestReplayDirectoryDeleteUsesConditionalRemoteDelete(t *testing.T) {
const storageType = "stub_replay_remote_cond"
stub := &stubConditionalRemoteClient{conditionalResult: true}
defer registerStubMaker(t, storageType, stub)()
conf := &remote_pb.RemoteConf{Name: "condstore", Type: storageType}
rs := NewFilerRemoteStorage()
rs.storageNameToConf[conf.Name] = conf
rs.mapDirectoryToRemoteStorage("/buckets/mybucket", &remote_pb.RemoteStorageLocation{
Name: "condstore",
Bucket: "mybucket",
Path: "/",
})
store := newStubFilerStore()
f := newTestFiler(t, store, rs)
ctx := context.Background()
put := func(e *Entry) {
if err := store.InsertEntry(ctx, e); err != nil {
t.Fatal(err)
}
}
for _, dir := range []string{"/buckets", "/buckets/mybucket", "/buckets/mybucket/d"} {
put(&Entry{FullPath: util.FullPath(dir), Attr: Attr{Mode: os.ModeDir}})
}
put(&Entry{
FullPath: "/buckets/mybucket/d/f.txt",
Attr: Attr{Mtime: time.Now(), Mode: 0644},
Remote: &filer_pb.RemoteEntry{RemoteMtime: 1700000000, RemoteSize: 10},
})
event := &filer_pb.SubscribeMetadataResponse{
Directory: "/buckets/mybucket",
EventNotification: &filer_pb.EventNotification{
OldEntry: &filer_pb.Entry{Name: "d", IsDirectory: true},
DeleteChunks: false,
},
TsNs: time.Now().UnixNano(),
}
if err := f.Replay(event); err != nil {
t.Fatalf("replay: %v", err)
}
if len(stub.conditionalCalls) != 1 || stub.conditionalCalls[0].Path != "/d/f.txt" {
t.Fatalf("expected one conditional delete of /d/f.txt, got %v", stub.conditionalCalls)
}
if len(stub.deleteCalls) != 0 {
t.Fatalf("an unconditional delete must not run beside the conditional one, got %v", stub.deleteCalls)
}
}
// A conditional delete reporting the object changed keeps the remote object
// but still sweeps the local metadata: the entry the peer held is gone either
// way.
func TestReplayDirectoryDeleteKeepsChangedRemoteObject(t *testing.T) {
const storageType = "stub_replay_remote_cond_keep"
stub := &stubConditionalRemoteClient{conditionalResult: false}
defer registerStubMaker(t, storageType, stub)()
conf := &remote_pb.RemoteConf{Name: "condkeepstore", Type: storageType}
rs := NewFilerRemoteStorage()
rs.storageNameToConf[conf.Name] = conf
rs.mapDirectoryToRemoteStorage("/buckets/mybucket", &remote_pb.RemoteStorageLocation{
Name: "condkeepstore",
Bucket: "mybucket",
Path: "/",
})
store := newStubFilerStore()
f := newTestFiler(t, store, rs)
ctx := context.Background()
put := func(e *Entry) {
if err := store.InsertEntry(ctx, e); err != nil {
t.Fatal(err)
}
}
for _, dir := range []string{"/buckets", "/buckets/mybucket", "/buckets/mybucket/d"} {
put(&Entry{FullPath: util.FullPath(dir), Attr: Attr{Mode: os.ModeDir}})
}
put(&Entry{
FullPath: "/buckets/mybucket/d/f.txt",
Attr: Attr{Mtime: time.Now(), Mode: 0644},
Remote: &filer_pb.RemoteEntry{RemoteMtime: 1700000000, RemoteSize: 10},
})
event := &filer_pb.SubscribeMetadataResponse{
Directory: "/buckets/mybucket",
EventNotification: &filer_pb.EventNotification{
OldEntry: &filer_pb.Entry{Name: "d", IsDirectory: true},
DeleteChunks: false,
},
TsNs: time.Now().UnixNano(),
}
if err := f.Replay(event); err != nil {
t.Fatalf("replay: %v", err)
}
if _, err := store.FindEntry(ctx, "/buckets/mybucket/d/f.txt"); !errors.Is(err, filer_pb.ErrNotFound) {
t.Fatalf("swept child should be gone, got %v", err)
}
if len(stub.deleteCalls) != 0 {
t.Fatalf("a changed remote object must be kept, got %v", stub.deleteCalls)
}
}
// A failed remote delete aborts the sweep so the replication stream retries
// instead of orphaning the object no other event will ever name.
func TestReplayDirectoryDeleteFailsOnRemoteDeleteError(t *testing.T) {
const storageType = "stub_replay_remote_err"
stub := &stubRemoteClient{
statResult: &filer_pb.RemoteEntry{RemoteMtime: 1700000000, RemoteSize: 10},
deleteErr: errors.New("remote unavailable"),
}
defer registerStubMaker(t, storageType, stub)()
conf := &remote_pb.RemoteConf{Name: "errstore", Type: storageType}
rs := NewFilerRemoteStorage()
rs.storageNameToConf[conf.Name] = conf
rs.mapDirectoryToRemoteStorage("/buckets/mybucket", &remote_pb.RemoteStorageLocation{
Name: "errstore",
Bucket: "mybucket",
Path: "/",
})
store := newStubFilerStore()
f := newTestFiler(t, store, rs)
ctx := context.Background()
put := func(e *Entry) {
if err := store.InsertEntry(ctx, e); err != nil {
t.Fatal(err)
}
}
for _, dir := range []string{"/buckets", "/buckets/mybucket", "/buckets/mybucket/d"} {
put(&Entry{FullPath: util.FullPath(dir), Attr: Attr{Mode: os.ModeDir}})
}
put(&Entry{
FullPath: "/buckets/mybucket/d/f.txt",
Attr: Attr{Mtime: time.Now(), Mode: 0644},
Remote: &filer_pb.RemoteEntry{RemoteMtime: 1700000000, RemoteSize: 10},
})
event := &filer_pb.SubscribeMetadataResponse{
Directory: "/buckets/mybucket",
EventNotification: &filer_pb.EventNotification{
OldEntry: &filer_pb.Entry{Name: "d", IsDirectory: true},
DeleteChunks: false,
},
TsNs: time.Now().UnixNano(),
}
if err := f.Replay(event); err == nil {
t.Fatal("a failed remote delete must fail the replay for retry")
}
if _, err := store.FindEntry(ctx, "/buckets/mybucket/d/f.txt"); err != nil {
t.Fatalf("the child must survive for the retry, got %v", err)
}
}
// A swept entry deleted while its rename copy was still pending reads its
// content from the source the marker names: removing the entry must release
// that object too, or it is orphaned — the destination key may hold nothing.
func TestReplayDirectoryDeleteReleasesPendingRenameSource(t *testing.T) {
const storageType = "stub_replay_remote_rename_src"
stub := &pathStatRemoteClient{objects: map[string]*filer_pb.RemoteEntry{
"/d/src.txt": {RemoteMtime: 1700000000, RemoteSize: 10},
}}
defer registerStubMaker(t, storageType, stub)()
conf := &remote_pb.RemoteConf{Name: "srcstore", Type: storageType}
rs := NewFilerRemoteStorage()
rs.storageNameToConf[conf.Name] = conf
rs.mapDirectoryToRemoteStorage("/buckets/mybucket", &remote_pb.RemoteStorageLocation{
Name: "srcstore",
Bucket: "mybucket",
Path: "/",
})
store := newStubFilerStore()
f := newTestFiler(t, store, rs)
ctx := context.Background()
put := func(e *Entry) {
if err := store.InsertEntry(ctx, e); err != nil {
t.Fatal(err)
}
}
for _, dir := range []string{"/buckets", "/buckets/mybucket", "/buckets/mybucket/d"} {
put(&Entry{FullPath: util.FullPath(dir), Attr: Attr{Mode: os.ModeDir}})
}
put(&Entry{
FullPath: "/buckets/mybucket/d/dst.txt",
Attr: Attr{Mtime: time.Now(), Mode: 0644},
Remote: &filer_pb.RemoteEntry{RemoteMtime: 1700000000, RemoteSize: 10},
Extended: map[string][]byte{ExtRemoteRenameSourceKey: []byte("/buckets/mybucket/d/src.txt")},
})
event := &filer_pb.SubscribeMetadataResponse{
Directory: "/buckets/mybucket",
EventNotification: &filer_pb.EventNotification{
OldEntry: &filer_pb.Entry{Name: "d", IsDirectory: true},
DeleteChunks: false,
},
TsNs: time.Now().UnixNano(),
}
if err := f.Replay(event); err != nil {
t.Fatalf("replay: %v", err)
}
if _, err := store.FindEntry(ctx, "/buckets/mybucket/d/dst.txt"); !errors.Is(err, filer_pb.ErrNotFound) {
t.Fatalf("swept child should be gone, got %v", err)
}
if len(stub.deleteCalls) != 1 || stub.deleteCalls[0].Path != "/d/src.txt" {
t.Fatalf("expected one remote delete of the pending rename's source /d/src.txt, got %v", stub.deleteCalls)
}
}
// The source survives while a remote-only entry holds its path: that entry's
// content is the same object. The swept destination entry's own object still
// goes while its stamp describes it.
func TestReplayDirectoryDeleteKeepsPendingRenameSourceOwnedByRemoteOnly(t *testing.T) {
const storageType = "stub_replay_remote_rename_owned"
stub := &pathStatRemoteClient{objects: map[string]*filer_pb.RemoteEntry{
"/d/dst.txt": {RemoteMtime: 1700000000, RemoteSize: 10},
"/other/src.txt": {RemoteMtime: 1700000000, RemoteSize: 10},
}}
defer registerStubMaker(t, storageType, stub)()
conf := &remote_pb.RemoteConf{Name: "ownstore", Type: storageType}
rs := NewFilerRemoteStorage()
rs.storageNameToConf[conf.Name] = conf
rs.mapDirectoryToRemoteStorage("/buckets/mybucket", &remote_pb.RemoteStorageLocation{
Name: "ownstore",
Bucket: "mybucket",
Path: "/",
})
store := newStubFilerStore()
f := newTestFiler(t, store, rs)
ctx := context.Background()
put := func(e *Entry) {
if err := store.InsertEntry(ctx, e); err != nil {
t.Fatal(err)
}
}
for _, dir := range []string{"/buckets", "/buckets/mybucket", "/buckets/mybucket/d", "/buckets/mybucket/other"} {
put(&Entry{FullPath: util.FullPath(dir), Attr: Attr{Mode: os.ModeDir}})
}
put(&Entry{
FullPath: "/buckets/mybucket/d/dst.txt",
Attr: Attr{Mtime: time.Now(), Mode: 0644},
Remote: &filer_pb.RemoteEntry{RemoteMtime: 1700000000, RemoteSize: 10},
Extended: map[string][]byte{ExtRemoteRenameSourceKey: []byte("/buckets/mybucket/other/src.txt")},
})
put(&Entry{
FullPath: "/buckets/mybucket/other/src.txt",
Attr: Attr{Mtime: time.Now(), Mode: 0644},
Remote: &filer_pb.RemoteEntry{RemoteMtime: 1700000000, RemoteSize: 10},
})
event := &filer_pb.SubscribeMetadataResponse{
Directory: "/buckets/mybucket",
EventNotification: &filer_pb.EventNotification{
OldEntry: &filer_pb.Entry{Name: "d", IsDirectory: true},
DeleteChunks: false,
},
TsNs: time.Now().UnixNano(),
}
if err := f.Replay(event); err != nil {
t.Fatalf("replay: %v", err)
}
if len(stub.deleteCalls) != 1 || stub.deleteCalls[0].Path != "/d/dst.txt" {
t.Fatalf("expected one remote delete of /d/dst.txt, got %v", stub.deleteCalls)
}
if _, err := store.FindEntry(ctx, "/buckets/mybucket/other/src.txt"); err != nil {
t.Fatalf("the remote-only source holder must survive, got %v", err)
}
}
// A remote-only entry that took the path since the listing reads the object
// at the key as its only copy — the sweep must keep both.
func TestReplaySweepKeepsRemoteOnlyOwnerOfPath(t *testing.T) {
const storageType = "stub_replay_remote_owner"
stub := &pathStatRemoteClient{objects: map[string]*filer_pb.RemoteEntry{
"/d/f.txt": {RemoteMtime: 1700000099, RemoteSize: 20},
}}
defer registerStubMaker(t, storageType, stub)()
conf := &remote_pb.RemoteConf{Name: "ownerstore", Type: storageType}
rs := NewFilerRemoteStorage()
rs.storageNameToConf[conf.Name] = conf
rs.mapDirectoryToRemoteStorage("/buckets/mybucket", &remote_pb.RemoteStorageLocation{
Name: "ownerstore",
Bucket: "mybucket",
Path: "/",
})
store := newStubFilerStore()
f := newTestFiler(t, store, rs)
ctx := context.Background()
owner := &Entry{
FullPath: "/buckets/mybucket/d/f.txt",
Attr: Attr{Mtime: time.Now(), Mode: 0644},
Remote: &filer_pb.RemoteEntry{RemoteMtime: 1700000099, RemoteSize: 20},
}
if err := store.InsertEntry(ctx, owner); err != nil {
t.Fatal(err)
}
stale := &Entry{
FullPath: "/buckets/mybucket/d/f.txt",
Attr: Attr{Mtime: time.Now(), Mode: 0644},
Remote: &filer_pb.RemoteEntry{RemoteMtime: 1700000000, RemoteSize: 10},
}
sweep := &folderSweep{chunksByPath: make(map[util.FullPath][]*filer_pb.FileChunk)}
message := &filer_pb.EventNotification{OldEntry: &filer_pb.Entry{Name: "d", IsDirectory: true}}
kept, err := f.deleteSweptChild(ctx, stale, message, 0, sweep, false, true)
if err != nil {
t.Fatalf("deleteSweptChild: %v", err)
}
if !kept {
t.Fatal("a remote-only entry that took the path must be kept")
}
if _, err := store.FindEntry(ctx, "/buckets/mybucket/d/f.txt"); err != nil {
t.Fatalf("the remote-only owner must survive, got %v", err)
}
if len(stub.deleteCalls) != 0 {
t.Fatalf("the owned remote object must be kept, got %v", stub.deleteCalls)
}
}