mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-11 14:28:33 +01:00
* vector: expose request metrics and registration-aware health endpoints * vector: report immutable read cache and storage fetch metrics * docs: describe vector engine health, metrics, and maintenance * vector: count canceled requests when their handler is dropped * vector: account for uncached large immutable storage reads * vector: reliably release the stalled credential reader in tests * vector: observe large reads as their streamed body is consumed * vector: bound heartbeats on established master registration streams * test: exercise hardened vector maintenance on Go and Rust volumes * vector: register engines in their configured filer group * vector: make retried index creation preserve compatible datasets * vector: allow registration to advertise a separate address * test: release stalled credential readers without blocking FIFO cleanup * test: allow hardened vector coverage in a named filer group * vector: discover engines in the same filer group as their gateway * vector: fall back when the advertised address is empty * admin: expose vector readiness, maintenance, and management tools (#12520) * s3vectors: share vector discovery with trusted management clients * admin: show vector engine readiness and recent maintenance activity * admin: suggest S3 Vectors IAM actions from the supported operations * admin: manage vector indexes, policies, tags, encryption, and query data * admin: validate SSE-S3 support before creating encrypted vector buckets * admin: discover vector engines in their registration group * admin: probe vector engines concurrently with independent time budgets * admin: verify encryption capability on the remote S3 gateway * admin: defer discovery until after the HTTP server can start * admin: distinguish unknown vector health services from failed engines * admin: count workers that can execute vector index maintenance * admin: allow read-only users to query vectors and inspect tags * vector: scope gateway and admin discovery to the configured filer group * admin: refresh policy state after mutations and serialize policy controls * vector: drain active RPCs before closing replaced discovery connections * vector: respect request cancellation while waiting for discovery * vector: prefer another registered engine after a transport failure * admin: check all masters before reporting no registered vector engines * iam: honor attached vector policies without broadening legacy grants * vector: allow short-lived discovery clients to close their connections * vector: refresh cached master addresses for each discovery lookup * iam: enforce vector bucket tag conditions in attached policies * iam: authorize vector bucket creation with the requested tags * test: exercise vector IAM grants and tag denies on Go and Rust volumes * admin: reject authenticated vector mutations without an admin role * test: run vector IAM assertions before the shared engine is stopped * vector: add native mini startup, shell tools, and a runnable Compose example (#12522) * s3vectors: share vector discovery with trusted management clients * admin: show vector engine readiness and recent maintenance activity * admin: suggest S3 Vectors IAM actions from the supported operations * admin: manage vector indexes, policies, tags, encryption, and query data * admin: validate SSE-S3 support before creating encrypted vector buckets * admin: discover vector engines in their registration group * admin: probe vector engines concurrently with independent time budgets * admin: verify encryption capability on the remote S3 gateway * admin: defer discovery until after the HTTP server can start * admin: distinguish unknown vector health services from failed engines * admin: count workers that can execute vector index maintenance * admin: allow read-only users to query vectors and inspect tags * vector: scope gateway and admin discovery to the configured filer group * admin: refresh policy state after mutations and serialize policy controls * vector: drain active RPCs before closing replaced discovery connections * vector: respect request cancellation while waiting for discovery * vector: prefer another registered engine after a transport failure * admin: check all masters before reporting no registered vector engines * iam: honor attached vector policies without broadening legacy grants * vector: allow short-lived discovery clients to close their connections * vector: refresh cached master addresses for each discovery lookup * iam: enforce vector bucket tag conditions in attached policies * iam: authorize vector bucket creation with the requested tags * test: exercise vector IAM grants and tag denies on Go and Rust volumes * admin: reject authenticated vector mutations without an admin role * test: run vector IAM assertions before the shared engine is stopped * mini: optionally start and supervise the vector engine * mini: bootstrap vector buckets with existing Lance catalog helpers * shell: manage vector buckets, indexes, and vector data * docs: provide a runnable vector and maintenance worker Compose stack * shell: scope vector discovery to the session and close its connections * mini: honor the bind address for vector and metrics listeners * mini: reserve vector ports before assigning gRPC and compatibility listeners * mini: wait for vector storage setup and master registration * mini: create requested vector buckets without requiring the Lance HTTP listener * shell: refresh master addresses during vector discovery * mini: resolve sibling vector executables with platform extensions * vector: preserve uncertain creates and fix mini listener allocation (#12525) * vector: retain catalog records when index creation has an unknown outcome * mini: keep chosen listener ports reserved throughout allocation * mini: resolve hostname binds before starting vector listeners * vector: fix integration test imports and recover interrupted index creation (#12533) * shell: register vector operations from the command package * s3vectors: resume interrupted index initialization safely * mini: exclude disabled WebDAV from listener allocation * s3vectors: retain uncertain creates across refused retries * s3vectors: claim pending indexes before retrying initialization * s3vectors: compare resolved distance metrics during recovery * s3vectors: preserve metadata keys when retrying initialization * s3vectors: verify ownership before initializing new catalog records
294 lines
13 KiB
Go
294 lines
13 KiB
Go
package s3vectors
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"slices"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/vector_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables/s3tablestest"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
)
|
|
|
|
type failingCreateVectors struct {
|
|
*fakeVectors
|
|
err error
|
|
onCreate func()
|
|
}
|
|
|
|
func (f *failingCreateVectors) CreateIndex(_ context.Context, request *vector_pb.CreateIndexRequest, _ ...grpc.CallOption) (*vector_pb.CreateIndexResponse, error) {
|
|
f.created = append(f.created, request)
|
|
err := f.err
|
|
if f.onCreate != nil {
|
|
f.onCreate()
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
func TestCreateIndexPreservesAnUnknownOutcome(t *testing.T) {
|
|
for _, code := range []codes.Code{codes.Unavailable, codes.DeadlineExceeded, codes.Canceled, codes.Internal, codes.InvalidArgument} {
|
|
t.Run(code.String(), func(t *testing.T) {
|
|
fs := s3tablestest.Start(t)
|
|
engine := &failingCreateVectors{fakeVectors: &fakeVectors{}, err: status.Error(code, "response lost")}
|
|
server := NewServer(s3tables.NewManagerClient(fs.Client), allowAllAuth{}, engine)
|
|
postJSON(t, server.handleCreateVectorBucket, &CreateVectorBucketRequest{VectorBucketName: "vec-bucket"})
|
|
result := postJSON(t, server.handleCreateIndex, &CreateIndexRequest{VectorBucketName: "vec-bucket", IndexName: "idx", DataType: "float32", Dimension: 3, DistanceMetric: "cosine"})
|
|
if result.Code == http.StatusOK {
|
|
t.Fatal("failed engine reported success")
|
|
}
|
|
got := postJSON(t, server.handleGetIndex, &GetIndexRequest{VectorBucketName: "vec-bucket", IndexName: "idx"})
|
|
want := http.StatusOK
|
|
if code == codes.InvalidArgument {
|
|
want = http.StatusNotFound
|
|
}
|
|
if got.Code != want {
|
|
t.Fatalf("catalog after %s: %d %s", code, got.Code, got.Body)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCreateIndexRetainsTheWriteErrorIfRediscoveryFails(t *testing.T) {
|
|
engine := &failingCreateVectors{fakeVectors: &fakeVectors{}, err: status.Error(codes.Unavailable, "response lost after commit")}
|
|
discovery := &vectorDiscovery{client: engine, nextResolve: time.Now().Add(time.Hour), discover: func([]pb.ServerAddress, grpc.DialOption) []string { return nil }}
|
|
_, err := discovery.CreateIndex(t.Context(), &vector_pb.CreateIndexRequest{})
|
|
if status.Convert(err).Message() != "response lost after commit" || len(engine.created) != 1 {
|
|
t.Fatalf("ambiguous result replaced by lookup failure: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCreateIndexResumesAnUnknownOutcome(t *testing.T) {
|
|
fs := s3tablestest.Start(t)
|
|
engine := &failingCreateVectors{fakeVectors: &fakeVectors{}, err: status.Error(codes.Unavailable, "request lost")}
|
|
server := NewServer(s3tables.NewManagerClient(fs.Client), allowAllAuth{}, engine)
|
|
postJSON(t, server.handleCreateVectorBucket, &CreateVectorBucketRequest{VectorBucketName: "vec-bucket"})
|
|
request := &CreateIndexRequest{VectorBucketName: "vec-bucket", IndexName: "idx", DataType: "float32", Dimension: 3, DistanceMetric: "cosine",
|
|
MetadataConfiguration: &MetadataConfiguration{NonFilterableMetadataKeys: []string{"b", "a"}}, Tags: map[string]string{"team": "search"}}
|
|
if result := postJSON(t, server.handleCreateIndex, request); result.Code == http.StatusOK {
|
|
t.Fatal("unavailable engine reported success")
|
|
}
|
|
before, err := server.indexRecord(t.Context(), "vec-bucket", "idx")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, change := range []func(*CreateIndexRequest){
|
|
func(r *CreateIndexRequest) { r.Dimension++ },
|
|
func(r *CreateIndexRequest) { r.DistanceMetric = "euclidean" },
|
|
func(r *CreateIndexRequest) { r.MetadataConfiguration = nil },
|
|
} {
|
|
other := *request
|
|
change(&other)
|
|
if result := postJSON(t, server.handleCreateIndex, &other); result.Code != http.StatusConflict {
|
|
t.Fatalf("incompatible retry: %d %s", result.Code, result.Body)
|
|
}
|
|
}
|
|
if len(engine.created) != 1 {
|
|
t.Fatal("incompatible retry reached the engine")
|
|
}
|
|
engine.err = status.Error(codes.InvalidArgument, "retry refused")
|
|
postJSON(t, server.handleCreateIndex, request)
|
|
if _, err := server.indexRecord(t.Context(), "vec-bucket", "idx"); err != nil {
|
|
t.Fatalf("retry removed an uncertain dataset's catalog: %v", err)
|
|
}
|
|
engine.err = nil
|
|
request.MetadataConfiguration.NonFilterableMetadataKeys = []string{"a", "b"}
|
|
if result := postJSON(t, server.handleCreateIndex, request); result.Code != http.StatusOK {
|
|
t.Fatalf("resume: %d %s", result.Code, result.Body)
|
|
}
|
|
after, err := server.indexRecord(t.Context(), "vec-bucket", "idx")
|
|
if err != nil || recordNonce(before) != recordNonce(after) {
|
|
t.Fatalf("resume replaced the catalog: %v", err)
|
|
}
|
|
if result := postJSON(t, server.handleCreateIndex, request); result.Code != http.StatusConflict {
|
|
t.Fatalf("completed index was initialized again: %d %s", result.Code, result.Body)
|
|
}
|
|
result := postJSON(t, server.handleListTagsForResource, &ListTagsForResourceRequest{ResourceArn: server.indexArn("vec-bucket", "idx")})
|
|
var tags ListTagsForResourceResponse
|
|
if err := json.Unmarshal(result.Body.Bytes(), &tags); err != nil || tags.Tags["team"] != "search" {
|
|
t.Fatalf("resume lost tags: %s (%v)", result.Body, err)
|
|
}
|
|
}
|
|
|
|
func TestCreateIndexRollbackPreservesACompletedRetry(t *testing.T) {
|
|
fs := s3tablestest.Start(t)
|
|
engine := &failingCreateVectors{fakeVectors: &fakeVectors{}, err: status.Error(codes.InvalidArgument, "first attempt failed")}
|
|
server := NewServer(s3tables.NewManagerClient(fs.Client), allowAllAuth{}, engine)
|
|
postJSON(t, server.handleCreateVectorBucket, &CreateVectorBucketRequest{VectorBucketName: "vec-bucket"})
|
|
request := &CreateIndexRequest{VectorBucketName: "vec-bucket", IndexName: "idx", DataType: "float32", Dimension: 3, DistanceMetric: "cosine"}
|
|
engine.onCreate = func() {
|
|
engine.onCreate = nil
|
|
engine.err = nil
|
|
if result := postJSON(t, server.handleCreateIndex, request); result.Code != http.StatusOK {
|
|
t.Fatalf("concurrent retry: %d %s", result.Code, result.Body)
|
|
}
|
|
}
|
|
if result := postJSON(t, server.handleCreateIndex, request); result.Code == http.StatusOK {
|
|
t.Fatal("first attempt reported success")
|
|
}
|
|
if _, err := server.indexRecord(t.Context(), "vec-bucket", "idx"); err != nil {
|
|
t.Fatalf("rollback removed the completed retry: %v", err)
|
|
}
|
|
if result := postJSON(t, server.handleCreateIndex, request); result.Code != http.StatusConflict {
|
|
t.Fatalf("completed retry lost its state: %d %s", result.Code, result.Body)
|
|
}
|
|
}
|
|
|
|
func TestCreateIndexRetainsAnUnknownOutcomeAfterARefusedRetry(t *testing.T) {
|
|
for _, code := range []codes.Code{codes.PermissionDenied, codes.FailedPrecondition} {
|
|
t.Run(code.String(), func(t *testing.T) {
|
|
fs := s3tablestest.Start(t)
|
|
first := &failingCreateVectors{fakeVectors: &fakeVectors{}, err: status.Error(codes.Unavailable, "response lost after commit")}
|
|
second := &failingCreateVectors{fakeVectors: &fakeVectors{}, err: status.Error(code, "retry refused")}
|
|
discovery := &vectorDiscovery{client: first, nextResolve: time.Now().Add(time.Hour)}
|
|
discovery.discover = func([]pb.ServerAddress, grpc.DialOption) []string {
|
|
discovery.mu.Lock()
|
|
discovery.client = second
|
|
discovery.mu.Unlock()
|
|
return nil
|
|
}
|
|
defer discovery.Close()
|
|
server := NewServer(s3tables.NewManagerClient(fs.Client), allowAllAuth{}, discovery)
|
|
postJSON(t, server.handleCreateVectorBucket, &CreateVectorBucketRequest{VectorBucketName: "vec-bucket"})
|
|
result := postJSON(t, server.handleCreateIndex, &CreateIndexRequest{VectorBucketName: "vec-bucket", IndexName: "idx", DataType: "float32", Dimension: 3, DistanceMetric: "cosine"})
|
|
if result.Code != http.StatusServiceUnavailable || len(first.created) != 1 || len(second.created) != 1 {
|
|
t.Fatalf("unknown outcome lost: %d %s (attempts %d, %d)", result.Code, result.Body, len(first.created), len(second.created))
|
|
}
|
|
if _, err := server.indexRecord(t.Context(), "vec-bucket", "idx"); err != nil {
|
|
t.Fatalf("refused retry erased a possibly committed dataset: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
type concurrentCreateVectors struct {
|
|
vector_pb.VectorServiceClient
|
|
create func() (*vector_pb.CreateIndexResponse, error)
|
|
}
|
|
|
|
func (f *concurrentCreateVectors) CreateIndex(context.Context, *vector_pb.CreateIndexRequest, ...grpc.CallOption) (*vector_pb.CreateIndexResponse, error) {
|
|
return f.create()
|
|
}
|
|
|
|
func TestCreateIndexRollbackPreservesAnActiveRetry(t *testing.T) {
|
|
fs := s3tablestest.Start(t)
|
|
firstStarted, retryStarted, finishRetry := make(chan struct{}), make(chan struct{}), make(chan struct{})
|
|
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
|
|
defer cancel()
|
|
var calls atomic.Int32
|
|
engine := &concurrentCreateVectors{create: func() (*vector_pb.CreateIndexResponse, error) {
|
|
if calls.Add(1) == 1 {
|
|
close(firstStarted)
|
|
select {
|
|
case <-retryStarted:
|
|
return nil, status.Error(codes.InvalidArgument, "first attempt refused")
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
}
|
|
}
|
|
close(retryStarted)
|
|
select {
|
|
case <-finishRetry:
|
|
return &vector_pb.CreateIndexResponse{}, nil
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
}
|
|
}}
|
|
server := NewServer(s3tables.NewManagerClient(fs.Client), allowAllAuth{}, engine)
|
|
postJSON(t, server.handleCreateVectorBucket, &CreateVectorBucketRequest{VectorBucketName: "vec-bucket"})
|
|
request := &CreateIndexRequest{VectorBucketName: "vec-bucket", IndexName: "idx", DataType: "float32", Dimension: 3, DistanceMetric: "cosine"}
|
|
first, retry := make(chan *httptest.ResponseRecorder, 1), make(chan *httptest.ResponseRecorder, 1)
|
|
go func() { first <- postJSON(t, server.handleCreateIndex, request) }()
|
|
select {
|
|
case <-firstStarted:
|
|
case <-ctx.Done():
|
|
t.Fatal("first create did not reach the engine")
|
|
}
|
|
go func() { retry <- postJSON(t, server.handleCreateIndex, request) }()
|
|
if result := <-first; result.Code == http.StatusOK {
|
|
t.Fatal("first attempt reported success")
|
|
}
|
|
if _, err := server.indexRecord(t.Context(), "vec-bucket", "idx"); err != nil {
|
|
t.Fatalf("rollback removed an active retry: %v", err)
|
|
}
|
|
close(finishRetry)
|
|
if result := <-retry; result.Code != http.StatusOK {
|
|
t.Fatalf("retry could not finish: %d %s", result.Code, result.Body)
|
|
}
|
|
}
|
|
|
|
func TestCreateIndexResumesWithTheDefaultMetric(t *testing.T) {
|
|
fs := s3tablestest.Start(t)
|
|
engine := &failingCreateVectors{fakeVectors: &fakeVectors{}, err: status.Error(codes.Unavailable, "request lost")}
|
|
server := NewServer(s3tables.NewManagerClient(fs.Client), allowAllAuth{}, engine)
|
|
postJSON(t, server.handleCreateVectorBucket, &CreateVectorBucketRequest{VectorBucketName: "vec-bucket"})
|
|
request := &CreateIndexRequest{VectorBucketName: "vec-bucket", IndexName: "idx", DataType: "float32", Dimension: 3}
|
|
postJSON(t, server.handleCreateIndex, request)
|
|
engine.err = nil
|
|
if result := postJSON(t, server.handleCreateIndex, request); result.Code != http.StatusOK {
|
|
t.Fatalf("default-metric recovery: %d %s", result.Code, result.Body)
|
|
}
|
|
if len(engine.created) != 2 || engine.created[1].DistanceMetric != vector_pb.DistanceMetric_L2 {
|
|
t.Fatal("recovery changed the default metric")
|
|
}
|
|
}
|
|
|
|
func TestCreateIndexRetryPreservesDuplicateMetadataKeys(t *testing.T) {
|
|
fs := s3tablestest.Start(t)
|
|
engine := &failingCreateVectors{fakeVectors: &fakeVectors{}, err: status.Error(codes.Unavailable, "response lost")}
|
|
server := NewServer(s3tables.NewManagerClient(fs.Client), allowAllAuth{}, engine)
|
|
postJSON(t, server.handleCreateVectorBucket, &CreateVectorBucketRequest{VectorBucketName: "vec-bucket"})
|
|
request := &CreateIndexRequest{VectorBucketName: "vec-bucket", IndexName: "idx", DataType: "float32", Dimension: 3, DistanceMetric: "cosine",
|
|
MetadataConfiguration: &MetadataConfiguration{NonFilterableMetadataKeys: []string{"b", "a", "a"}}}
|
|
postJSON(t, server.handleCreateIndex, request)
|
|
engine.err = nil
|
|
if result := postJSON(t, server.handleCreateIndex, request); result.Code != http.StatusOK {
|
|
t.Fatalf("duplicate-key recovery: %d %s", result.Code, result.Body)
|
|
}
|
|
if len(engine.created) != 2 {
|
|
t.Fatalf("engine calls: %d", len(engine.created))
|
|
}
|
|
for _, call := range engine.created {
|
|
keys := slices.Clone(call.NonFilterableMetadataKeys)
|
|
slices.Sort(keys)
|
|
if !slices.Equal(keys, []string{"a", "a", "b"}) {
|
|
t.Fatalf("initialization changed metadata keys: %q", keys)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCreateIndexDoesNotInitializeAConcurrentCatalogCreate(t *testing.T) {
|
|
fs := s3tablestest.Start(t)
|
|
engine := &fakeVectors{}
|
|
server := NewServer(s3tables.NewManagerClient(fs.Client), allowAllAuth{}, engine)
|
|
postJSON(t, server.handleCreateVectorBucket, &CreateVectorBucketRequest{VectorBucketName: "vec-bucket"})
|
|
postJSON(t, server.handleCreateIndex, &CreateIndexRequest{VectorBucketName: "vec-bucket", IndexName: "seed", DataType: "float32", Dimension: 7, DistanceMetric: "cosine"})
|
|
dir, name := splitEntryPath(s3tables.GetTablePath("vec-bucket", indexNamespace, "idx"))
|
|
seed := fs.Get(dir, "seed")
|
|
var lookups int
|
|
fs.OnLookup = func(d, n string) {
|
|
if d == dir && n == name {
|
|
lookups++
|
|
if lookups == 2 {
|
|
fs.Put(dir, name, seed.Extended)
|
|
}
|
|
}
|
|
}
|
|
result := postJSON(t, server.handleCreateIndex, &CreateIndexRequest{VectorBucketName: "vec-bucket", IndexName: "idx", DataType: "float32", Dimension: 3, DistanceMetric: "cosine"})
|
|
if result.Code != http.StatusConflict || len(engine.created) != 1 {
|
|
t.Fatalf("initialized a competing record: %d %s (%d engine calls)", result.Code, result.Body, len(engine.created))
|
|
}
|
|
record, err := server.indexRecord(t.Context(), "vec-bucket", "idx")
|
|
if err != nil || indexFromRecord(server, "vec-bucket", "idx", record).Dimension != 7 {
|
|
t.Fatalf("changed the competing record: %v", err)
|
|
}
|
|
}
|