Files
seaweedfs/weed/server/filer_grpc_server_batch_lookup_test.go
T
Chris Lu 8112f2733a filer: batch exact lookup RPC, authoritative volume lookup, VolumeDelete status codes (#11122)
* storage: make DeleteVolume errors inspectable with errors.Is

An absent volume wraps ErrVolumeNotFound and an only-empty refusal now
wraps ErrVolumeNotEmpty with %w instead of %v, so callers no longer have
to match on the message.

Claude-Session: https://claude.ai/code/session_01T4MEV3ETqFFKN46Uu2ZrUm

* volume server: return NotFound and FailedPrecondition from VolumeDelete

An absent volume maps to codes.NotFound and a non-empty volume under
only_empty to codes.FailedPrecondition, so a caller retiring a volume can
treat NotFound as already done. The store message is kept in the status
description because the EC empty-replica sweep still matches on it.

Claude-Session: https://claude.ai/code/session_01T4MEV3ETqFFKN46Uu2ZrUm

* wdclient: add LookupVolumeIdsAuthoritative

Bypasses the vid map and asks the provider directly, for callers where a
stale positive location is unsafe.

Claude-Session: https://claude.ai/code/session_01T4MEV3ETqFFKN46Uu2ZrUm

* filer: add LookupDirectoryEntries batch lookup RPC

Up to 4096 exact-path lookups in one call, resolved concurrently with
results in request order, plus one deduplicated location lookup for every
volume the returned entries reference and per-fid read tokens when the
filer signs reads. unavailable_volume_is_miss lets cache-style callers
take an entry whose volume has no live location as a miss, resolved
against the master rather than the filer's location cache.

Claude-Session: https://claude.ai/code/session_01T4MEV3ETqFFKN46Uu2ZrUm

* filer: test that an expired file entry is deleted on read

Claude-Session: https://claude.ai/code/session_01T4MEV3ETqFFKN46Uu2ZrUm

* filer: test that AssignVolume and CreateEntry resolve the same TTL rule

Claude-Session: https://claude.ai/code/session_01T4MEV3ETqFFKN46Uu2ZrUm

* master: refuse partial lookups while warming up

LookupVolume returned Unavailable during warm-up only when every requested
volume was missing. A batch mixing a reported volume with one whose server
has not reconnected yet came back as a partial answer with a per-volume
not-found, which a caller treating the master as authoritative reads as
gone. Any not-found during warm-up is now Unavailable, which callers
already retry.

Claude-Session: https://claude.ai/code/session_01T4MEV3ETqFFKN46Uu2ZrUm

* filer: build batch test requests instead of copying a proto message

Copying a generated message copies its internal mutex, which go vet's
copylocks check rejects.

Claude-Session: https://claude.ai/code/session_01T4MEV3ETqFFKN46Uu2ZrUm

* filer: match ErrNotFound with errors.Is and state the miss rule's contract

A wrapped not-found from the store would otherwise be reported as an
error rather than a miss. The comments now say why a nil location map is
the only sign of an unanswered lookup: the provider returns nil when it
got no answer and a populated map, with unserved volumes reported as
errors, when the master did answer.

Claude-Session: https://claude.ai/code/session_01T4MEV3ETqFFKN46Uu2ZrUm

* volume server: map absent and non-empty VolumeDelete errors in the Rust server

Matches the Go server: an absent volume is NotFound and an only_empty
refusal is FailedPrecondition instead of Internal, with the messages the
EC empty-replica sweep matches on.

Claude-Session: https://claude.ai/code/session_01T4MEV3ETqFFKN46Uu2ZrUm

* filer: test that a malformed entry keeps its error outside cache mode

Same test file as the enterprise tree, so the next sync sees one version.

Claude-Session: https://claude.ai/code/session_01T4MEV3ETqFFKN46Uu2ZrUm
2026-09-03 14:52:05 -07:00

419 lines
16 KiB
Go

package weed_server
import (
"context"
"errors"
"fmt"
"reflect"
"sort"
"strconv"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/util"
"github.com/seaweedfs/seaweedfs/weed/wdclient"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func TestLookupDirectoryEntriesRejectsInvalidRequests(t *testing.T) {
valid := &filer_pb.LookupDirectoryEntryRequest{Directory: "/batch", Name: "key"}
tests := []struct {
name string
req *filer_pb.LookupDirectoryEntriesRequest
wantCode codes.Code
}{
{name: "nil request", wantCode: codes.InvalidArgument},
{name: "empty batch", req: &filer_pb.LookupDirectoryEntriesRequest{}, wantCode: codes.InvalidArgument},
{name: "oversized", req: repeatedBatchLookupRequest(maxFilerBatchLookupRequests+1, valid), wantCode: codes.ResourceExhausted},
{name: "nil item", req: &filer_pb.LookupDirectoryEntriesRequest{Requests: []*filer_pb.LookupDirectoryEntryRequest{nil}}, wantCode: codes.InvalidArgument},
{name: "relative directory", req: &filer_pb.LookupDirectoryEntriesRequest{Requests: []*filer_pb.LookupDirectoryEntryRequest{{Directory: "batch", Name: "key"}}}, wantCode: codes.InvalidArgument},
{name: "empty name", req: &filer_pb.LookupDirectoryEntriesRequest{Requests: []*filer_pb.LookupDirectoryEntryRequest{{Directory: "/batch"}}}, wantCode: codes.InvalidArgument},
{name: "nested name", req: &filer_pb.LookupDirectoryEntriesRequest{Requests: []*filer_pb.LookupDirectoryEntryRequest{{Directory: "/batch", Name: "a/b"}}}, wantCode: codes.InvalidArgument},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
called := false
_, err := lookupDirectoryEntries(context.Background(), tt.req, 17,
func(context.Context, util.FullPath) (*filer.Entry, int64, error) {
called = true
return nil, 0, nil
},
func(context.Context, []string) (map[string][]wdclient.Location, error) {
called = true
return nil, nil
},
)
if status.Code(err) != tt.wantCode {
t.Fatalf("error code = %v, want %v: %v", status.Code(err), tt.wantCode, err)
}
if called {
t.Fatal("invalid request reached lookup implementation")
}
})
}
}
func TestLookupDirectoryEntriesAcceptsMaximumBatch(t *testing.T) {
req := repeatedBatchLookupRequest(maxFilerBatchLookupRequests, &filer_pb.LookupDirectoryEntryRequest{
Directory: "/batch",
Name: "key",
})
response, err := lookupDirectoryEntries(context.Background(), req, 13,
func(context.Context, util.FullPath) (*filer.Entry, int64, error) {
return nil, 99, filer_pb.ErrNotFound
},
func(context.Context, []string) (map[string][]wdclient.Location, error) {
t.Fatal("volume lookup called for an all-miss batch")
return nil, nil
},
)
if err != nil {
t.Fatalf("maximum-sized batch failed: %v", err)
}
if got := len(response.Results); got != maxFilerBatchLookupRequests {
t.Fatalf("result count = %d, want %d", got, maxFilerBatchLookupRequests)
}
for _, index := range []int{0, maxFilerBatchLookupRequests - 1} {
if got := response.Results[index]; got.LogTsNs != 99 || got.LogSignature != 13 {
t.Fatalf("miss fence[%d] = (%d,%d), want (99,13)", index, got.LogTsNs, got.LogSignature)
}
}
}
func TestLookupDirectoryEntriesUsesFilerSignatureForFoundAndMiss(t *testing.T) {
store := newRenameTestStore()
if err := store.InsertEntry(context.Background(), newFileEntry("/batch/found", 1)); err != nil {
t.Fatalf("seed entry: %v", err)
}
testFiler := newRenameTestFiler(t, store)
testFiler.Signature = 73
server := &FilerServer{
filer: testFiler,
entryLockTable: util.NewLockTable[util.FullPath](),
}
response, err := server.LookupDirectoryEntries(context.Background(), &filer_pb.LookupDirectoryEntriesRequest{
Requests: []*filer_pb.LookupDirectoryEntryRequest{
{Directory: "/batch", Name: "found"},
{Directory: "/batch", Name: "missing"},
},
})
if err != nil {
t.Fatalf("batch lookup: %v", err)
}
if !response.Results[0].Found || response.Results[1].Found {
t.Fatalf("unexpected found states: %v, %v", response.Results[0].Found, response.Results[1].Found)
}
for index, result := range response.Results {
if result.LogSignature != 73 || result.LogTsNs == 0 {
t.Fatalf("result[%d] fence = (%d,%d), want nonzero timestamp and signature 73", index, result.LogTsNs, result.LogSignature)
}
}
}
func TestPopulateBatchReadAuthMintsExactFidCapabilities(t *testing.T) {
response := &filer_pb.LookupDirectoryEntriesResponse{Results: []*filer_pb.LookupDirectoryEntryResult{
{Found: true, Entry: &filer_pb.Entry{Chunks: []*filer_pb.FileChunk{
{FileId: "7,abc"}, {FileId: "8,def"}, {FileId: "7,abc"},
}}},
{Found: false},
}}
populateBatchReadAuth(response, func(fid string) string { return "jwt:" + fid })
if !reflect.DeepEqual(response.ReadAuth, map[string]string{
"7,abc": "jwt:7,abc",
"8,def": "jwt:8,def",
}) {
t.Fatalf("read auth = %#v", response.ReadAuth)
}
}
func TestPopulateBatchReadAuthDoesNotInventCapabilitiesWhenSigningIsDisabled(t *testing.T) {
response := &filer_pb.LookupDirectoryEntriesResponse{Results: []*filer_pb.LookupDirectoryEntryResult{
{Found: true, Entry: &filer_pb.Entry{Chunks: []*filer_pb.FileChunk{{FileId: "7,abc"}}}},
}}
populateBatchReadAuth(response, func(string) string { return "" })
if len(response.ReadAuth) != 0 {
t.Fatalf("read auth must be empty without a signing key: %#v", response.ReadAuth)
}
}
func TestLookupDirectoryEntriesPreservesOrderBoundsParallelismAndDeduplicatesVolumes(t *testing.T) {
const requestCount = 96
requests := make([]*filer_pb.LookupDirectoryEntryRequest, requestCount)
for index := range requests {
requests[index] = &filer_pb.LookupDirectoryEntryRequest{
Directory: "/batch",
Name: fmt.Sprintf("item-%03d", index),
}
}
var active atomic.Int32
var activeMax atomic.Int32
findEntry := func(_ context.Context, path util.FullPath) (*filer.Entry, int64, error) {
index, err := strconv.Atoi(strings.TrimPrefix(string(path), "/batch/item-"))
if err != nil {
return nil, 0, err
}
current := active.Add(1)
defer active.Add(-1)
for {
previous := activeMax.Load()
if current <= previous || activeMax.CompareAndSwap(previous, current) {
break
}
}
// Reverse completion order within each wave so append-based implementations fail.
time.Sleep(time.Duration(requestCount-index) * 50 * time.Microsecond)
if index == 7 {
return nil, int64(1000 + index), errors.New("lookup failed")
}
if index == 11 {
return nil, int64(1000 + index), filer_pb.ErrNotFound
}
volumeID := uint32(index%3 + 1)
chunk := &filer_pb.FileChunk{Fid: &filer_pb.FileId{VolumeId: volumeID, FileKey: uint64(index + 1), Cookie: 17}}
if index == 5 {
chunk = &filer_pb.FileChunk{FileId: "2,0294cbb9892b"}
}
return &filer.Entry{FullPath: path, Chunks: []*filer_pb.FileChunk{chunk, chunk}}, int64(1000 + index), nil
}
volumeLookupCalls := 0
var lookedUpVolumeIDs []string
lookupVolumes := func(_ context.Context, volumeIDs []string) (map[string][]wdclient.Location, error) {
volumeLookupCalls++
lookedUpVolumeIDs = append([]string(nil), volumeIDs...)
return map[string][]wdclient.Location{
"1": {{Url: "volume-1:8080"}},
"2": {{Url: "volume-2:8080"}},
"3": {{Url: "volume-3:8080"}},
}, nil
}
const logSignature = 0x1234
response, err := lookupDirectoryEntries(context.Background(), &filer_pb.LookupDirectoryEntriesRequest{Requests: requests}, logSignature, findEntry, lookupVolumes)
if err != nil {
t.Fatalf("batch lookup: %v", err)
}
if got := activeMax.Load(); got <= 1 || got > maxFilerBatchLookupWorkers {
t.Fatalf("parallel workers peaked at %d, want 2..%d", got, maxFilerBatchLookupWorkers)
}
if volumeLookupCalls != 1 {
t.Fatalf("volume lookup calls = %d, want 1", volumeLookupCalls)
}
if want := []string{"1", "2", "3"}; !reflect.DeepEqual(lookedUpVolumeIDs, want) {
t.Fatalf("volume IDs = %v, want %v", lookedUpVolumeIDs, want)
}
if got := len(response.LocationsMap); got != 3 {
t.Fatalf("location map size = %d, want 3", got)
}
for index, result := range response.Results {
if result.LogTsNs != int64(1000+index) || result.LogSignature != logSignature {
t.Fatalf("result[%d] fence = (%d,%d), want (%d,%d)", index, result.LogTsNs, result.LogSignature, 1000+index, logSignature)
}
switch index {
case 7:
if result.Found || result.Entry != nil || result.Error != "lookup failed" {
t.Fatalf("error result[%d] = %+v", index, result)
}
case 11:
if result.Found || result.Entry != nil || result.Error != "" {
t.Fatalf("not-found result[%d] = %+v", index, result)
}
default:
if !result.Found || result.Entry == nil || result.Error != "" {
t.Fatalf("found result[%d] = %+v", index, result)
}
if want := fmt.Sprintf("item-%03d", index); result.Entry.Name != want {
t.Fatalf("result[%d] name = %q, want %q", index, result.Entry.Name, want)
}
}
}
}
func TestLookupDirectoryEntriesMarksEntriesWithUnavailableVolumes(t *testing.T) {
response, err := lookupDirectoryEntries(context.Background(), &filer_pb.LookupDirectoryEntriesRequest{
Requests: []*filer_pb.LookupDirectoryEntryRequest{{Directory: "/batch", Name: "key"}},
}, 42, func(_ context.Context, path util.FullPath) (*filer.Entry, int64, error) {
return &filer.Entry{
FullPath: path,
Chunks: []*filer_pb.FileChunk{{
Fid: &filer_pb.FileId{VolumeId: 9, FileKey: 1, Cookie: 2},
}},
}, 123, nil
}, func(_ context.Context, volumeIDs []string) (map[string][]wdclient.Location, error) {
if !sort.StringsAreSorted(volumeIDs) {
t.Fatalf("volume IDs are not sorted: %v", volumeIDs)
}
return nil, errors.New("master unavailable")
})
if err != nil {
t.Fatalf("batch lookup: %v", err)
}
result := response.Results[0]
if result.LogTsNs != 123 || result.LogSignature != 42 {
t.Fatalf("result fence = (%d,%d), want (123,42)", result.LogTsNs, result.LogSignature)
}
if !result.Found || result.Entry == nil {
t.Fatalf("metadata result lost when volume lookup failed: %+v", result)
}
if !strings.Contains(result.Error, "volume 9 has no locations") || !strings.Contains(result.Error, "master unavailable") {
t.Fatalf("result error = %q", result.Error)
}
locations, ok := response.LocationsMap["9"]
if !ok || locations == nil || len(locations.Locations) != 0 {
t.Fatalf("missing volume map entry = %#v, present=%v", locations, ok)
}
}
func TestLookupDirectoryEntriesKeepsMalformedEntryError(t *testing.T) {
response, err := lookupDirectoryEntries(context.Background(), &filer_pb.LookupDirectoryEntriesRequest{
Requests: []*filer_pb.LookupDirectoryEntryRequest{{Directory: "/batch", Name: "key"}},
}, 42, func(_ context.Context, path util.FullPath) (*filer.Entry, int64, error) {
return &filer.Entry{
FullPath: path,
Chunks: []*filer_pb.FileChunk{
{Fid: &filer_pb.FileId{VolumeId: 9, FileKey: 1, Cookie: 2}},
{FileId: "not-a-file-id"},
},
}, 123, nil
}, func(context.Context, []string) (map[string][]wdclient.Location, error) {
return map[string][]wdclient.Location{}, nil
})
if err != nil {
t.Fatalf("batch lookup: %v", err)
}
result := response.Results[0]
if !result.Found || result.Entry == nil || !strings.Contains(result.Error, "invalid file id") {
t.Fatalf("malformed entry was reported as a clean miss: %+v", result)
}
}
func cacheVolumeEntry(_ context.Context, path util.FullPath) (*filer.Entry, int64, error) {
return &filer.Entry{
FullPath: path,
Chunks: []*filer_pb.FileChunk{{
Fid: &filer_pb.FileId{VolumeId: 9, FileKey: 1, Cookie: 2},
}},
}, 123, nil
}
func TestLookupDirectoryEntriesCanTreatRetiredCacheVolumeAsMiss(t *testing.T) {
response, err := lookupDirectoryEntries(context.Background(), &filer_pb.LookupDirectoryEntriesRequest{
Requests: []*filer_pb.LookupDirectoryEntryRequest{{Directory: "/batch", Name: "key"}},
UnavailableVolumeIsMiss: true,
}, 42, cacheVolumeEntry, func(_ context.Context, volumeIDs []string) (map[string][]wdclient.Location, error) {
// The master answered and left volume 9 out.
return map[string][]wdclient.Location{}, errors.New("volume 9: volume id 9 not found")
})
if err != nil {
t.Fatalf("batch lookup: %v", err)
}
result := response.Results[0]
if result.Found || result.Entry != nil || result.Error != "" {
t.Fatalf("retired cache volume did not become a clean miss: %+v", result)
}
if result.LogTsNs != 123 || result.LogSignature != 42 {
t.Fatalf("miss fence = (%d,%d), want (123,42)", result.LogTsNs, result.LogSignature)
}
if _, exists := response.LocationsMap["9"]; exists {
t.Fatalf("cache miss returned an unusable location: %#v", response.LocationsMap["9"])
}
}
func TestLookupDirectoryEntriesKeepsCacheEntryWhenMasterIsUnreachable(t *testing.T) {
response, err := lookupDirectoryEntries(context.Background(), &filer_pb.LookupDirectoryEntriesRequest{
Requests: []*filer_pb.LookupDirectoryEntryRequest{{Directory: "/batch", Name: "key"}},
UnavailableVolumeIsMiss: true,
}, 42, cacheVolumeEntry, func(context.Context, []string) (map[string][]wdclient.Location, error) {
return nil, errors.New("master unavailable")
})
if err != nil {
t.Fatalf("batch lookup: %v", err)
}
result := response.Results[0]
if !result.Found || result.Entry == nil {
t.Fatalf("unanswered lookup was reported as a miss: %+v", result)
}
if !strings.Contains(result.Error, "volume 9 has no locations") || !strings.Contains(result.Error, "master unavailable") {
t.Fatalf("result error = %q", result.Error)
}
locations, ok := response.LocationsMap["9"]
if !ok || locations == nil || len(locations.Locations) != 0 {
t.Fatalf("unresolved volume map entry = %#v, present=%v", locations, ok)
}
}
func TestLookupDirectoryEntriesKeepsMalformedEntryErrorInCacheMode(t *testing.T) {
response, err := lookupDirectoryEntries(context.Background(), &filer_pb.LookupDirectoryEntriesRequest{
Requests: []*filer_pb.LookupDirectoryEntryRequest{{Directory: "/batch", Name: "key"}},
UnavailableVolumeIsMiss: true,
}, 42, func(_ context.Context, path util.FullPath) (*filer.Entry, int64, error) {
return &filer.Entry{
FullPath: path,
Chunks: []*filer_pb.FileChunk{
{Fid: &filer_pb.FileId{VolumeId: 9, FileKey: 1, Cookie: 2}},
{FileId: "not-a-file-id"},
},
}, 123, nil
}, func(context.Context, []string) (map[string][]wdclient.Location, error) {
return map[string][]wdclient.Location{}, nil
})
if err != nil {
t.Fatalf("batch lookup: %v", err)
}
result := response.Results[0]
if !result.Found || result.Entry == nil || !strings.Contains(result.Error, "invalid file id") {
t.Fatalf("malformed entry was reported as a clean miss: %+v", result)
}
}
func TestCacheBatchLookupSelectsAuthoritativeVolumeLocations(t *testing.T) {
cachedCalls, authoritativeCalls := 0, 0
cached := func(context.Context, []string) (map[string][]wdclient.Location, error) {
cachedCalls++
return map[string][]wdclient.Location{"9": {{Url: "retired-volume:8080"}}}, nil
}
authoritative := func(context.Context, []string) (map[string][]wdclient.Location, error) {
authoritativeCalls++
return nil, nil
}
lookup := selectFilerBatchVolumeLookup(&filer_pb.LookupDirectoryEntriesRequest{
UnavailableVolumeIsMiss: true,
}, cached, authoritative)
locations, err := lookup(context.Background(), []string{"9"})
if err != nil {
t.Fatal(err)
}
if cachedCalls != 0 || authoritativeCalls != 1 || len(locations) != 0 {
t.Fatalf("cache lookup used stale path: cached=%d authoritative=%d locations=%v", cachedCalls, authoritativeCalls, locations)
}
lookup = selectFilerBatchVolumeLookup(&filer_pb.LookupDirectoryEntriesRequest{}, cached, authoritative)
if _, err := lookup(context.Background(), []string{"9"}); err != nil {
t.Fatal(err)
}
if cachedCalls != 1 || authoritativeCalls != 1 {
t.Fatalf("ordinary lookup did not keep cached path: cached=%d authoritative=%d", cachedCalls, authoritativeCalls)
}
}
func repeatedBatchLookupRequest(count int, request *filer_pb.LookupDirectoryEntryRequest) *filer_pb.LookupDirectoryEntriesRequest {
requests := make([]*filer_pb.LookupDirectoryEntryRequest, count)
for index := range requests {
requests[index] = &filer_pb.LookupDirectoryEntryRequest{
Directory: request.Directory,
Name: request.Name,
}
}
return &filer_pb.LookupDirectoryEntriesRequest{Requests: requests}
}