mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-06 06:22:05 +02:00
* filer.remote.sync: do not pin the sync offset on completed work * filer.remote.sync: a superseded rename uploads the current entry; typed NotFound for a stamp on a deleted entry * filer.remote.sync: a superseded rename keeps the old key when it is the only copy and uploads once * filer.remote.sync: a rename whose content is now remote-only fails the event instead of completing it * filer.remote.sync: a remote-only rename copies the old object to the destination before deleting it * filer.remote.sync: the remote-only rename path follows the filer's current entry and verifies the destination object * filer.remote.sync: an event that described an entry without data is superseded once the filer wrote to it * filer.remote.sync: a superseded rename does only the work left to do uploadCurrentEntry met a remote-only current entry with a fixed error, but a sync plus remote.uncache in the meantime leaves the destination holding the stamped object; that state is complete, not lost. The remote-only case now finishes through completeRemoteOnlyRename, which verifies the destination against the entry stamp and fails only when neither key holds the content. A current entry whose stamp covers its content was already uploaded by the superseding event; skip it instead of writing the same bytes again. * filer.remote.sync: an inherited stamp does not prove the content synced The stamp-coverage skip in uploadCurrentEntry read LastLocalSyncTsNs as proof the current content was uploaded, but a rename carries the source entry's stamp to the destination: a rewrite hidden by that stamp (the case the fallback upload exists for) carries a LastLocalSyncTsNs at or after its mtime and would have been skipped. Drop the check; the remote-only path verifies content at the destination itself through describes. --------- Co-authored-by: James Sas <james@medable.com> Co-authored-by: Chris Lu <chris.lu@gmail.com>
1291 lines
53 KiB
Go
1291 lines
53 KiB
Go
package command
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/filer"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/remote_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/remote_storage"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
|
"github.com/seaweedfs/seaweedfs/weed/util"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
"google.golang.org/protobuf/proto"
|
|
)
|
|
|
|
// TestVersionedFilePathRewrittenForRemote verifies that the fix for
|
|
// https://github.com/seaweedfs/seaweedfs/discussions/8481#discussioncomment-16209342
|
|
// works correctly: internal .versions/v_{id} paths are rewritten to the
|
|
// original S3 object key before syncing to the remote.
|
|
func TestVersionedFilePathRewrittenForRemote(t *testing.T) {
|
|
bucketsDir := "/buckets"
|
|
bucketName := "devicetransaction"
|
|
bucket := util.FullPath(bucketsDir).Child(bucketName)
|
|
|
|
objectPath := "9e149757-2363-11f1-bfa6-11c8ff31b539/transactionlog-2026-03-19-16-30-00.xml"
|
|
versionId := "6761c63812bd9b64704acf08a3ba5800"
|
|
versionFileName := "v_" + versionId
|
|
|
|
// The filer event for the version file creation
|
|
versionedParentPath := string(bucket) + "/" + objectPath + s3_constants.VersionsFolder
|
|
|
|
// The CREATE event that remote_gateway receives
|
|
event := &filer_pb.SubscribeMetadataResponse{
|
|
Directory: versionedParentPath,
|
|
EventNotification: &filer_pb.EventNotification{
|
|
NewParentPath: versionedParentPath,
|
|
NewEntry: &filer_pb.Entry{
|
|
Name: versionFileName,
|
|
Content: []byte("test content"),
|
|
},
|
|
},
|
|
}
|
|
|
|
// Verify preconditions
|
|
if !filer_pb.IsCreate(event) {
|
|
t.Fatal("expected create event")
|
|
}
|
|
if isMultipartUploadFile(event.EventNotification.NewParentPath, event.EventNotification.NewEntry.Name) {
|
|
t.Fatal("should not be detected as multipart upload")
|
|
}
|
|
if !filer.HasData(event.EventNotification.NewEntry) {
|
|
t.Fatal("version file should have data")
|
|
}
|
|
if !shouldSendToRemote(event.EventNotification.NewEntry) {
|
|
t.Fatal("version file should be eligible for remote sync")
|
|
}
|
|
|
|
// Apply the versioned path rewriting (as remote_gateway now does)
|
|
parentPath, entryName := event.EventNotification.NewParentPath, event.EventNotification.NewEntry.Name
|
|
if newParent, newName, ok := rewriteVersionedSourcePath(parentPath, entryName); ok {
|
|
parentPath, entryName = newParent, newName
|
|
}
|
|
|
|
// Compute the remote destination with the rewritten path
|
|
remoteStorageMountLocation := &remote_pb.RemoteStorageLocation{
|
|
Name: "central",
|
|
Bucket: bucketName,
|
|
Path: "/",
|
|
}
|
|
sourcePath := util.NewFullPath(parentPath, entryName)
|
|
dest := toRemoteStorageLocation(bucket, sourcePath, remoteStorageMountLocation)
|
|
|
|
// Verify the destination does NOT contain internal .versions structure
|
|
if strings.Contains(dest.Path, s3_constants.VersionsFolder) {
|
|
t.Errorf("remote destination path still contains .versions: %s", dest.Path)
|
|
}
|
|
|
|
expectedPath := "/" + objectPath
|
|
if dest.Path != expectedPath {
|
|
t.Errorf("remote destination path = %q, want %q", dest.Path, expectedPath)
|
|
}
|
|
}
|
|
|
|
// TestVersionsDirectoryFilteredByHasData verifies that the .versions
|
|
// directory creation event is correctly filtered out (no data), so only
|
|
// the version file event needs path rewriting.
|
|
func TestVersionsDirectoryFilteredByHasData(t *testing.T) {
|
|
bucket := "/buckets/devicetransaction"
|
|
objectPath := "9e149757-2363-11f1-bfa6-11c8ff31b539/transactionlog-2026-03-19-16-30-00.xml"
|
|
|
|
dirEvent := &filer_pb.SubscribeMetadataResponse{
|
|
Directory: bucket + "/9e149757-2363-11f1-bfa6-11c8ff31b539",
|
|
EventNotification: &filer_pb.EventNotification{
|
|
NewParentPath: bucket + "/9e149757-2363-11f1-bfa6-11c8ff31b539",
|
|
NewEntry: &filer_pb.Entry{
|
|
Name: objectPath[strings.LastIndex(objectPath, "/")+1:] + s3_constants.VersionsFolder,
|
|
IsDirectory: true,
|
|
},
|
|
},
|
|
}
|
|
|
|
if filer.HasData(dirEvent.EventNotification.NewEntry) {
|
|
t.Error(".versions directory should not have data")
|
|
}
|
|
}
|
|
|
|
func TestIsVersionedPath(t *testing.T) {
|
|
tests := []struct {
|
|
label string
|
|
dir string
|
|
name string
|
|
isDir bool
|
|
expected bool
|
|
}{
|
|
// Version file inside .versions directory (file, v_ prefix)
|
|
{
|
|
label: "version file in .versions dir",
|
|
dir: "/buckets/mybucket/path/to/file.xml" + s3_constants.VersionsFolder,
|
|
name: "v_6761c63812bd9b64704acf08a3ba5800",
|
|
isDir: false,
|
|
expected: true,
|
|
},
|
|
// Regular file (not versioned)
|
|
{
|
|
label: "regular file",
|
|
dir: "/buckets/mybucket/path/to",
|
|
name: "file.xml",
|
|
isDir: false,
|
|
expected: false,
|
|
},
|
|
// .versions directory entry itself
|
|
{
|
|
label: ".versions directory entry",
|
|
dir: "/buckets/mybucket/path/to",
|
|
name: "file.xml" + s3_constants.VersionsFolder,
|
|
isDir: true,
|
|
expected: true,
|
|
},
|
|
// Non-version file inside .versions dir (no v_ prefix) — not internal
|
|
{
|
|
label: "non-version file in .versions dir",
|
|
dir: "/buckets/mybucket/file.xml" + s3_constants.VersionsFolder,
|
|
name: "some_other_file",
|
|
isDir: false,
|
|
expected: false,
|
|
},
|
|
// User-created directory whose name ends with .versions — not
|
|
// treated as versioned when isDir=false (file inside it)
|
|
{
|
|
label: "file in user dir ending with .versions but no v_ prefix",
|
|
dir: "/buckets/mybucket/my" + s3_constants.VersionsFolder,
|
|
name: "data.txt",
|
|
isDir: false,
|
|
expected: false,
|
|
},
|
|
// Regular directory (not .versions)
|
|
{
|
|
label: "regular directory",
|
|
dir: "/buckets/mybucket/path/to",
|
|
name: "subdir",
|
|
isDir: true,
|
|
expected: false,
|
|
},
|
|
// Entry whose name ends with .versions but is a file, not a dir
|
|
{
|
|
label: "file named like .versions dir",
|
|
dir: "/buckets/mybucket/path/to",
|
|
name: "file.xml" + s3_constants.VersionsFolder,
|
|
isDir: false,
|
|
expected: false,
|
|
},
|
|
// Non-bucket mount: .versions dir should not match
|
|
{
|
|
label: ".versions dir outside /buckets/",
|
|
dir: "/mnt/remote/path/to",
|
|
name: "file.xml" + s3_constants.VersionsFolder,
|
|
isDir: true,
|
|
expected: false,
|
|
},
|
|
// Non-bucket mount: v_ file should not match
|
|
{
|
|
label: "v_ file outside /buckets/",
|
|
dir: "/data/archive/file.xml" + s3_constants.VersionsFolder,
|
|
name: "v_abc123",
|
|
isDir: false,
|
|
expected: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.label, func(t *testing.T) {
|
|
got := isVersionedPath(tt.dir, tt.name, tt.isDir)
|
|
if got != tt.expected {
|
|
t.Errorf("isVersionedPath(%q, %q, %v) = %v, want %v",
|
|
tt.dir, tt.name, tt.isDir, got, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestDeleteMarkerDetectedBeforeHasDataFilter verifies that a delete marker
|
|
// (zero-content version entry with ExtDeleteMarkerKey="true") is detected
|
|
// and can be propagated as a deletion, rather than being silently dropped
|
|
// by the HasData() check.
|
|
func TestDeleteMarkerDetectedBeforeHasDataFilter(t *testing.T) {
|
|
bucketsDir := "/buckets"
|
|
bucketName := "devicetransaction"
|
|
bucket := util.FullPath(bucketsDir).Child(bucketName)
|
|
|
|
objectPath := "docs/report.pdf"
|
|
versionId := "aabb112233445566"
|
|
versionFileName := "v_" + versionId
|
|
versionedParentPath := string(bucket) + "/" + objectPath + s3_constants.VersionsFolder
|
|
|
|
// A delete marker CREATE event: has ExtDeleteMarkerKey but no content
|
|
deleteMarkerEntry := &filer_pb.Entry{
|
|
Name: versionFileName,
|
|
Extended: map[string][]byte{
|
|
s3_constants.ExtDeleteMarkerKey: []byte("true"),
|
|
s3_constants.ExtVersionIdKey: []byte(versionId),
|
|
},
|
|
// Content and Chunks are nil → HasData() returns false
|
|
}
|
|
|
|
// Preconditions
|
|
if filer.HasData(deleteMarkerEntry) {
|
|
t.Fatal("delete marker should have no data")
|
|
}
|
|
if !isDeleteMarker(deleteMarkerEntry) {
|
|
t.Fatal("should be detected as a delete marker")
|
|
}
|
|
|
|
// The versioned path should be rewritable to the original key
|
|
newParent, newName, ok := rewriteVersionedSourcePath(versionedParentPath, deleteMarkerEntry.Name)
|
|
if !ok {
|
|
t.Fatal("delete marker path should be rewritable")
|
|
}
|
|
|
|
// Verify the rewritten path points to the original object
|
|
remoteStorageMountLocation := &remote_pb.RemoteStorageLocation{
|
|
Name: "central",
|
|
Bucket: bucketName,
|
|
Path: "/",
|
|
}
|
|
dest := toRemoteStorageLocation(bucket, util.NewFullPath(newParent, newName), remoteStorageMountLocation)
|
|
|
|
expectedPath := "/" + objectPath
|
|
if dest.Path != expectedPath {
|
|
t.Errorf("delete marker destination = %q, want %q", dest.Path, expectedPath)
|
|
}
|
|
if strings.Contains(dest.Path, s3_constants.VersionsFolder) {
|
|
t.Errorf("delete marker destination should not contain .versions: %s", dest.Path)
|
|
}
|
|
}
|
|
|
|
func TestRewriteVersionedSourcePath(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
dir string
|
|
entryName string
|
|
wantDir string
|
|
wantName string
|
|
wantChanged bool
|
|
}{
|
|
{
|
|
name: "version file in .versions dir",
|
|
dir: "/buckets/bucket/path/to/file.xml" + s3_constants.VersionsFolder,
|
|
entryName: "v_6761c63812bd9b64704acf08a3ba5800",
|
|
wantDir: "/buckets/bucket/path/to",
|
|
wantName: "file.xml",
|
|
wantChanged: true,
|
|
},
|
|
{
|
|
name: "regular file",
|
|
dir: "/buckets/bucket/path/to",
|
|
entryName: "file.xml",
|
|
wantDir: "/buckets/bucket/path/to",
|
|
wantName: "file.xml",
|
|
wantChanged: false,
|
|
},
|
|
{
|
|
name: "version file at bucket root",
|
|
dir: "/buckets/bucket/report.pdf" + s3_constants.VersionsFolder,
|
|
entryName: "v_abc123",
|
|
wantDir: "/buckets/bucket",
|
|
wantName: "report.pdf",
|
|
wantChanged: true,
|
|
},
|
|
{
|
|
name: "non-bucket path not rewritten",
|
|
dir: "/file.xml" + s3_constants.VersionsFolder,
|
|
entryName: "v_abc123",
|
|
wantDir: "/file.xml" + s3_constants.VersionsFolder,
|
|
wantName: "v_abc123",
|
|
wantChanged: false,
|
|
},
|
|
{
|
|
name: "non-bucket mount not rewritten",
|
|
dir: "/mnt/remote/file.xml" + s3_constants.VersionsFolder,
|
|
entryName: "v_abc123",
|
|
wantDir: "/mnt/remote/file.xml" + s3_constants.VersionsFolder,
|
|
wantName: "v_abc123",
|
|
wantChanged: false,
|
|
},
|
|
{
|
|
name: "non-version file in .versions dir",
|
|
dir: "/buckets/bucket/file.xml" + s3_constants.VersionsFolder,
|
|
entryName: "some_other_file",
|
|
wantDir: "/buckets/bucket/file.xml" + s3_constants.VersionsFolder,
|
|
wantName: "some_other_file",
|
|
wantChanged: false,
|
|
},
|
|
{
|
|
name: "dir not ending in .versions",
|
|
dir: "/buckets/bucket/path/to",
|
|
entryName: "v_abc123",
|
|
wantDir: "/buckets/bucket/path/to",
|
|
wantName: "v_abc123",
|
|
wantChanged: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
gotDir, gotName, gotChanged := rewriteVersionedSourcePath(tt.dir, tt.entryName)
|
|
if gotDir != tt.wantDir || gotName != tt.wantName || gotChanged != tt.wantChanged {
|
|
t.Errorf("rewriteVersionedSourcePath(%q, %q) = (%q, %q, %v), want (%q, %q, %v)",
|
|
tt.dir, tt.entryName, gotDir, gotName, gotChanged, tt.wantDir, tt.wantName, tt.wantChanged)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func chunkedEntry(name string, remote *filer_pb.RemoteEntry, etags ...string) *filer_pb.Entry {
|
|
entry := &filer_pb.Entry{Name: name, Attributes: &filer_pb.FuseAttributes{Mtime: 1786096669}, RemoteEntry: remote}
|
|
for i, etag := range etags {
|
|
entry.Chunks = append(entry.Chunks, &filer_pb.FileChunk{FileId: fmt.Sprintf("3,%02x", i), Offset: int64(i) * 1024, Size: 1024, ETag: etag})
|
|
}
|
|
return entry
|
|
}
|
|
|
|
func TestIsMetadataOnlyUpdate(t *testing.T) {
|
|
const dir = "/buckets/media"
|
|
replicated := &filer_pb.RemoteEntry{StorageName: "b2", RemoteETag: "abc", RemoteSize: 2048, RemoteMtime: 1786096669}
|
|
|
|
tests := []struct {
|
|
name string
|
|
dir string
|
|
oldEntry *filer_pb.Entry
|
|
newEntry *filer_pb.Entry
|
|
want bool
|
|
}{
|
|
{
|
|
name: "same chunks",
|
|
dir: dir,
|
|
oldEntry: chunkedEntry("output.pdf", replicated, "e1", "e2"),
|
|
newEntry: chunkedEntry("output.pdf", replicated, "e1", "e2"),
|
|
want: true,
|
|
},
|
|
{
|
|
name: "same chunks, no RemoteEntry",
|
|
dir: dir,
|
|
oldEntry: chunkedEntry("output.pdf", nil, "e1", "e2"),
|
|
newEntry: chunkedEntry("output.pdf", nil, "e1", "e2"),
|
|
want: true,
|
|
},
|
|
{
|
|
name: "rewritten chunks",
|
|
dir: dir,
|
|
oldEntry: chunkedEntry("output.pdf", replicated, "e1", "e2"),
|
|
newEntry: chunkedEntry("output.pdf", replicated, "e1", "e3"),
|
|
want: false,
|
|
},
|
|
{
|
|
name: "first write into an empty entry",
|
|
dir: dir,
|
|
oldEntry: chunkedEntry("output.pdf", nil),
|
|
newEntry: chunkedEntry("output.pdf", nil, "e1"),
|
|
want: false,
|
|
},
|
|
{
|
|
name: "rename",
|
|
dir: dir,
|
|
oldEntry: chunkedEntry("output.pdf", replicated, "e1"),
|
|
newEntry: chunkedEntry("renamed.pdf", replicated, "e1"),
|
|
want: false,
|
|
},
|
|
{
|
|
name: "move to another directory",
|
|
dir: "/buckets/media/inbox",
|
|
oldEntry: chunkedEntry("output.pdf", replicated, "e1"),
|
|
newEntry: chunkedEntry("output.pdf", replicated, "e1"),
|
|
want: false,
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
message := &filer_pb.EventNotification{NewParentPath: dir, OldEntry: tt.oldEntry, NewEntry: tt.newEntry}
|
|
if got := isMetadataOnlyUpdate(tt.dir, message); got != tt.want {
|
|
t.Errorf("isMetadataOnlyUpdate = %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// stubFilerClient serves one entry; nil means not found. A non-nil err fails
|
|
// every lookup instead.
|
|
type stubFilerClient struct {
|
|
filer_pb.SeaweedFilerClient
|
|
entry *filer_pb.Entry
|
|
err error
|
|
updateErr error
|
|
lookups int
|
|
updates int
|
|
}
|
|
|
|
func (c *stubFilerClient) LookupDirectoryEntry(context.Context, *filer_pb.LookupDirectoryEntryRequest, ...grpc.CallOption) (*filer_pb.LookupDirectoryEntryResponse, error) {
|
|
c.lookups++
|
|
if c.err != nil {
|
|
return nil, c.err
|
|
}
|
|
return &filer_pb.LookupDirectoryEntryResponse{Entry: c.entry}, nil
|
|
}
|
|
|
|
func (c *stubFilerClient) UpdateEntry(context.Context, *filer_pb.UpdateEntryRequest, ...grpc.CallOption) (*filer_pb.UpdateEntryResponse, error) {
|
|
c.updates++
|
|
if c.updateErr != nil {
|
|
return &filer_pb.UpdateEntryResponse{}, c.updateErr
|
|
}
|
|
return &filer_pb.UpdateEntryResponse{}, nil
|
|
}
|
|
|
|
func (c *stubFilerClient) WithFilerClient(_ bool, fn func(filer_pb.SeaweedFilerClient) error) error {
|
|
return fn(c)
|
|
}
|
|
|
|
func (c *stubFilerClient) AdjustedUrl(location *filer_pb.Location) string { return location.Url }
|
|
|
|
func (c *stubFilerClient) GetDataCenter() string { return "" }
|
|
|
|
// TestLiveRemoteEntry tells apart the two update events that carry no
|
|
// RemoteEntry: a file never replicated (#11139), which must be uploaded, and a
|
|
// chmod logged before the sync finished uploading the preceding write, which
|
|
// must not be uploaded again.
|
|
func TestLiveRemoteEntry(t *testing.T) {
|
|
const dir = "/buckets/media"
|
|
stamped := &filer_pb.RemoteEntry{StorageName: "b2", RemoteETag: "abc", RemoteSize: 2048, RemoteMtime: 1786096669}
|
|
|
|
t.Run("event carries the RemoteEntry, no lookup", func(t *testing.T) {
|
|
filerClient := &stubFilerClient{}
|
|
got, err := liveRemoteEntry(filerClient, dir, chunkedEntry("output.pdf", stamped, "e1"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got != stamped {
|
|
t.Errorf("got %+v, want the event's own RemoteEntry", got)
|
|
}
|
|
if filerClient.lookups != 0 {
|
|
t.Errorf("looked up the filer %d times with the answer already in hand", filerClient.lookups)
|
|
}
|
|
})
|
|
|
|
t.Run("never replicated", func(t *testing.T) {
|
|
event := chunkedEntry("output.pdf", nil, "e1")
|
|
if !shouldSendToRemote(event) {
|
|
t.Fatal("an entry with no RemoteEntry should be eligible for sending")
|
|
}
|
|
filerClient := &stubFilerClient{entry: chunkedEntry("output.pdf", nil, "e1")}
|
|
got, err := liveRemoteEntry(filerClient, dir, event)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got != nil {
|
|
t.Errorf("got %+v, want nil so the update takes the write path", got)
|
|
}
|
|
})
|
|
|
|
t.Run("stamped after the event was logged", func(t *testing.T) {
|
|
filerClient := &stubFilerClient{entry: chunkedEntry("output.pdf", stamped, "e1")}
|
|
got, err := liveRemoteEntry(filerClient, dir, chunkedEntry("output.pdf", nil, "e1"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got != stamped {
|
|
t.Errorf("got %+v, want the filer's current RemoteEntry so the update stays on the metadata path", got)
|
|
}
|
|
if filerClient.lookups != 1 {
|
|
t.Errorf("looked up the filer %d times, want 1", filerClient.lookups)
|
|
}
|
|
})
|
|
|
|
t.Run("deleted since", func(t *testing.T) {
|
|
_, err := liveRemoteEntry(&stubFilerClient{}, dir, chunkedEntry("output.pdf", nil, "e1"))
|
|
if !errors.Is(err, filer_pb.ErrNotFound) {
|
|
t.Errorf("err = %v, want filer_pb.ErrNotFound so the update is skipped rather than uploaded from a deleted entry", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func chunk(fileId, etag string) *filer_pb.FileChunk {
|
|
return &filer_pb.FileChunk{FileId: fileId, Size: 1024, ETag: etag}
|
|
}
|
|
|
|
func entryWith(name string, remote *filer_pb.RemoteEntry, chunks ...*filer_pb.FileChunk) *filer_pb.Entry {
|
|
return &filer_pb.Entry{Name: name, Attributes: &filer_pb.FuseAttributes{Mtime: 1786096669}, RemoteEntry: remote, Chunks: chunks}
|
|
}
|
|
|
|
// TestIsSuperseded decides what a failed upload means for its event (#11148).
|
|
// A replay from an earlier offset re-emits creates for entries the filer has
|
|
// since deleted or rewritten; their chunks are gone, so the upload can never
|
|
// succeed, and failing the event pins the offset before it forever. Those are
|
|
// skipped. A failure to upload an entry the filer still holds as described is
|
|
// a real failure and must keep failing the event so the subscription retries.
|
|
//
|
|
// Every write stores its chunks under new file ids, so the entry is compared
|
|
// by chunk identity, the way the filer itself decides which chunks an update
|
|
// leaves for deletion. Content fingerprints would miss a delete-and-recreate
|
|
// of identical bytes and fail the event forever on its dead chunks.
|
|
func TestIsSuperseded(t *testing.T) {
|
|
const dir = "/buckets/ingest"
|
|
event := entryWith("tmpjt9req69__Alan_Doe_Resume-1.pdf", nil, chunk("3,01", "e1"), chunk("3,02", "e2"))
|
|
stamped := &filer_pb.RemoteEntry{StorageName: "b2", RemoteMtime: 1786096669}
|
|
|
|
tests := []struct {
|
|
name string
|
|
filer *stubFilerClient
|
|
want bool
|
|
}{
|
|
{name: "deleted since", filer: &stubFilerClient{}, want: true},
|
|
{name: "rewritten since", filer: &stubFilerClient{entry: entryWith(event.Name, nil, chunk("3,01", "e1"), chunk("4,07", "e3"))}, want: true},
|
|
{name: "recreated with identical content", filer: &stubFilerClient{entry: entryWith(event.Name, nil, chunk("5,11", "e1"), chunk("5,12", "e2"))}, want: true},
|
|
{name: "recreated with other content", filer: &stubFilerClient{entry: entryWith(event.Name, nil, chunk("6,01", "e9"))}, want: true},
|
|
{name: "truncated since", filer: &stubFilerClient{entry: entryWith(event.Name, nil, chunk("3,01", "e1"))}, want: true},
|
|
{name: "still as described", filer: &stubFilerClient{entry: entryWith(event.Name, nil, chunk("3,01", "e1"), chunk("3,02", "e2"))}, want: false},
|
|
{name: "still as described, since replicated", filer: &stubFilerClient{entry: entryWith(event.Name, stamped, chunk("3,01", "e1"), chunk("3,02", "e2"))}, want: false},
|
|
{name: "appended since, chunks still live", filer: &stubFilerClient{entry: entryWith(event.Name, nil, chunk("3,01", "e1"), chunk("3,02", "e2"), chunk("3,03", "e3"))}, want: false},
|
|
{name: "filer lookup failed", filer: &stubFilerClient{err: errors.New("rpc error: code = Unavailable")}, want: false},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := isSuperseded(tt.filer, dir, event); got != tt.want {
|
|
t.Errorf("isSuperseded = %v, want %v", got, tt.want)
|
|
}
|
|
if tt.filer.lookups != 1 {
|
|
t.Errorf("looked up the filer %d times, want 1", tt.filer.lookups)
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("written since: the event described an entry without data", func(t *testing.T) {
|
|
remoteOnly := entryWith("video.mp4", &filer_pb.RemoteEntry{StorageName: "b2", RemoteSize: 20971520, RemoteMtime: 1786096669})
|
|
if !isSuperseded(&stubFilerClient{entry: entryWith("video.mp4", remoteOnly.RemoteEntry, chunk("3,09", "e9"))}, dir, remoteOnly) {
|
|
t.Error("isSuperseded = false, want true: the filer wrote local data to a remote-only entry")
|
|
}
|
|
if isSuperseded(&stubFilerClient{entry: entryWith("video.mp4", remoteOnly.RemoteEntry)}, dir, remoteOnly) {
|
|
t.Error("isSuperseded = true, want false: the entry is still remote-only")
|
|
}
|
|
empty := &filer_pb.Entry{Name: "touch.txt", Attributes: &filer_pb.FuseAttributes{Mtime: 1786096669}}
|
|
if !isSuperseded(&stubFilerClient{entry: &filer_pb.Entry{Name: "touch.txt", Content: []byte("now")}}, dir, empty) {
|
|
t.Error("isSuperseded = false, want true: the empty file was written to")
|
|
}
|
|
if isSuperseded(&stubFilerClient{entry: &filer_pb.Entry{Name: "touch.txt"}}, dir, empty) {
|
|
t.Error("isSuperseded = true, want false: the file is still empty")
|
|
}
|
|
})
|
|
|
|
t.Run("inline content rewritten since", func(t *testing.T) {
|
|
event := &filer_pb.Entry{Name: "note.txt", Content: []byte("v1")}
|
|
if !isSuperseded(&stubFilerClient{entry: &filer_pb.Entry{Name: "note.txt", Content: []byte("v2")}}, dir, event) {
|
|
t.Error("isSuperseded = false, want true for an entry whose inline content has changed")
|
|
}
|
|
if isSuperseded(&stubFilerClient{entry: &filer_pb.Entry{Name: "note.txt", Content: []byte("v1")}}, dir, event) {
|
|
t.Error("isSuperseded = true, want false for an entry whose inline content is unchanged")
|
|
}
|
|
})
|
|
}
|
|
|
|
// failingRemote fails every WriteFile with err and counts the attempts.
|
|
type failingRemote struct {
|
|
remote_storage.RemoteStorageClient
|
|
err error
|
|
writes int
|
|
}
|
|
|
|
func (r *failingRemote) WriteFile(*remote_pb.RemoteStorageLocation, *filer_pb.Entry, io.Reader) (*filer_pb.RemoteEntry, error) {
|
|
r.writes++
|
|
return nil, r.err
|
|
}
|
|
|
|
// TestRetriedWriteFileStopsWhenSuperseded checks that a failed upload asks the
|
|
// filer at once, and stops retrying when the entry is gone: the ~13s of backoff
|
|
// util.Retry would spend on a dead chunk buys nothing. An upload of an entry the
|
|
// filer still holds keeps the retry policy it had.
|
|
func TestRetriedWriteFileStopsWhenSuperseded(t *testing.T) {
|
|
const dir = "/buckets/ingest"
|
|
event := entryWith("tmpjt9req69__Alan_Doe_Resume-1.pdf", nil, chunk("3,01", "e1"))
|
|
dest := &remote_pb.RemoteStorageLocation{Name: "b2", Bucket: "tier", Path: "/" + event.Name}
|
|
// What the S3 SDK reports when the volume server no longer has the chunk:
|
|
// "requesterror" makes IsTransientError retry it to exhaustion.
|
|
deadChunk := errors.New("RequestError: send request failed\ncaused by: Put \"https://s3.example.com/tier/x\": http://volume:8444/3,01?readDeleted=true: 404 Not Found: not found")
|
|
|
|
t.Run("superseded before the first attempt, no write", func(t *testing.T) {
|
|
remote := &failingRemote{err: deadChunk}
|
|
filerClient := &stubFilerClient{}
|
|
start := time.Now()
|
|
_, err := retriedWriteFile(remote, filerClient, dir, event, dest)
|
|
if !errors.Is(err, errSuperseded) {
|
|
t.Errorf("err = %v, want errSuperseded", err)
|
|
}
|
|
if remote.writes != 0 {
|
|
t.Errorf("wrote %d times, want 0: a superseded version is not worth uploading", remote.writes)
|
|
}
|
|
if filerClient.lookups != 1 {
|
|
t.Errorf("looked up the filer %d times, want 1", filerClient.lookups)
|
|
}
|
|
if elapsed := time.Since(start); elapsed > 500*time.Millisecond {
|
|
t.Errorf("took %v, want no backoff", elapsed)
|
|
}
|
|
})
|
|
|
|
t.Run("superseded during the attempt, one write", func(t *testing.T) {
|
|
remote := &failingRemote{err: deadChunk}
|
|
filerClient := &supersedingFilerClient{live: entryWith(event.Name, nil, chunk("3,01", "e1"))}
|
|
_, err := retriedWriteFile(remote, filerClient, dir, event, dest)
|
|
if !errors.Is(err, errSuperseded) {
|
|
t.Errorf("err = %v, want errSuperseded", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "404 Not Found") {
|
|
t.Errorf("err = %v, want it to keep the write failure", err)
|
|
}
|
|
if remote.writes != 1 {
|
|
t.Errorf("wrote %d times, want 1: retrying a dead chunk cannot succeed", remote.writes)
|
|
}
|
|
if filerClient.lookups != 2 {
|
|
t.Errorf("looked up the filer %d times, want one before the attempt and one after it failed", filerClient.lookups)
|
|
}
|
|
})
|
|
|
|
t.Run("still as described, transient failure keeps retrying", func(t *testing.T) {
|
|
prev := util.RetryWaitTime
|
|
util.RetryWaitTime = 1600 * time.Millisecond // two attempts: 1s, then 1.5s
|
|
t.Cleanup(func() { util.RetryWaitTime = prev })
|
|
remote := &failingRemote{err: deadChunk}
|
|
filerClient := &stubFilerClient{entry: entryWith(event.Name, nil, chunk("3,01", "e1"))}
|
|
_, err := retriedWriteFile(remote, filerClient, dir, event, dest)
|
|
if errors.Is(err, errSuperseded) {
|
|
t.Errorf("err = %v, want the write failure itself", err)
|
|
}
|
|
if remote.writes != 2 {
|
|
t.Errorf("wrote %d times, want 2: a transient failure on a live entry is still retried", remote.writes)
|
|
}
|
|
if filerClient.lookups != 3 {
|
|
t.Errorf("looked up the filer %d times, want one before the first attempt and one per failed attempt", filerClient.lookups)
|
|
}
|
|
})
|
|
|
|
t.Run("still as described, permanent failure not retried", func(t *testing.T) {
|
|
remote := &failingRemote{err: errors.New("AccessDenied: Access Denied")}
|
|
filerClient := &stubFilerClient{entry: entryWith(event.Name, nil, chunk("3,01", "e1"))}
|
|
_, err := retriedWriteFile(remote, filerClient, dir, event, dest)
|
|
if err == nil || errors.Is(err, errSuperseded) {
|
|
t.Errorf("err = %v, want the write failure itself", err)
|
|
}
|
|
if remote.writes != 1 {
|
|
t.Errorf("wrote %d times, want 1", remote.writes)
|
|
}
|
|
})
|
|
}
|
|
|
|
type recordingRemote struct {
|
|
remote_storage.RemoteStorageClient
|
|
deletes []*remote_pb.RemoteStorageLocation
|
|
writes []*remote_pb.RemoteStorageLocation
|
|
written [][]byte
|
|
// chunk ids of each written entry: which version of the file went up
|
|
writtenChunks [][]string
|
|
deleteErr error
|
|
// objects present on the remote, by path: StatFile and ReadFile answer
|
|
// from it, everything else is ErrRemoteObjectNotFound.
|
|
objects map[string][]byte
|
|
}
|
|
|
|
func (r *recordingRemote) WriteFile(loc *remote_pb.RemoteStorageLocation, entry *filer_pb.Entry, reader io.Reader) (*filer_pb.RemoteEntry, error) {
|
|
r.writes = append(r.writes, loc)
|
|
var ids []string
|
|
for _, c := range entry.GetChunks() {
|
|
ids = append(ids, c.GetFileIdString())
|
|
}
|
|
r.writtenChunks = append(r.writtenChunks, ids)
|
|
// A chunked upload's reader walks volume servers the stub filer cannot
|
|
// name, so it is not read; the copy path hands over the old object's
|
|
// stream for a chunkless entry, which is drained.
|
|
var body []byte
|
|
if rc, ok := reader.(io.ReadCloser); ok && len(entry.GetChunks()) == 0 && r.objects != nil {
|
|
body, _ = io.ReadAll(rc)
|
|
}
|
|
r.written = append(r.written, body)
|
|
return &filer_pb.RemoteEntry{StorageName: loc.Name, RemoteETag: "etag", RemoteSize: int64(len(entry.Content)), RemoteMtime: entry.Attributes.GetMtime()}, nil
|
|
}
|
|
|
|
func (r *recordingRemote) StatFile(loc *remote_pb.RemoteStorageLocation) (*filer_pb.RemoteEntry, error) {
|
|
body, ok := r.objects[loc.Path]
|
|
if !ok {
|
|
return nil, remote_storage.ErrRemoteObjectNotFound
|
|
}
|
|
return &filer_pb.RemoteEntry{StorageName: loc.Name, RemoteSize: int64(len(body))}, nil
|
|
}
|
|
|
|
func (r *recordingRemote) ReadFile(loc *remote_pb.RemoteStorageLocation, offset int64, size int64) ([]byte, error) {
|
|
body, ok := r.objects[loc.Path]
|
|
if !ok {
|
|
return nil, remote_storage.ErrRemoteObjectNotFound
|
|
}
|
|
return body[offset : offset+size], nil
|
|
}
|
|
|
|
func (r *recordingRemote) DeleteFile(loc *remote_pb.RemoteStorageLocation) error {
|
|
r.deletes = append(r.deletes, loc)
|
|
return r.deleteErr
|
|
}
|
|
|
|
// TestRenameWithInheritedRemoteEntryWritesNewKey reproduces #11261: a rename
|
|
// arrives as an update whose NewEntry carries the RemoteEntry inherited from
|
|
// the source. shouldSendToRemote returns false for it (RemoteMtime >= Mtime),
|
|
// so the old code skipped the event and never wrote the new key, while the
|
|
// filer had already deleted the old object. A path change must always write.
|
|
func TestRenameWithInheritedRemoteEntryWritesNewKey(t *testing.T) {
|
|
const mountedDir = "/buckets"
|
|
mountLoc := &remote_pb.RemoteStorageLocation{Name: "b2", Bucket: "bucket", Path: "/"}
|
|
|
|
replicated := &filer_pb.RemoteEntry{StorageName: "b2", RemoteETag: "abc", RemoteSize: 2048, RemoteMtime: 1786096669}
|
|
oldEntry := &filer_pb.Entry{Name: "probe.bin", Attributes: &filer_pb.FuseAttributes{Mtime: 1786096669}, RemoteEntry: replicated}
|
|
newEntry := &filer_pb.Entry{
|
|
Name: "probe.bin",
|
|
Content: []byte("payload"),
|
|
Attributes: &filer_pb.FuseAttributes{Mtime: 1786096669},
|
|
RemoteEntry: replicated,
|
|
}
|
|
resp := &filer_pb.SubscribeMetadataResponse{
|
|
Directory: "/buckets/b/src",
|
|
EventNotification: &filer_pb.EventNotification{
|
|
OldEntry: oldEntry,
|
|
NewParentPath: "/buckets/b/dst",
|
|
NewEntry: newEntry,
|
|
},
|
|
}
|
|
|
|
if shouldSendToRemote(newEntry) {
|
|
t.Fatal("precondition: inherited RemoteEntry should make shouldSendToRemote false, the bug's trigger")
|
|
}
|
|
|
|
remote := &recordingRemote{}
|
|
// the filer still holds the renamed entry as the event described it
|
|
filerClient := &stubFilerClient{entry: newEntry}
|
|
if err := processUpdateEvent(filerClient, filerClient, "", remote, mountedDir, mountLoc, resp); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
wantDelete := &remote_pb.RemoteStorageLocation{Name: "b2", Bucket: "bucket", Path: "/b/src/probe.bin"}
|
|
if len(remote.deletes) != 1 || !proto.Equal(remote.deletes[0], wantDelete) {
|
|
t.Errorf("deletes = %+v, want the old key %s deleted", remote.deletes, remote_storage.FormatLocation(wantDelete))
|
|
}
|
|
wantWrite := &remote_pb.RemoteStorageLocation{Name: "b2", Bucket: "bucket", Path: "/b/dst/probe.bin"}
|
|
if len(remote.writes) != 1 || !proto.Equal(remote.writes[0], wantWrite) {
|
|
t.Errorf("writes = %+v, want the new key %s written", remote.writes, remote_storage.FormatLocation(wantWrite))
|
|
}
|
|
}
|
|
|
|
// TestRenameRemoteOnlyEntrySkipsEmptyUpload guards the edge case from the
|
|
// review of #11261: a remote-only entry (no local chunks, content lives only
|
|
// on the remote object the filer already deleted) must not be re-uploaded,
|
|
// since NewFileReader would supply EOF and create a zero-byte object.
|
|
func TestRenameRemoteOnlyEntrySkipsEmptyUpload(t *testing.T) {
|
|
const mountedDir = "/buckets"
|
|
mountLoc := &remote_pb.RemoteStorageLocation{Name: "b2", Bucket: "bucket", Path: "/"}
|
|
|
|
remoteOnly := &filer_pb.RemoteEntry{StorageName: "b2", RemoteETag: "abc", RemoteSize: 20971520, RemoteMtime: 1786096669}
|
|
oldEntry := &filer_pb.Entry{Name: "video.mp4", Attributes: &filer_pb.FuseAttributes{Mtime: 1786096669}, RemoteEntry: remoteOnly}
|
|
newEntry := &filer_pb.Entry{
|
|
Name: "video.mp4",
|
|
Attributes: &filer_pb.FuseAttributes{Mtime: 1786096669},
|
|
RemoteEntry: remoteOnly,
|
|
}
|
|
if !newEntry.IsInRemoteOnly() {
|
|
t.Fatal("precondition: entry should be remote-only")
|
|
}
|
|
if filer.HasData(newEntry) {
|
|
t.Fatal("precondition: remote-only entry should have no local data")
|
|
}
|
|
|
|
resp := &filer_pb.SubscribeMetadataResponse{
|
|
Directory: "/buckets/b/src",
|
|
EventNotification: &filer_pb.EventNotification{
|
|
OldEntry: oldEntry,
|
|
NewParentPath: "/buckets/b/dst",
|
|
NewEntry: newEntry,
|
|
},
|
|
}
|
|
|
|
remote := &recordingRemote{}
|
|
filerClient := &stubFilerClient{}
|
|
if err := processUpdateEvent(filerClient, filerClient, "", remote, mountedDir, mountLoc, resp); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(remote.writes) != 0 {
|
|
t.Errorf("writes = %+v, want none: a remote-only rename must not upload an empty object", remote.writes)
|
|
}
|
|
}
|
|
|
|
// TestRenameDeleteOldKeyFailureReturnsError checks that a failed delete of the
|
|
// old key on a rename is returned so MetadataProcessor retries the event,
|
|
// instead of silently continuing and leaving both keys on the remote.
|
|
func TestRenameDeleteOldKeyFailureReturnsError(t *testing.T) {
|
|
const mountedDir = "/buckets"
|
|
mountLoc := &remote_pb.RemoteStorageLocation{Name: "b2", Bucket: "bucket", Path: "/"}
|
|
|
|
newEntry := &filer_pb.Entry{
|
|
Name: "probe.bin",
|
|
Content: []byte("payload"),
|
|
Attributes: &filer_pb.FuseAttributes{Mtime: 1786096669},
|
|
}
|
|
oldEntry := &filer_pb.Entry{Name: "probe.bin", Attributes: &filer_pb.FuseAttributes{Mtime: 1786096669}}
|
|
resp := &filer_pb.SubscribeMetadataResponse{
|
|
Directory: "/buckets/b/src",
|
|
EventNotification: &filer_pb.EventNotification{
|
|
OldEntry: oldEntry,
|
|
NewParentPath: "/buckets/b/dst",
|
|
NewEntry: newEntry,
|
|
},
|
|
}
|
|
|
|
deleteErr := errors.New("AccessDenied: Access Denied")
|
|
remote := &recordingRemote{deleteErr: deleteErr}
|
|
filerClient := &stubFilerClient{}
|
|
err := processUpdateEvent(filerClient, filerClient, "", remote, mountedDir, mountLoc, resp)
|
|
if !errors.Is(err, deleteErr) {
|
|
t.Errorf("err = %v, want the delete failure returned so the event is retried", err)
|
|
}
|
|
if len(remote.writes) != 0 {
|
|
t.Errorf("writes = %+v, want none: must not write the new key when the old key delete failed", remote.writes)
|
|
}
|
|
}
|
|
|
|
// TestRenameDeleteOldKeyNotFoundStillWrites checks that an already-deleted old
|
|
// key (the filer deletes the source object synchronously during rename) does
|
|
// not block the destination write. GCS reports a missing object as
|
|
// ErrRemoteObjectNotFound, unlike S3/Azure whose deletes are idempotent, so
|
|
// treating it as a real error would pin the sync offset and never write the
|
|
// new key.
|
|
func TestRenameDeleteOldKeyNotFoundStillWrites(t *testing.T) {
|
|
const mountedDir = "/buckets"
|
|
mountLoc := &remote_pb.RemoteStorageLocation{Name: "gcs", Bucket: "bucket", Path: "/"}
|
|
|
|
newEntry := &filer_pb.Entry{
|
|
Name: "probe.bin",
|
|
Content: []byte("payload"),
|
|
Attributes: &filer_pb.FuseAttributes{Mtime: 1786096669},
|
|
}
|
|
oldEntry := &filer_pb.Entry{Name: "probe.bin", Attributes: &filer_pb.FuseAttributes{Mtime: 1786096669}}
|
|
resp := &filer_pb.SubscribeMetadataResponse{
|
|
Directory: "/buckets/b/src",
|
|
EventNotification: &filer_pb.EventNotification{
|
|
OldEntry: oldEntry,
|
|
NewParentPath: "/buckets/b/dst",
|
|
NewEntry: newEntry,
|
|
},
|
|
}
|
|
|
|
remote := &recordingRemote{deleteErr: remote_storage.ErrRemoteObjectNotFound}
|
|
// the filer still holds the renamed entry as the event described it
|
|
filerClient := &stubFilerClient{entry: newEntry}
|
|
if err := processUpdateEvent(filerClient, filerClient, "", remote, mountedDir, mountLoc, resp); err != nil {
|
|
t.Fatalf("err = %v, want nil: an already-deleted old key must not block the write", err)
|
|
}
|
|
wantWrite := &remote_pb.RemoteStorageLocation{Name: "gcs", Bucket: "bucket", Path: "/b/dst/probe.bin"}
|
|
if len(remote.writes) != 1 || !proto.Equal(remote.writes[0], wantWrite) {
|
|
t.Errorf("writes = %+v, want the new key %s written", remote.writes, remote_storage.FormatLocation(wantWrite))
|
|
}
|
|
}
|
|
|
|
func TestIfEntryEqualCarriesTheEventEntry(t *testing.T) {
|
|
entry := &filer_pb.Entry{
|
|
Name: "f",
|
|
Content: []byte("inline"),
|
|
Chunks: []*filer_pb.FileChunk{
|
|
{FileId: "3,01a"},
|
|
{Fid: &filer_pb.FileId{VolumeId: 4, FileKey: 0x2b, Cookie: 0x0c}},
|
|
},
|
|
}
|
|
cond := ifEntryEqual(entry)
|
|
if len(cond.Clauses) != 1 || cond.Clauses[0].Kind != filer_pb.WriteCondition_IF_ENTRY_EQUAL {
|
|
t.Fatalf("condition = %v, want one IF_ENTRY_EQUAL clause", cond)
|
|
}
|
|
if got := cond.Clauses[0].ExpectedEntry; !proto.Equal(got, entry) {
|
|
t.Fatalf("expected_entry = %v, want %v", got, entry)
|
|
}
|
|
}
|
|
|
|
// The remote-bound entry loses (or gains) the storage class attribute while
|
|
// the event entry keeps it, so IF_ENTRY_EQUAL still sees the entry the filer
|
|
// stored — otherwise every stamp on an S3-written object reads as stale.
|
|
func TestRemoteWriteEntryLeavesEventEntryUntouched(t *testing.T) {
|
|
entry := &filer_pb.Entry{
|
|
Name: "f",
|
|
Attributes: &filer_pb.FuseAttributes{Mtime: 1},
|
|
Extended: map[string][]byte{s3_constants.AmzStorageClass: []byte("STANDARD")},
|
|
}
|
|
|
|
stripped := remoteWriteEntry(entry, "")
|
|
if _, ok := stripped.Extended[s3_constants.AmzStorageClass]; ok {
|
|
t.Fatalf("remote entry still carries %s", s3_constants.AmzStorageClass)
|
|
}
|
|
if string(entry.Extended[s3_constants.AmzStorageClass]) != "STANDARD" {
|
|
t.Fatalf("event entry Extended mutated: %v", entry.Extended)
|
|
}
|
|
|
|
overridden := remoteWriteEntry(entry, "GLACIER")
|
|
if got := string(overridden.Extended[s3_constants.AmzStorageClass]); got != "GLACIER" {
|
|
t.Fatalf("override = %q, want GLACIER", got)
|
|
}
|
|
if string(entry.Extended[s3_constants.AmzStorageClass]) != "STANDARD" {
|
|
t.Fatalf("event entry Extended mutated: %v", entry.Extended)
|
|
}
|
|
|
|
bare := &filer_pb.Entry{Name: "g", Attributes: &filer_pb.FuseAttributes{Mtime: 1}}
|
|
if got := string(remoteWriteEntry(bare, "GLACIER").Extended[s3_constants.AmzStorageClass]); got != "GLACIER" {
|
|
t.Fatalf("override on nil Extended = %q, want GLACIER", got)
|
|
}
|
|
}
|
|
|
|
func TestIsFailedPrecondition(t *testing.T) {
|
|
refused := status.Error(codes.FailedPrecondition, "precondition failed: /buckets/b/f")
|
|
cases := []struct {
|
|
name string
|
|
err error
|
|
want bool
|
|
}{
|
|
{"nil", nil, false},
|
|
{"status", refused, true},
|
|
{"wrapped status", fmt.Errorf("update entry: %w", refused), true},
|
|
{"message only", errors.New("rpc error: code = FailedPrecondition desc = precondition failed: /f"), false},
|
|
{"other", status.Error(codes.Unavailable, "filer down"), false},
|
|
}
|
|
for _, c := range cases {
|
|
if got := isFailedPrecondition(c.err); got != c.want {
|
|
t.Errorf("%s: isFailedPrecondition = %v, want %v", c.name, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// supersedingFilerClient answers the first lookup with the live entry and
|
|
// every later one with "not found": the entry is deleted while the upload is
|
|
// in flight.
|
|
type supersedingFilerClient struct {
|
|
filer_pb.SeaweedFilerClient
|
|
live *filer_pb.Entry
|
|
lookups int
|
|
}
|
|
|
|
func (c *supersedingFilerClient) LookupDirectoryEntry(context.Context, *filer_pb.LookupDirectoryEntryRequest, ...grpc.CallOption) (*filer_pb.LookupDirectoryEntryResponse, error) {
|
|
c.lookups++
|
|
if c.lookups == 1 {
|
|
return &filer_pb.LookupDirectoryEntryResponse{Entry: c.live}, nil
|
|
}
|
|
return nil, filer_pb.ErrNotFound
|
|
}
|
|
|
|
func (c *supersedingFilerClient) WithFilerClient(_ bool, fn func(filer_pb.SeaweedFilerClient) error) error {
|
|
return fn(c)
|
|
}
|
|
|
|
func (c *supersedingFilerClient) AdjustedUrl(location *filer_pb.Location) string { return location.Url }
|
|
|
|
func (c *supersedingFilerClient) GetDataCenter() string { return "" }
|
|
|
|
// TestDeleteEventAbsentRemoteObjectIsSuccess: an entry created and deleted
|
|
// before the sync uploaded it has no remote object. GCS reports the delete as
|
|
// ErrRemoteObjectNotFound; the event has nothing left to do, so it must not
|
|
// fail (a failed event pins the sync offset and is replayed on every restart).
|
|
func TestDeleteEventAbsentRemoteObjectIsSuccess(t *testing.T) {
|
|
const mountedDir = "/buckets"
|
|
mountLoc := &remote_pb.RemoteStorageLocation{Name: "gcs", Bucket: "bucket", Path: "/"}
|
|
resp := &filer_pb.SubscribeMetadataResponse{
|
|
Directory: "/buckets/b/vol",
|
|
EventNotification: &filer_pb.EventNotification{
|
|
OldEntry: &filer_pb.Entry{Name: "state.db.tmp", Attributes: &filer_pb.FuseAttributes{Mtime: 1786096669}},
|
|
DeleteChunks: true,
|
|
},
|
|
}
|
|
wantDelete := &remote_pb.RemoteStorageLocation{Name: "gcs", Bucket: "bucket", Path: "/b/vol/state.db.tmp"}
|
|
|
|
t.Run("absent object", func(t *testing.T) {
|
|
remote := &recordingRemote{deleteErr: remote_storage.ErrRemoteObjectNotFound}
|
|
if err := processDeleteEvent(remote, mountedDir, mountLoc, resp); err != nil {
|
|
t.Fatalf("err = %v, want nil: deleting an absent object is complete", err)
|
|
}
|
|
if len(remote.deletes) != 1 || !proto.Equal(remote.deletes[0], wantDelete) {
|
|
t.Errorf("deletes = %+v, want %s", remote.deletes, remote_storage.FormatLocation(wantDelete))
|
|
}
|
|
})
|
|
|
|
t.Run("other failure still fails", func(t *testing.T) {
|
|
remote := &recordingRemote{deleteErr: errors.New("AccessDenied: Access Denied")}
|
|
if err := processDeleteEvent(remote, mountedDir, mountLoc, resp); err == nil {
|
|
t.Fatal("err = nil, want the delete failure")
|
|
}
|
|
})
|
|
|
|
t.Run("delete marker, absent object", func(t *testing.T) {
|
|
remote := &recordingRemote{deleteErr: remote_storage.ErrRemoteObjectNotFound}
|
|
filerClient := &stubFilerClient{}
|
|
markerDelete := &remote_pb.RemoteStorageLocation{Name: "gcs", Bucket: "bucket", Path: "/b/vol/state.db"}
|
|
message := &filer_pb.EventNotification{
|
|
NewParentPath: "/buckets/b/vol",
|
|
NewEntry: &filer_pb.Entry{Name: "state.db", Attributes: &filer_pb.FuseAttributes{Mtime: 1786096669}},
|
|
}
|
|
if err := syncDeleteMarker(remote, filerClient, message, markerDelete); err != nil {
|
|
t.Fatalf("err = %v, want nil", err)
|
|
}
|
|
if len(remote.deletes) != 1 || !proto.Equal(remote.deletes[0], markerDelete) {
|
|
t.Errorf("deletes = %+v, want %s", remote.deletes, remote_storage.FormatLocation(markerDelete))
|
|
}
|
|
if filerClient.updates != 1 {
|
|
t.Errorf("stamped %d times, want 1: the marker is recorded locally so a replay is a no-op", filerClient.updates)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestUpdateLocalEntrySkipsDeletedEntry: the stamp that follows an upload lands
|
|
// on an entry a later event already deleted. The filer answers "not found"; the
|
|
// delete's own event follows in the log, so the stamp is skipped rather than
|
|
// failed.
|
|
func TestUpdateLocalEntrySkipsDeletedEntry(t *testing.T) {
|
|
const dir = "/buckets/b/vol"
|
|
remoteEntry := &filer_pb.RemoteEntry{StorageName: "gcs", RemoteSize: 7}
|
|
|
|
t.Run("plain not found from the filer", func(t *testing.T) {
|
|
filerClient := &stubFilerClient{updateErr: fmt.Errorf("not found %s: %w", dir+"/state.db", filer_pb.ErrNotFound)}
|
|
if err := updateLocalEntry(filerClient, dir, entryWith("state.db", nil, chunk("3,01", "e1")), remoteEntry); err != nil {
|
|
t.Errorf("err = %v, want nil", err)
|
|
}
|
|
})
|
|
|
|
t.Run("not found over grpc", func(t *testing.T) {
|
|
filerClient := &stubFilerClient{updateErr: status.Error(codes.Unknown, "not found "+dir+"/state.db: "+filer_pb.ErrNotFound.Error())}
|
|
if err := updateLocalEntry(filerClient, dir, entryWith("state.db", nil, chunk("3,01", "e1")), remoteEntry); err != nil {
|
|
t.Errorf("err = %v, want nil", err)
|
|
}
|
|
})
|
|
|
|
t.Run("typed not found from the filer", func(t *testing.T) {
|
|
filerClient := &stubFilerClient{updateErr: status.Errorf(codes.NotFound, "not found %s/state.db: %v", dir, filer_pb.ErrNotFound)}
|
|
if err := updateLocalEntry(filerClient, dir, entryWith("state.db", nil, chunk("3,01", "e1")), remoteEntry); err != nil {
|
|
t.Errorf("err = %v, want nil", err)
|
|
}
|
|
})
|
|
|
|
t.Run("the sentinel inside the path is not a missing entry", func(t *testing.T) {
|
|
name := filer_pb.ErrNotFound.Error()
|
|
filerClient := &stubFilerClient{updateErr: status.Error(codes.Unknown, "not found "+dir+"/"+name+": database unavailable")}
|
|
if err := updateLocalEntry(filerClient, dir, entryWith(name, nil, chunk("3,01", "e1")), remoteEntry); err == nil {
|
|
t.Error("err = nil, want the store failure: the stamp must be retried, not dropped")
|
|
}
|
|
})
|
|
|
|
t.Run("other failure still fails", func(t *testing.T) {
|
|
filerClient := &stubFilerClient{updateErr: status.Error(codes.Unavailable, "filer is shutting down")}
|
|
if err := updateLocalEntry(filerClient, dir, entryWith("state.db", nil, chunk("3,01", "e1")), remoteEntry); err == nil {
|
|
t.Error("err = nil, want the update failure")
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestSupersededRenameUploadsCurrentEntry: a rename A -> B queued behind a
|
|
// rewrite of B. The rename's snapshot is superseded (B's chunks changed) and
|
|
// the old key A is already deleted. The rewrite's own event uploads B unless
|
|
// shouldSendToRemote skips it on the inherited RemoteEntry (same-second
|
|
// mtimes); only then does the rename upload what the filer holds now, so the
|
|
// content goes up exactly once.
|
|
func TestSupersededRenameUploadsCurrentEntry(t *testing.T) {
|
|
const mountedDir = "/buckets"
|
|
mountLoc := &remote_pb.RemoteStorageLocation{Name: "gcs", Bucket: "bucket", Path: "/"}
|
|
eventEntry := entryWith("b.txt", nil, chunk("3,01", "e1"))
|
|
resp := &filer_pb.SubscribeMetadataResponse{
|
|
Directory: "/buckets/b/dir",
|
|
EventNotification: &filer_pb.EventNotification{
|
|
OldEntry: entryWith("a.txt", nil, chunk("3,01", "e1")),
|
|
NewParentPath: "/buckets/b/dir",
|
|
NewEntry: eventEntry,
|
|
},
|
|
}
|
|
wantDelete := &remote_pb.RemoteStorageLocation{Name: "gcs", Bucket: "bucket", Path: "/b/dir/a.txt"}
|
|
wantWrite := &remote_pb.RemoteStorageLocation{Name: "gcs", Bucket: "bucket", Path: "/b/dir/b.txt"}
|
|
|
|
t.Run("rewrite hidden by the inherited stamp: upload once here", func(t *testing.T) {
|
|
// RemoteMtime inherited from a.txt equals the rewrite's mtime.
|
|
current := entryWith("b.txt", &filer_pb.RemoteEntry{StorageName: "gcs", RemoteMtime: 1786096669, RemoteSize: 1024}, chunk("3,02", "e2"))
|
|
remote := &recordingRemote{}
|
|
filerClient := &stubFilerClient{entry: current}
|
|
if err := processUpdateEvent(filerClient, filerClient, "", remote, mountedDir, mountLoc, resp); err != nil {
|
|
t.Fatalf("err = %v, want nil", err)
|
|
}
|
|
if len(remote.deletes) != 1 || !proto.Equal(remote.deletes[0], wantDelete) {
|
|
t.Errorf("deletes = %+v, want the old key %s deleted", remote.deletes, remote_storage.FormatLocation(wantDelete))
|
|
}
|
|
if len(remote.writes) != 1 || !proto.Equal(remote.writes[0], wantWrite) {
|
|
t.Fatalf("writes = %+v, want the new key %s written once with the current entry", remote.writes, remote_storage.FormatLocation(wantWrite))
|
|
}
|
|
if filerClient.updates != 1 {
|
|
t.Errorf("stamped %d times, want 1: the current entry is stamped", filerClient.updates)
|
|
}
|
|
})
|
|
|
|
t.Run("rewrite newer than the stamp: its own event uploads, not this one", func(t *testing.T) {
|
|
current := entryWith("b.txt", &filer_pb.RemoteEntry{StorageName: "gcs", RemoteMtime: 1786096000, RemoteSize: 1024}, chunk("3,02", "e2"))
|
|
remote := &recordingRemote{}
|
|
filerClient := &stubFilerClient{entry: current}
|
|
if err := processUpdateEvent(filerClient, filerClient, "", remote, mountedDir, mountLoc, resp); err != nil {
|
|
t.Fatalf("err = %v, want nil", err)
|
|
}
|
|
if len(remote.deletes) != 1 {
|
|
t.Errorf("deletes = %+v, want the old key deleted", remote.deletes)
|
|
}
|
|
if len(remote.writes) != 0 {
|
|
t.Errorf("writes = %+v, want none: the rewrite event behind this one uploads b.txt", remote.writes)
|
|
}
|
|
})
|
|
|
|
t.Run("deleted meanwhile: nothing to upload", func(t *testing.T) {
|
|
remote := &recordingRemote{}
|
|
filerClient := &stubFilerClient{}
|
|
if err := processUpdateEvent(filerClient, filerClient, "", remote, mountedDir, mountLoc, resp); err != nil {
|
|
t.Fatalf("err = %v, want nil", err)
|
|
}
|
|
if len(remote.writes) != 0 {
|
|
t.Errorf("writes = %+v, want none: the delete event follows", remote.writes)
|
|
}
|
|
})
|
|
|
|
t.Run("uncached meanwhile, old object present: copied to the destination, stamped, old key deleted", func(t *testing.T) {
|
|
current := entryWith("b.txt", &filer_pb.RemoteEntry{StorageName: "gcs", RemoteMtime: 1786096669, RemoteSize: 7})
|
|
remote := &recordingRemote{objects: map[string][]byte{"/b/dir/a.txt": []byte("payload")}}
|
|
filerClient := &stubFilerClient{entry: current}
|
|
if err := processUpdateEvent(filerClient, filerClient, "", remote, mountedDir, mountLoc, resp); err != nil {
|
|
t.Fatalf("err = %v, want nil", err)
|
|
}
|
|
if len(remote.writes) != 1 || !proto.Equal(remote.writes[0], wantWrite) || string(remote.written[0]) != "payload" {
|
|
t.Fatalf("writes = %+v (%q), want the old object's bytes written at %s", remote.writes, remote.written, remote_storage.FormatLocation(wantWrite))
|
|
}
|
|
if filerClient.updates != 1 {
|
|
t.Errorf("stamped %d times, want 1: the entry now points at the destination object", filerClient.updates)
|
|
}
|
|
if len(remote.deletes) != 1 || !proto.Equal(remote.deletes[0], wantDelete) {
|
|
t.Errorf("deletes = %+v, want the old key deleted after the copy", remote.deletes)
|
|
}
|
|
})
|
|
|
|
t.Run("uncached after an earlier run completed the rename: destination present, old key deleted, nothing written", func(t *testing.T) {
|
|
current := entryWith("b.txt", &filer_pb.RemoteEntry{StorageName: "gcs", RemoteMtime: 1786096669, RemoteSize: 7})
|
|
remote := &recordingRemote{objects: map[string][]byte{"/b/dir/a.txt": []byte("payload"), "/b/dir/b.txt": []byte("payload")}}
|
|
filerClient := &stubFilerClient{entry: current}
|
|
if err := processUpdateEvent(filerClient, filerClient, "", remote, mountedDir, mountLoc, resp); err != nil {
|
|
t.Fatalf("err = %v, want nil: the replayed rename is already complete", err)
|
|
}
|
|
if len(remote.writes) != 0 {
|
|
t.Errorf("writes = %+v, want none", remote.writes)
|
|
}
|
|
if len(remote.deletes) != 1 || !proto.Equal(remote.deletes[0], wantDelete) {
|
|
t.Errorf("deletes = %+v, want the old key deleted", remote.deletes)
|
|
}
|
|
})
|
|
|
|
t.Run("uncached and neither object exists: the event fails and holds the offset", func(t *testing.T) {
|
|
current := entryWith("b.txt", &filer_pb.RemoteEntry{StorageName: "gcs", RemoteMtime: 1786096669, RemoteSize: 7})
|
|
remote := &recordingRemote{}
|
|
filerClient := &stubFilerClient{entry: current}
|
|
err := processUpdateEvent(filerClient, filerClient, "", remote, mountedDir, mountLoc, resp)
|
|
if err == nil || !strings.Contains(err.Error(), "neither") {
|
|
t.Fatalf("err = %v, want the lost-content failure", err)
|
|
}
|
|
if len(remote.writes) != 0 || len(remote.deletes) != 0 {
|
|
t.Errorf("writes = %+v deletes = %+v, want none", remote.writes, remote.deletes)
|
|
}
|
|
})
|
|
|
|
t.Run("destination holds an object of another size: replaced from the old key", func(t *testing.T) {
|
|
current := entryWith("b.txt", &filer_pb.RemoteEntry{StorageName: "gcs", RemoteMtime: 1786096669, RemoteSize: 7})
|
|
remote := &recordingRemote{objects: map[string][]byte{"/b/dir/a.txt": []byte("payload"), "/b/dir/b.txt": []byte("something else")}}
|
|
filerClient := &stubFilerClient{entry: current}
|
|
if err := processUpdateEvent(filerClient, filerClient, "", remote, mountedDir, mountLoc, resp); err != nil {
|
|
t.Fatalf("err = %v, want nil", err)
|
|
}
|
|
if len(remote.writes) != 1 || string(remote.written[0]) != "payload" {
|
|
t.Fatalf("writes = %+v (%q), want the old object copied over the destination", remote.writes, remote.written)
|
|
}
|
|
if len(remote.deletes) != 1 || !proto.Equal(remote.deletes[0], wantDelete) {
|
|
t.Errorf("deletes = %+v, want the old key deleted after the copy", remote.deletes)
|
|
}
|
|
})
|
|
|
|
t.Run("snapshot remote-only but rewritten since: the rewrite's bytes go up, nothing is copied", func(t *testing.T) {
|
|
remoteOnly := &filer_pb.RemoteEntry{StorageName: "gcs", RemoteMtime: 1786096669, RemoteSize: 7}
|
|
snapshot := &filer_pb.SubscribeMetadataResponse{
|
|
Directory: "/buckets/b/dir",
|
|
EventNotification: &filer_pb.EventNotification{
|
|
OldEntry: entryWith("a.txt", remoteOnly),
|
|
NewParentPath: "/buckets/b/dir",
|
|
NewEntry: entryWith("b.txt", remoteOnly),
|
|
},
|
|
}
|
|
// rewritten with the inherited stamp still hiding it from shouldSendToRemote
|
|
current := entryWith("b.txt", remoteOnly, chunk("3,09", "e9"))
|
|
remote := &recordingRemote{objects: map[string][]byte{"/b/dir/a.txt": []byte("payload")}}
|
|
filerClient := &stubFilerClient{entry: current}
|
|
if err := processUpdateEvent(filerClient, filerClient, "", remote, mountedDir, mountLoc, snapshot); err != nil {
|
|
t.Fatalf("err = %v, want nil", err)
|
|
}
|
|
if len(remote.writes) != 1 || !proto.Equal(remote.writes[0], wantWrite) {
|
|
t.Fatalf("writes = %+v, want one upload of the rewritten b.txt", remote.writes)
|
|
}
|
|
if len(remote.writtenChunks[0]) != 1 || remote.writtenChunks[0][0] != "3,09" {
|
|
t.Errorf("uploaded chunks = %v, want the rewrite's chunk 3,09, not the chunkless snapshot", remote.writtenChunks[0])
|
|
}
|
|
if string(remote.written[0]) == "payload" {
|
|
t.Error("the old object's bytes were copied over a rewritten entry")
|
|
}
|
|
if len(remote.deletes) != 1 {
|
|
t.Errorf("deletes = %+v, want the old key deleted", remote.deletes)
|
|
}
|
|
})
|
|
|
|
t.Run("snapshot already remote-only: completed the same way", func(t *testing.T) {
|
|
remoteOnly := &filer_pb.RemoteEntry{StorageName: "gcs", RemoteMtime: 1786096669, RemoteSize: 7}
|
|
snapshot := &filer_pb.SubscribeMetadataResponse{
|
|
Directory: "/buckets/b/dir",
|
|
EventNotification: &filer_pb.EventNotification{
|
|
OldEntry: entryWith("a.txt", remoteOnly),
|
|
NewParentPath: "/buckets/b/dir",
|
|
NewEntry: entryWith("b.txt", remoteOnly),
|
|
},
|
|
}
|
|
remote := &recordingRemote{objects: map[string][]byte{"/b/dir/a.txt": []byte("payload")}}
|
|
filerClient := &stubFilerClient{entry: entryWith("b.txt", remoteOnly)}
|
|
if err := processUpdateEvent(filerClient, filerClient, "", remote, mountedDir, mountLoc, snapshot); err != nil {
|
|
t.Fatalf("err = %v, want nil", err)
|
|
}
|
|
if len(remote.writes) != 1 || string(remote.written[0]) != "payload" {
|
|
t.Errorf("writes = %+v (%q), want the old object copied to the destination", remote.writes, remote.written)
|
|
}
|
|
if len(remote.deletes) != 1 {
|
|
t.Errorf("deletes = %+v, want the old key deleted after the copy", remote.deletes)
|
|
}
|
|
})
|
|
|
|
t.Run("uncached after the old key was deleted: the event fails and holds the offset", func(t *testing.T) {
|
|
current := entryWith("b.txt", &filer_pb.RemoteEntry{StorageName: "gcs", RemoteMtime: 1786096669, RemoteSize: 1024})
|
|
remote := &recordingRemote{}
|
|
filerClient := &stubFilerClient{entry: current}
|
|
err := uploadCurrentEntry(filerClient, filerClient, remote, "/buckets/b/dir", "b.txt", wantDelete, wantWrite, "")
|
|
if err == nil || !strings.Contains(err.Error(), "neither") {
|
|
t.Errorf("err = %v, want the lost-content failure", err)
|
|
}
|
|
if len(remote.writes) != 0 {
|
|
t.Errorf("writes = %+v, want none", remote.writes)
|
|
}
|
|
})
|
|
|
|
t.Run("uncached with the destination already synced: complete without a write", func(t *testing.T) {
|
|
current := entryWith("b.txt", &filer_pb.RemoteEntry{StorageName: "gcs", RemoteMtime: 1786096669, RemoteSize: 7})
|
|
remote := &recordingRemote{objects: map[string][]byte{"/b/dir/b.txt": []byte("payload")}}
|
|
filerClient := &stubFilerClient{entry: current}
|
|
if err := uploadCurrentEntry(filerClient, filerClient, remote, "/buckets/b/dir", "b.txt", wantDelete, wantWrite, ""); err != nil {
|
|
t.Fatalf("err = %v, want nil: the destination object matches the entry's stamp", err)
|
|
}
|
|
if len(remote.writes) != 0 {
|
|
t.Errorf("writes = %+v, want none", remote.writes)
|
|
}
|
|
if len(remote.deletes) != 1 || !proto.Equal(remote.deletes[0], wantDelete) {
|
|
t.Errorf("deletes = %+v, want the old key deleted", remote.deletes)
|
|
}
|
|
})
|
|
}
|