mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-30 17:00:48 +01:00
* filer.sync: skip superseded replays before fetching any chunks On a directory under heavy overwrite, most entries the sync replays are already superseded on the source. The sink only learned that after chunk reads failed — or worse, after fetching and uploading bytes a later event replaces — so the backlog drained slowly and the log filled with 404s. CreateEntry and UpdateEntry now check sourceMovedOn before touching a chunk: one entry lookup skips a stale replay entirely. A mid-replication supersession is still caught by the existing retry gates. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * replication: bound the source liveness probes with a timeout The supersession checks added a source entry lookup to the replication path, and every lookup ran on context.Background() with no deadline. A source that accepts the call but whose metadata store stalls would hold the sync forever — before a single chunk fetch, even when every chunk is still readable. Bound the probe at 30s; an unanswered lookup proves nothing, so replication proceeds and the existing retry gates decide. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * replication: sync the live version a superseded replay points at Skipping a stale replay assumed a later event always re-carries the current content. It does not when the superseding write was itself replicated in from another cluster: those events are dropped before they reach a filer sink, so the destination could be left missing the file. When the source reports a live entry newer than the replay, commit that version through the normal path instead — equivalent to applying the later event, with no dependence on its delivery. A gone source entry still skips, since the destination already matches the source. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * replication: reconcile moved entry content after a native rename AtomicRenameEntry relocates whatever version the destination held. If the preceding update replay was skipped because the source had already moved to the new name, the moved entry keeps the old bytes and — with no further writes — stays stale forever. MoveEntry now runs the same reconciliation as an in-place update against the rename's snapshot, so the moved entry picks up pending content changes. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * replication: repair from the live snapshot a superseded replay resolves to An incremental diff against the replayed oldEntry can resolve manifests the overwrite already vacuumed, which fails every retry of the event. --------- Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
447 lines
13 KiB
Go
447 lines
13 KiB
Go
package replication
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/filer"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/replication/sink"
|
|
"github.com/seaweedfs/seaweedfs/weed/replication/source"
|
|
"github.com/seaweedfs/seaweedfs/weed/util"
|
|
)
|
|
|
|
var _ sink.ReplicationSink = (*recordingSink)(nil)
|
|
|
|
type deleteCall struct {
|
|
key string
|
|
isDirectory bool
|
|
}
|
|
|
|
type createCall struct {
|
|
key string
|
|
}
|
|
|
|
type updateCall struct {
|
|
key string
|
|
newParentPath string
|
|
}
|
|
|
|
type recordingSink struct {
|
|
name string
|
|
sinkToDirectory string
|
|
incremental bool
|
|
updateFoundExisting bool
|
|
|
|
deleteCalls []deleteCall
|
|
createCalls []createCall
|
|
updateCalls []updateCall
|
|
|
|
// ordered records the sink method names in call order so tests can assert
|
|
// create-before-delete sequencing.
|
|
ordered []string
|
|
}
|
|
|
|
func (s *recordingSink) GetName() string {
|
|
return s.name
|
|
}
|
|
|
|
func (s *recordingSink) Initialize(util.Configuration, string) error {
|
|
return nil
|
|
}
|
|
|
|
func (s *recordingSink) DeleteEntry(key string, isDirectory, deleteIncludeChunks bool, signatures []int32) error {
|
|
s.deleteCalls = append(s.deleteCalls, deleteCall{key: key, isDirectory: isDirectory})
|
|
s.ordered = append(s.ordered, "delete")
|
|
return nil
|
|
}
|
|
|
|
func (s *recordingSink) CreateEntry(key string, entry *filer_pb.Entry, signatures []int32) error {
|
|
s.createCalls = append(s.createCalls, createCall{key: key})
|
|
s.ordered = append(s.ordered, "create")
|
|
return nil
|
|
}
|
|
|
|
func (s *recordingSink) UpdateEntry(key string, oldEntry *filer_pb.Entry, newParentPath string, newEntry *filer_pb.Entry, deleteIncludeChunks bool, signatures []int32) (bool, error) {
|
|
s.updateCalls = append(s.updateCalls, updateCall{key: key, newParentPath: newParentPath})
|
|
s.ordered = append(s.ordered, "update")
|
|
return s.updateFoundExisting, nil
|
|
}
|
|
|
|
func (s *recordingSink) GetSinkToDirectory() string {
|
|
return s.sinkToDirectory
|
|
}
|
|
|
|
func (s *recordingSink) GetDestinationIdentity() string {
|
|
return s.sinkToDirectory
|
|
}
|
|
|
|
func (s *recordingSink) SetSourceFiler(*source.FilerSource) {}
|
|
|
|
func (s *recordingSink) IsIncremental() bool {
|
|
return s.incremental
|
|
}
|
|
|
|
type moveCall struct {
|
|
oldKey string
|
|
newKey string
|
|
}
|
|
|
|
// movingSink is a recordingSink that also implements sink.EntryMover, modeling
|
|
// a sink (like the filer) with a native atomic move.
|
|
type movingSink struct {
|
|
*recordingSink
|
|
moveCalls []moveCall
|
|
}
|
|
|
|
var _ sink.EntryMover = (*movingSink)(nil)
|
|
|
|
func (s *movingSink) MoveEntry(oldKey, newKey string, oldEntry, newEntry *filer_pb.Entry, signatures []int32) error {
|
|
s.moveCalls = append(s.moveCalls, moveCall{oldKey: oldKey, newKey: newKey})
|
|
s.ordered = append(s.ordered, "move")
|
|
return nil
|
|
}
|
|
|
|
func TestReplicateRenameUsesTargetKeyForNonFilerSink(t *testing.T) {
|
|
s := &recordingSink{name: "local", sinkToDirectory: "/dest"}
|
|
r := &Replicator{
|
|
sink: s,
|
|
source: &source.FilerSource{Dir: "/source"},
|
|
}
|
|
|
|
err := r.Replicate(context.Background(), "/source/old/file.txt", &filer_pb.EventNotification{
|
|
OldEntry: &filer_pb.Entry{
|
|
Name: "file.txt",
|
|
Attributes: &filer_pb.FuseAttributes{
|
|
Mtime: 123,
|
|
},
|
|
},
|
|
NewEntry: &filer_pb.Entry{
|
|
Name: "renamed.txt",
|
|
Attributes: &filer_pb.FuseAttributes{
|
|
Mtime: 123,
|
|
},
|
|
},
|
|
NewParentPath: "/source/new",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Replicate rename: %v", err)
|
|
}
|
|
|
|
if len(s.updateCalls) != 0 {
|
|
t.Fatalf("expected rename to bypass UpdateEntry, got %d calls", len(s.updateCalls))
|
|
}
|
|
if len(s.createCalls) != 1 || s.createCalls[0].key != "/dest/new/renamed.txt" {
|
|
t.Fatalf("create calls = %+v, want target sink key", s.createCalls)
|
|
}
|
|
if len(s.deleteCalls) != 1 || s.deleteCalls[0].key != "/dest/old/file.txt" {
|
|
t.Fatalf("delete calls = %+v, want old sink key", s.deleteCalls)
|
|
}
|
|
if got, want := s.ordered, []string{"create", "delete"}; !equalStrings(got, want) {
|
|
t.Fatalf("call order = %v, want create before delete %v", got, want)
|
|
}
|
|
}
|
|
|
|
// A real move to a filer sink also goes through create-then-delete (the
|
|
// filer-sink special-case that previously routed renames to UpdateEntry is
|
|
// gone): UpdateEntry cannot move an entry across paths.
|
|
// A sink without a native move falls back to create-then-delete for a rename.
|
|
func TestReplicateRenameWithoutMoverUsesCreateThenDelete(t *testing.T) {
|
|
s := &recordingSink{
|
|
name: "s3",
|
|
sinkToDirectory: "/dest",
|
|
updateFoundExisting: true,
|
|
}
|
|
r := &Replicator{
|
|
sink: s,
|
|
source: &source.FilerSource{Dir: "/source"},
|
|
}
|
|
|
|
err := r.Replicate(context.Background(), "/source/old/file.txt", &filer_pb.EventNotification{
|
|
OldEntry: &filer_pb.Entry{
|
|
Name: "file.txt",
|
|
Attributes: &filer_pb.FuseAttributes{
|
|
Mtime: 123,
|
|
},
|
|
},
|
|
NewEntry: &filer_pb.Entry{
|
|
Name: "renamed.txt",
|
|
Attributes: &filer_pb.FuseAttributes{
|
|
Mtime: 123,
|
|
},
|
|
},
|
|
NewParentPath: "/source/new",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Replicate rename: %v", err)
|
|
}
|
|
|
|
if len(s.updateCalls) != 0 {
|
|
t.Fatalf("expected rename to bypass UpdateEntry, got %d calls", len(s.updateCalls))
|
|
}
|
|
if len(s.createCalls) != 1 || s.createCalls[0].key != "/dest/new/renamed.txt" {
|
|
t.Fatalf("create calls = %+v, want target sink key", s.createCalls)
|
|
}
|
|
if len(s.deleteCalls) != 1 || s.deleteCalls[0].key != "/dest/old/file.txt" {
|
|
t.Fatalf("delete calls = %+v, want old sink key", s.deleteCalls)
|
|
}
|
|
if got, want := s.ordered, []string{"create", "delete"}; !equalStrings(got, want) {
|
|
t.Fatalf("call order = %v, want create before delete %v", got, want)
|
|
}
|
|
}
|
|
|
|
// A sink with a native move (the filer) relocates a rename via MoveEntry — never
|
|
// create-then-delete, so a failed copy can't delete the only valid destination.
|
|
func TestReplicateRenameUsesMoveEntryWhenSupported(t *testing.T) {
|
|
s := &movingSink{recordingSink: &recordingSink{name: "filer", sinkToDirectory: "/dest"}}
|
|
r := &Replicator{
|
|
sink: s,
|
|
source: &source.FilerSource{Dir: "/source"},
|
|
}
|
|
|
|
err := r.Replicate(context.Background(), "/source/old/file.txt", &filer_pb.EventNotification{
|
|
OldEntry: &filer_pb.Entry{Name: "file.txt"},
|
|
NewEntry: &filer_pb.Entry{Name: "renamed.txt"},
|
|
NewParentPath: "/source/new",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Replicate rename: %v", err)
|
|
}
|
|
|
|
if len(s.moveCalls) != 1 || s.moveCalls[0].oldKey != "/dest/old/file.txt" || s.moveCalls[0].newKey != "/dest/new/renamed.txt" {
|
|
t.Fatalf("move calls = %+v, want one move /dest/old/file.txt => /dest/new/renamed.txt", s.moveCalls)
|
|
}
|
|
if len(s.createCalls) != 0 || len(s.deleteCalls) != 0 || len(s.updateCalls) != 0 {
|
|
t.Fatalf("native move must not create/delete/update: creates=%v deletes=%v updates=%v", s.createCalls, s.deleteCalls, s.updateCalls)
|
|
}
|
|
}
|
|
|
|
// An in-place update (same parent + same name, so oldSinkKey == newSinkKey)
|
|
// still routes to UpdateEntry rather than create-then-delete.
|
|
func TestReplicateInPlaceUpdateUsesUpdateEntry(t *testing.T) {
|
|
s := &recordingSink{
|
|
name: "filer",
|
|
sinkToDirectory: "/dest",
|
|
updateFoundExisting: true,
|
|
}
|
|
r := &Replicator{
|
|
sink: s,
|
|
source: &source.FilerSource{Dir: "/source"},
|
|
}
|
|
|
|
err := r.Replicate(context.Background(), "/source/dir/file.txt", &filer_pb.EventNotification{
|
|
OldEntry: &filer_pb.Entry{
|
|
Name: "file.txt",
|
|
Attributes: &filer_pb.FuseAttributes{
|
|
Mtime: 123,
|
|
},
|
|
},
|
|
NewEntry: &filer_pb.Entry{
|
|
Name: "file.txt",
|
|
Attributes: &filer_pb.FuseAttributes{
|
|
Mtime: 456,
|
|
},
|
|
},
|
|
NewParentPath: "/source/dir",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Replicate in-place update: %v", err)
|
|
}
|
|
|
|
if len(s.updateCalls) != 1 {
|
|
t.Fatalf("update calls = %d, want 1", len(s.updateCalls))
|
|
}
|
|
if s.updateCalls[0].key != "/dest/dir/file.txt" {
|
|
t.Fatalf("update key = %q, want /dest/dir/file.txt", s.updateCalls[0].key)
|
|
}
|
|
if s.updateCalls[0].newParentPath != "/dest/dir" {
|
|
t.Fatalf("update newParentPath = %q, want /dest/dir", s.updateCalls[0].newParentPath)
|
|
}
|
|
if len(s.deleteCalls) != 0 || len(s.createCalls) != 0 {
|
|
t.Fatalf("unexpected delete/create calls: deletes=%+v creates=%+v", s.deleteCalls, s.createCalls)
|
|
}
|
|
}
|
|
|
|
// When the in-place update finds no existing entry to update, fall back to
|
|
// delete-then-create at the same key.
|
|
func TestReplicateInPlaceUpdateFallbackCreates(t *testing.T) {
|
|
s := &recordingSink{
|
|
name: "filer",
|
|
sinkToDirectory: "/dest",
|
|
updateFoundExisting: false,
|
|
}
|
|
r := &Replicator{
|
|
sink: s,
|
|
source: &source.FilerSource{Dir: "/source"},
|
|
}
|
|
|
|
err := r.Replicate(context.Background(), "/source/dir/file.txt", &filer_pb.EventNotification{
|
|
OldEntry: &filer_pb.Entry{
|
|
Name: "file.txt",
|
|
Attributes: &filer_pb.FuseAttributes{
|
|
Mtime: 123,
|
|
},
|
|
},
|
|
NewEntry: &filer_pb.Entry{
|
|
Name: "file.txt",
|
|
Attributes: &filer_pb.FuseAttributes{
|
|
Mtime: 456,
|
|
},
|
|
},
|
|
NewParentPath: "/source/dir",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Replicate in-place update fallback: %v", err)
|
|
}
|
|
|
|
if len(s.updateCalls) != 1 {
|
|
t.Fatalf("update calls = %d, want 1", len(s.updateCalls))
|
|
}
|
|
if len(s.deleteCalls) != 1 || s.deleteCalls[0].key != "/dest/dir/file.txt" {
|
|
t.Fatalf("delete calls = %+v, want same sink key", s.deleteCalls)
|
|
}
|
|
if len(s.createCalls) != 1 || s.createCalls[0].key != "/dest/dir/file.txt" {
|
|
t.Fatalf("create calls = %+v, want same sink key", s.createCalls)
|
|
}
|
|
if got, want := s.ordered, []string{"update", "delete", "create"}; !equalStrings(got, want) {
|
|
t.Fatalf("call order = %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
func equalStrings(a, b []string) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i := range a {
|
|
if a[i] != b[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func TestPathIsEqualOrUnderUsesDirectoryBoundaries(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
candidate string
|
|
other string
|
|
expected bool
|
|
}{
|
|
{name: "equal", candidate: "/foo", other: "/foo", expected: true},
|
|
{name: "descendant", candidate: "/foo/bar", other: "/foo", expected: true},
|
|
{name: "sibling prefix", candidate: "/foobar/bar", other: "/foo", expected: false},
|
|
{name: "root", candidate: "/foo/bar", other: "/", expected: true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := util.IsEqualOrUnder(tt.candidate, tt.other); got != tt.expected {
|
|
t.Fatalf("IsEqualOrUnder(%q, %q) = %v, want %v", tt.candidate, tt.other, got, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestReplicateRenameOutToSiblingPrefixBecomesDelete(t *testing.T) {
|
|
s := &recordingSink{name: "local", sinkToDirectory: "/dest"}
|
|
r := &Replicator{
|
|
sink: s,
|
|
source: &source.FilerSource{Dir: "/foo"},
|
|
}
|
|
|
|
err := r.Replicate(context.Background(), "/foo/old/file.txt", &filer_pb.EventNotification{
|
|
OldEntry: &filer_pb.Entry{
|
|
Name: "file.txt",
|
|
Attributes: &filer_pb.FuseAttributes{
|
|
Mtime: 123,
|
|
},
|
|
},
|
|
NewEntry: &filer_pb.Entry{
|
|
Name: "file.txt",
|
|
Attributes: &filer_pb.FuseAttributes{
|
|
Mtime: 123,
|
|
},
|
|
},
|
|
NewParentPath: "/foobar/new",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Replicate rename out to sibling prefix: %v", err)
|
|
}
|
|
|
|
if len(s.deleteCalls) != 1 || s.deleteCalls[0].key != "/dest/old/file.txt" {
|
|
t.Fatalf("delete calls = %+v, want old sink key", s.deleteCalls)
|
|
}
|
|
if len(s.createCalls) != 0 || len(s.updateCalls) != 0 {
|
|
t.Fatalf("unexpected create/update calls: creates=%+v updates=%+v", s.createCalls, s.updateCalls)
|
|
}
|
|
}
|
|
|
|
func TestReplicateRenameFromExcludedDirBecomesCreate(t *testing.T) {
|
|
s := &recordingSink{name: "local", sinkToDirectory: "/dest"}
|
|
r := &Replicator{
|
|
sink: s,
|
|
source: &source.FilerSource{Dir: "/foo"},
|
|
excludeDirs: []string{"/foo/excluded"},
|
|
}
|
|
|
|
err := r.Replicate(context.Background(), "/foo/excluded/file.txt", &filer_pb.EventNotification{
|
|
OldEntry: &filer_pb.Entry{
|
|
Name: "file.txt",
|
|
Attributes: &filer_pb.FuseAttributes{
|
|
Mtime: 123,
|
|
},
|
|
},
|
|
NewEntry: &filer_pb.Entry{
|
|
Name: "file.txt",
|
|
Attributes: &filer_pb.FuseAttributes{
|
|
Mtime: 123,
|
|
},
|
|
},
|
|
NewParentPath: "/foo/live",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Replicate rename from excluded dir: %v", err)
|
|
}
|
|
|
|
if len(s.createCalls) != 1 || s.createCalls[0].key != "/dest/live/file.txt" {
|
|
t.Fatalf("create calls = %+v, want target sink key", s.createCalls)
|
|
}
|
|
if len(s.deleteCalls) != 0 || len(s.updateCalls) != 0 {
|
|
t.Fatalf("unexpected delete/update calls: deletes=%+v updates=%+v", s.deleteCalls, s.updateCalls)
|
|
}
|
|
}
|
|
|
|
type keepingSink struct {
|
|
*recordingSink
|
|
keepCalls []deleteCall
|
|
}
|
|
|
|
var _ sink.MetadataOnlyDeleter = (*keepingSink)(nil)
|
|
|
|
func (s *keepingSink) DeleteEntryKeepingRemoteObject(key string, isDirectory, deleteIncludeChunks bool, signatures []int32) error {
|
|
s.keepCalls = append(s.keepCalls, deleteCall{key: key, isDirectory: isDirectory})
|
|
return nil
|
|
}
|
|
|
|
func TestReplicateMetadataOnlyDeleteKeepsTheRemoteObject(t *testing.T) {
|
|
s := &keepingSink{recordingSink: &recordingSink{name: "filer", sinkToDirectory: "/dest"}}
|
|
r := &Replicator{sink: s, source: &source.FilerSource{Dir: "/source"}}
|
|
deleteOf := func(name string, extended map[string][]byte) *filer_pb.EventNotification {
|
|
return &filer_pb.EventNotification{OldEntry: &filer_pb.Entry{Name: name, Attributes: &filer_pb.FuseAttributes{}, Extended: extended}}
|
|
}
|
|
|
|
if err := r.Replicate(context.Background(), "/source/dir/kept.bin", deleteOf("kept.bin", map[string][]byte{filer.ExtKeepRemoteObjectKey: []byte("true")})); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := r.Replicate(context.Background(), "/source/dir/gone.bin", deleteOf("gone.bin", nil)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if len(s.keepCalls) != 1 || s.keepCalls[0].key != "/dest/dir/kept.bin" {
|
|
t.Fatalf("metadata-only deletes = %+v, want /dest/dir/kept.bin", s.keepCalls)
|
|
}
|
|
if len(s.deleteCalls) != 1 || s.deleteCalls[0].key != "/dest/dir/gone.bin" {
|
|
t.Fatalf("ordinary deletes = %+v, want /dest/dir/gone.bin", s.deleteCalls)
|
|
}
|
|
}
|