Files
seaweedfs/weed/server/filer_grpc_server_batch_lookup.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

248 lines
7.9 KiB
Go

package weed_server
import (
"context"
"errors"
"fmt"
"sort"
"strconv"
"strings"
"sync"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/storage/needle"
"github.com/seaweedfs/seaweedfs/weed/util"
"github.com/seaweedfs/seaweedfs/weed/wdclient"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
const (
maxFilerBatchLookupRequests = 4096
maxFilerBatchLookupWorkers = 32
)
type filerBatchEntryFinder func(context.Context, util.FullPath) (*filer.Entry, int64, error)
type filerBatchVolumeLookup func(context.Context, []string) (map[string][]wdclient.Location, error)
// LookupDirectoryEntries performs exact entry lookups concurrently while
// preserving request order, then resolves every referenced volume in one
// deduplicated master lookup.
func (fs *FilerServer) LookupDirectoryEntries(ctx context.Context, req *filer_pb.LookupDirectoryEntriesRequest) (*filer_pb.LookupDirectoryEntriesResponse, error) {
lookupVolumes := selectFilerBatchVolumeLookup(req,
fs.filer.MasterClient.LookupVolumeIdsWithFallback,
fs.filer.MasterClient.LookupVolumeIdsAuthoritative)
response, err := lookupDirectoryEntries(ctx, req, fs.filer.Signature, fs.fencedFindEntry, lookupVolumes)
if err != nil {
return nil, err
}
if fs.volumeGuard != nil {
populateBatchReadAuth(response, fs.maybeGetVolumeReadJwtAuthorizationToken)
}
return response, nil
}
func selectFilerBatchVolumeLookup(req *filer_pb.LookupDirectoryEntriesRequest, cached, authoritative filerBatchVolumeLookup) filerBatchVolumeLookup {
if req != nil && req.GetUnavailableVolumeIsMiss() {
// DeletedVids is an asynchronous cache invalidation hint and its delivery
// is deliberately best-effort. Cache reads must therefore confirm the
// unique Volume IDs with Master before treating a location as live.
return authoritative
}
return cached
}
func populateBatchReadAuth(response *filer_pb.LookupDirectoryEntriesResponse, mint func(string) string) {
response.ReadAuth = make(map[string]string)
for _, result := range response.Results {
if result == nil || result.Entry == nil {
continue
}
for _, chunk := range result.Entry.Chunks {
fid := chunk.GetFileIdString()
if fid == "" {
continue
}
if token := mint(fid); token != "" {
response.ReadAuth[fid] = token
}
}
}
}
func lookupDirectoryEntries(
ctx context.Context,
req *filer_pb.LookupDirectoryEntriesRequest,
logSignature int32,
findEntry filerBatchEntryFinder,
lookupVolumes filerBatchVolumeLookup,
) (*filer_pb.LookupDirectoryEntriesResponse, error) {
if err := validateFilerBatchLookupRequest(req); err != nil {
return nil, err
}
results := make([]*filer_pb.LookupDirectoryEntryResult, len(req.Requests))
resultVolumeIDs := make([]map[string]struct{}, len(req.Requests))
jobs := make(chan int)
workerCount := min(len(req.Requests), maxFilerBatchLookupWorkers)
var workers sync.WaitGroup
workers.Add(workerCount)
for range workerCount {
go func() {
defer workers.Done()
for index := range jobs {
request := req.Requests[index]
entry, logTsNs, err := findEntry(ctx, util.JoinPath(request.Directory, request.Name))
result := &filer_pb.LookupDirectoryEntryResult{
LogTsNs: logTsNs,
LogSignature: logSignature,
}
switch {
case errors.Is(err, filer_pb.ErrNotFound):
results[index] = result
case err != nil:
result.Error = err.Error()
results[index] = result
case entry == nil:
result.Error = "entry lookup returned no entry"
results[index] = result
default:
pbEntry := entry.ToProtoEntry()
volumeIDs, volumeErr := filerBatchEntryVolumeIDs(pbEntry)
result.Found = true
result.Entry = pbEntry
if volumeErr != nil {
result.Error = volumeErr.Error()
}
results[index] = result
resultVolumeIDs[index] = volumeIDs
}
}
}()
}
for index := range req.Requests {
select {
case jobs <- index:
case <-ctx.Done():
close(jobs)
workers.Wait()
return nil, status.FromContextError(ctx.Err()).Err()
}
}
close(jobs)
workers.Wait()
if err := ctx.Err(); err != nil {
return nil, status.FromContextError(err).Err()
}
allVolumeIDs := make(map[string]struct{})
for _, volumeIDs := range resultVolumeIDs {
for volumeID := range volumeIDs {
allVolumeIDs[volumeID] = struct{}{}
}
}
volumeIDs := make([]string, 0, len(allVolumeIDs))
for volumeID := range allVolumeIDs {
volumeIDs = append(volumeIDs, volumeID)
}
sort.Strings(volumeIDs)
response := &filer_pb.LookupDirectoryEntriesResponse{
Results: results,
LocationsMap: make(map[string]*filer_pb.Locations, len(volumeIDs)),
}
if len(volumeIDs) == 0 {
return response, nil
}
locationsByVolume, lookupErr := lookupVolumes(ctx, volumeIDs)
// The provider returns a nil map when it got no answer, and a populated map
// with the volumes the master does not serve reported as errors when it did,
// so a nil map is the only sign of an unanswered lookup. Only a volume the
// master itself left out may become a miss; anything else stays an error.
missIsAuthoritative := req.UnavailableVolumeIsMiss && locationsByVolume != nil
for _, volumeID := range volumeIDs {
locations := locationsByVolume[volumeID]
if len(locations) != 0 {
response.LocationsMap[volumeID] = &filer_pb.Locations{
Locations: wdclientLocationsToPb(locations),
}
continue
}
if !missIsAuthoritative {
response.LocationsMap[volumeID] = &filer_pb.Locations{}
}
for index, entryVolumeIDs := range resultVolumeIDs {
if _, affected := entryVolumeIDs[volumeID]; !affected {
continue
}
if missIsAuthoritative && results[index].Error == "" {
results[index].Found = false
results[index].Entry = nil
resultVolumeIDs[index] = nil
continue
}
message := fmt.Sprintf("volume %s has no locations", volumeID)
if lookupErr != nil {
message += ": " + lookupErr.Error()
}
results[index].Error = appendFilerBatchLookupError(results[index].Error, message)
}
}
return response, nil
}
func validateFilerBatchLookupRequest(req *filer_pb.LookupDirectoryEntriesRequest) error {
if req == nil || len(req.Requests) == 0 {
return status.Error(codes.InvalidArgument, "batch lookup requires at least one request")
}
if len(req.Requests) > maxFilerBatchLookupRequests {
return status.Errorf(codes.ResourceExhausted, "batch lookup has %d requests; maximum is %d", len(req.Requests), maxFilerBatchLookupRequests)
}
for index, request := range req.Requests {
if request == nil {
return status.Errorf(codes.InvalidArgument, "batch lookup request %d is missing", index)
}
if request.Directory == "" || !strings.HasPrefix(request.Directory, "/") || strings.ContainsRune(request.Directory, '\x00') {
return status.Errorf(codes.InvalidArgument, "batch lookup request %d has invalid directory", index)
}
if request.Name == "" || request.Name == "." || request.Name == ".." || strings.ContainsAny(request.Name, "/\x00") {
return status.Errorf(codes.InvalidArgument, "batch lookup request %d has invalid name", index)
}
}
return nil
}
func filerBatchEntryVolumeIDs(entry *filer_pb.Entry) (map[string]struct{}, error) {
volumeIDs := make(map[string]struct{})
for index, chunk := range entry.GetChunks() {
if chunk == nil {
return volumeIDs, fmt.Errorf("entry chunk %d is missing", index)
}
if chunk.Fid != nil {
volumeIDs[strconv.FormatUint(uint64(chunk.Fid.VolumeId), 10)] = struct{}{}
continue
}
if chunk.FileId == "" {
return volumeIDs, fmt.Errorf("entry chunk %d has no file id", index)
}
fid, err := needle.ParseFileIdFromString(chunk.FileId)
if err != nil {
return volumeIDs, fmt.Errorf("entry chunk %d has invalid file id: %w", index, err)
}
volumeIDs[strconv.FormatUint(uint64(fid.VolumeId), 10)] = struct{}{}
}
return volumeIDs, nil
}
func appendFilerBatchLookupError(existing, next string) string {
if existing == "" {
return next
}
return existing + "; " + next
}