mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-08-28 11:56:07 +00:00
* filer.backup: key the checkpoint by source path and sink destination The checkpoint id hashed only sink name + directory, so two backups to different buckets or endpoints sharing a directory layout advanced one checkpoint: whichever job was running pushed the shared offset forward, and a stopped or failing job later resumed from the other's position, silently skipping changes. Backups of different source paths to the same destination shared a checkpoint the same way. Each sink now reports a destination identity (endpoint or account, bucket or container, directory) and the checkpoint is keyed by the source path plus that identity. Reads fall back to the historical name+directory key when the new key has no value, so existing backups resume where they left off; writes go only to the new key. * filer.sync: include the target path in the offset key The offset stored on the target filer was keyed by source path and source filer signature only, so two syncs from the same source cluster and path to different directories on the same target cluster advanced one shared checkpoint, and the slower one could resume past events it never applied. The target path now participates in the key; "/" keeps the historical form, and a sync with a non-root target path falls back to the historical key once when its own key has no value yet. * join checkpoint key fields with NUL so they cannot alias A path or configuration value spelling out the separator could concatenate two different field tuples to the same checkpoint key. NUL cannot appear in a CLI path argument or any sane configuration value, making the encoding injective.
412 lines
12 KiB
Go
412 lines
12 KiB
Go
package replication
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"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, 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)
|
|
}
|
|
}
|