s3vectors: bound GetVectors and DeleteVectors key lists (#12238)

* s3vectors: bound GetVectors and DeleteVectors key lists

Both forwarded req.Keys to the engine unbounded; AWS caps these at 500,
the same window a PutVectors batch shares.

* s3vectors: cap GetVectors at the AWS 100-key limit

GetVectors allows at most 100 keys per request while DeleteVectors
allows 500, so give vectorKeysBounds the per-call maximum instead of
sharing the put-batch window.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* s3vectors: assert out-of-window key lists never reach the engine

Record get/delete calls on the fake engine and cover both handlers
rejecting empty and oversized lists with 400 ValidationException.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Chris LuandDevin authored and GitHub committed 2026-10-30 00:50:55 -07:00
1 parent a2496ad7ae
commit 72ad975803
2 files changed
+78

No files matched your search

+18
View File
@@ -1301,12 +1301,25 @@ func toOutputVectors(vectors []*vector_pb.Vector, returnData, returnMetadata boo
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())
@@ -1409,6 +1422,7 @@ func (s *Server) handleListVectors(w http.ResponseWriter, r *http.Request) {
// pages like a plain one does; vectorsMaxTopK is the AWS QueryVectors limit.
const (
vectorsDefaultPage = 500
vectorsMaxGetKeys = 100
vectorsMaxPutBatch = 500
vectorsMaxPage = 1000
vectorsMaxListPage = 500
@@ -1469,6 +1483,10 @@ func (s *Server) handleDeleteVectors(w http.ResponseWriter, r *http.Request) {
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())
+60
View File
@@ -27,6 +27,8 @@ type fakeVectors struct {
rows []*vector_pb.Vector
created []*vector_pb.CreateIndexRequest
puts []*vector_pb.PutVectorsRequest
gets []*vector_pb.GetVectorsRequest
deletes []*vector_pb.DeleteVectorsRequest
}
func newFakeVectors(n int) *fakeVectors {
@@ -73,9 +75,15 @@ func (f *fakeVectors) CreateIndex(ctx context.Context, req *vector_pb.CreateInde
}
func (f *fakeVectors) GetVectors(ctx context.Context, req *vector_pb.GetVectorsRequest, opts ...grpc.CallOption) (*vector_pb.GetVectorsResponse, error) {
f.gets = append(f.gets, req)
return &vector_pb.GetVectorsResponse{}, nil
}
func (f *fakeVectors) DeleteVectors(ctx context.Context, req *vector_pb.DeleteVectorsRequest, opts ...grpc.CallOption) (*vector_pb.DeleteVectorsResponse, error) {
f.deletes = append(f.deletes, req)
return &vector_pb.DeleteVectorsResponse{}, nil
}
func (f *fakeVectors) PutVectors(ctx context.Context, req *vector_pb.PutVectorsRequest, opts ...grpc.CallOption) (*vector_pb.PutVectorsResponse, error) {
f.puts = append(f.puts, req)
return &vector_pb.PutVectorsResponse{}, nil
@@ -907,3 +915,55 @@ func TestDeleteVectorBucketPolicyRemovesLegacyDoc(t *testing.T) {
t.Fatalf("empty shared policy should be removed: %s", doc)
}
}
func keys(n int) []string {
out := make([]string, n)
for i := range out {
out[i] = "k"
}
return out
}
func TestVectorKeysBounds(t *testing.T) {
for _, tc := range []struct {
name string
keys []string
max int
ok bool
}{
{"empty", nil, vectorsMaxGetKeys, false},
{"one", []string{"a"}, vectorsMaxGetKeys, true},
{"get full", keys(vectorsMaxGetKeys), vectorsMaxGetKeys, true},
{"get over", keys(vectorsMaxGetKeys + 1), vectorsMaxGetKeys, false},
{"delete full", keys(vectorsMaxPutBatch), vectorsMaxPutBatch, true},
{"delete over", keys(vectorsMaxPutBatch + 1), vectorsMaxPutBatch, false},
} {
if err := vectorKeysBounds(tc.keys, tc.max); (err == nil) != tc.ok {
t.Fatalf("%s: accepted=%v, want %v", tc.name, err == nil, tc.ok)
}
}
}
// An out-of-window key list is rejected before the engine sees it.
func TestVectorKeysBoundsRejectsBeforeEngine(t *testing.T) {
f := newFakeVectors(0)
s := &Server{vectors: f}
for _, tc := range []struct {
name string
handler http.HandlerFunc
payload interface{}
}{
{"get empty", s.handleGetVectors, &GetVectorsRequest{}},
{"get over", s.handleGetVectors, &GetVectorsRequest{Keys: keys(vectorsMaxGetKeys + 1)}},
{"delete empty", s.handleDeleteVectors, &DeleteVectorsRequest{}},
{"delete over", s.handleDeleteVectors, &DeleteVectorsRequest{Keys: keys(vectorsMaxPutBatch + 1)}},
} {
w := postJSONAs(t, "", tc.handler, tc.payload)
if w.Code != http.StatusBadRequest || !strings.Contains(w.Body.String(), "ValidationException") {
t.Fatalf("%s: %d %s, want 400 ValidationException", tc.name, w.Code, w.Body.String())
}
}
if len(f.gets)+len(f.deletes) != 0 {
t.Fatalf("engine saw %d get/%d delete calls, want none", len(f.gets), len(f.deletes))
}
}