mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-01 18:00:46 +01:00
* fix(s3tables): keep the data under a deleted namespace A rename leaves the source table directory holding data the renamed table still points at, and DeleteNamespace removed it recursively. The delete now keeps the directory, minus its catalog record, when files remain under it; CreateNamespace can take it back. * fix(s3tables): keep hidden-directory data, report name takeover as 409 - DeleteNamespace also looks for files under hidden directories, since a table location may sit under one. - CreateNamespace answers 409 when a file takes the name during the take-back of a kept directory. * s3tables: a vanished leftover holds nothing to keep A bare directory removed between the namespace listing and the walk made dirHoldsFiles answer its ListEntries NotFound, and the delete reported that miss as a 404 or 500 on a live namespace. Skip the gone directory instead: whatever it held is already gone, so it cannot keep the namespace. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * s3tables: a delete's gone entry is a conflict, not a success The filer answers a precondition delete on an entry that is already gone with FailedPrecondition carrying the not-found text, which DoRemoveRequest reads as a completed delete -- DeleteNamespace answered 200 for a delete that never ran. deleteDirectoryIfMatches now calls DeleteEntry directly, keeping the FailedPrecondition-to-conflict mapping and refusing a response-level error. keepAsLeftover likewise reports a directory removed since the scan as ErrConcurrentUpdate, so the handler answers 409 rather than 500. --------- Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Chris Lu <chris.lu@gmail.com>
428 lines
17 KiB
Go
428 lines
17 KiB
Go
package s3tables
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"path"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables/s3tablestest"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"google.golang.org/grpc"
|
|
)
|
|
|
|
// replaceAfterLookupClient swaps the entry at dir/name for a file as soon as
|
|
// its nth lookup has returned, so the next read finds a file where the one
|
|
// before it found a directory.
|
|
type replaceAfterLookupClient struct {
|
|
filer_pb.SeaweedFilerClient
|
|
f *s3tablestest.MemFiler
|
|
dir string
|
|
name string
|
|
nth int
|
|
lookups int
|
|
}
|
|
|
|
func (c *replaceAfterLookupClient) LookupDirectoryEntry(ctx context.Context, req *filer_pb.LookupDirectoryEntryRequest, opts ...grpc.CallOption) (*filer_pb.LookupDirectoryEntryResponse, error) {
|
|
resp, err := c.SeaweedFilerClient.LookupDirectoryEntry(ctx, req, opts...)
|
|
if req.Directory == c.dir && req.Name == c.name {
|
|
c.lookups++
|
|
if c.lookups == c.nth {
|
|
c.f.PutFile(c.dir, c.name, time.Now())
|
|
}
|
|
}
|
|
return resp, err
|
|
}
|
|
|
|
const keptDataFile = "part-0.parquet"
|
|
|
|
// keptDataDir is where the renamed table's data stays: a rename is catalog-only,
|
|
// so the destination keeps pointing at the original table directory.
|
|
func keptDataDir() string {
|
|
return path.Join(GetTablePath(renameTestBucket, "ns", "t"), "data")
|
|
}
|
|
|
|
func listedNamespaces(t *testing.T, m *Manager, fs *s3tablestest.MemFiler) []string {
|
|
t.Helper()
|
|
resp := &ListNamespacesResponse{}
|
|
require.NoError(t, m.Execute(context.Background(), NewManagerClient(fs.Client), "ListNamespaces",
|
|
&ListNamespacesRequest{TableBucketARN: mustBucketARN(t)}, resp, ""))
|
|
names := make([]string, 0, len(resp.Namespaces))
|
|
for _, namespace := range resp.Namespaces {
|
|
names = append(names, flattenNamespace(namespace.Namespace))
|
|
}
|
|
return names
|
|
}
|
|
|
|
// renameTableToNs2 moves ns/t to ns2/t, which leaves ns/t behind as a directory
|
|
// without a record.
|
|
func renameTableToNs2(t *testing.T, m *Manager, fs *s3tablestest.MemFiler) {
|
|
t.Helper()
|
|
requireOK(t, m, fs, "CreateNamespace", &CreateNamespaceRequest{
|
|
TableBucketARN: mustBucketARN(t),
|
|
Namespace: []string{"ns2"},
|
|
})
|
|
requireOK(t, m, fs, "RenameTable", &RenameTableRequest{
|
|
TableBucketARN: mustBucketARN(t),
|
|
SourceNamespace: []string{"ns"},
|
|
SourceName: "t",
|
|
DestNamespace: []string{"ns2"},
|
|
DestName: "t",
|
|
})
|
|
require.NotNil(t, fs.Get(GetNamespacePath(renameTestBucket, "ns"), "t"), "the renamed-away directory is the leftover under test")
|
|
}
|
|
|
|
// startDeletedNamespaceWithRenamedData renames ns/t to ns2/t, then deletes ns.
|
|
// The table's data file sits under ns/t, where the renamed table still reads it.
|
|
func startDeletedNamespaceWithRenamedData(t *testing.T) (*s3tablestest.MemFiler, *Manager) {
|
|
t.Helper()
|
|
fs, m := startRenameManager(t)
|
|
fs.PutFile(keptDataDir(), keptDataFile, time.Now())
|
|
renameTableToNs2(t, m, fs)
|
|
requireOK(t, m, fs, "DeleteNamespace", deleteNamespaceRequest(t))
|
|
return fs, m
|
|
}
|
|
|
|
// A rename leaves the source table directory behind, holding the data the
|
|
// renamed table still points at. Deleting the namespace must not take it: the
|
|
// namespace stops existing, and the directory stays with the data under it.
|
|
func TestDeleteNamespaceKeepsRenamedTableData(t *testing.T) {
|
|
fs, m := startDeletedNamespaceWithRenamedData(t)
|
|
|
|
status, resp := runHandler(t, m, fs.Client, "GetNamespace", &GetNamespaceRequest{
|
|
TableBucketARN: mustBucketARN(t),
|
|
Namespace: []string{"ns"},
|
|
})
|
|
assert.Equal(t, http.StatusNotFound, status, "%+v", resp)
|
|
assert.Equal(t, ErrCodeNoSuchNamespace, resp.Type, "%+v", resp)
|
|
assert.Equal(t, []string{"ns2"}, listedNamespaces(t, m, fs))
|
|
|
|
assert.NotNil(t, fs.Get(keptDataDir(), keptDataFile), "the renamed table's data must survive")
|
|
kept := fs.Get(GetTableBucketPath(renameTestBucket), "ns")
|
|
require.NotNil(t, kept, "the directory holding the data must stay")
|
|
for _, key := range s3tablesExtendedKeys {
|
|
assert.NotContains(t, kept.Extended, key, "the kept directory must carry no catalog attribute, claim included")
|
|
}
|
|
|
|
status, resp = runHandler(t, m, fs.Client, "GetTable", &GetTableRequest{
|
|
TableBucketARN: mustBucketARN(t),
|
|
Namespace: []string{"ns2"},
|
|
Name: "t",
|
|
})
|
|
assert.Equal(t, http.StatusOK, status, "the renamed table must still resolve: %+v", resp)
|
|
}
|
|
|
|
// The kept directory is not a namespace, so every call that names it answers
|
|
// NoSuchNamespace and writes nothing to it.
|
|
func TestKeptDirectoryIsNoSuchNamespace(t *testing.T) {
|
|
fs, m := startDeletedNamespaceWithRenamedData(t)
|
|
before := extendedOf(t, fs, GetTableBucketPath(renameTestBucket), "ns")
|
|
|
|
ops := []struct {
|
|
operation string
|
|
input interface{}
|
|
}{
|
|
{"GetNamespace", &GetNamespaceRequest{TableBucketARN: mustBucketARN(t), Namespace: []string{"ns"}}},
|
|
{"UpdateNamespace", &UpdateNamespaceRequest{TableBucketARN: mustBucketARN(t), Namespace: []string{"ns"}, Properties: map[string]string{"k": "v"}}},
|
|
{"DeleteNamespace", deleteNamespaceRequest(t)},
|
|
}
|
|
for _, op := range ops {
|
|
t.Run(op.operation, func(t *testing.T) {
|
|
status, resp := runHandler(t, m, fs.Client, op.operation, op.input)
|
|
assert.Equal(t, http.StatusNotFound, status, "%+v", resp)
|
|
assert.Equal(t, ErrCodeNoSuchNamespace, resp.Type, "%+v", resp)
|
|
})
|
|
}
|
|
|
|
assert.Equal(t, before, extendedOf(t, fs, GetTableBucketPath(renameTestBucket), "ns"), "the kept directory must not be written to")
|
|
assert.NotNil(t, fs.Get(keptDataDir(), keptDataFile))
|
|
}
|
|
|
|
// Directories with no file under them at any depth are not worth keeping, so a
|
|
// namespace left with only those is removed whole, as before.
|
|
func TestDeleteNamespaceRemovesEmptyLeftovers(t *testing.T) {
|
|
fs, m := startRenameManager(t)
|
|
renameTableToNs2(t, m, fs)
|
|
|
|
requireOK(t, m, fs, "DeleteNamespace", deleteNamespaceRequest(t))
|
|
|
|
assert.Nil(t, fs.Get(GetTableBucketPath(renameTestBucket), "ns"), "a namespace holding no file is removed")
|
|
assert.Nil(t, fs.Get(GetNamespacePath(renameTestBucket, "ns"), "t"))
|
|
assert.Equal(t, []string{"ns2"}, listedNamespaces(t, m, fs))
|
|
}
|
|
|
|
// A file that sits deep under a leftover directory, past other empty branches,
|
|
// is still found.
|
|
func TestDeleteNamespaceKeepsDeeplyNestedData(t *testing.T) {
|
|
fs, m := startRenameManager(t)
|
|
renameTableToNs2(t, m, fs)
|
|
leftover := GetTablePath(renameTestBucket, "ns", "leftover")
|
|
fs.Put(GetNamespacePath(renameTestBucket, "ns"), "leftover", nil)
|
|
fs.Put(leftover, "empty", nil)
|
|
fs.Put(leftover, "year=2026", nil)
|
|
fs.Put(path.Join(leftover, "year=2026"), "month=10", nil)
|
|
fs.PutFile(path.Join(leftover, "year=2026", "month=10"), keptDataFile, time.Now())
|
|
|
|
requireOK(t, m, fs, "DeleteNamespace", deleteNamespaceRequest(t))
|
|
|
|
assert.NotNil(t, fs.Get(path.Join(leftover, "year=2026", "month=10"), keptDataFile))
|
|
assert.Equal(t, []string{"ns2"}, listedNamespaces(t, m, fs))
|
|
}
|
|
|
|
// A table's location may sit under a hidden directory of its namespace. Hidden
|
|
// entries are never children, but the files under a hidden directory are data
|
|
// the renamed table still reads, so they keep the namespace directory too.
|
|
func TestDeleteNamespaceKeepsDataUnderAHiddenDirectory(t *testing.T) {
|
|
nsPath := GetNamespacePath(renameTestBucket, "ns")
|
|
hidden := path.Join(nsPath, ".hidden")
|
|
cases := []struct {
|
|
name string
|
|
seed func(fs *s3tablestest.MemFiler)
|
|
kept bool
|
|
}{
|
|
{"files under a hidden directory", func(fs *s3tablestest.MemFiler) {
|
|
fs.Put(nsPath, ".hidden", nil)
|
|
fs.Put(hidden, "data", nil)
|
|
fs.PutFile(path.Join(hidden, "data"), keptDataFile, time.Now())
|
|
}, true},
|
|
{"only empty hidden directories", func(fs *s3tablestest.MemFiler) {
|
|
fs.Put(nsPath, ".hidden", nil)
|
|
fs.Put(hidden, "data", nil)
|
|
}, false},
|
|
{"a hidden file", func(fs *s3tablestest.MemFiler) {
|
|
fs.PutFile(nsPath, ".DS_Store", time.Now())
|
|
}, false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
fs, m := startRenameManager(t)
|
|
renameTableToNs2(t, m, fs)
|
|
tc.seed(fs)
|
|
|
|
requireOK(t, m, fs, "DeleteNamespace", deleteNamespaceRequest(t))
|
|
|
|
kept := fs.Get(GetTableBucketPath(renameTestBucket), "ns") != nil
|
|
assert.Equal(t, tc.kept, kept, "namespace directory kept")
|
|
if tc.kept {
|
|
assert.NotNil(t, fs.Get(path.Join(hidden, "data"), keptDataFile), "the hidden directory's data must survive")
|
|
}
|
|
assert.Equal(t, []string{"ns2"}, listedNamespaces(t, m, fs))
|
|
})
|
|
}
|
|
}
|
|
|
|
// The walk follows a directory's later pages. With more children than one page
|
|
// holds, the only file sits past the first page — directly in the leftover, or
|
|
// under a directory listed on the second page — and missing it would let the
|
|
// delete take the data.
|
|
func TestDeleteNamespaceKeepsDataPastTheFirstListingPage(t *testing.T) {
|
|
const children = dirListPageSize + 10
|
|
cases := []struct {
|
|
name string
|
|
put func(fs *s3tablestest.MemFiler, leftover string)
|
|
}{
|
|
{"file on the second page", func(fs *s3tablestest.MemFiler, leftover string) {
|
|
fs.PutFile(leftover, "z-"+keptDataFile, time.Now())
|
|
}},
|
|
{"file under a directory on the second page", func(fs *s3tablestest.MemFiler, leftover string) {
|
|
fs.PutFile(path.Join(leftover, fmt.Sprintf("d%04d", children-1)), keptDataFile, time.Now())
|
|
}},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
fs, m := startRenameManager(t)
|
|
renameTableToNs2(t, m, fs)
|
|
leftover := GetTablePath(renameTestBucket, "ns", "wide")
|
|
fs.Put(GetNamespacePath(renameTestBucket, "ns"), "wide", nil)
|
|
for i := 0; i < children; i++ {
|
|
fs.Put(leftover, fmt.Sprintf("d%04d", i), nil)
|
|
}
|
|
tc.put(fs, leftover)
|
|
|
|
requireOK(t, m, fs, "DeleteNamespace", deleteNamespaceRequest(t))
|
|
|
|
require.NotNil(t, fs.Get(GetTableBucketPath(renameTestBucket), "ns"), "the directory holding the data must stay")
|
|
assert.Equal(t, []string{"ns2"}, listedNamespaces(t, m, fs))
|
|
})
|
|
}
|
|
}
|
|
|
|
// listContextClient remembers the context of every listing it serves.
|
|
type listContextClient struct {
|
|
filer_pb.SeaweedFilerClient
|
|
contexts []context.Context
|
|
}
|
|
|
|
func (c *listContextClient) ListEntries(ctx context.Context, in *filer_pb.ListEntriesRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[filer_pb.ListEntriesResponse], error) {
|
|
c.contexts = append(c.contexts, ctx)
|
|
return c.SeaweedFilerClient.ListEntries(ctx, in, opts...)
|
|
}
|
|
|
|
// The walk stops at the first file with a page still unread. Its listings must
|
|
// end with the call, not with the request, so the filer stops producing that page.
|
|
func TestDirHoldsFilesEndsItsListings(t *testing.T) {
|
|
fs, m := startRenameManager(t)
|
|
leftover := GetTablePath(renameTestBucket, "ns", "leftover")
|
|
fs.Put(leftover, "a-dir", nil)
|
|
fs.PutFile(leftover, "b-file", time.Now())
|
|
fs.PutFile(leftover, "c-file", time.Now())
|
|
client := &listContextClient{SeaweedFilerClient: fs.Client}
|
|
|
|
holds, err := m.handler.dirHoldsFiles(context.Background(), client, leftover)
|
|
|
|
require.NoError(t, err)
|
|
assert.True(t, holds)
|
|
require.NotEmpty(t, client.contexts)
|
|
for _, ctx := range client.contexts {
|
|
assert.Error(t, ctx.Err(), "a listing must not outlive the walk")
|
|
}
|
|
}
|
|
|
|
// A table still in the namespace keeps it from being deleted, even with a
|
|
// leftover directory holding data beside it.
|
|
func TestDeleteNamespaceWithTableIsStillNotEmpty(t *testing.T) {
|
|
fs, m := startRenameManager(t)
|
|
fs.PutFile(keptDataDir(), keptDataFile, time.Now())
|
|
fs.Put(GetNamespacePath(renameTestBucket, "ns"), "leftover", nil)
|
|
fs.PutFile(GetTablePath(renameTestBucket, "ns", "leftover"), keptDataFile, time.Now())
|
|
|
|
status, resp := runHandler(t, m, fs.Client, "DeleteNamespace", deleteNamespaceRequest(t))
|
|
assert.Equal(t, http.StatusConflict, status, "%+v", resp)
|
|
assert.Equal(t, ErrCodeNamespaceNotEmpty, resp.Type, "%+v", resp)
|
|
|
|
namespace := fs.Get(GetTableBucketPath(renameTestBucket), "ns")
|
|
require.NotNil(t, namespace)
|
|
assert.Contains(t, namespace.Extended, ExtendedKeyMetadata, "a refused delete leaves the namespace a namespace")
|
|
assert.NotContains(t, namespace.Extended, ExtendedKeyDeleting, "a refused delete releases its claim")
|
|
}
|
|
|
|
// The name of a deleted namespace is free again even though its directory is
|
|
// still there: the create takes the directory back, with no claim stuck on it,
|
|
// and the data under it is untouched.
|
|
func TestCreateNamespaceTakesBackAKeptDirectory(t *testing.T) {
|
|
fs, m := startDeletedNamespaceWithRenamedData(t)
|
|
|
|
requireOK(t, m, fs, "CreateNamespace", &CreateNamespaceRequest{
|
|
TableBucketARN: mustBucketARN(t),
|
|
Namespace: []string{"ns"},
|
|
})
|
|
requireOK(t, m, fs, "CreateTable", createTableRequest(t, "ns", "x"))
|
|
|
|
assert.ElementsMatch(t, []string{"ns", "ns2"}, listedNamespaces(t, m, fs))
|
|
assert.NotNil(t, fs.Get(keptDataDir(), keptDataFile), "the renamed table's data must survive")
|
|
status, resp := runHandler(t, m, fs.Client, "GetTable", &GetTableRequest{
|
|
TableBucketARN: mustBucketARN(t),
|
|
Namespace: []string{"ns2"},
|
|
Name: "t",
|
|
})
|
|
assert.Equal(t, http.StatusOK, status, "%+v", resp)
|
|
|
|
// The directory is a real namespace again, so a second create is refused.
|
|
status, resp = runHandler(t, m, fs.Client, "CreateNamespace", &CreateNamespaceRequest{
|
|
TableBucketARN: mustBucketARN(t),
|
|
Namespace: []string{"ns"},
|
|
})
|
|
assert.Equal(t, http.StatusConflict, status, "%+v", resp)
|
|
assert.Equal(t, ErrCodeNamespaceAlreadyExists, resp.Type, "%+v", resp)
|
|
}
|
|
|
|
// A file that replaces the leftover directory while the create is taking it
|
|
// over must not end up tagged as a namespace: the create checks the type on the
|
|
// entry it writes, and the filer refuses to write a directory over the file.
|
|
// The name is the file's now, so the create answers a name conflict.
|
|
func TestCreateNamespaceDoesNotTagAFileThatReplacesTheDirectory(t *testing.T) {
|
|
fs, m := startDeletedNamespaceWithRenamedData(t)
|
|
bucketPath := GetTableBucketPath(renameTestBucket)
|
|
// The create looks the name up twice: once to see whether it exists, and
|
|
// once to read the leftover it takes over. The file lands after the second.
|
|
client := &replaceAfterLookupClient{SeaweedFilerClient: fs.Client, f: fs, dir: bucketPath, name: "ns", nth: 2}
|
|
|
|
status, resp := runHandler(t, m, client, "CreateNamespace", &CreateNamespaceRequest{
|
|
TableBucketARN: mustBucketARN(t),
|
|
Namespace: []string{"ns"},
|
|
})
|
|
|
|
require.GreaterOrEqual(t, client.lookups, 2, "the replacement has to land after the create's second read")
|
|
assert.Equal(t, http.StatusConflict, status, "%+v", resp)
|
|
assert.Equal(t, ErrCodeNamespaceAlreadyExists, resp.Type, "%+v", resp)
|
|
file := fs.Get(bucketPath, "ns")
|
|
require.NotNil(t, file)
|
|
assert.False(t, file.IsDirectory)
|
|
assert.NotContains(t, file.Extended, ExtendedKeyMetadata, "the file must not be tagged as a namespace")
|
|
}
|
|
|
|
// Removing the claimed namespace between the scan and the keep is a
|
|
// concurrent change: the delete answers a conflict the caller can retry, not
|
|
// an internal error.
|
|
func TestDeleteNamespaceConflictsWhenTheKeptDirectoryVanishes(t *testing.T) {
|
|
fs, m := startRenameManager(t)
|
|
fs.PutFile(keptDataDir(), keptDataFile, time.Now())
|
|
renameTableToNs2(t, m, fs)
|
|
|
|
bucketPath := GetTableBucketPath(renameTestBucket)
|
|
// The delete claims the namespace before it scans, so the next lookup of
|
|
// the claimed directory is the keep's own read — remove it there.
|
|
fs.OnLookup = func(dir, name string) {
|
|
if dir != bucketPath || name != "ns" {
|
|
return
|
|
}
|
|
if entry := fs.Get(dir, name); entry == nil || entry.Extended[ExtendedKeyDeleting] == nil {
|
|
return
|
|
}
|
|
fs.OnLookup = nil
|
|
_, _ = fs.Client.DeleteEntry(context.Background(), &filer_pb.DeleteEntryRequest{
|
|
Directory: bucketPath,
|
|
Name: "ns",
|
|
IsRecursive: true,
|
|
IsDeleteData: true,
|
|
})
|
|
}
|
|
|
|
status, resp := runHandler(t, m, fs.Client, "DeleteNamespace", deleteNamespaceRequest(t))
|
|
|
|
assert.Equal(t, http.StatusConflict, status, "%+v", resp)
|
|
assert.Equal(t, ErrCodeConflict, resp.Type, "%+v", resp)
|
|
assert.Nil(t, fs.Get(bucketPath, "ns"), "the competing removal keeps the directory it took")
|
|
}
|
|
|
|
// A namespace removed between the claim and the conditional delete is a
|
|
// concurrent change too, not a delete this call completed.
|
|
func TestDeleteNamespaceConflictsWhenItVanishesBeforeTheDelete(t *testing.T) {
|
|
fs, m := startRenameManager(t)
|
|
renameTableToNs2(t, m, fs)
|
|
|
|
bucketPath := GetTableBucketPath(renameTestBucket)
|
|
fs.BeforeDelete = func() {
|
|
_, _ = fs.Client.DeleteEntry(context.Background(), &filer_pb.DeleteEntryRequest{
|
|
Directory: bucketPath,
|
|
Name: "ns",
|
|
IsRecursive: true,
|
|
IsDeleteData: true,
|
|
})
|
|
}
|
|
|
|
status, resp := runHandler(t, m, fs.Client, "DeleteNamespace", deleteNamespaceRequest(t))
|
|
|
|
assert.Equal(t, http.StatusConflict, status, "%+v", resp)
|
|
assert.Equal(t, ErrCodeConflict, resp.Type, "%+v", resp)
|
|
}
|
|
|
|
// A file is not a leftover directory to take over: the create keeps refusing a
|
|
// name a file already holds.
|
|
func TestCreateNamespaceRefusesAFileInTheWay(t *testing.T) {
|
|
fs, m := startRenameManager(t)
|
|
fs.PutFile(GetTableBucketPath(renameTestBucket), "occupied", time.Now())
|
|
|
|
status, resp := runHandler(t, m, fs.Client, "CreateNamespace", &CreateNamespaceRequest{
|
|
TableBucketARN: mustBucketARN(t),
|
|
Namespace: []string{"occupied"},
|
|
})
|
|
assert.Equal(t, http.StatusConflict, status, "%+v", resp)
|
|
assert.Equal(t, ErrCodeNamespaceAlreadyExists, resp.Type, "%+v", resp)
|
|
assert.Empty(t, fs.Get(GetTableBucketPath(renameTestBucket), "occupied").Extended, "the file must not be tagged as a namespace")
|
|
}
|