mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-31 09:20:50 +01:00
* filer.backup: add -s3FlattenVersions to mirror the current S3 view Versioned buckets are stored as <key>.versions/v_<versionId> files plus a latest-version pointer on the .versions directory, so a backup mirror held internal version files and could never be restored through the S3 API. With -s3FlattenVersions the backup maps events on .versions directories onto the flattened object key: a new latest version uploads to <key>, a latest delete marker or a cleared pointer deletes <key> when -doDeleteFiles is set, and the suspended-versioning null object resolves to the regular path. Version files themselves are never mirrored. The initial snapshot seeds only the version the pointer names. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * filer.backup: serialize flattened writes per object key Same-path non-barrier dir updates run concurrently in the metadata processor, so two pointer updates for one object -- or a pointer update and a regular-path write to it -- could overlap and let the older upload finish last. Lock on the flattened key: versions events lock the object key, every other event its own path, via a striped mutex. * filer.backup: apply exclusions to the flattened object key File-name and path excludes ran against the .versions entry, so an excluded object was still copied flat and its deletion still removed the destination copy. Match the rules against the flattened key on both the apply and the versions-dir-removal paths. * filer.backup: re-resolve the pointer on version-file events A tag or attribute update on the current version touches the v_* file, not the .versions pointer, so dropping every version-file event left the mirror with stale tags until the next pointer flip. Re-resolve the pointer on each version-file event; noncurrent versions still stay out. * filer.backup: tolerate vanished versions dirs in flattened snapshot The flattened seed path returned source errors before the existing missing-source policy ran, so one version deleted mid-walk could force a full re-walk. Apply the same 404/superseded skip as the generic seed branch, and cover the review fixes with tests. * filer.backup: lock both flattened keys an event touches A rename or delete can retire one flattened key while writing another, so serialize on the old- and new-side keys together. Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * filer.backup: refresh the flattened key only for the still-current child A v_* event on a non-pointed version cannot move the flattened key, so skip the re-upload; tag and attribute updates on the current version still flow through. Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * replication: let resolved-latest writes bypass the source staleness check A filer sink skips a CreateEntry whose source path no longer holds the entry, but a flattened write lands on a key whose source path is not the one it was read from, so the check dropped every flattened upload. Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * filer.backup: reclaim destination chunks on flattened deletes Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * filer.backup: route regular-path events for versioned keys through the pointer A null-object write or delete at the regular path can coexist with a .versions pointer that names a live version; the pointer decides the flattened key, so deleting the null object must not remove it. Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * filer.backup: make the checkpoint identity cover the flattening mode Resuming a checkpoint taken in the other mode would skip the flattened re-seed and leave mirrored version files behind; a mode change now starts a fresh snapshot. Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test: cover flattened regular-path routing and per-mode checkpoints Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * filer.backup: resolve both sides of a flattened rename * test: cover flattened renames with one versioned side * replication: let resolved-latest writes overwrite a newer destination The stale-destination skip trusted a newer destination mtime, but a resolved-latest write can legitimately install an older version over the copy of a since-deleted latest. Skip only on provably identical content (matching chunk provenance, or equal ETag with full byte coverage). * filer.backup: rescan .versions when the latest null object is gone Deleting ?versionId=null removes the regular-path object without touching the null-is-latest pointer, so the flattened key kept its stale copy. Fall back to the newest remaining version (or a flat delete), the same rescan the S3 read path applies. * replication: compare inline and remote content in the destination-holds checks A chunk-less entry keeps its bytes in Content and a remote-only entry in the object RemoteEntry points at, but the identical-content shortcuts only compared chunk lists and chunk-derived ETags. Two different inline files looked identical, so a flattened re-copy or a same-mtime rewrite kept the previous version's bytes. Compare Content bytes and remote object identity wherever the checks claim the destination already holds the reference's content. * filer.backup: paginate the null-latest .versions rescan A limit=0 listing is capped at DirListingLimit server-side, and old-format version names sort oldest-first, so a history longer than one page left the flattened copy on an older version after the null object was removed. Page through the directory like the S3 read path does. * replication: require matching writable metadata for resolved-latest skips A skip keeps the destination's stored metadata, so identical bytes were not enough: stale attributes or extended entries (e.g. a superseded version id) survived under equal content. Compare the fields a rewrite controls — attributes minus filer-assigned inode/crtime, with the store's atime/ctime zero-defaults applied — plus the extended map, in both the provenance and ETag fast paths. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * replication: require the extended key to exist when comparing metadata * replication: ignore a stored rule TTL when the resolved write carries none * filer.backup: rename -s3FlattenVersions to -s3.flattenVersions Match the -s3.* flag naming convention used by the other s3 options. --------- Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
172 lines
6.5 KiB
Go
172 lines
6.5 KiB
Go
package command
|
|
|
|
import (
|
|
"context"
|
|
"net"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/util"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/credentials/insecure"
|
|
)
|
|
|
|
// kvFilerServer implements just the KV calls of the filer gRPC API over an
|
|
// in-memory map, mirroring the real server's behavior of returning an empty
|
|
// response, not an error, for a missing key.
|
|
type kvFilerServer struct {
|
|
filer_pb.UnimplementedSeaweedFilerServer
|
|
mu sync.Mutex
|
|
kv map[string][]byte
|
|
}
|
|
|
|
func (s *kvFilerServer) KvGet(_ context.Context, req *filer_pb.KvGetRequest) (*filer_pb.KvGetResponse, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return &filer_pb.KvGetResponse{Value: s.kv[string(req.Key)]}, nil
|
|
}
|
|
|
|
func (s *kvFilerServer) KvPut(_ context.Context, req *filer_pb.KvPutRequest) (*filer_pb.KvPutResponse, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.kv[string(req.Key)] = req.Value
|
|
return &filer_pb.KvPutResponse{}, nil
|
|
}
|
|
|
|
type failingKvFilerServer struct {
|
|
filer_pb.UnimplementedSeaweedFilerServer
|
|
calls atomic.Int32
|
|
}
|
|
|
|
func (s *failingKvFilerServer) KvGet(context.Context, *filer_pb.KvGetRequest) (*filer_pb.KvGetResponse, error) {
|
|
if s.calls.Add(1) == 1 {
|
|
return &filer_pb.KvGetResponse{Error: "kv store unavailable"}, nil
|
|
}
|
|
return &filer_pb.KvGetResponse{}, nil
|
|
}
|
|
|
|
// a miss reads as ErrNotFound so the retried attempt skips the KEK retry loop
|
|
func (s *failingKvFilerServer) LookupDirectoryEntry(context.Context, *filer_pb.LookupDirectoryEntryRequest) (*filer_pb.LookupDirectoryEntryResponse, error) {
|
|
return &filer_pb.LookupDirectoryEntryResponse{}, nil
|
|
}
|
|
|
|
func startKvFiler(t *testing.T) (pb.ServerAddress, grpc.DialOption) {
|
|
return startFiler(t, &kvFilerServer{kv: map[string][]byte{}})
|
|
}
|
|
|
|
func startFiler(t *testing.T, server filer_pb.SeaweedFilerServer) (pb.ServerAddress, grpc.DialOption) {
|
|
t.Helper()
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
grpcServer := grpc.NewServer()
|
|
filer_pb.RegisterSeaweedFilerServer(grpcServer, server)
|
|
go func() { _ = grpcServer.Serve(listener) }()
|
|
t.Cleanup(func() { grpcServer.Stop() })
|
|
grpcPort := listener.Addr().(*net.TCPAddr).Port
|
|
return pb.NewServerAddressWithGrpcPort("127.0.0.1:8888", grpcPort),
|
|
grpc.WithTransportCredentials(insecure.NewCredentials())
|
|
}
|
|
|
|
// An upgraded backup finds no destination-scoped checkpoint yet and must
|
|
// resume from the historical name+directory key; once its own key holds a
|
|
// value, the historical key — possibly still advanced by another backup that
|
|
// shared it — must no longer influence it.
|
|
func TestBackupOffset_HistoricalFallbackThenIndependence(t *testing.T) {
|
|
filerAddr, dial := startKvFiler(t)
|
|
|
|
s := &stubSink{name: "s3", dir: "/", destination: "endpoint-a|bucket-a|/"}
|
|
sinkId, legacySinkId := backupCheckpointIds("/", s, false)
|
|
|
|
// pre-upgrade state: only the shared historical checkpoint exists
|
|
if err := setOffset(dial, filerAddr, BackupKeyPrefix, legacySinkId, 111); err != nil {
|
|
t.Fatalf("seed historical offset: %v", err)
|
|
}
|
|
got, err := getOffsetWithFallback(dial, filerAddr, BackupKeyPrefix, sinkId, BackupKeyPrefix, legacySinkId)
|
|
if err != nil || got != 111 {
|
|
t.Fatalf("fallback read = (%d, %v), want (111, nil)", got, err)
|
|
}
|
|
|
|
// the backup checkpoints under its own key and prefers it from then on
|
|
if err := setOffset(dial, filerAddr, BackupKeyPrefix, sinkId, 222); err != nil {
|
|
t.Fatalf("write destination-scoped offset: %v", err)
|
|
}
|
|
got, err = getOffsetWithFallback(dial, filerAddr, BackupKeyPrefix, sinkId, BackupKeyPrefix, legacySinkId)
|
|
if err != nil || got != 222 {
|
|
t.Fatalf("destination-scoped read = (%d, %v), want (222, nil)", got, err)
|
|
}
|
|
|
|
// another backup advancing the historical key no longer moves this one
|
|
if err := setOffset(dial, filerAddr, BackupKeyPrefix, legacySinkId, 333); err != nil {
|
|
t.Fatalf("advance historical offset: %v", err)
|
|
}
|
|
got, err = getOffsetWithFallback(dial, filerAddr, BackupKeyPrefix, sinkId, BackupKeyPrefix, legacySinkId)
|
|
if err != nil || got != 222 {
|
|
t.Fatalf("read after historical advance = (%d, %v), want (222, nil)", got, err)
|
|
}
|
|
}
|
|
|
|
// The collision scenario from the report, over the real KV wire path: two
|
|
// backups whose configurations differ only in endpoint and bucket write
|
|
// their checkpoints without disturbing each other's.
|
|
func TestBackupOffset_DistinctDestinationsDoNotShare(t *testing.T) {
|
|
filerAddr, dial := startKvFiler(t)
|
|
|
|
idA, legacyA := backupCheckpointIds("/", &stubSink{name: "s3", dir: "/", destination: "s3.us-west-004.backblazeb2.com|seaweed-backup-a|/"}, false)
|
|
idB, legacyB := backupCheckpointIds("/", &stubSink{name: "s3", dir: "/", destination: "s3.us-east-005.backblazeb2.com|seaweed-backup-b|/"}, false)
|
|
|
|
if err := setOffset(dial, filerAddr, BackupKeyPrefix, idA, 1000); err != nil {
|
|
t.Fatalf("write A: %v", err)
|
|
}
|
|
if err := setOffset(dial, filerAddr, BackupKeyPrefix, idB, 2000); err != nil {
|
|
t.Fatalf("write B: %v", err)
|
|
}
|
|
|
|
if got, err := getOffsetWithFallback(dial, filerAddr, BackupKeyPrefix, idA, BackupKeyPrefix, legacyA); err != nil || got != 1000 {
|
|
t.Fatalf("A reads (%d, %v), want (1000, nil)", got, err)
|
|
}
|
|
if got, err := getOffsetWithFallback(dial, filerAddr, BackupKeyPrefix, idB, BackupKeyPrefix, legacyB); err != nil || got != 2000 {
|
|
t.Fatalf("B reads (%d, %v), want (2000, nil)", got, err)
|
|
}
|
|
}
|
|
|
|
func TestFilerBackup_OffsetReadFailureFailsAttempt(t *testing.T) {
|
|
server := &failingKvFilerServer{}
|
|
filerAddr, dial := startFiler(t, server)
|
|
|
|
config := util.GetViper()
|
|
config.Set("sink.local.enabled", true)
|
|
config.Set("sink.local.directory", t.TempDir())
|
|
t.Cleanup(func() {
|
|
config.Set("sink.local.enabled", false)
|
|
config.Set("sink.local.directory", "")
|
|
})
|
|
|
|
options := filerBackupOptions
|
|
address := string(filerAddr)
|
|
options.filer = &address
|
|
|
|
resumeTsNs, err := doFilerBackup(dial, &options, nil, nil, nil, 1, 1, 0)
|
|
if err == nil || !strings.Contains(err.Error(), "kv store unavailable") {
|
|
t.Fatalf("doFilerBackup error = %v, want the offset read failure", err)
|
|
}
|
|
if resumeTsNs != 0 {
|
|
t.Fatalf("resume position = %d, want 0", resumeTsNs)
|
|
}
|
|
|
|
// the next attempt re-reads the checkpoint and gets past it once the KV
|
|
// recovers, so it must fail later, not on the offset read
|
|
_, err = doFilerBackup(dial, &options, nil, nil, nil, 1, 2, resumeTsNs)
|
|
if err == nil || strings.Contains(err.Error(), "kv store unavailable") {
|
|
t.Fatalf("retried doFilerBackup error = %v, want a later-stage failure", err)
|
|
}
|
|
if got := server.calls.Load(); got < 2 {
|
|
t.Fatalf("checkpoint reads across attempts = %d, want a re-read", got)
|
|
}
|
|
}
|