Files
seaweedfs/weed/shell/command_remote_meta_sync_test.go
T
Chris LuandClaude Opus 5.5 ca15fdbff6 remote.uncache, remote.meta.sync, remote.cache: keep local writes that land while they run (#12382)
* remote.uncache: skip a file overwritten since the walk read it

The uncache cleared the chunks of the entry its directory walk returned and
wrote it back unconditionally. A file overwritten after the walk read it
was reset to the remote pointer, and its new chunks were dropped, so an
acknowledged write was lost. Write back only while the filer still holds
the entry as read, and report the file as skipped otherwise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* remote.meta.sync: keep files written locally after the directory listing

meta.sync lists each local directory once and works from that listing.
A changed remote file was written back over the listed entry
unconditionally, and a name missing from the listing was created without
O_EXCL, so a file written locally in between was replaced by the remote
pointer either way. Update only while the filer still holds the listed
entry, create exclusively, and report such files as skipped. The create
now also surfaces errors the filer returns in its response.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* remote.cache: keep files written locally after the lookup

The metadata step looks each file up and then either rewrites it from the
remote unconditionally or creates it without O_EXCL, so a local write
landing between the lookup and the write was replaced by the remote
pointer. Reuse doSaveRemoteEntry, which updates only while the filer still
holds the looked-up entry, create exclusively, and report such files as
skipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* remote.meta.sync: do not descend into a name a local file took

When the exclusive create of a remote directory finds the name taken, the
sync still descended into it, and creating the remote children under a
local file would turn that file into a directory. Descend only when the
name now holds a directory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* remote.meta.sync: delete only the file version it listed

The type-change and -deleteStale paths removed a remote-backed file
unconditionally, so a local write landing after the directory listing was
deleted with it. Condition the delete on the listed mtime, as fs.verify
and volume.fsck do; the filer then keeps a file written since.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* remote.meta.sync: condition directory and mtime-less deletes too

The listed-mtime cutoff was only sent for files, so a remote-backed
directory replaced locally by a file between the emptiness check and the
delete was removed unconditionally, and so was a file listed without an
mtime. Send the cutoff for directories as well, and for an entry listed
without an mtime keep it once a write has set one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* filer: test that DeleteEntry applies the request's mtime cutoff

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* remote.meta.sync: keep the listed nanoseconds as the cutoff of an epoch mtime

An entry listed at the epoch with a sub-second mtime was sent a 1 ns
cutoff, so the filer kept it unchanged although the old code deleted it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* remote.meta.sync: report an entry the filer kept instead of deleting it

The filer answers a delete it skipped under the mtime cutoff with
success, so the sync printed (delete) for an entry a local write kept.
deleteLocalEntry now looks the name up after the delete and prints the
outcome, and a replaced file that was kept is not recreated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 14:53:42 -08:00

246 lines
11 KiB
Go

package shell
import (
"context"
"strings"
"testing"
"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/util"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/proto"
)
type exclusiveCreateFiler struct {
filer_pb.SeaweedFilerClient
resp *filer_pb.CreateEntryResponse
req *filer_pb.CreateEntryRequest
}
func (s *exclusiveCreateFiler) CreateEntry(_ context.Context, req *filer_pb.CreateEntryRequest, _ ...grpc.CallOption) (*filer_pb.CreateEntryResponse, error) {
s.req = req
return s.resp, nil
}
// meta.sync updates entries from a directory listing taken once per
// directory; a file written locally after that listing must not be reset to
// the remote pointer.
func TestDoSaveRemoteEntryIsConditionedOnTheListedEntry(t *testing.T) {
listed := &filer_pb.Entry{
Name: "f",
Attributes: &filer_pb.FuseAttributes{FileSize: 3, Mtime: 10},
Chunks: []*filer_pb.FileChunk{{Fid: &filer_pb.FileId{VolumeId: 3, FileKey: 1, Cookie: 7}}},
RemoteEntry: &filer_pb.RemoteEntry{StorageName: "r", RemoteETag: "old", RemoteSize: 3, RemoteMtime: 10},
}
remote := &filer_pb.RemoteEntry{StorageName: "r", RemoteETag: "new", RemoteSize: 5, RemoteMtime: 20}
for _, tc := range []struct {
name string
err error
stale bool
}{
{"unchanged", nil, false},
{"written since listed", status.Error(codes.FailedPrecondition, "precondition failed"), true},
{"deleted since listed", status.Error(codes.NotFound, "not found"), true},
} {
t.Run(tc.name, func(t *testing.T) {
fake := &conditionalUpdateFiler{err: tc.err}
stale, err := doSaveRemoteEntry(fake, "/buckets/b", proto.Clone(listed).(*filer_pb.Entry), remote)
if stale != tc.stale || (err != nil) != (tc.err != nil) {
t.Fatalf("stale=%v err=%v, want stale=%v err=%v", stale, err, tc.stale, tc.err)
}
clauses := fake.req.GetCondition().GetClauses()
if len(clauses) != 1 || clauses[0].Kind != filer_pb.WriteCondition_IF_ENTRY_EQUAL || !proto.Equal(clauses[0].ExpectedEntry, listed) {
t.Fatalf("condition %v must expect the listed entry", fake.req.GetCondition())
}
written := fake.req.Entry
if len(written.Chunks) != 0 || !proto.Equal(written.RemoteEntry, remote) || written.Attributes.FileSize != 5 {
t.Fatalf("written entry %v must point at the new remote copy", written)
}
})
}
}
// A name missing from the listing may be created locally before meta.sync
// gets to it; the remote-only entry must not replace it.
func TestCreateRemoteEntryKeepsAnEntryCreatedSinceListed(t *testing.T) {
child := &remoteChild{remoteEntry: &filer_pb.RemoteEntry{StorageName: "r", RemoteSize: 5, RemoteMtime: 20}}
for _, tc := range []struct {
name string
resp *filer_pb.CreateEntryResponse
output string
err bool
}{
{"absent", &filer_pb.CreateEntryResponse{}, "/buckets/b/f (create)", false},
{"created since listed", &filer_pb.CreateEntryResponse{Error: "exists", ErrorCode: filer_pb.FilerError_ENTRY_ALREADY_EXISTS}, "/buckets/b/f (skip, created since listed)", false},
{"refused", &filer_pb.CreateEntryResponse{Error: "read only", ErrorCode: filer_pb.FilerError_READ_ONLY}, "", true},
} {
t.Run(tc.name, func(t *testing.T) {
fake := &exclusiveCreateFiler{resp: tc.resp}
var out strings.Builder
taken, err := createRemoteEntry(context.Background(), fake, &out, util.FullPath("/buckets/b"), "f", child, "r")
if (err != nil) != tc.err || taken != (tc.resp.ErrorCode == filer_pb.FilerError_ENTRY_ALREADY_EXISTS) {
t.Fatalf("taken=%v err=%v, want error %v", taken, err, tc.err)
}
if !fake.req.OExcl {
t.Fatalf("create of %s must be exclusive", fake.req.Entry.Name)
}
if got := strings.TrimSpace(out.String()); got != tc.output {
t.Fatalf("output %q, want %q", got, tc.output)
}
})
}
}
type takenNameFiler struct {
stubFilerSvc
creates []string
}
func (s *takenNameFiler) CreateEntry(_ context.Context, req *filer_pb.CreateEntryRequest, _ ...grpc.CallOption) (*filer_pb.CreateEntryResponse, error) {
s.creates = append(s.creates, string(util.FullPath(req.Directory).Child(req.Entry.Name)))
return &filer_pb.CreateEntryResponse{Error: "exists", ErrorCode: filer_pb.FilerError_ENTRY_ALREADY_EXISTS}, nil
}
type listingRemote struct {
remote_storage.RemoteStorageClient
dirs map[string]map[string]bool
}
func (r *listingRemote) ListDirectory(_ context.Context, loc *remote_pb.RemoteStorageLocation, visitFn remote_storage.VisitFunc) error {
for name, isDirectory := range r.dirs[loc.Path] {
if err := visitFn(loc.Path, name, isDirectory, &filer_pb.RemoteEntry{StorageName: "r", RemoteSize: 1, RemoteMtime: 1}); err != nil {
return err
}
}
return nil
}
// A remote directory whose name is taken locally after the listing is only
// descended into when the name now holds a directory: creating remote
// children under a local file would turn that file into a directory.
func TestPullMetadataDirectoryDescendsOnlyIntoATakenDirectory(t *testing.T) {
remote := &listingRemote{dirs: map[string]map[string]bool{
"/": {"docs": true},
"/docs": {"a.txt": false},
}}
for _, tc := range []struct {
name string
isDir bool
creates []string
}{
{"taken by a file", false, []string{"/buckets/b/docs"}},
{"taken by a directory", true, []string{"/buckets/b/docs", "/buckets/b/docs/a.txt"}},
} {
t.Run(tc.name, func(t *testing.T) {
fake := &takenNameFiler{stubFilerSvc: stubFilerSvc{lookupResp: &filer_pb.LookupDirectoryEntryResponse{
Entry: &filer_pb.Entry{Name: "docs", IsDirectory: tc.isDir},
}}}
var out strings.Builder
loc := &remote_pb.RemoteStorageLocation{Name: "r", Bucket: "rb", Path: "/"}
if err := pullMetadataDirectory(context.Background(), fake, &out, remote, util.FullPath("/buckets/b"), loc, false, false); err != nil {
t.Fatalf("sync: %v", err)
}
if strings.Join(fake.creates, ",") != strings.Join(tc.creates, ",") {
t.Fatalf("creates %v, want %v", fake.creates, tc.creates)
}
})
}
}
type deleteRecordingFiler struct {
takenNameFiler
deletes []*filer_pb.DeleteEntryRequest
listing map[string][]*filer_pb.Entry
}
func (s *deleteRecordingFiler) ListEntries(_ context.Context, req *filer_pb.ListEntriesRequest, _ ...grpc.CallOption) (filer_pb.SeaweedFiler_ListEntriesClient, error) {
return &stubListStream{entries: s.listing[req.Directory]}, nil
}
func (s *deleteRecordingFiler) DeleteEntry(_ context.Context, req *filer_pb.DeleteEntryRequest, _ ...grpc.CallOption) (*filer_pb.DeleteEntryResponse, error) {
s.deletes = append(s.deletes, req)
return &filer_pb.DeleteEntryResponse{}, nil
}
// A remote-backed entry that meta.sync drops may be replaced locally after the
// directory listing; the delete must only remove the listed version.
func TestPullMetadataDirectoryDeletesOnlyTheListedFile(t *testing.T) {
remoteFile := &filer_pb.RemoteEntry{StorageName: "r", RemoteSize: 1, RemoteMtime: 10}
for _, tc := range []struct {
name string
listed *filer_pb.Entry
remote map[string]bool
deleteStale bool
sec, ns int64
}{
{"remote turned it into a directory", &filer_pb.Entry{Name: "f", Attributes: &filer_pb.FuseAttributes{Mtime: 10, MtimeNs: 5}, RemoteEntry: remoteFile}, map[string]bool{"f": true}, false, 10, 10e9 + 5},
{"gone from the remote", &filer_pb.Entry{Name: "f", Attributes: &filer_pb.FuseAttributes{Mtime: 10, MtimeNs: 5}, RemoteEntry: remoteFile}, map[string]bool{}, true, 10, 10e9 + 5},
{"listed without an mtime", &filer_pb.Entry{Name: "f", Attributes: &filer_pb.FuseAttributes{}, RemoteEntry: remoteFile}, map[string]bool{}, true, 0, 1},
{"listed at the epoch with nanoseconds", &filer_pb.Entry{Name: "f", Attributes: &filer_pb.FuseAttributes{MtimeNs: 500}, RemoteEntry: remoteFile}, map[string]bool{}, true, 0, 500},
{"empty directory gone from the remote", &filer_pb.Entry{Name: "f", IsDirectory: true, Attributes: &filer_pb.FuseAttributes{Mtime: 20}, RemoteEntry: &filer_pb.RemoteEntry{StorageName: "r"}}, map[string]bool{}, true, 20, 20e9},
} {
t.Run(tc.name, func(t *testing.T) {
fake := &deleteRecordingFiler{
takenNameFiler: takenNameFiler{stubFilerSvc: stubFilerSvc{
lookupResp: &filer_pb.LookupDirectoryEntryResponse{Entry: &filer_pb.Entry{Name: "f"}},
}},
listing: map[string][]*filer_pb.Entry{"/buckets/b": {tc.listed}},
}
remote := &listingRemote{dirs: map[string]map[string]bool{"/": tc.remote}}
var out strings.Builder
loc := &remote_pb.RemoteStorageLocation{Name: "r", Bucket: "rb", Path: "/"}
if err := pullMetadataDirectory(context.Background(), fake, &out, remote, util.FullPath("/buckets/b"), loc, tc.deleteStale, false); err != nil {
t.Fatalf("sync: %v", err)
}
if len(fake.deletes) != 1 || fake.deletes[0].Name != "f" || fake.deletes[0].IfNotModifiedAfter != tc.sec || fake.deletes[0].IfNotModifiedAfterNs != tc.ns {
t.Fatalf("deletes %v must be conditioned on the listed mtime", fake.deletes)
}
})
}
}
// The filer answers a delete it skipped under the cutoff with success, so the
// sync reports what a lookup after the delete finds.
func TestPullMetadataDirectoryReportsAKeptEntry(t *testing.T) {
listed := &filer_pb.Entry{Name: "f", Attributes: &filer_pb.FuseAttributes{Mtime: 10}, RemoteEntry: &filer_pb.RemoteEntry{StorageName: "r", RemoteSize: 1, RemoteMtime: 10}}
for _, tc := range []struct {
name string
remote map[string]bool
deleteStale bool
kept bool
want string
creates int
}{
{"stale file kept", map[string]bool{}, true, true, "/buckets/b/f (skip, changed since listed)\n", 0},
{"stale file deleted", map[string]bool{}, true, false, "/buckets/b/f (delete)\n", 0},
{"file replaced by a remote directory kept", map[string]bool{"f": true}, false, true, "/buckets/b/f (skip, changed since listed)\n", 0},
{"file replaced by a remote directory deleted", map[string]bool{"f": true}, false, false, "/buckets/b/f (delete)\n/buckets/b/f (skip, created since listed)\n", 1},
} {
t.Run(tc.name, func(t *testing.T) {
stub := stubFilerSvc{lookupErr: filer_pb.ErrNotFound}
if tc.kept {
stub = stubFilerSvc{lookupResp: &filer_pb.LookupDirectoryEntryResponse{Entry: listed}}
}
fake := &deleteRecordingFiler{
takenNameFiler: takenNameFiler{stubFilerSvc: stub},
listing: map[string][]*filer_pb.Entry{"/buckets/b": {listed}},
}
remote := &listingRemote{dirs: map[string]map[string]bool{"/": tc.remote}}
var out strings.Builder
loc := &remote_pb.RemoteStorageLocation{Name: "r", Bucket: "rb", Path: "/"}
if err := pullMetadataDirectory(context.Background(), fake, &out, remote, util.FullPath("/buckets/b"), loc, tc.deleteStale, false); err != nil {
t.Fatalf("sync: %v", err)
}
if out.String() != tc.want || len(fake.creates) != tc.creates {
t.Fatalf("output %q with %d creates, want %q with %d", out.String(), len(fake.creates), tc.want, tc.creates)
}
})
}
}