mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-03 18:47:41 +01:00
* s3api: hash an s3vectors body up to the Vectors API limit SigV4 verification of a non-S3 request hashed at most 10 MiB of the body and silently dropped the rest, so any S3 Vectors request over 10 MiB failed its signature check with 403, although the API accepts 32 MiB. The limit now follows the signing service, and a body over it is reported as ErrEntityTooLarge instead of being truncated; the embedded IAM answers that with InvalidInput, as it already does for a body over its own limit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * s3vectors: answer an oversized request with a ValidationException A body over 32 MiB was refused as "403 AccessDenied: authentication failed". A declared Content-Length over the limit is now refused before authentication, and a streamed body over it, whether the signature check or the handler reads it, gets the same "request body exceeds 32 MiB" ValidationException. The limit is the one SigV4 verification uses. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * s3vectors: log engine and catalog failures at the default level An engine error reached the client as a bare "internal error", and the cause was logged only with -v=1, so a misconfigured engine left nothing in a default log. Engine and catalog failures are now logged as errors, and a reported unavailability as a warning, with the operation, bucket and index. The response still leaves the engine's message out, since it can name internal hosts and paths, and points at the gateway log instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * s3api: hash the signed body through readRequestBody streamHashRequestBody repeated readRequestBody's read-past-the-limit check; it now reads the body with readRequestBody and hashes the bytes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1960 lines
66 KiB
Go
1960 lines
66 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,
|
|
}
|
|
|
|
// 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
|
|
}
|
|
// 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)
|
|
if !s3tables.CheckPermissionWithContext("s3vectors:CreateVectorBucket", principal, s.tables.PrincipalFor(nil), "", "", s.tables.PolicyContext(r, "")) {
|
|
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,
|
|
}, &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
|
|
}
|
|
if _, err := s.authorizeVector(r.Context(), r, "s3vectors:DeleteVectorBucket", arns.bucket, ""); err != nil {
|
|
catalogErr(w, err)
|
|
return
|
|
}
|
|
var resp map[string]interface{}
|
|
err = s.internalCatalog(r.Context(), "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(r.Context()), "DeleteNamespace",
|
|
&s3tables.DeleteNamespaceRequest{
|
|
TableBucketARN: s.s3tablesArn(arns.bucket),
|
|
Namespace: []string{indexNamespace},
|
|
}, &nsResp)
|
|
if nsErr == nil {
|
|
err = s.internalCatalog(r.Context(), "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"}
|
|
|
|
// 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.
|
|
func (s *Server) checkVectorPolicyDoc(r *http.Request, action, bucket, owner string, ext map[string][]byte) bool {
|
|
principal := s.tables.PrincipalFor(r)
|
|
return s3tables.CheckPermissionWithContext(action, principal, owner,
|
|
vectorPolicyFromExt(ext), s.vectorBucketArn(bucket), s.tables.PolicyContext(r, bucket))
|
|
}
|
|
|
|
// 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 !s.checkVectorPolicyDoc(r, "s3vectors:PutVectorBucketPolicy", arns.bucket, record.OwnerAccountID, ext) {
|
|
return errVectorDenied
|
|
}
|
|
// 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 !s.checkVectorPolicyDoc(r, "s3vectors:DeleteVectorBucketPolicy", arns.bucket, record.OwnerAccountID, ext) {
|
|
return errVectorDenied
|
|
}
|
|
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
|
|
}
|
|
// The create grant is exercised before any write: a denied caller must not
|
|
// leave even the implicit namespace behind.
|
|
if _, err := s.authorizeVector(r.Context(), r, "s3vectors:CreateIndex", arns.bucket, arns.index); err != nil {
|
|
catalogErr(w, err)
|
|
return
|
|
}
|
|
// The namespace is implicit on AWS and internal bookkeeping here.
|
|
if err := s.ensureIndexNamespace(r.Context(), 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(r.Context(), "CreateTable", &s3tables.CreateTableRequest{
|
|
TableBucketARN: s.s3tablesArn(arns.bucket),
|
|
Namespace: []string{indexNamespace},
|
|
Name: arns.index,
|
|
Format: s3tables.FormatLance,
|
|
Metadata: &s3tables.TableMetadata{FullMetadata: metadataJSON},
|
|
}, 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
|
|
}
|
|
if _, err := s.authorizeVector(r.Context(), r, "s3vectors:DeleteIndex", arns.bucket, arns.index); 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.
|
|
err = s.internalCatalog(r.Context(), "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)
|
|
}
|