Files
seaweedfs/weed/command/filer_remote_gateway_buckets_test.go
T
206d7b1418 filer.remote.sync: bounded filer lookups, one chunk cache per process (#12441)
* filer.remote.sync: bounded filer lookups, one chunk cache per process

The filer lookups on the sync path (currentEntry, isSuperseded,
uploadCurrentEntry, liveRemoteEntry, the mount directory read) ran with no
deadline. A lookup that never answers keeps its job alive, the job keeps
its path in the MetadataProcessor conflict index, AddSyncJob waits on the
condition and the daemon stops consuming events with no log line. Each
lookup now carries a 30 s deadline, so the job fails and the processor's
stop-and-resubscribe path runs.

remoteUploadReader built a chunk cache per upload; the cache's worker
goroutine is never stopped, so each upload leaked one goroutine and its
cache (21k goroutines, ~150 MB/h under lock-file churn, OOM at the memory
limit). One chunk cache per process.

(cherry picked from commit ff77fd222772d4f3b68f34d5ee39a66684f90e78)

* filer.remote.sync: a failed mount lookup at startup never resolves to now

collectLastSyncOffset returned time.Now() when the mounted directory lookup
failed, which skips every event since the saved offset. The saved offset is
used instead; without one the daemon exits so a restart retries.

(cherry picked from commit 8fd3124f5b3d3f8c3a291263a710047b4aff81ae)

* filer.remote.sync: no upload on an unanswered supersession lookup; bounded offset read

retriedWriteFile returns the supersession lookup's error instead of
treating an unanswered lookup as "not superseded": in a replay an older
snapshot could otherwise overwrite a newer remote object. The processor
retries the event. isSuperseded keeps its boolean form for the other call
sites. GetSyncOffset's KvGet carries a 30 s deadline so a startup that falls
back to the saved offset cannot hang on it.

TestGatewayUncachedRewriteIsStillUploaded asserted the upload on a failed
lookup; it now asserts the withheld upload and the lookup error.

(cherry picked from commit 8fc2e718f7cd8322615b4e40007be903ade2dbfa)

* filer.remote.sync test: keep the slices import the other gateway tests use

* filer.remote.sync: an unanswered lookup after a failed attempt ends the retry loop; an unread offset never resolves to now minus timeAgo

After a failed upload attempt the supersession lookup's error ended in
isSuperseded's boolean and the loop could upload the snapshot again
unverified. The lookup error now ends the loop (errLookupUnanswered, not
retried) and the processor retries the event. With -timeAgo set, a failed
read of the saved offset resolved to now minus timeAgo, skipping everything
older; the daemon exits instead so a restart retries.

(cherry picked from commit 55ca2a88e01d7b556d494a404b9cc263c27b2566)

* filer.remote.sync: an unresolved start offset returns to the retry loop instead of exiting

resolveLastSyncOffset returns the lookup or offset-read error; both callers
(directory and buckets) hand it to util.RetryUntil, which retries with
backoff instead of the process exiting.

* filer.remote.sync: keep doc comments on the functions they describe

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 952d80c4d3)

* filer.remote.sync: isSuperseded returns the lookup error itself

Its only production callers needed the error, so the boolean wrapper was
left to the tests. The tests now assert the error on a failed lookup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 51f603b5ec)

* filer.remote.sync: bound the stamp written after each upload

updateLocalEntry's UpdateEntry was the last filer call on the sync path
without a deadline. A filer that never answers the stamp keeps the job and
its path in the conflict index the same way an unanswered lookup did. The
stamp carries IF_ENTRY_EQUAL, so a retried event after a timeout either
stamps again or skips a stamp that already landed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit c7338fb68f)

* filer.remote.sync: an unread offset never resolves to the mount's creation time

With the offset read now bounded, a timed-out read fell through to the
directory's creation time and replayed every event already synced. The
error goes back to the startup retry loop, as with -timeAgo; a missing
offset still reads as zero without an error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 0a1b6c2944)

