Files
seaweedfs/weed/s3api/s3vectors/handlers.go
T
Chris LuandClaude Opus 5.5 ec968aa016 s3vectors: support TagResource, UntagResource and ListTagsForResource (#12335)
* s3vectors: tag vector buckets and indexes

TagResource, UntagResource and ListTagsForResource answer on
/tags/{resourceArn} (POST, DELETE, GET) and by name. Each authorizes its
own s3vectors action on the bucket or index ARN, then reads or writes the
catalog record's tags. A resource holds at most 50 tags.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3vectors: apply the tags passed to CreateVectorBucket and CreateIndex

The tags field was accepted and dropped. It is now stored on the new
bucket or index, and creating with tags needs s3vectors:TagResource in
addition to the create action.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test/s3vectors: cover resource tagging end to end

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3tables: keep tag operations to a bound table bucket

TagResource, UntagResource and ListTagsForResource run through Execute
with WithTableBucketCreatedAt now answer not found when the bucket name
holds another bucket, as the bound creates and deletes already do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3vectors: bind tagging to the authorized bucket and to vector indexes

The catalog tag calls carry the creation time of the bucket authorizeVector
approved, so a bucket created again under the name is not touched. An index
ARN must name a Lance table: another table in the vector namespace of a
format-less bucket is refused, as DeleteIndex does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3vectors: match a TagResource grant scoped to the bucket being created

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3tables: merge tag writes into the tags the record holds at the write

TagResource and UntagResource merged into the tags they read before the
compare-and-swap and wrote that map on every attempt, so a tag write that
landed in between was undone. The merge now runs inside the mutation.
WithMaxTags lets an in-process caller cap the merged set there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3vectors: check the 50-tag limit inside the catalog's tag write

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3tables: check a tag operation's bound bucket after its reads

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3vectors: validate each tag key UntagResource removes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3vectors: validate untag keys one at a time

UntagResource sized a map by the number of submitted tag keys before
validating them, so a large body of repeated keys allocated far more than
the request itself, before authorization ran.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3vectors: authorize UntagResource before validating its keys

Validating a near-32 MiB body of tag keys costs about 13x its JSON
decode, so an unauthorized caller could make the gateway spend that
work on a request it then refuses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 10:33:26 -08:00

2005 lines
68 KiB
Go

package s3vectors
import (
"context"
"encoding/json"
"errors"
"fmt"
"math"
"net/http"
"strconv"
"strings"
"time"
awsarn "github.com/aws/aws-sdk-go-v2/aws/arn"
"github.com/google/uuid"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/vector_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/structpb"
)
var handlers = map[string]func(*Server, http.ResponseWriter, *http.Request){
"CreateVectorBucket": (*Server).handleCreateVectorBucket,
"GetVectorBucket": (*Server).handleGetVectorBucket,
"ListVectorBuckets": (*Server).handleListVectorBuckets,
"DeleteVectorBucket": (*Server).handleDeleteVectorBucket,
"PutVectorBucketPolicy": (*Server).handlePutVectorBucketPolicy,
"GetVectorBucketPolicy": (*Server).handleGetVectorBucketPolicy,
"DeleteVectorBucketPolicy": (*Server).handleDeleteVectorBucketPolicy,
"CreateIndex": (*Server).handleCreateIndex,
"GetIndex": (*Server).handleGetIndex,
"ListIndexes": (*Server).handleListIndexes,
"DeleteIndex": (*Server).handleDeleteIndex,
"PutVectors": (*Server).handlePutVectors,
"GetVectors": (*Server).handleGetVectors,
"ListVectors": (*Server).handleListVectors,
"DeleteVectors": (*Server).handleDeleteVectors,
"QueryVectors": (*Server).handleQueryVectors,
"TagResource": (*Server).handleTagResource,
"UntagResource": (*Server).handleUntagResource,
"ListTagsForResource": (*Server).handleListTagsForResource,
}
// lance table ids put indexes under a "vector" namespace inside their bucket.
const indexNamespace = s3tables.VectorIndexNamespace
// ensureIndexNamespaceAttempts bounds how often ensureIndexNamespace declares
// the namespace again after losing it to a concurrent declare.
const ensureIndexNamespaceAttempts = 3
// Vector bucket records are s3tables table buckets; the S3 Vectors ARN form is
// arn:aws:s3vectors:{region}:{account}:bucket/{name}[/index/{name}].
func (s *Server) vectorBucketArn(bucket string) string {
return fmt.Sprintf("arn:aws:s3vectors:%s:%s:bucket/%s", s.region, s.accountID, bucket)
}
func (s *Server) indexArn(bucket, index string) string {
return fmt.Sprintf("%s/index/%s", s.vectorBucketArn(bucket), index)
}
// s3tablesArn renders the same bucket in the table-bucket ARN form the S3
// Tables catalog keys records by.
func (s *Server) s3tablesArn(bucket string) string {
return fmt.Sprintf("arn:aws:s3tables:%s:%s:bucket/%s", s.region, s.accountID, bucket)
}
func (s *Server) arnParts(v *vectorBucketARNs, name, arn string) error {
if arn == "" {
v.bucket = name
return nil
}
if !strings.HasPrefix(arn, "arn:") {
v.bucket = arn
return nil
}
parsed, err := awsarn.Parse(arn)
if err != nil {
return fmt.Errorf("malformed ARN %q", arn)
}
if !strings.HasPrefix(arn, s.vectorBucketArn("")) {
return fmt.Errorf("ARN %q does not belong to this vector namespace", arn)
}
parts := strings.Split(parsed.Resource, "/")
if (len(parts) != 2 && len(parts) != 4) || parts[0] != "bucket" || parts[1] == "" {
return fmt.Errorf("malformed ARN %q", arn)
}
if len(parts) == 4 && (parts[2] != "index" || parts[3] == "") {
return fmt.Errorf("malformed ARN %q", arn)
}
v.bucket, v.index = parts[1], ""
if len(parts) == 4 {
v.index = parts[3]
}
return nil
}
// bucketARNs resolves bucket selectors and rejects index ARNs.
func (s *Server) bucketARNs(name, arn string) (vectorBucketARNs, error) {
var v vectorBucketARNs
if err := s.arnParts(&v, name, arn); err != nil {
return v, err
}
if v.index != "" {
return v, fmt.Errorf("vectorBucketArn %q names an index, not a bucket", arn)
}
if name != "" && name != v.bucket {
return v, fmt.Errorf("vectorBucketName and vectorBucketArn name different buckets")
}
return v, nil
}
// resolveIndexARNs resolves names, a bucket ARN, or an index ARN.
func (s *Server) resolveIndexARNs(bucketName, bucketArn, indexName, indexArn string) (vectorBucketARNs, error) {
var v vectorBucketARNs
if strings.HasPrefix(indexArn, "arn:") {
if err := s.arnParts(&v, "", indexArn); err != nil {
return v, err
}
if v.index == "" {
return v, fmt.Errorf("indexArn %q does not name an index", indexArn)
}
} else if indexArn != "" {
return v, fmt.Errorf("indexArn must be an ARN, got %q", indexArn)
}
if bucketArn != "" {
if !strings.HasPrefix(bucketArn, "arn:") {
return v, fmt.Errorf("vectorBucketArn must be an ARN, got %q", bucketArn)
}
bucket, err := s.bucketARNs("", bucketArn)
if err != nil {
return v, err
}
if v.bucket != "" && v.bucket != bucket.bucket {
return v, fmt.Errorf("vectorBucketArn and indexArn name different buckets")
}
v.bucket = bucket.bucket
}
if v.bucket == "" {
v.bucket = bucketName
} else if bucketName != "" && bucketName != v.bucket {
return v, fmt.Errorf("vectorBucketName and ARN bucket name different buckets")
}
if v.index == "" {
v.index = indexName
} else if indexName != "" && indexName != v.index {
return v, fmt.Errorf("indexName and indexArn name different indexes")
}
if v.bucket == "" || v.index == "" {
return v, fmt.Errorf("vectorBucketName and indexName (or indexArn) are required")
}
return v, nil
}
// indexRecord fetches the index's catalog record on the trusted path; the
// operation's own vector permission — checked in authorizeVector before any
// handler reaches this — is what gates access.
func (s *Server) indexRecord(ctx context.Context, bucket, index string) (map[string]interface{}, error) {
var resp map[string]interface{}
err := s.internalCatalog(ctx, "GetTable", &s3tables.GetTableRequest{
TableBucketARN: s.s3tablesArn(bucket),
Namespace: []string{indexNamespace},
Name: index,
}, &resp)
return resp, err
}
// internalCatalog runs catalog bookkeeping under the trusted identity. It is
// for reads and writes that accompany — never replace — the operation's own
// permission check: bucket format lookups, the implicit index namespace, and
// rollback of a record this request just created.
func (s *Server) internalCatalog(ctx context.Context, operation string, req, resp interface{}) error {
return s.internal.Execute(ctx, s.filerClient, operation, req, resp, "")
}
func catalogErrCode(err error) string {
var apiErr *s3tables.S3TablesError
if errors.As(err, &apiErr) {
return apiErr.Type
}
return ""
}
func catalogErr(w http.ResponseWriter, err error) {
var apiErr *s3tables.S3TablesError
if !errors.As(err, &apiErr) {
glog.Errorf("S3 Vectors: catalog: %v", err)
writeError(w, http.StatusInternalServerError, "InternalFailure", internalErrorMessage)
return
}
switch {
case apiErr.Type == s3tables.ErrCodeAccessDenied:
writeError(w, http.StatusForbidden, "AccessDeniedException", "access denied")
case strings.HasPrefix(apiErr.Type, "NoSuch"):
writeError(w, http.StatusNotFound, "NotFoundException", apiErr.Message)
case strings.Contains(apiErr.Type, "AlreadyExists") || strings.Contains(apiErr.Type, "NotEmpty") ||
apiErr.Type == s3tables.ErrCodeConflict:
writeError(w, http.StatusConflict, "ConflictException", apiErr.Message)
case apiErr.Type == s3tables.ErrCodeInternalError:
writeError(w, http.StatusInternalServerError, "InternalFailure", apiErr.Message)
default:
writeError(w, http.StatusBadRequest, "ValidationException", apiErr.Message)
}
}
const internalErrorMessage = "internal error, see the S3 gateway log"
// vectorErr maps a VectorService gRPC status onto the API's error shape.
// Engine messages reach clients only for codes that describe the request
// itself; transport and internal details stay in the server log.
func vectorErr(w http.ResponseWriter, r *http.Request, arns vectorBucketARNs, err error) {
msg := status.Convert(err).Message()
switch status.Code(err) {
case codes.NotFound:
writeError(w, http.StatusNotFound, "NotFoundException", msg)
case codes.InvalidArgument, codes.FailedPrecondition, codes.OutOfRange:
writeError(w, http.StatusBadRequest, "ValidationException", msg)
case codes.PermissionDenied, codes.Unauthenticated:
writeError(w, http.StatusForbidden, "AccessDeniedException", "access denied")
case codes.Unavailable:
glog.Warningf("S3 Vectors: %s %s/%s: %v", operationFromRequest(r), arns.bucket, arns.index, err)
writeError(w, http.StatusServiceUnavailable, "ServiceUnavailableException", "vector service unavailable")
default:
glog.Errorf("S3 Vectors: %s %s/%s: %v", operationFromRequest(r), arns.bucket, arns.index, err)
writeError(w, http.StatusInternalServerError, "InternalFailure", internalErrorMessage)
}
}
// ensureIndexNamespace declares the implicit index namespace when absent. The
// calls are internal: a caller's CreateTable grant is what authorizes the
// index, so it must not also need namespace reads or writes.
func (s *Server) ensureIndexNamespace(ctx context.Context, bucket string) error {
var err error
for attempt := 0; attempt < ensureIndexNamespaceAttempts; attempt++ {
if attempt > 0 {
time.Sleep(time.Duration(attempt) * 50 * time.Millisecond)
}
var resp map[string]interface{}
err = s.internalCatalog(ctx, "GetNamespace", &s3tables.GetNamespaceRequest{
TableBucketARN: s.s3tablesArn(bucket),
Namespace: []string{indexNamespace},
}, &resp)
if err == nil {
return nil
}
if code := catalogErrCode(err); code != s3tables.ErrCodeNoSuchNamespace && code != s3tables.ErrCodeNoSuchBucket {
return err
}
err = s.internalCatalog(ctx, "CreateNamespace", &s3tables.CreateNamespaceRequest{
TableBucketARN: s.s3tablesArn(bucket),
Namespace: []string{indexNamespace},
}, &resp)
// A concurrent CreateIndex may have won the create, or taken over this
// one's namespace before it was confirmed.
if err == nil || catalogErrCode(err) == s3tables.ErrCodeNamespaceAlreadyExists {
return nil
}
if catalogErrCode(err) != s3tables.ErrCodeConflict {
return err
}
}
return err
}
func (s *Server) handleCreateVectorBucket(w http.ResponseWriter, r *http.Request) {
var req CreateVectorBucketRequest
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
if req.VectorBucketName == "" {
writeError(w, http.StatusBadRequest, "ValidationException", "vectorBucketName is required")
return
}
if err := s3tables.ValidateEncryptionConfiguration(req.EncryptionConfiguration, "sseType"); err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
if err := s3tables.ValidateTagsWithLimit(req.Tags, maxResourceTags); err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
// The catalog keeps the s3tables field name; GetVectorBucket renames it back.
enc := req.EncryptionConfiguration
if len(enc) != 0 {
enc = map[string]interface{}{"sseAlgorithm": enc["sseType"]}
}
// No bucket exists yet, so check against the handler account as the
// catalog does for CreateTableBucket.
principal := s.tables.PrincipalFor(r)
allowed := func(action, bucket string) bool {
return s3tables.CheckPermissionWithContext(action, principal, s.tables.PrincipalFor(nil), "", "", s.tables.PolicyContext(r, bucket))
}
// Tagging names the new bucket, so a grant scoped to it applies.
if !allowed("s3vectors:CreateVectorBucket", "") ||
(len(req.Tags) > 0 && !allowed("s3vectors:TagResource", req.VectorBucketName)) {
catalogErr(w, errVectorDenied)
return
}
var resp map[string]interface{}
err := s.internal.Execute(r.Context(), s.filerClient, "CreateTableBucket",
&s3tables.CreateTableBucketRequest{
Name: req.VectorBucketName,
Format: s3tables.FormatLance,
EncryptionConfiguration: enc,
Tags: req.Tags,
}, &resp, identityOf(r))
if err != nil {
catalogErr(w, err)
return
}
writeJSON(w, http.StatusOK, &CreateVectorBucketResponse{VectorBucketArn: s.vectorBucketArn(req.VectorBucketName)})
}
func (s *Server) handleGetVectorBucket(w http.ResponseWriter, r *http.Request) {
var req GetVectorBucketRequest
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
arns, err := s.bucketARNs(req.VectorBucketName, req.VectorBucketArn)
if err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
record, err := s.authorizeVector(r.Context(), r, "s3vectors:GetVectorBucket", arns.bucket, "")
if err != nil {
catalogErr(w, err)
return
}
bucket := &VectorBucket{
VectorBucketArn: s.vectorBucketArn(arns.bucket),
VectorBucketName: arns.bucket,
CreationTime: record.CreatedAt,
}
if cfg, ok := record.EncryptionConfiguration["sseAlgorithm"]; ok {
bucket.EncryptionConfiguration = map[string]interface{}{"sseType": cfg}
} else if len(record.EncryptionConfiguration) > 0 {
bucket.EncryptionConfiguration = record.EncryptionConfiguration
}
writeJSON(w, http.StatusOK, &GetVectorBucketResponse{VectorBucket: bucket})
}
func (s *Server) handleListVectorBuckets(w http.ResponseWriter, r *http.Request) {
var req ListVectorBucketsRequest
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
maxResults := 0
if req.MaxResults != nil {
if *req.MaxResults < 1 || *req.MaxResults > vectorsMaxListPage {
writeError(w, http.StatusBadRequest, "ValidationException", fmt.Sprintf("maxResults must be between 1 and %d", vectorsMaxListPage))
return
}
maxResults = *req.MaxResults
}
var resp struct {
TableBuckets []struct {
Arn string `json:"arn"`
Name string `json:"name"`
CreatedAt string `json:"createdAt"`
Format string `json:"format"`
} `json:"tableBuckets"`
ContinuationToken string `json:"continuationToken"`
}
// The listing runs internal and filters on the vector policy itself: a
// shared-slot check would hide buckets the caller was granted under
// s3vectors actions, while showing ones they were not.
err := s.internalCatalog(r.Context(), "ListTableBuckets",
&s3tables.ListTableBucketsRequest{
MaxBuckets: maxResults, ContinuationToken: req.NextToken, Prefix: req.Prefix,
}, &resp)
if err != nil {
catalogErr(w, err)
return
}
principal := s.tables.PrincipalFor(r)
// The catalog token continues the filtered page: ICEBERG buckets drop out
// of the listing, Lance and format-less legacy buckets stay.
out := &ListVectorBucketsResponse{NextToken: resp.ContinuationToken, VectorBuckets: []*VectorBucket{}}
for _, b := range resp.TableBuckets {
if b.Format != "" && b.Format != s3tables.FormatLance {
continue
}
owner, policy, err := s.vectorPolicyView(r.Context(), b.Name)
if err != nil {
// A bucket that vanished mid-list drops out of the page rather
// than failing the whole listing.
continue
}
arn := s.vectorBucketArn(b.Name)
pctx := s.tables.PolicyContext(r, b.Name)
if !s3tables.CheckPermissionWithContext("s3vectors:ListVectorBuckets", principal, owner, policy, arn, pctx) &&
!s3tables.CheckPermissionWithContext("s3vectors:GetVectorBucket", principal, owner, policy, arn, pctx) {
continue
}
created, _ := time.Parse(time.RFC3339, b.CreatedAt)
out.VectorBuckets = append(out.VectorBuckets, &VectorBucket{
VectorBucketArn: s.vectorBucketArn(b.Name),
VectorBucketName: b.Name,
CreationTime: created,
})
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleDeleteVectorBucket(w http.ResponseWriter, r *http.Request) {
var req DeleteVectorBucketRequest
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
arns, err := s.bucketARNs(req.VectorBucketName, req.VectorBucketArn)
if err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
record, err := s.authorizeVector(r.Context(), r, "s3vectors:DeleteVectorBucket", arns.bucket, "")
if err != nil {
catalogErr(w, err)
return
}
// Only the bucket authorized above is deleted, not one created again
// under its name since.
ctx := s3tables.WithTableBucketCreatedAt(r.Context(), record.CreatedAt)
var resp map[string]interface{}
err = s.internalCatalog(ctx, "DeleteTableBucket",
&s3tables.DeleteTableBucketRequest{TableBucketARN: s.s3tablesArn(arns.bucket)}, &resp)
if err != nil && catalogErrCode(err) == s3tables.ErrCodeBucketNotEmpty {
// The implicit index namespace counts as bucket content even when it
// holds no indexes; drop it under the delete's own authorization and
// retry once. A namespace still holding indexes keeps the conflict.
var nsResp map[string]interface{}
nsErr := s.internalCatalog(context.WithoutCancel(ctx), "DeleteNamespace",
&s3tables.DeleteNamespaceRequest{
TableBucketARN: s.s3tablesArn(arns.bucket),
Namespace: []string{indexNamespace},
}, &nsResp)
if nsErr == nil {
err = s.internalCatalog(ctx, "DeleteTableBucket",
&s3tables.DeleteTableBucketRequest{TableBucketARN: s.s3tablesArn(arns.bucket)}, &resp)
} else if code := catalogErrCode(nsErr); code != s3tables.ErrCodeNoSuchNamespace {
glog.V(1).Infof("S3 Vectors: drop internal namespace in %s: %v", arns.bucket, nsErr)
}
}
if err != nil {
catalogErr(w, err)
return
}
writeJSON(w, http.StatusOK, &DeleteVectorBucketResponse{})
}
func distanceMetric(metric string) (vector_pb.DistanceMetric, bool) {
switch strings.ToLower(metric) {
case "", "euclidean":
return vector_pb.DistanceMetric_L2, true
case "cosine":
return vector_pb.DistanceMetric_COSINE, true
case "dotproduct":
return vector_pb.DistanceMetric_DOT_PRODUCT, true
}
return vector_pb.DistanceMetric_L2, false
}
func tableID(bucket, index string) *vector_pb.Table {
return &vector_pb.Table{Id: []string{bucket, indexNamespace, index}}
}
// vectorPolicyKey names the table-bucket attribute holding the bucket's
// s3vectors policy. The document stays in s3vectors form: the shared
// evaluator only honors catalog actions, so vector operations evaluate it
// themselves — against the s3vectors action and ARN the request names. A
// separate key keeps a vector grant from ever authorizing a catalog call.
// The constant lives in s3tables so the catalog asserts it in the update
// preconditions of its own whole-entry writes.
const vectorPolicyKey = s3tables.ExtendedKeyVectorPolicy
// errNoVectorPolicy reports a missing policy document; handlers map it to a
// 404 the same way the catalog maps a missing resource policy.
var errNoVectorPolicy = errors.New("vector bucket policy not found")
// validateVectorPolicy rejects a document the evaluator cannot honor: the
// shared Statement shape has no negated fields, so a rule relying on one would
// silently lose its exclusion; conditions are unsupported since the vector
// evaluation supplies no condition keys; and a rule naming another service is
// a dead rule the writer believes is enforced. The document must decode into
// the same typed form the evaluator uses.
func validateVectorPolicy(raw string) error {
var typed s3tables.PolicyDocument
if err := json.Unmarshal([]byte(raw), &typed); err != nil {
return fmt.Errorf("invalid policy document: %w", err)
}
var doc map[string]interface{}
if err := json.Unmarshal([]byte(raw), &doc); err != nil {
return fmt.Errorf("invalid policy document: %w", err)
}
for _, stmt := range policyStatements(doc) {
for key := range stmt {
for _, field := range []string{"NotAction", "NotResource", "NotPrincipal", "Condition"} {
if strings.EqualFold(key, field) {
return fmt.Errorf("invalid policy document: %s is not supported", field)
}
}
}
}
for _, stmt := range typed.Statement {
for _, action := range statementActions(stmt.Action) {
if service, _, found := strings.Cut(action, ":"); found && service != "s3vectors" {
return fmt.Errorf("invalid policy document: action %q is not enforceable in a vector bucket policy", action)
}
}
for _, resource := range statementActions(stmt.Resource) {
if service := arnService(resource); service != "" && service != "s3vectors" {
return fmt.Errorf("invalid policy document: resource %q is not enforceable in a vector bucket policy", resource)
}
}
}
return nil
}
// arnService returns the service field of an ARN in any partition.
func arnService(resource string) string {
parts := strings.SplitN(resource, ":", 4)
if len(parts) == 4 && parts[0] == "arn" {
return parts[2]
}
return ""
}
func policyStatements(doc map[string]interface{}) []map[string]interface{} {
var stmts []map[string]interface{}
switch s := doc["Statement"].(type) {
case []interface{}:
for _, item := range s {
if stmt, ok := item.(map[string]interface{}); ok {
stmts = append(stmts, stmt)
}
}
case map[string]interface{}:
stmts = append(stmts, s)
}
return stmts
}
// policyHasVectorAction reports whether any statement names an s3vectors
// action — how a raw vector document in the shared policy slot is recognized.
// It reads the typed document so detection matches what the evaluator sees.
func policyHasVectorAction(raw string) bool {
var doc s3tables.PolicyDocument
if json.Unmarshal([]byte(raw), &doc) != nil {
return false
}
for _, stmt := range doc.Statement {
for _, action := range statementActions(stmt.Action) {
if strings.HasPrefix(action, "s3vectors:") {
return true
}
}
}
return false
}
func statementActions(spec interface{}) []string {
switch a := spec.(type) {
case string:
return []string{a}
case []interface{}:
var actions []string
for _, item := range a {
if str, ok := item.(string); ok {
actions = append(actions, str)
}
}
return actions
}
return nil
}
func splitEntryPath(p string) (dir, name string) {
i := strings.LastIndex(p, "/")
return p[:i], p[i+1:]
}
// vectorPolicyFromExt resolves the effective s3vectors policy from an entry's
// attributes: the dedicated key wins, else a shared-slot document that still
// names vector actions.
func vectorPolicyFromExt(ext map[string][]byte) string {
if doc := ext[vectorPolicyKey]; len(doc) > 0 {
return string(doc)
}
if doc := string(ext[s3tables.ExtendedKeyPolicy]); policyHasVectorAction(doc) {
return doc
}
return ""
}
// stripVectorActions removes s3vectors statements from a shared-slot
// document, keeping its catalog rules: a statement mixing both kinds keeps
// its catalog actions, and a now-empty statement drops out. It reports the
// document to write back — empty when nothing remains — and whether anything
// changed.
func stripVectorActions(raw string) (string, bool) {
var doc map[string]interface{}
if err := json.Unmarshal([]byte(raw), &doc); err != nil {
return "", false
}
changed := false
var kept []map[string]interface{}
for _, stmt := range policyStatements(doc) {
dropped := false
for key, value := range stmt {
if !strings.EqualFold(key, "Action") {
continue
}
var actions []string
keyDropped := false
for _, a := range statementActions(value) {
if strings.HasPrefix(a, "s3vectors:") {
keyDropped = true
continue
}
actions = append(actions, a)
}
if !keyDropped {
continue
}
dropped = true
if len(actions) > 0 {
stmt[key] = actions
} else {
delete(stmt, key)
}
}
if !dropped {
kept = append(kept, stmt)
continue
}
changed = true
for key := range stmt {
if strings.EqualFold(key, "Action") {
kept = append(kept, stmt)
break
}
}
}
if !changed {
return "", false
}
if len(kept) == 0 {
return "", true
}
doc["Statement"] = kept
out, err := json.Marshal(doc)
if err != nil {
return "", false
}
return string(out), true
}
// vectorBucket fetches the bucket's catalog record under the trusted
// identity for the authorization lookup; nothing reaches the caller until
// the permission check below it passes.
func (s *Server) vectorBucket(ctx context.Context, bucket string) (*s3tables.GetTableBucketResponse, error) {
var resp s3tables.GetTableBucketResponse
if err := s.internalCatalog(ctx, "GetTableBucket",
&s3tables.GetTableBucketRequest{TableBucketARN: s.s3tablesArn(bucket)}, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// vectorPolicy reads the bucket's stored s3vectors policy. A bucket written
// before the dedicated key carried the raw document in the shared policy
// slot; one that still names vector actions keeps working until the next
// vector policy write migrates it out.
func (s *Server) vectorPolicy(ctx context.Context, bucket string) (string, error) {
dir, name := splitEntryPath(s3tables.GetTableBucketPath(bucket))
var policy string
err := s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
resp, err := filer_pb.LookupEntry(ctx, client, &filer_pb.LookupDirectoryEntryRequest{
Directory: dir,
Name: name,
})
if err != nil {
return err
}
policy = vectorPolicyFromExt(resp.Entry.Extended)
return nil
})
return policy, err
}
// vectorPolicyView resolves a bucket's owner and stored s3vectors policy in
// one filer lookup — the per-bucket check the vector listing runs.
func (s *Server) vectorPolicyView(ctx context.Context, bucket string) (owner, policy string, err error) {
dir, name := splitEntryPath(s3tables.GetTableBucketPath(bucket))
err = s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
resp, err := filer_pb.LookupEntry(ctx, client, &filer_pb.LookupDirectoryEntryRequest{
Directory: dir,
Name: name,
})
if err != nil {
return err
}
var meta struct {
OwnerAccountID string `json:"ownerAccountId"`
}
if raw := resp.Entry.Extended[s3tables.ExtendedKeyMetadata]; len(raw) > 0 {
_ = json.Unmarshal(raw, &meta)
}
owner = meta.OwnerAccountID
policy = vectorPolicyFromExt(resp.Entry.Extended)
return nil
})
return owner, policy, err
}
// mutateVectorPolicy applies a change to the bucket's vector-policy
// attribute under the entry's extended-attribute compare-and-swap, retrying
// a racing writer the way the catalog's own policy updates do.
func (s *Server) mutateVectorPolicy(ctx context.Context, bucket string, mutate func(ext map[string][]byte) error) error {
dir, name := splitEntryPath(s3tables.GetTableBucketPath(bucket))
return s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
for attempt := 0; attempt < 3; attempt++ {
resp, err := filer_pb.LookupEntry(ctx, client, &filer_pb.LookupDirectoryEntryRequest{
Directory: dir,
Name: name,
})
if err != nil {
return err
}
entry := resp.Entry
// Assert the vector policy key stays absent too, so a concurrent
// policy write trips the compare-and-swap instead of being
// silently reverted by the whole-entry update.
expected := s3tables.SnapshotExtended(entry.Extended, vectorPolicyKey)
if entry.Extended == nil {
entry.Extended = make(map[string][]byte)
}
if err := mutate(entry.Extended); err != nil {
return err
}
if _, err := client.UpdateEntry(ctx, &filer_pb.UpdateEntryRequest{
Directory: dir,
Entry: entry,
ExpectedExtended: expected,
}); err == nil {
return nil
} else if status.Code(err) != codes.FailedPrecondition {
return err
}
}
return &s3tables.S3TablesError{Type: s3tables.ErrCodeConflict,
Message: "vector bucket policy changed concurrently, retry the request"}
})
}
// isVectorBucket reports whether the catalog record describes a vector
// bucket: an explicit ICEBERG bucket is not one, while a bucket declared
// before formats existed reads as Lance.
func isVectorBucket(record *s3tables.GetTableBucketResponse) bool {
return record.Format == "" || record.Format == s3tables.FormatLance
}
// errVectorDenied marks a failed vector permission check inside a policy
// mutation; catalogErr renders it as AccessDenied.
var errVectorDenied = &s3tables.S3TablesError{Type: s3tables.ErrCodeAccessDenied, Message: "access denied"}
var errVectorBucketReplaced = &s3tables.S3TablesError{Type: s3tables.ErrCodeConflict,
Message: "vector bucket changed concurrently, retry the request"}
// checkVectorPolicyDoc evaluates the caller's vector action against the
// document the entry currently holds. Called inside a policy mutation's
// compare-and-swap, so a grant revoked between reads never authorizes the
// pending write, and the entry must still be the bucket record was read from.
func (s *Server) checkVectorPolicyDoc(r *http.Request, action, bucket string, record *s3tables.GetTableBucketResponse, ext map[string][]byte) error {
var meta struct {
CreatedAt time.Time `json:"createdAt"`
}
if json.Unmarshal(ext[s3tables.ExtendedKeyMetadata], &meta) != nil || !meta.CreatedAt.Equal(record.CreatedAt) {
return errVectorBucketReplaced
}
principal := s.tables.PrincipalFor(r)
if !s3tables.CheckPermissionWithContext(action, principal, record.OwnerAccountID,
vectorPolicyFromExt(ext), s.vectorBucketArn(bucket), s.tables.PolicyContext(r, bucket)) {
return errVectorDenied
}
return nil
}
// authorizeVector grants the request's own vector operation — not a catalog
// stand-in — against the stored s3vectors policy and the ARN it actually
// targets, so an index-scoped rule reaches the index it names and a read
// grant never carries a write. Catalog bookkeeping below rides on the
// trusted internal path, the same pattern the implicit namespace uses.
// The returned record saves the handler a second bucket lookup.
func (s *Server) authorizeVector(ctx context.Context, r *http.Request, action, bucket, index string) (*s3tables.GetTableBucketResponse, error) {
record, err := s.vectorBucket(ctx, bucket)
if err != nil {
return nil, err
}
policy, err := s.vectorPolicy(ctx, bucket)
if err != nil {
return nil, err
}
resourceARN := s.vectorBucketArn(bucket)
if index != "" {
resourceARN = s.indexArn(bucket, index)
}
principal := s.tables.PrincipalFor(r)
if !s3tables.CheckPermissionWithContext(action, principal, record.OwnerAccountID, policy, resourceARN, s.tables.PolicyContext(r, bucket)) {
return nil, &s3tables.S3TablesError{Type: s3tables.ErrCodeAccessDenied, Message: "access denied"}
}
if !isVectorBucket(record) {
return nil, &s3tables.S3TablesError{Type: s3tables.ErrCodeNoSuchBucket,
Message: fmt.Sprintf("vector bucket %s not found", bucket)}
}
return record, nil
}
func (s *Server) handlePutVectorBucketPolicy(w http.ResponseWriter, r *http.Request) {
var req PutVectorBucketPolicyRequest
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
arns, err := s.bucketARNs(req.VectorBucketName, req.VectorBucketArn)
if err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
if err := validateVectorPolicy(req.Policy); err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
record, err := s.vectorBucket(r.Context(), arns.bucket)
if err != nil {
catalogErr(w, err)
return
}
if !isVectorBucket(record) {
writeError(w, http.StatusNotFound, "NotFoundException",
fmt.Sprintf("vector bucket %s not found", arns.bucket))
return
}
err = s.mutateVectorPolicy(r.Context(), arns.bucket, func(ext map[string][]byte) error {
if err := s.checkVectorPolicyDoc(r, "s3vectors:PutVectorBucketPolicy", arns.bucket, record, ext); err != nil {
return err
}
// A raw vector document still in the shared slot migrates out with
// this write; catalog rules in it stay untouched.
if stripped, changed := stripVectorActions(string(ext[s3tables.ExtendedKeyPolicy])); changed {
if stripped == "" {
delete(ext, s3tables.ExtendedKeyPolicy)
} else {
ext[s3tables.ExtendedKeyPolicy] = []byte(stripped)
}
}
ext[vectorPolicyKey] = []byte(req.Policy)
return nil
})
if err != nil {
catalogErr(w, err)
return
}
writeJSON(w, http.StatusOK, &PutVectorBucketPolicyResponse{})
}
func (s *Server) handleGetVectorBucketPolicy(w http.ResponseWriter, r *http.Request) {
var req GetVectorBucketPolicyRequest
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
arns, err := s.bucketARNs(req.VectorBucketName, req.VectorBucketArn)
if err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
if _, err := s.authorizeVector(r.Context(), r, "s3vectors:GetVectorBucketPolicy", arns.bucket, ""); err != nil {
catalogErr(w, err)
return
}
policy, err := s.vectorPolicy(r.Context(), arns.bucket)
if err != nil {
catalogErr(w, err)
return
}
if policy == "" {
writeError(w, http.StatusNotFound, "NotFoundException", errNoVectorPolicy.Error())
return
}
writeJSON(w, http.StatusOK, &GetVectorBucketPolicyResponse{Policy: policy})
}
func (s *Server) handleDeleteVectorBucketPolicy(w http.ResponseWriter, r *http.Request) {
var req DeleteVectorBucketPolicyRequest
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
arns, err := s.bucketARNs(req.VectorBucketName, req.VectorBucketArn)
if err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
record, err := s.vectorBucket(r.Context(), arns.bucket)
if err != nil {
catalogErr(w, err)
return
}
if !isVectorBucket(record) {
writeError(w, http.StatusNotFound, "NotFoundException",
fmt.Sprintf("vector bucket %s not found", arns.bucket))
return
}
err = s.mutateVectorPolicy(r.Context(), arns.bucket, func(ext map[string][]byte) error {
if err := s.checkVectorPolicyDoc(r, "s3vectors:DeleteVectorBucketPolicy", arns.bucket, record, ext); err != nil {
return err
}
removed := false
if _, ok := ext[vectorPolicyKey]; ok {
delete(ext, vectorPolicyKey)
removed = true
}
// A legacy document in the shared slot loses its vector rules with
// the delete; catalog rules in it stay untouched.
if stripped, changed := stripVectorActions(string(ext[s3tables.ExtendedKeyPolicy])); changed {
removed = true
if stripped == "" {
delete(ext, s3tables.ExtendedKeyPolicy)
} else {
ext[s3tables.ExtendedKeyPolicy] = []byte(stripped)
}
}
if !removed {
return errNoVectorPolicy
}
return nil
})
if errors.Is(err, errNoVectorPolicy) {
writeError(w, http.StatusNotFound, "NotFoundException", errNoVectorPolicy.Error())
return
}
if err != nil {
catalogErr(w, err)
return
}
writeJSON(w, http.StatusOK, &DeleteVectorBucketPolicyResponse{})
}
func (s *Server) handleCreateIndex(w http.ResponseWriter, r *http.Request) {
var req CreateIndexRequest
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
if strings.ToLower(req.DataType) != "float32" {
writeError(w, http.StatusBadRequest, "ValidationException", "only float32 vectors are supported")
return
}
metric, ok := distanceMetric(req.DistanceMetric)
if !ok {
writeError(w, http.StatusBadRequest, "ValidationException", "unknown distanceMetric "+req.DistanceMetric)
return
}
arns, err := s.resolveIndexARNs(req.VectorBucketName, req.VectorBucketArn, req.IndexName, req.IndexArn)
if err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
if req.Dimension <= 0 || req.Dimension > 4096 {
writeError(w, http.StatusBadRequest, "ValidationException", "dimension must be 1..=4096")
return
}
if err := s3tables.ValidateTagsWithLimit(req.Tags, maxResourceTags); err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
// The create grant is exercised before any write: a denied caller must not
// leave even the implicit namespace behind.
bucketRecord, err := s.authorizeVector(r.Context(), r, "s3vectors:CreateIndex", arns.bucket, arns.index)
if err != nil {
catalogErr(w, err)
return
}
if len(req.Tags) > 0 {
if _, err := s.authorizeVector(r.Context(), r, "s3vectors:TagResource", arns.bucket, arns.index); err != nil {
catalogErr(w, err)
return
}
}
// The catalog writes land only in the bucket authorized above.
bound := s3tables.WithTableBucketCreatedAt(r.Context(), bucketRecord.CreatedAt)
// The namespace is implicit on AWS and internal bookkeeping here.
if err := s.ensureIndexNamespace(bound, arns.bucket); err != nil {
catalogErr(w, err)
return
}
// Only a caller who passed the create check may learn an index exists.
var existing map[string]interface{}
getErr := s.internalCatalog(r.Context(), "GetTable", &s3tables.GetTableRequest{
TableBucketARN: s.s3tablesArn(arns.bucket),
Namespace: []string{indexNamespace},
Name: arns.index,
}, &existing)
switch {
case getErr == nil:
writeError(w, http.StatusConflict, "ConflictException",
fmt.Sprintf("index %s already exists", arns.index))
return
case catalogErrCode(getErr) != s3tables.ErrCodeNoSuchTable:
catalogErr(w, getErr)
return
}
metadata := map[string]string{
"dimension": fmt.Sprint(req.Dimension),
"distanceMetric": strings.ToLower(req.DistanceMetric),
// The nonce marks the record this request created, so a rollback after
// an engine failure can never erase a pre-existing index.
"createNonce": uuid.NewString(),
}
var nonFilterable []string
if req.MetadataConfiguration != nil {
nonFilterable = req.MetadataConfiguration.NonFilterableMetadataKeys
if keys, err := json.Marshal(nonFilterable); err == nil {
metadata["nonFilterableMetadataKeys"] = string(keys)
}
}
metadataJSON, _ := json.Marshal(metadata)
err = s.internalCatalog(bound, "CreateTable", &s3tables.CreateTableRequest{
TableBucketARN: s.s3tablesArn(arns.bucket),
Namespace: []string{indexNamespace},
Name: arns.index,
Format: s3tables.FormatLance,
Metadata: &s3tables.TableMetadata{FullMetadata: metadataJSON},
Tags: req.Tags,
}, nil)
if err != nil {
catalogErr(w, err)
return
}
_, err = s.vectors.CreateIndex(r.Context(), &vector_pb.CreateIndexRequest{
Table: tableID(arns.bucket, arns.index),
Dimension: uint32(req.Dimension),
DistanceMetric: metric,
NonFilterableMetadataKeys: nonFilterable,
})
if err != nil {
// Roll the index record back so a half-created index does not linger.
// The delete runs past a client disconnect and only removes the record
// this request created: the nonce must match, and the versionToken
// compare-and-swap keeps a racing create from being erased.
ctx := context.WithoutCancel(r.Context())
var rec map[string]interface{}
if gerr := s.internalCatalog(ctx, "GetTable", &s3tables.GetTableRequest{
TableBucketARN: s.s3tablesArn(arns.bucket),
Namespace: []string{indexNamespace},
Name: arns.index,
}, &rec); gerr == nil && recordNonce(rec) == metadata["createNonce"] {
var delResp map[string]interface{}
if derr := s.internalCatalog(ctx, "DeleteTable", &s3tables.DeleteTableRequest{
TableBucketARN: s.s3tablesArn(arns.bucket),
Namespace: []string{indexNamespace},
Name: arns.index,
VersionToken: recordVersionToken(rec),
}, &delResp); derr != nil {
glog.Errorf("S3 Vectors: rollback of catalog table %s/%s failed: %v", arns.bucket, arns.index, derr)
}
} else if gerr != nil {
glog.Errorf("S3 Vectors: rollback lookup for %s/%s failed: %v", arns.bucket, arns.index, gerr)
}
vectorErr(w, r, arns, err)
return
}
writeJSON(w, http.StatusOK, &CreateIndexResponse{IndexArn: s.indexArn(arns.bucket, arns.index)})
}
// recordNonce and recordVersionToken read the catalog record's internal
// bookkeeping for the create-index rollback and DeleteIndex.
func recordNonce(record map[string]interface{}) string {
if meta, ok := record["metadata"].(map[string]interface{}); ok {
if fm, ok := meta["fullMetadata"].(map[string]interface{}); ok {
return fmt.Sprint(fm["createNonce"])
}
}
return ""
}
func recordVersionToken(record map[string]interface{}) string {
token, _ := record["versionToken"].(string)
return token
}
func indexFromRecord(s *Server, bucket, name string, m map[string]interface{}) *Index {
idx := &Index{
IndexArn: s.indexArn(bucket, name),
IndexName: name,
VectorBucketName: bucket,
DataType: "float32",
}
if created, ok := m["createdAt"].(string); ok {
idx.CreationTime, _ = time.Parse(time.RFC3339, created)
}
if meta, ok := m["metadata"].(map[string]interface{}); ok {
if fm, ok := meta["fullMetadata"].(map[string]interface{}); ok {
var dim float64
fmt.Sscan(fmt.Sprint(fm["dimension"]), &dim)
idx.Dimension = int32(dim)
idx.DistanceMetric = strings.ToLower(fmt.Sprint(fm["distanceMetric"]))
switch keys := fm["nonFilterableMetadataKeys"].(type) {
case string:
var list []string
if err := json.Unmarshal([]byte(keys), &list); err == nil && list != nil {
idx.MetadataConfiguration = &MetadataConfiguration{NonFilterableMetadataKeys: list}
}
case []interface{}:
var list []string
for _, k := range keys {
list = append(list, fmt.Sprint(k))
}
idx.MetadataConfiguration = &MetadataConfiguration{NonFilterableMetadataKeys: list}
}
}
}
if idx.DistanceMetric == "" || idx.DistanceMetric == "<nil>" {
idx.DistanceMetric = "euclidean"
}
return idx
}
func (s *Server) handleGetIndex(w http.ResponseWriter, r *http.Request) {
var req GetIndexRequest
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
arns, err := s.resolveIndexARNs(req.VectorBucketName, req.VectorBucketArn, req.IndexName, req.IndexArn)
if err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
if _, err := s.authorizeVector(r.Context(), r, "s3vectors:GetIndex", arns.bucket, arns.index); err != nil {
catalogErr(w, err)
return
}
var resp map[string]interface{}
err = s.internalCatalog(r.Context(), "GetTable", &s3tables.GetTableRequest{
TableBucketARN: s.s3tablesArn(arns.bucket),
Namespace: []string{indexNamespace},
Name: arns.index,
}, &resp)
if err != nil {
catalogErr(w, err)
return
}
writeJSON(w, http.StatusOK, &GetIndexResponse{Index: indexFromRecord(s, arns.bucket, arns.index, resp)})
}
func (s *Server) handleListIndexes(w http.ResponseWriter, r *http.Request) {
var req ListIndexesRequest
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
arns, err := s.bucketARNs(req.VectorBucketName, req.VectorBucketArn)
if err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
maxResults := 0
if req.MaxResults != nil {
if *req.MaxResults < 1 || *req.MaxResults > vectorsMaxListPage {
writeError(w, http.StatusBadRequest, "ValidationException", fmt.Sprintf("maxResults must be between 1 and %d", vectorsMaxListPage))
return
}
maxResults = *req.MaxResults
}
if _, err := s.authorizeVector(r.Context(), r, "s3vectors:ListIndexes", arns.bucket, ""); err != nil {
catalogErr(w, err)
return
}
var resp struct {
TableSummaries []struct {
Name string `json:"name"`
Format string `json:"format"`
CreatedAt string `json:"createdAt"`
} `json:"tables"`
ContinuationToken string `json:"continuationToken"`
}
err = s.internalCatalog(r.Context(), "ListTables", &s3tables.ListTablesRequest{
TableBucketARN: s.s3tablesArn(arns.bucket),
Namespace: []string{indexNamespace},
MaxTables: maxResults,
ContinuationToken: req.NextToken,
Prefix: req.Prefix,
}, &resp)
if err != nil {
catalogErr(w, err)
return
}
// Summaries carry only what the catalog listing knows; a summary that
// asserted a dimension or metric without a record lookup would lie.
out := &ListIndexesResponse{NextToken: resp.ContinuationToken, Indexes: []*IndexSummary{}}
for _, t := range resp.TableSummaries {
// A non-Lance table in the index namespace is not an index.
if t.Format != "" && t.Format != s3tables.FormatLance {
continue
}
created, _ := time.Parse(time.RFC3339, t.CreatedAt)
out.Indexes = append(out.Indexes, &IndexSummary{
IndexName: t.Name,
IndexArn: s.indexArn(arns.bucket, t.Name),
VectorBucketName: arns.bucket,
CreationTime: created,
})
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleDeleteIndex(w http.ResponseWriter, r *http.Request) {
var req DeleteIndexRequest
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
arns, err := s.resolveIndexARNs(req.VectorBucketName, req.VectorBucketArn, req.IndexName, req.IndexArn)
if err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
bucketRecord, err := s.authorizeVector(r.Context(), r, "s3vectors:DeleteIndex", arns.bucket, arns.index)
if err != nil {
catalogErr(w, err)
return
}
// A named table is an index only while it carries the Lance format —
// an Iceberg or format-less table under the same namespace is not an
// index and must not be deleted by this operation.
var record map[string]interface{}
if err := s.internalCatalog(r.Context(), "GetTable", &s3tables.GetTableRequest{
TableBucketARN: s.s3tablesArn(arns.bucket),
Namespace: []string{indexNamespace},
Name: arns.index,
}, &record); err != nil {
catalogErr(w, err)
return
}
if format, _ := record["format"].(string); format != s3tables.FormatLance {
writeError(w, http.StatusBadRequest, "ValidationException",
fmt.Sprintf("index %q is not a vector index", arns.index))
return
}
// Deleting the catalog record is the delete: the catalog purges the
// dataset under the index's location along with the entry.
var resp map[string]interface{}
// The token limits the delete to the record checked above, and the bound
// bucket to the one authorized.
err = s.internalCatalog(s3tables.WithTableBucketCreatedAt(r.Context(), bucketRecord.CreatedAt), "DeleteTable", &s3tables.DeleteTableRequest{
TableBucketARN: s.s3tablesArn(arns.bucket),
Namespace: []string{indexNamespace},
Name: arns.index,
VersionToken: recordVersionToken(record),
}, &resp)
if err != nil {
catalogErr(w, err)
return
}
writeJSON(w, http.StatusOK, &DeleteIndexResponse{})
}
func metadataStruct(m map[string]interface{}) (*structpb.Struct, error) {
if m == nil {
return nil, nil
}
return structpb.NewStruct(m)
}
func structToMap(s *structpb.Struct) map[string]interface{} {
if s == nil {
return nil
}
return s.AsMap()
}
func (s *Server) handlePutVectors(w http.ResponseWriter, r *http.Request) {
var req PutVectorsRequest
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
arns, err := s.resolveIndexARNs(req.VectorBucketName, req.VectorBucketArn, req.IndexName, req.IndexArn)
if err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
if len(req.Vectors) == 0 || len(req.Vectors) > vectorsMaxPutBatch {
writeError(w, http.StatusBadRequest, "ValidationException",
fmt.Sprintf("vectors must contain between 1 and %d items", vectorsMaxPutBatch))
return
}
// A read grant on the index does not carry a write: mutations require the
// vector write grant, the same check credential vending applies.
if _, err := s.authorizeVector(r.Context(), r, "s3vectors:PutVectors", arns.bucket, arns.index); err != nil {
catalogErr(w, err)
return
}
record, err := s.indexRecord(r.Context(), arns.bucket, arns.index)
if err != nil {
catalogErr(w, err)
return
}
dimension := indexFromRecord(s, arns.bucket, arns.index, record).Dimension
vectors := make([]*vector_pb.Vector, 0, len(req.Vectors))
for _, v := range req.Vectors {
pbVector, err := toPutVector(v, dimension)
if err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
vectors = append(vectors, pbVector)
}
_, err = s.vectors.PutVectors(r.Context(), &vector_pb.PutVectorsRequest{
Table: tableID(arns.bucket, arns.index),
Vectors: vectors,
})
if err != nil {
vectorErr(w, r, arns, err)
return
}
writeJSON(w, http.StatusOK, &PutVectorsResponse{})
}
func toPutVector(v *PutInputVector, dimension int32) (*vector_pb.Vector, error) {
if v == nil {
return nil, fmt.Errorf("vectors contains a null element")
}
if v.Key == "" {
return nil, fmt.Errorf("vector key must not be empty")
}
metadata, err := metadataStruct(v.Metadata)
if err != nil {
return nil, err
}
var data []float32
if v.Data != nil {
data = v.Data.Float32
}
if int32(len(data)) != dimension {
return nil, fmt.Errorf("vector %q has %d dimensions, the index declares %d", v.Key, len(data), dimension)
}
return &vector_pb.Vector{Key: v.Key, Data: data, Metadata: metadata}, nil
}
func toOutputVectors(vectors []*vector_pb.Vector, returnData, returnMetadata bool) []*OutputVector {
out := make([]*OutputVector, 0, len(vectors))
for _, v := range vectors {
o := &OutputVector{Key: v.Key}
if returnData {
o.Data = &VectorData{Float32: v.Data}
}
if returnMetadata {
o.Metadata = structToMap(v.Metadata)
}
out = append(out, o)
}
return out
}
// vectorKeysBounds enforces the AWS 1..max key list window; GetVectors is
// capped lower than a PutVectors or DeleteVectors batch.
func vectorKeysBounds(keys []string, max int) error {
if len(keys) == 0 || len(keys) > max {
return fmt.Errorf("keys must be between 1 and %d", max)
}
return nil
}
func (s *Server) handleGetVectors(w http.ResponseWriter, r *http.Request) {
var req GetVectorsRequest
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
if err := vectorKeysBounds(req.Keys, vectorsMaxGetKeys); err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
arns, err := s.resolveIndexARNs(req.VectorBucketName, req.VectorBucketArn, req.IndexName, req.IndexArn)
if err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
if _, err := s.authorizeVector(r.Context(), r, "s3vectors:GetVectors", arns.bucket, arns.index); err != nil {
catalogErr(w, err)
return
}
if _, err := s.indexRecord(r.Context(), arns.bucket, arns.index); err != nil {
catalogErr(w, err)
return
}
resp, err := s.vectors.GetVectors(r.Context(), &vector_pb.GetVectorsRequest{
Table: tableID(arns.bucket, arns.index),
Keys: req.Keys,
ReturnData: req.ReturnData,
ReturnMetadata: req.ReturnMetadata,
})
if err != nil {
vectorErr(w, r, arns, err)
return
}
writeJSON(w, http.StatusOK, &GetVectorsResponse{
Vectors: toOutputVectors(resp.Vectors, req.ReturnData, req.ReturnMetadata),
})
}
func (s *Server) handleListVectors(w http.ResponseWriter, r *http.Request) {
var req ListVectorsRequest
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
if err := listVectorsBounds(&req); err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
arns, err := s.resolveIndexARNs(req.VectorBucketName, req.VectorBucketArn, req.IndexName, req.IndexArn)
if err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
if _, err := s.authorizeVector(r.Context(), r, "s3vectors:ListVectors", arns.bucket, arns.index); err != nil {
catalogErr(w, err)
return
}
if _, err := s.indexRecord(r.Context(), arns.bucket, arns.index); err != nil {
catalogErr(w, err)
return
}
maxResults := 0
if req.MaxResults != nil {
maxResults = *req.MaxResults
}
// AWS exposes a static split: segmentIndex/segmentCount divides the full
// listing. A segment is the rows whose position modulo segmentCount is
// segmentIndex, and its token is the row position to resume from.
var offset uint64
if req.SegmentCount != nil && req.NextToken != "" {
var err error
if offset, err = strconv.ParseUint(req.NextToken, 10, 64); err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", "invalid nextToken")
return
}
}
listReq := &vector_pb.ListVectorsRequest{
Table: tableID(arns.bucket, arns.index),
MaxResults: uint32(maxResults),
NextToken: req.NextToken,
ReturnData: req.ReturnData,
ReturnMetadata: req.ReturnMetadata,
}
if req.SegmentCount != nil {
listReq.SegmentCount = uint32(*req.SegmentCount)
listReq.SegmentIndex = uint32(*req.SegmentIndex)
}
resp, err := s.vectors.ListVectors(r.Context(), listReq)
if err != nil {
vectorErr(w, r, arns, err)
return
}
// An engine that predates segment_count lists every row; page through
// it here, starting with the page already read, and keep only the
// caller's share.
if req.SegmentCount != nil && !resp.Segmented {
out, nextToken, err := s.listSegmentVectors(r.Context(), listReq.Table,
offset, maxResults, uint64(*req.SegmentCount), uint64(*req.SegmentIndex), req.ReturnData, req.ReturnMetadata, resp)
if err != nil {
vectorErr(w, r, arns, err)
return
}
writeJSON(w, http.StatusOK, &ListVectorsResponse{
NextToken: nextToken,
Vectors: toOutputVectors(out, req.ReturnData, req.ReturnMetadata),
})
return
}
writeJSON(w, http.StatusOK, &ListVectorsResponse{
NextToken: resp.NextToken,
Vectors: toOutputVectors(resp.Vectors, req.ReturnData, req.ReturnMetadata),
})
}
// The page constants mirror the engine's page sizing so a segmented listing
// pages like a plain one does; vectorsMaxPage is also the AWS ListVectors
// maxResults cap, and vectorsMaxTopK the AWS QueryVectors limit.
const (
vectorsDefaultPage = 500
vectorsMaxGetKeys = 100
vectorsMaxPutBatch = 500
vectorsMaxPage = 1000
vectorsMaxListPage = 500
vectorsMaxTopK = 10000
vectorsMaxSegments = 16
)
// listVectorsBounds enforces the AWS ListVectors windows: maxResults 1..1000,
// segmentCount 1..16, and segmentIndex below it, the two given together.
func listVectorsBounds(req *ListVectorsRequest) error {
if req.MaxResults != nil && (*req.MaxResults < 1 || *req.MaxResults > vectorsMaxPage) {
return fmt.Errorf("maxResults must be between 1 and %d", vectorsMaxPage)
}
if (req.SegmentCount == nil) != (req.SegmentIndex == nil) {
return fmt.Errorf("segmentCount and segmentIndex must be specified together")
}
if req.SegmentCount == nil {
return nil
}
if *req.SegmentCount < 1 || *req.SegmentCount > vectorsMaxSegments {
return fmt.Errorf("segmentCount must be between 1 and %d", vectorsMaxSegments)
}
if *req.SegmentIndex < 0 || *req.SegmentIndex >= *req.SegmentCount {
return fmt.Errorf("segmentIndex must be in [0, segmentCount)")
}
return nil
}
// listSegmentVectors scans the index from offset, keeping the rows that fall
// in the caller's segment until limit are collected or the index runs out.
// The engine's next_token is a row offset, so the returned token is the offset
// scanning resumes from. A non-nil first is the engine page at offset.
func (s *Server) listSegmentVectors(ctx context.Context, table *vector_pb.Table, offset uint64, maxResults int, segmentCount, segmentIndex uint64, returnData, returnMetadata bool, first *vector_pb.ListVectorsResponse) ([]*vector_pb.Vector, string, error) {
limit := maxResults
if limit <= 0 {
limit = vectorsDefaultPage
} else if limit > vectorsMaxPage {
limit = vectorsMaxPage
}
var out []*vector_pb.Vector
nextToken := ""
token := strconv.FormatUint(offset, 10)
for {
page := first
first = nil
if page == nil {
var err error
page, err = s.vectors.ListVectors(ctx, &vector_pb.ListVectorsRequest{
Table: table,
MaxResults: vectorsMaxPage,
NextToken: token,
ReturnData: returnData,
ReturnMetadata: returnMetadata,
})
if err != nil {
return nil, "", err
}
}
base := offset
offset += uint64(len(page.Vectors))
full := false
for j, v := range page.Vectors {
if (base+uint64(j))%segmentCount == segmentIndex {
out = append(out, v)
if len(out) == limit {
if j+1 < len(page.Vectors) || page.NextToken != "" {
nextToken = strconv.FormatUint(base+uint64(j)+1, 10)
}
full = true
break
}
}
}
if full || len(page.Vectors) == 0 || page.NextToken == "" {
break
}
token = page.NextToken
}
return out, nextToken, nil
}
func (s *Server) handleDeleteVectors(w http.ResponseWriter, r *http.Request) {
var req DeleteVectorsRequest
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
if err := vectorKeysBounds(req.Keys, vectorsMaxPutBatch); err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
arns, err := s.resolveIndexARNs(req.VectorBucketName, req.VectorBucketArn, req.IndexName, req.IndexArn)
if err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
if _, err := s.authorizeVector(r.Context(), r, "s3vectors:DeleteVectors", arns.bucket, arns.index); err != nil {
catalogErr(w, err)
return
}
if _, err := s.indexRecord(r.Context(), arns.bucket, arns.index); err != nil {
catalogErr(w, err)
return
}
_, err = s.vectors.DeleteVectors(r.Context(), &vector_pb.DeleteVectorsRequest{
Table: tableID(arns.bucket, arns.index),
Keys: req.Keys,
})
if err != nil {
vectorErr(w, r, arns, err)
return
}
writeJSON(w, http.StatusOK, &DeleteVectorsResponse{})
}
// translateFilter renders an AWS S3 Vectors filter document as a Lance SQL
// predicate over the metadata JSON column. Only the documented operators are
// translated; anything else fails loudly instead of silently overmatching.
func translateFilter(filter map[string]interface{}) (string, error) {
if len(filter) == 0 {
return "", nil
}
return filterExpr(filter)
}
// nonFilterableField returns the first blocked metadata key a filter
// references; the bool reports whether any matched.
func nonFilterableField(cfg *MetadataConfiguration, filter map[string]interface{}) (string, bool) {
if cfg == nil {
return "", false
}
fields := map[string]bool{}
filterFields(filter, fields)
for _, k := range cfg.NonFilterableMetadataKeys {
if fields[k] {
return k, true
}
}
return "", false
}
// filterFields collects the field names a filter expression references —
// $-operators recurse; every other key is a field.
func filterFields(filter map[string]interface{}, out map[string]bool) {
for key, cond := range filter {
if strings.HasPrefix(key, "$") {
if sub, ok := cond.([]interface{}); ok {
for _, item := range sub {
if m, ok := item.(map[string]interface{}); ok {
filterFields(m, out)
}
}
}
continue
}
out[key] = true
}
}
func filterExpr(filter map[string]interface{}) (string, error) {
parts := make([]string, 0, len(filter))
for key, cond := range filter {
if strings.HasPrefix(key, "$") {
sub, ok := cond.([]interface{})
if !ok {
return "", fmt.Errorf("%s expects a list", key)
}
if len(sub) == 0 {
return "", fmt.Errorf("%s expects a non-empty list", key)
}
exprs := make([]string, 0, len(sub))
for _, item := range sub {
m, ok := item.(map[string]interface{})
if !ok {
return "", fmt.Errorf("%s expects objects", key)
}
e, err := filterExpr(m)
if err != nil {
return "", err
}
if e == "" {
return "", fmt.Errorf("%s expects non-empty objects", key)
}
exprs = append(exprs, e)
}
joiner := " AND "
if key == "$or" {
joiner = " OR "
}
if key != "$and" && key != "$or" {
return "", fmt.Errorf("unsupported operator %s", key)
}
parts = append(parts, "("+strings.Join(exprs, joiner)+")")
continue
}
clause, err := fieldClause(key, cond)
if err != nil {
return "", err
}
parts = append(parts, clause)
}
return strings.Join(parts, " AND "), nil
}
// fieldAccess renders the operand's comparison column over the serialized
// json_extract text. Strings and booleans compare the serialized form
// directly, which is exact-typed and never errors — booleans serialize as
// exactly 'true'/'false', and a cast would also accept the '1'/'0' a stored
// number produces. Numbers go through TRY_CAST, which is NULL on any other
// stored type. The json_get_* extractors are not used — they convert
// numeric-looking strings and booleans, and error on mismatched types.
func fieldAccess(field string, operand interface{}) (string, error) {
path := sqlEscape(existsPath(field))
switch operand.(type) {
case float64:
return fmt.Sprintf("TRY_CAST(json_extract(`metadata`, '%s') AS DOUBLE)", path), nil
case bool, string:
return fmt.Sprintf("json_extract(`metadata`, '%s')", path), nil
default:
return "", fmt.Errorf("unsupported filter operand %v", operand)
}
}
// fieldMatch renders "field <rhs>".
func fieldMatch(field string, operand interface{}, rhs string) (string, error) {
col, err := fieldAccess(field, operand)
if err != nil {
return "", err
}
return col + " " + rhs, nil
}
// existsPath renders the field as a JSONPath for the json_exists and
// json_extract parsers: bracket notation keeps dots, spaces, and other
// specials inside the key literal.
func existsPath(field string) string {
field = strings.ReplaceAll(field, `\`, `\\`)
field = strings.ReplaceAll(field, `"`, `\"`)
return `$["` + field + `"]`
}
func sqlEscape(s string) string {
return strings.ReplaceAll(s, "'", "''")
}
func fieldClause(field string, cond interface{}) (string, error) {
switch c := cond.(type) {
case map[string]interface{}:
parts := []string{}
for op, operand := range c {
switch op {
case "$exists":
if b, ok := operand.(bool); ok {
expr := fmt.Sprintf("json_exists(`metadata`, '%s')", sqlEscape(existsPath(field)))
if !b {
expr = "NOT " + expr
}
parts = append(parts, expr)
} else {
return "", fmt.Errorf("$exists expects a boolean")
}
case "$eq", "$ne":
match, err := equalMatch(field, operand)
if err != nil {
return "", err
}
if op == "$ne" {
match = "NOT " + match
}
parts = append(parts, match)
case "$in", "$nin":
list, ok := operand.([]interface{})
if !ok {
return "", fmt.Errorf("%s expects a list", op)
}
if len(list) == 0 {
if op == "$in" {
parts = append(parts, "false")
} else {
parts = append(parts, "true")
}
continue
}
// Members may mix types; each extractor only sees its own, so
// members sharing one go into a single IN that reads the field
// once. A group whose extractor returns NULL must not let NOT
// invert NULL into NULL under $nin, so every group coalesces.
var cols []string
members := map[string][]string{}
for _, item := range list {
lit, err := sqlLiteral(item)
if err != nil {
return "", err
}
col, err := fieldAccess(field, item)
if err != nil {
return "", err
}
if _, seen := members[col]; !seen {
cols = append(cols, col)
}
members[col] = append(members[col], lit)
}
groups := make([]string, 0, len(cols))
for _, col := range cols {
groups = append(groups, fmt.Sprintf("COALESCE(%s in (%s), false)", col, strings.Join(members[col], ", ")))
}
for _, item := range list {
element, err := elementMatch(field, item)
if err != nil {
return "", err
}
groups = append(groups, element)
}
expr := "(" + strings.Join(groups, " OR ") + ")"
if op == "$nin" {
expr = "NOT " + expr
}
parts = append(parts, expr)
case "$lt", "$lte", "$gt", "$gte":
if _, ok := operand.(float64); !ok {
return "", fmt.Errorf("%s expects a number", op)
}
lit, err := sqlLiteral(operand)
if err != nil {
return "", err
}
sym := map[string]string{"$lt": "<", "$lte": "<=", "$gt": ">", "$gte": ">="}[op]
col, err := fieldAccess(field, operand)
if err != nil {
return "", err
}
parts = append(parts, fmt.Sprintf("%s %s %s", col, sym, lit))
default:
return "", fmt.Errorf("unsupported operator %s", op)
}
}
if len(parts) == 0 {
return "", fmt.Errorf("%s expects at least one operator", field)
}
return strings.Join(parts, " AND "), nil
default:
return equalMatch(field, cond)
}
}
// equalMatch renders "field equals operand": the stored value itself or, as
// AWS documents for $eq, any element of a stored array.
func equalMatch(field string, operand interface{}) (string, error) {
lit, err := sqlLiteral(operand)
if err != nil {
return "", err
}
match, err := fieldMatch(field, operand, "= "+lit)
if err != nil {
return "", err
}
element, err := elementMatch(field, operand)
if err != nil {
return "", err
}
return "(COALESCE(" + match + ", false) OR " + element + ")", nil
}
// elementMatch renders "the array stored at field holds operand". The engine
// compares the elements as typed JSON values, so 1 does not match "1".
func elementMatch(field string, operand interface{}) (string, error) {
var b strings.Builder
enc := json.NewEncoder(&b)
enc.SetEscapeHTML(false)
if err := enc.Encode(operand); err != nil {
return "", err
}
return fmt.Sprintf("COALESCE(json_array_contains(`metadata`, '%s', '%s'), false)",
sqlEscape(existsPath(field)), sqlEscape(strings.TrimSuffix(b.String(), "\n"))), nil
}
// jsonbString renders s as the JSON text json_extract returns for a stored
// string: quoted, with the same escapes the jsonb serializer emits.
func jsonbString(s string) string {
var b strings.Builder
b.WriteByte('"')
for _, r := range s {
switch r {
case '"':
b.WriteString(`\"`)
case '\\':
b.WriteString(`\\`)
case '\n':
b.WriteString(`\n`)
case '\r':
b.WriteString(`\r`)
case '\t':
b.WriteString(`\t`)
case 0:
b.WriteString(`\0`)
default:
if r < 0x20 || (r >= 0x7f && r <= 0x9f) {
fmt.Fprintf(&b, `\u{%x}`, r)
} else {
b.WriteRune(r)
}
}
}
b.WriteByte('"')
return b.String()
}
func sqlLiteral(v interface{}) (string, error) {
switch t := v.(type) {
case string:
return "'" + sqlEscape(jsonbString(t)) + "'", nil
case bool:
if t {
return "'true'", nil
}
return "'false'", nil
case float64:
return fmt.Sprintf("%v", t), nil
}
return "", fmt.Errorf("unsupported filter operand %v", v)
}
func (s *Server) handleQueryVectors(w http.ResponseWriter, r *http.Request) {
var req QueryVectorsRequest
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
arns, err := s.resolveIndexARNs(req.VectorBucketName, req.VectorBucketArn, req.IndexName, req.IndexArn)
if err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
var queryVector []float32
if req.QueryVector != nil {
queryVector = req.QueryVector.Float32
}
if len(queryVector) == 0 {
writeError(w, http.StatusBadRequest, "ValidationException", "queryVector is required")
return
}
if req.TopK <= 0 || req.TopK > vectorsMaxTopK {
writeError(w, http.StatusBadRequest, "ValidationException",
fmt.Sprintf("topK must be between 1 and %d", vectorsMaxTopK))
return
}
if _, err := s.authorizeVector(r.Context(), r, "s3vectors:QueryVectors", arns.bucket, arns.index); err != nil {
catalogErr(w, err)
return
}
record, err := s.indexRecord(r.Context(), arns.bucket, arns.index)
if err != nil {
catalogErr(w, err)
return
}
idx := indexFromRecord(s, arns.bucket, arns.index, record)
if int32(len(queryVector)) != idx.Dimension {
writeError(w, http.StatusBadRequest, "ValidationException",
fmt.Sprintf("queryVector has %d dimensions, the index declares %d", len(queryVector), idx.Dimension))
return
}
if key, blocked := nonFilterableField(idx.MetadataConfiguration, req.Filter); blocked {
writeError(w, http.StatusBadRequest, "ValidationException",
fmt.Sprintf("metadata key %q is not filterable", key))
return
}
metric := idx.DistanceMetric
filter, err := translateFilter(req.Filter)
if err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
resp, err := s.vectors.QueryVectors(r.Context(), &vector_pb.QueryVectorsRequest{
Table: tableID(arns.bucket, arns.index),
QueryVector: queryVector,
TopK: uint32(req.TopK),
Filter: filter,
ReturnMetadata: req.ReturnMetadata,
ReturnDistance: req.ReturnDistance,
})
if err != nil {
vectorErr(w, r, arns, err)
return
}
out := &QueryVectorsResponse{DistanceMetric: metric, Vectors: []*QueryOutputVector{}}
for _, v := range resp.Vectors {
o := &QueryOutputVector{Key: v.Key}
if req.ReturnDistance {
d := v.Distance
if metric == "euclidean" {
// Lance's L2 distance is the square of the Euclidean distance.
d = float32(math.Sqrt(math.Max(0, float64(d))))
}
o.Distance = &d
}
if req.ReturnMetadata {
o.Metadata = structToMap(v.Metadata)
}
out.Vectors = append(out.Vectors, o)
}
writeJSON(w, http.StatusOK, out)
}