s3tables: keep the vector-namespace fence on a declare of an existing index (#12293)

s3tables: keep the fence on a declare of an existing index

Deciding the existing-index exception before the handler ran left a
window: an index deleted between that lookup and the handler's own check
would be recreated, unfenced, by a caller without s3vectors:CreateIndex.

Drop the up-front exception, so every CreateTable runs fenced and a
fresh create is refused at its first write. The engine's declare still
passes because the idempotent path writes nothing; for a build whose
idempotent path re-applies state already in place, the fence answers an
UpdateEntry that leaves the entry exactly as stored without writing.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chris LuandClaude Opus 5.5 authored and GitHub committed 2026-10-31 16:07:31 -07:00
1 parent e2826c48bd
commit ab355b43b9
2 files changed
+35 -44

No files matched your search

+16 -28
View File
@@ -12,6 +12,7 @@ import (
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"google.golang.org/grpc"
"google.golang.org/protobuf/proto"
)
// VectorIndexNamespace holds a vector bucket's indexes. Only the S3 Vectors
@@ -53,9 +54,9 @@ func (h *S3TablesHandler) checkVectorNamespaceWrite(ctx context.Context, filerCl
// fenceVectorNamespace returns a fence for an untrusted write naming the
// vector namespace of a LANCE bucket. The request still runs, so the
// operation's own checks answer everything short of a write and an ungranted
// caller gets its usual denial. A CreateTable of an index that already exists
// is the vector engine's declare and passes unfenced.
// operation's own checks answer everything short of a write: an ungranted
// caller gets its usual denial, and a CreateTable that finds the index already
// there (the vector engine's declare) succeeds.
func (h *S3TablesHandler) fenceVectorNamespace(w http.ResponseWriter, r *http.Request, filerClient FilerClient, operation string) *vectorNamespaceFence {
if h.trusted || strings.HasPrefix(operation, "Get") || strings.HasPrefix(operation, "List") {
return nil
@@ -69,7 +70,6 @@ func (h *S3TablesHandler) fenceVectorNamespace(w http.ResponseWriter, r *http.Re
SourceNamespace []string `json:"sourceNamespace"`
DestNamespace []string `json:"destNamespace"`
ResourceARN string `json:"resourceArn"`
Name string `json:"name"`
}
// Like the handler's own decode, this keeps going past a mistyped field.
_ = json.Unmarshal(body, &req)
@@ -83,34 +83,13 @@ func (h *S3TablesHandler) fenceVectorNamespace(w http.ResponseWriter, r *http.Re
targets = append(targets, target{bucket, []string{namespace}})
}
for _, t := range targets {
if t.bucket == "" || h.checkVectorNamespaceWrite(r.Context(), filerClient, t.bucket, t.namespace) == nil {
continue
if t.bucket != "" && h.checkVectorNamespaceWrite(r.Context(), filerClient, t.bucket, t.namespace) != nil {
return &vectorNamespaceFence{ResponseWriter: w, h: h, filer: filerClient}
}
if operation == "CreateTable" && isVectorIndexNamespace(req.Namespace) && h.vectorIndexExists(r.Context(), filerClient, bucketName, req.Name) {
return nil
}
return &vectorNamespaceFence{ResponseWriter: w, h: h, filer: filerClient}
}
return nil
}
func (h *S3TablesHandler) vectorIndexExists(ctx context.Context, filerClient FilerClient, bucketName, name string) bool {
if _, err := validateTableName(name); err != nil {
return false
}
exists := false
_ = filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
entry, err := h.lookupEntry(ctx, client, GetTablePath(bucketName, VectorIndexNamespace, name))
if err != nil {
return err
}
_, hasMetadata := entry.Extended[ExtendedKeyMetadata]
exists = hasMetadata && !isUnfinishedCreate(entry.Extended) && EntryType(entry.Extended) == EntryTypeTable
return nil
})
return exists
}
// vectorNamespaceFence passes reads, refuses filer writes, and answers the
// request as read-only once a write was refused. Handlers write only after
// their permission check, so an ungranted caller never sees that answer.
@@ -169,7 +148,16 @@ func (c readOnlyFilerClient) CreateEntry(context.Context, *filer_pb.CreateEntryR
return nil, c.refuse()
}
func (c readOnlyFilerClient) UpdateEntry(context.Context, *filer_pb.UpdateEntryRequest, ...grpc.CallOption) (*filer_pb.UpdateEntryResponse, error) {
// UpdateEntry answers a rewrite that leaves the entry exactly as stored
// without writing, so an idempotent path that repairs state already in place
// still passes.
func (c readOnlyFilerClient) UpdateEntry(ctx context.Context, req *filer_pb.UpdateEntryRequest, opts ...grpc.CallOption) (*filer_pb.UpdateEntryResponse, error) {
if req.GetEntry() != nil {
resp, err := c.SeaweedFilerClient.LookupDirectoryEntry(ctx, &filer_pb.LookupDirectoryEntryRequest{Directory: req.Directory, Name: req.Entry.Name}, opts...)
if err == nil && proto.Equal(resp.GetEntry(), req.Entry) {
return &filer_pb.UpdateEntryResponse{}, nil
}
}
return nil, c.refuse()
}