* filer.remote.sync: stamp a copy of the event entry

updateLocalEntry set the RemoteEntry and dropped the rename marker on the
caller's entry before the RPC. A transient stamp failure retries the same
event in place, which then read as already synced and skipped both the
upload and the stamp. The stub filer now applies a successful update the
way the filer does, since tests had relied on that shared object.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 1f332e5866)

* filer.remote.sync: a directory not created yet starts now, as before

A gateway started before the first bucket looked up a missing buckets
directory, had no saved offset, and returned an error to the retry loop
on every attempt until a bucket appeared. A missing directory with no
saved offset has nothing to replay, so it starts now as it did before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit c31497a9a3)

* filer.remote.sync: take the start time before the mount lookup

A bucket created between a not-found lookup and the end of the offset
read had its creation before a start taken afterwards, so the gateway
never created its remote bucket.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit f535fca8f6)

* filer.remote.sync: save the resume position when a subscription ends

A failed event ends the subscription with ErrResubscribe, and the retry
loop runs startup again, recomputing -timeAgo from a later now. The
periodic checkpoint only runs as events arrive, so a failure before the
first one could start the retry after the event it failed on. The resume
position is now saved whenever the subscription returns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 7be919ded1)

* remote_storage: bound the sync offset write like the read

The offset write runs in the subscription's checkpoint callback and, now,
when a subscription ends; a filer that never answers it stalled either.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit f888ac6b8f)

* filer.remote.sync: say that any saved offset wins over the creation time

The creation time in seconds was compared with the offset in
milliseconds, which made any saved offset win. That is the behaviour to
keep: remote.unmount recreates the directory and then saves the offset,
usually in the same second, so a seconds comparison would start at the
creation second and replay the purge. The comparison now states it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit a0d60ee00f)

* filer.remote.sync: keep the resume position for the retry when its write fails

The retry in the same process now resumes from the position the ended
subscription returned, as filer.backup does, so a failed offset write
followed by an empty read cannot recompute -timeAgo past a failed event.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 39ef551bcf)

* filer.remote.sync test: give the same-second saved offset a fractional part

With whole seconds, returning the creation second resolved to the same
time and passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 4bb74382a6)

---------

Co-authored-by: james <james@medable.com>
Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 09:35:16 -08:00

486 lines
21 KiB
Go

