mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-08 15:41:15 +02:00
* 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
248 lines
7.9 KiB
Go
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
|
|
}
|