package command
import (
"slices"
"strings"
"testing"
"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/replication/source"
)
type recordingRemoteMaker struct{ client *recordingRemote }
func (m recordingRemoteMaker) Make(*remote_pb.RemoteConf) (remote_storage.RemoteStorageClient, error) {
return m.client, nil
}
func (m recordingRemoteMaker) HasBucket() bool { return true }
func gatewayTestOption(storageType string) (*RemoteGatewayOptions, *recordingRemote) {
remote := &recordingRemote{}
remote_storage.RemoteStorageClientMakers[storageType] = recordingRemoteMaker{client: remote}
filerAddress := "localhost:1"
return &RemoteGatewayOptions{
filerAddress: &filerAddress,
bucketsDir: "/buckets",
mappings: &remote_pb.RemoteStorageMapping{Mappings: map[string]*remote_pb.RemoteStorageLocation{
"/buckets/gold": {Name: storageType, Bucket: "gold", Path: "/"},
}},
remoteConfs: map[string]*remote_pb.RemoteConf{storageType: {Name: storageType, Type: storageType}},
}, remote
}
// TestGatewayDroppedLocalCopyIsNotUploaded: remote.uncache drops a synced
// entry's chunks and clears its sync stamp; shouldSendToRemote reads the
// cleared stamp in the remote mtime's second as dirty, so the update reaches
// the upload. The object at the key is the entry's only content and the
// chunkless upload would replace it with zero bytes, so it is skipped.
func TestGatewayDroppedLocalCopyIsNotUploaded(t *testing.T) {
option, remote := gatewayTestOption("gatewaytest-uncache")
t.Cleanup(func() { delete(remote_storage.RemoteStorageClientMakers, "gatewaytest-uncache") })
process, err := option.makeBucketedEventProcessor(&source.FilerSource{})
if err != nil {
t.Fatal(err)
}
resp := droppedCopyUpdate(
droppedCopyEntry(droppedCopySynced, chunk("3,01", "e1")),
droppedCopyEntry(droppedCopyStamp()),
)
if !shouldSendToRemote(resp.EventNotification.NewEntry) {
t.Fatal("precondition: the cleared stamp reads the entry as dirty")
}
if err := process(resp); err != nil {
t.Fatal(err)
}
if len(remote.writes) != 0 {
t.Errorf("writes = %+v, want none: the remote object is the entry's content and an upload would zero it", remote.writes)
}
if len(remote.deletes) != 0 || len(remote.metadataUpdates) != 0 {
t.Errorf("deletes = %+v, metadata updates = %+v, want the remote object left alone", remote.deletes, remote.metadataUpdates)
}
}
// TestGatewayUncachedRewriteWaitsForTheSupersessionLookup: a rewrite of an
// uncached entry holds local data, so the dropped-copy skip does not apply
// and the rewrite is an upload. The lookup that guards the upload cannot
// reach a filer in the test, so the event fails with that error and nothing
// is uploaded: an unanswered lookup never lets a snapshot overwrite the
// remote object, and the processor retries the event.
func TestGatewayUncachedRewriteWaitsForTheSupersessionLookup(t *testing.T) {
option, remote := gatewayTestOption("gatewaytest-rewrite")
t.Cleanup(func() { delete(remote_storage.RemoteStorageClientMakers, "gatewaytest-rewrite") })
process, err := option.makeBucketedEventProcessor(&source.FilerSource{})
if err != nil {
t.Fatal(err)
}
rewrite := entryWith(droppedCopyName, droppedCopyStamp(), chunk("4,02", "e2"))
resp := droppedCopyUpdate(droppedCopyEntry(droppedCopyStamp()), rewrite)
err = process(resp)
if err == nil || !strings.Contains(err.Error(), "supersession lookup") {
t.Fatalf("process = %v, want the supersession lookup error", err)
}
if len(remote.writes) != 0 {
t.Fatalf("writes = %+v, want none until the lookup answers", remote.writes)
}
}
// TestGatewayRemoteOnlyRenameCopiesTheObject: a remote-only entry holds its
// content only as the object the renamed-from path maps to. The rename copies
// that object to the destination key, stamps the entry so reads stop going
// through the pending-source marker, and removes the source. Uploading the
// chunkless entry would put a zero-byte object over it, and deleting the
// source first would lose the only copy.
func TestGatewayRemoteOnlyRenameCopiesTheObject(t *testing.T) {
option, remote := gatewayTestOption("gatewaytest-rename")
t.Cleanup(func() { delete(remote_storage.RemoteStorageClientMakers, "gatewaytest-rename") })
oldPath := "/buckets/gold/dir/old.txt"
stamp := &filer_pb.RemoteEntry{StorageName: "gatewaytest-rename", RemoteETag: `"abc"`, RemoteSize: 5, RemoteMtime: 100}
oldEntry := &filer_pb.Entry{Name: "old.txt", Attributes: &filer_pb.FuseAttributes{Mtime: 100, FileSize: 5}, RemoteEntry: stamp}
current := &filer_pb.Entry{
Name: "new.txt",
Attributes: &filer_pb.FuseAttributes{Mtime: 200, FileSize: 5},
RemoteEntry: stamp,
Extended: map[string][]byte{filer.ExtRemoteRenameSourceKey: []byte(oldPath)},
}
resp := &filer_pb.SubscribeMetadataResponse{
Directory: "/buckets/gold/dir",
EventNotification: &filer_pb.EventNotification{
OldEntry: oldEntry, NewParentPath: "/buckets/gold/dir", NewEntry: current,
},
}
filerClient := &stubFilerClient{entries: map[string]*filer_pb.Entry{"/buckets/gold/dir/new.txt": current}}
remote.objects = map[string][]byte{"/dir/old.txt": []byte("hello")}
remote.statETag = `"abc"`
remote.statMtime = 100
if err := gatewayRemoteOnlyRename(filerClient, option, resp, current); err != nil {
t.Fatal(err)
}
if len(remote.writes) != 1 || remote.writes[0].Path != "/dir/new.txt" {
t.Fatalf("writes = %+v, want the source object copied to /dir/new.txt", remote.writes)
}
if string(remote.written[0]) != "hello" {
t.Errorf("copied %q, want the source object's content", remote.written[0])
}
if filerClient.updates != 1 {
t.Errorf("stamped the entry %d times, want 1 to clear the pending-source marker", filerClient.updates)
}
if len(remote.deletes) != 1 || remote.deletes[0].Path != "/dir/old.txt" {
t.Errorf("deletes = %+v, want the source object removed", remote.deletes)
}
}
// TestGatewayRemoteOnlyRenameAlreadyComplete: when the destination already
// holds what the stamp describes (an earlier run copied it), the rename only
// refreshes the stamp and removes the source.
func TestGatewayRemoteOnlyRenameAlreadyComplete(t *testing.T) {
option, remote := gatewayTestOption("gatewaytest-rename-complete")
t.Cleanup(func() { delete(remote_storage.RemoteStorageClientMakers, "gatewaytest-rename-complete") })
oldPath := "/buckets/gold/dir/old.txt"
stamp := &filer_pb.RemoteEntry{StorageName: "gatewaytest-rename-complete", RemoteETag: `"abc"`, RemoteSize: 5, RemoteMtime: 100}
oldEntry := &filer_pb.Entry{Name: "old.txt", Attributes: &filer_pb.FuseAttributes{Mtime: 100, FileSize: 5}, RemoteEntry: stamp}
current := &filer_pb.Entry{
Name: "new.txt",
Attributes: &filer_pb.FuseAttributes{Mtime: 200, FileSize: 5},
RemoteEntry: stamp,
Extended: map[string][]byte{filer.ExtRemoteRenameSourceKey: []byte(oldPath)},
}
resp := &filer_pb.SubscribeMetadataResponse{
Directory: "/buckets/gold/dir",
EventNotification: &filer_pb.EventNotification{
OldEntry: oldEntry, NewParentPath: "/buckets/gold/dir", NewEntry: current,
},
}
filerClient := &stubFilerClient{entries: map[string]*filer_pb.Entry{"/buckets/gold/dir/new.txt": current}}
remote.objects = map[string][]byte{"/dir/old.txt": []byte("hello"), "/dir/new.txt": []byte("hello")}
remote.statETag = `"abc"`
remote.statMtime = 100
if err := gatewayRemoteOnlyRename(filerClient, option, resp, current); err != nil {
t.Fatal(err)
}
if len(remote.writes) != 0 {
t.Errorf("writes = %+v, want none: the destination already holds the content", remote.writes)
}
if len(remote.deletes) != 1 || remote.deletes[0].Path != "/dir/old.txt" {
t.Errorf("deletes = %+v, want the source object removed", remote.deletes)
}
}
// TestGatewayRenameReleasesPendingSource: a rename whose old entry read a
// pending source (an earlier rename's deferred copy had not finished) leaves
// that source behind once the renamed entry no longer reads through a
// marker. The gateway deletes it, guarded by the old entry's stamp.
func TestGatewayRenameReleasesPendingSource(t *testing.T) {
option, remote := gatewayTestOption("gatewaytest-release")
t.Cleanup(func() { delete(remote_storage.RemoteStorageClientMakers, "gatewaytest-release") })
stamp := &filer_pb.RemoteEntry{StorageName: "gatewaytest-release", RemoteETag: `"abc"`, RemoteSize: 5, RemoteMtime: 100}
oldEntry := &filer_pb.Entry{
Name: "mid.txt",
Attributes: &filer_pb.FuseAttributes{Mtime: 100, FileSize: 5},
RemoteEntry: stamp,
Extended: map[string][]byte{filer.ExtRemoteRenameSourceKey: []byte("/buckets/gold/dir/orig.txt")},
}
newEntry := &filer_pb.Entry{Name: "new.txt", Attributes: &filer_pb.FuseAttributes{Mtime: 200, FileSize: 5}, RemoteEntry: stamp}
message := &filer_pb.EventNotification{OldEntry: oldEntry, NewParentPath: "/buckets/gold/dir", NewEntry: newEntry}
filerClient := &stubFilerClient{entries: map[string]*filer_pb.Entry{
"/buckets/gold/dir/new.txt": newEntry,
}}
remote.objects = map[string][]byte{"/dir/orig.txt": []byte("hello")}
remote.statETag = `"abc"`
remote.statMtime = 100
if err := releaseGatewayRenamedSource(filerClient, option, message); err != nil {
t.Fatal(err)
}
if len(remote.deletes) != 1 || remote.deletes[0].Path != "/dir/orig.txt" {
t.Errorf("deletes = %+v, want the pending source released", remote.deletes)
}
}
// TestGatewayRenameKeepsHeldPendingSource: the pending source stays while a
// remote-only entry still holds its path.
func TestGatewayRenameKeepsHeldPendingSource(t *testing.T) {
option, remote := gatewayTestOption("gatewaytest-release")
t.Cleanup(func() { delete(remote_storage.RemoteStorageClientMakers, "gatewaytest-release") })
stamp := &filer_pb.RemoteEntry{StorageName: "gatewaytest-release", RemoteETag: `"abc"`, RemoteSize: 5, RemoteMtime: 100}
oldEntry := &filer_pb.Entry{
Name: "mid.txt",
Attributes: &filer_pb.FuseAttributes{Mtime: 100, FileSize: 5},
RemoteEntry: stamp,
Extended: map[string][]byte{filer.ExtRemoteRenameSourceKey: []byte("/buckets/gold/dir/orig.txt")},
}
newEntry := &filer_pb.Entry{Name: "new.txt", Attributes: &filer_pb.FuseAttributes{Mtime: 200, FileSize: 5}, RemoteEntry: stamp}
heldSource := &filer_pb.Entry{Name: "orig.txt", Attributes: &filer_pb.FuseAttributes{Mtime: 300, FileSize: 5}, RemoteEntry: stamp}
message := &filer_pb.EventNotification{OldEntry: oldEntry, NewParentPath: "/buckets/gold/dir", NewEntry: newEntry}
filerClient := &stubFilerClient{entries: map[string]*filer_pb.Entry{
"/buckets/gold/dir/new.txt": newEntry,
"/buckets/gold/dir/orig.txt": heldSource,
}}
remote.objects = map[string][]byte{"/dir/orig.txt": []byte("hello")}
remote.statETag = `"abc"`
remote.statMtime = 100
if err := releaseGatewayRenamedSource(filerClient, option, message); err != nil {
t.Fatal(err)
}
if len(remote.deletes) != 0 {
t.Errorf("deletes = %+v, want the pending source kept while an entry holds its path", remote.deletes)
}
}
// TestGatewayRemoteOnlyRenameSourceOutOfCoverage: a rename source outside the
// gateway's buckets is left untouched; when the destination already holds
// the content the rename only refreshes the stamp instead of failing.
func TestGatewayRemoteOnlyRenameSourceOutOfCoverage(t *testing.T) {
option, remote := gatewayTestOption("gatewaytest-outsrc")
t.Cleanup(func() { delete(remote_storage.RemoteStorageClientMakers, "gatewaytest-outsrc") })
stamp := &filer_pb.RemoteEntry{StorageName: "gatewaytest-outsrc", RemoteETag: `"abc"`, RemoteSize: 5, RemoteMtime: 100}
oldEntry := &filer_pb.Entry{Name: "old.txt", Attributes: &filer_pb.FuseAttributes{Mtime: 100, FileSize: 5}, RemoteEntry: stamp}
current := &filer_pb.Entry{
Name: "new.txt",
Attributes: &filer_pb.FuseAttributes{Mtime: 200, FileSize: 5},
RemoteEntry: stamp,
Extended: map[string][]byte{filer.ExtRemoteRenameSourceKey: []byte("/unmanaged/dir/old.txt")},
}
resp := &filer_pb.SubscribeMetadataResponse{
Directory: "/buckets/gold/dir",
EventNotification: &filer_pb.EventNotification{
OldEntry: oldEntry, NewParentPath: "/buckets/gold/dir", NewEntry: current,
},
}
filerClient := &stubFilerClient{entries: map[string]*filer_pb.Entry{"/buckets/gold/dir/new.txt": current}}
remote.objects = map[string][]byte{"/dir/new.txt": []byte("hello")}
remote.statETag = `"abc"`
remote.statMtime = 100
if err := gatewayRemoteOnlyRename(filerClient, option, resp, current); err != nil {
t.Fatalf("the completed destination should finalize without touching the out-of-coverage source: %v", err)
}
if filerClient.updates != 1 {
t.Errorf("stamped the entry %d times, want 1 to clear the pending-source marker", filerClient.updates)
}
if len(remote.deletes) != 0 || len(remote.writes) != 0 {
t.Errorf("deletes = %+v, writes = %+v, want the unmanaged source and destination left alone", remote.deletes, remote.writes)
}
}
// TestGatewayRenameOutKeepsRemoteOnlyObject: a remote-only entry renamed out
// of the gateway's coverage keeps the remote object: it is the entry's only
// content.
func TestGatewayRenameOutKeepsRemoteOnlyObject(t *testing.T) {
option, remote := gatewayTestOption("gatewaytest-out")
t.Cleanup(func() { delete(remote_storage.RemoteStorageClientMakers, "gatewaytest-out") })
process, err := option.makeBucketedEventProcessor(&source.FilerSource{})
if err != nil {
t.Fatal(err)
}
stamp := &filer_pb.RemoteEntry{StorageName: "gatewaytest-out", RemoteETag: `"abc"`, RemoteSize: 5, RemoteMtime: 100}
resp := &filer_pb.SubscribeMetadataResponse{
Directory: "/buckets/gold/dir",
EventNotification: &filer_pb.EventNotification{
OldEntry: &filer_pb.Entry{Name: "old.txt", Attributes: &filer_pb.FuseAttributes{Mtime: 100, FileSize: 5}, RemoteEntry: stamp},
NewParentPath: "/outside/dir",
NewEntry: &filer_pb.Entry{Name: "new.txt", Attributes: &filer_pb.FuseAttributes{Mtime: 200, FileSize: 5}, RemoteEntry: stamp},
},
}
if err := process(resp); err != nil {
t.Fatal(err)
}
if len(remote.deletes) != 0 {
t.Errorf("deletes = %+v, want the remote-only object kept", remote.deletes)
}
}
func TestGatewayMetadataOnlyDeleteKeepsTheRemoteObject(t *testing.T) {
remote := &recordingRemote{}
remote_storage.RemoteStorageClientMakers["gatewaytest"] = recordingRemoteMaker{client: remote}
t.Cleanup(func() { delete(remote_storage.RemoteStorageClientMakers, "gatewaytest") })
option := &RemoteGatewayOptions{
bucketsDir: "/buckets",
mappings: &remote_pb.RemoteStorageMapping{Mappings: map[string]*remote_pb.RemoteStorageLocation{"/buckets/b": {Name: "gatewaytest", Bucket: "b", Path: "/"}}},
remoteConfs: map[string]*remote_pb.RemoteConf{"gatewaytest": {Name: "gatewaytest", Type: "gatewaytest"}},
}
process, err := option.makeBucketedEventProcessor(nil)
if err != nil {
t.Fatal(err)
}
deleteEvent := func(extended map[string][]byte) *filer_pb.SubscribeMetadataResponse {
return &filer_pb.SubscribeMetadataResponse{
Directory: "/buckets/b/dir",
EventNotification: &filer_pb.EventNotification{
OldEntry: &filer_pb.Entry{Name: "obj.bin", Attributes: &filer_pb.FuseAttributes{}, Extended: extended},
},
}
}
if err := process(deleteEvent(map[string][]byte{filer.ExtKeepRemoteObjectKey: []byte("true")})); err != nil {
t.Fatal(err)
}
if len(remote.deletes) != 0 {
t.Fatalf("deletes = %+v, want none for a metadata-only delete", remote.deletes)
}
if err := process(deleteEvent(nil)); err != nil {
t.Fatal(err)
}
if len(remote.deletes) != 1 {
t.Fatalf("deletes = %+v, want the ordinary delete", remote.deletes)
}
}
func TestGatewayUnmountedBucketDeleteIsSkipped(t *testing.T) {
remote := &recordingRemote{}
remote_storage.RemoteStorageClientMakers["gatewaytest"] = recordingRemoteMaker{client: remote}
t.Cleanup(func() { delete(remote_storage.RemoteStorageClientMakers, "gatewaytest") })
option := &RemoteGatewayOptions{
bucketsDir: "/buckets",
mappings: &remote_pb.RemoteStorageMapping{Mappings: map[string]*remote_pb.RemoteStorageLocation{}},
remoteConfs: map[string]*remote_pb.RemoteConf{"gatewaytest": {Name: "gatewaytest", Type: "gatewaytest"}},
}
process, err := option.makeBucketedEventProcessor(nil)
if err != nil {
t.Fatal(err)
}
deleteBucketEvent := &filer_pb.SubscribeMetadataResponse{
Directory: "/buckets",
EventNotification: &filer_pb.EventNotification{
OldEntry: &filer_pb.Entry{
Name: "unmounted-bucket",
IsDirectory: true,
},
},
}
// Deleting a bucket that is not mounted (for instance because it was already unmounted
// by an earlier copy of the delete event) should return nil and not error.
if err := process(deleteBucketEvent); err != nil {
t.Fatalf("expected nil for unmounted bucket delete, got: %v", err)
}
}
// TestGatewayCreatesABucketAfterTheMappingFileIsDeleted: deleting
// mount.mapping empties the mapping table, and a later bucket create still
// records its mount in it.
func TestGatewayCreatesABucketAfterTheMappingFileIsDeleted(t *testing.T) {
option, remote := gatewayTestOption("gatewaytest-mapping-reset")
t.Cleanup(func() {
delete(remote_storage.RemoteStorageClientMakers, "gatewaytest-mapping-reset")
remote_storage.ResetRemoteStorageClients()
})
createAt, noFilter, noSuffix := "gatewaytest-mapping-reset", "", false
option.createBucketAt, option.include, option.exclude, option.createBucketRandomSuffix = &createAt, &noFilter, &noFilter, &noSuffix
process, err := option.makeBucketedEventProcessor(nil)
if err != nil {
t.Fatal(err)
}
if err := process(&filer_pb.SubscribeMetadataResponse{
Directory: filer.DirectoryEtcRemote,
EventNotification: &filer_pb.EventNotification{OldEntry: &filer_pb.Entry{Name: filer.REMOTE_STORAGE_MOUNT_FILE}},
}); err != nil {
t.Fatal(err)
}
// saving the new mount needs a filer, so the create itself errors
_ = process(&filer_pb.SubscribeMetadataResponse{
Directory: "/buckets",
EventNotification: &filer_pb.EventNotification{
NewParentPath: "/buckets",
NewEntry: &filer_pb.Entry{Name: "fresh", IsDirectory: true},
},
})
if !slices.Equal(remote.bucketCreates, []string{"fresh"}) {
t.Errorf("bucket creates = %v, want [fresh]", remote.bucketCreates)
}
if loc := option.mappings.Mappings["/buckets/fresh"]; loc.GetBucket() != "fresh" {
t.Errorf("mapping for /buckets/fresh = %v, want bucket fresh", loc)
}
}
// TestGatewayDeleteOfAnAbsentObjectSucceeds: a repeated delete finds the
// object gone, which GCS reports as ErrRemoteObjectNotFound; the event has
// nothing left to do and must not hold the sync offset.
func TestGatewayDeleteOfAnAbsentObjectSucceeds(t *testing.T) {
option, remote := gatewayTestOption("gatewaytest-absent-delete")
t.Cleanup(func() {
delete(remote_storage.RemoteStorageClientMakers, "gatewaytest-absent-delete")
remote_storage.ResetRemoteStorageClients()
})
remote.deleteErr = remote_storage.ErrRemoteObjectNotFound
process, err := option.makeBucketedEventProcessor(nil)
if err != nil {
t.Fatal(err)
}
if err := process(&filer_pb.SubscribeMetadataResponse{
Directory: "/buckets/gold/dir",
EventNotification: &filer_pb.EventNotification{
OldEntry: &filer_pb.Entry{Name: "obj.bin", Attributes: &filer_pb.FuseAttributes{}},
},
}); err != nil {
t.Fatalf("delete of an absent object: %v", err)
}
if len(remote.deletes) != 1 || remote.deletes[0].Path != "/dir/obj.bin" {
t.Errorf("deletes = %+v, want /dir/obj.bin", remote.deletes)
}
}
// TestGatewayKeepsTheDefaultStorageAcrossMappingChanges: the default storage
// for new buckets is derived at startup and never saved in mount.mapping, so
// replacing or deleting that file must not drop it.
func TestGatewayKeepsTheDefaultStorageAcrossMappingChanges(t *testing.T) {
for _, tt := range []struct {
name string
event *filer_pb.EventNotification
}{
{"mapping updated", &filer_pb.EventNotification{NewParentPath: filer.DirectoryEtcRemote, NewEntry: &filer_pb.Entry{Name: filer.REMOTE_STORAGE_MOUNT_FILE}}},
{"mapping deleted", &filer_pb.EventNotification{OldEntry: &filer_pb.Entry{Name: filer.REMOTE_STORAGE_MOUNT_FILE}}},
} {
t.Run(tt.name, func(t *testing.T) {
option, remote := gatewayTestOption("gatewaytest-default-storage")
t.Cleanup(func() {
delete(remote_storage.RemoteStorageClientMakers, "gatewaytest-default-storage")
remote_storage.ResetRemoteStorageClients()
})
option.mappings.PrimaryBucketStorageName = "gatewaytest-default-storage"
createAt, noFilter, noSuffix := "", "", false
option.createBucketAt, option.include, option.exclude, option.createBucketRandomSuffix = &createAt, &noFilter, &noFilter, &noSuffix
process, err := option.makeBucketedEventProcessor(nil)
if err != nil {
t.Fatal(err)
}
if err := process(&filer_pb.SubscribeMetadataResponse{Directory: filer.DirectoryEtcRemote, EventNotification: tt.event}); err != nil {
t.Fatal(err)
}
// saving the new mount needs a filer, so the create itself errors
_ = process(&filer_pb.SubscribeMetadataResponse{
Directory: "/buckets",
EventNotification: &filer_pb.EventNotification{
NewParentPath: "/buckets",
NewEntry: &filer_pb.Entry{Name: "fresh", IsDirectory: true},
},
})
if !slices.Equal(remote.bucketCreates, []string{"fresh"}) {
t.Errorf("bucket creates = %v, want [fresh] in the default storage", remote.bucketCreates)
}
})
}
}