vector: address remaining s3vectors review findings (#12115)

* vector: address remaining s3vectors review findings

Catalog and permission model:
- Vector buckets are declared Lance table buckets end to end: create sets
  format=lance, get/list/delete reject or skip ICEBERG buckets, and the
  format lookup runs on an internal trusted catalog so callers need no
  GetTableBucket grant.
- The implicit "vector" index namespace is created through the same
  internal manager, so a CreateTable grant alone authorizes CreateIndex;
  typed S3TablesError codes replace string matching on err.Error().
- CreateIndex pre-checks existence after the authorize, tags the record
  with a create nonce, and rolls back on engine failure under
  context.WithoutCancel with a versionToken CAS, so a retry never deletes
  a table it did not create.
- DeleteIndex drops the catalog record directly; DropIndex is
  unimplemented on the shared VectorService.
- DeleteVectorBucket removes the empty internal namespace on
  BucketNotEmpty and retries once.
- PutVectors/DeleteVectors require the update grant, and engine gRPC
  errors map to typed API errors instead of generic 500s.

API correctness:
- CreateVectorBucket/CreateIndex return their resource ARNs.
- ARNs reject empty bucket segments; vectorBucketArn must be an ARN.
- ListIndexes returns summaries built only from catalog fields.
- ListVectors honors maxResults/nextToken inside a segment via
  "seg:<pageStart>:<nextIndex>" resume tokens and validates
  segmentCount/segmentIndex.
- QueryVectors requires positive topK; PutVectors rejects empty keys.
- CreateVectorBucket rejects a supplied encryptionConfiguration rather
  than silently dropping it.
- Metadata filters emit Lance's real JSONB UDFs: json_get_string/
  json_get_float/json_get_bool picked by operand type, json_exists with a
  bracketed JSONPath, and per-member accessors for $in/$nin.

Engine:
- weed-vector-server stores the metadata column as Lance JSONB
  (lance.json extension) so the json_* UDFs apply; reads accept legacy
  Utf8 JSON columns.

Vectors port TLS:
- The TLS config is built before binding listeners, loads -cacert.file
  into ClientCAs when client-cert verification is on, fails startup on an
  empty/invalid CA file, and closes the gRPC conn on every failure path.

* s3vectors: authorize index create before writing the implicit namespace

A denied caller could still leave a persistent namespace behind because
ensureIndexNamespace wrote through the trusted catalog before the
caller's create grant ran. AuthorizeCreateTable now tolerates the
not-yet-declared implicit namespace (bucket policy alone decides) and
runs before any write; real table creates keep requiring the namespace.

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

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

* s3vectors: keep engine error details out of the API response

Unavailable now maps to 503 ServiceUnavailableException and unclassified
codes return a generic message; the real gRPC error stays in the log.

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

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

* s3vectors: exact-type metadata filters + NULL-safe $nin

The jsonb extractors convert numeric-looking strings through
json_get_float and 'true'/'false' through json_get_bool, so a stored '3'
could satisfy a filter for the number 3. Numeric and boolean comparisons
now also require the stored value not be a string, and each $in/$nin
group coalesces NULL so NOT cannot invert NULL into NULL.

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

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

* s3: validate the vectors TLS key pair before binding

The reloading certificate provider reads the files asynchronously, so a
missing or mismatched pair would only surface as failed handshakes on an
announced HTTPS port. Load it once up front.

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

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

* s3tables: make a token-checked table delete atomic

The version token was read in one filer call and enforced in another, so
a delete+recreate slipping between them let the delete erase the
replacement. When a token is supplied the name path now carries the
observed mtime as an if_not_modified_after precondition (evaluated under
the path lock), and a decoupled delete strips the catalog marker under a
compare of the captured attributes before touching the data path.

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

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

* s3vectors: compare filter operands against the stored JSON type

jsonb's strict converters make json_get_string return text for numbers
and booleans too, so a get_string IS NULL guard is never true for scalar
metadata — numeric and boolean comparisons silently matched nothing.
Numbers and booleans now compare through TRY_CAST of the serialized
json_extract text, which yields NULL on any other stored type; string
comparisons stay on json_get_string behind a CASE guard so the extractor
only runs on rows storing a JSON string.

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

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

* s3tables: allocate implicit namespace metadata before use

A first table create in an undeclared namespace took the
allowMissingNamespace branch with a nil record and panicked on the
owner assignment.

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

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

* s3tables: read the delete target's metadata and snapshot in one lookup

Two separate lookups let a delete+recreate in between validate the
caller token against the old record while the conditional delete aimed
at the replacement.

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

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

* s3tables: assert the captured record before a conditional table delete

An xattr update keeps Mtime, so the IfNotModifiedAfterNs guard alone
could not see a metadata write that had already rotated the version
token, and a recreate preserving the mtime could slip the mtime check.
The delete now first proves the entry still carries the captured
attributes under the filer's path lock, then keeps the mtime
precondition.

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

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

* s3vectors: match only JSON boolean literals in metadata filters

* filer,s3tables: delete versioned tables under one attribute precondition

* s3vectors: compare serialized JSON for string filter operands

json_get_string converts numeric-looking strings and booleans, so it
cannot serve exact-typed string comparisons even behind a guard. String
operands now compare the serialized json_extract text directly, which is
exact-typed and never errors; a Rust test pins the semantics against a
mixed-type dataset.

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

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

* s3vectors: compare boolean filter operands as serialized text

Arrow's boolean cast accepts '1' and '0', so a TRY_CAST column let a
stored number satisfy a boolean filter. Booleans now compare the
serialized json_extract text like strings do — a JSON boolean only ever
serializes as 'true' or 'false'.

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

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

* s3tables: claim a catalog name's markers in one compare-checked write

A create wrote its markers attribute-by-attribute after checking the
name was free. A delete's restore landing in the check-to-write gap put
the old markers back first, and the create then overwrote only the ones
it sets — leaving the old tags, policy, and maintenance settings on the
new table. CreateTable, RegisterTable, and CreateView now write all
their markers in a single update that refuses to run while any catalog
marker is present, so the second writer loses with a conflict instead
of inheriting them.

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

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

* s3tables: test the attribute-preconditioned table delete and name claim

Adds a BeforeDelete hook plus ExpectedExtended evaluation to the
in-memory filer so tests can land a competing write in a handler's
check-to-delete window, and covers two losing races: a token delete
whose record changed since the read, and a create colliding with a
delete's marker restore.

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

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

* s3tables: reserve a catalog record under one compare-checked claim

claimCatalogRecord writes a deleting marker onto the record while it
still holds exactly the caller's captured attributes, so a delete or
rename can hold the record through a multi-step operation and a losing
writer backs out before any data or destination entry is written. The
claim key joins the markers a name-claim asserts absent, so the name
cannot be reused while the reservation stands.

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

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

* s3tables: claim the record before purging or publishing past it

A token delete of a decoupled table purged the data first and checked
the marker afterwards, so a rename that finished in between left the
moved record pointing at purged data while the delete reported a
conflict. The delete now claims the record under the captured
attributes before any data is removed and releases the claim if the
purge fails, keeping the record intact for a retry.

The rename claims the source before publishing the destination, so a
losing rename backs out while no destination entry exists instead of
leaving one behind pointing at data a delete went on to remove.

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

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

* s3tables: test the record claim across delete and rename races

A rename completing in the token delete's check-to-claim window turns
the delete into a conflict with the data intact, and a delete's claim
on the source turns the rename into a conflict before the destination
entry is published.

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-26 20:49:44 -07:00
1 parent 8379ee9251
commit ee315527cb
20 files changed
+1572 -344

No files matched your search

+92
View File
@@ -17,6 +17,7 @@ import (
var (
ErrAttributeNotFound = errors.New("attribute not found")
ErrConcurrentUpdate = errors.New("attribute changed concurrently")
ErrNameTaken = errors.New("catalog name is taken")
)
// extendedAttributeUpdateAttempts bounds the read-modify-write retry loop in
@@ -179,6 +180,75 @@ func (h *S3TablesHandler) removeExtendedAttributesIf(
})
}
// claimCatalogEntry writes a new catalog entry's marker attributes in one
// compare-checked update. Every catalog marker must still be absent: a
// delete's restore or a competing create that landed since the caller's check
// is a name conflict, not a record this write may overwrite — an overwrite
// would leave the loser's tags, policy, and maintenance settings on the new
// record.
func (h *S3TablesHandler) claimCatalogEntry(ctx context.Context, client filer_pb.SeaweedFilerClient, path string, attrs map[string][]byte) error {
return h.mutateEntryExtended(ctx, client, path, func(extended map[string][]byte) error {
for _, key := range s3tablesExtendedKeys {
if _, ok := extended[key]; ok {
return fmt.Errorf("%w: %s set on %s", ErrNameTaken, key, path)
}
}
for key, data := range attrs {
extended[key] = data
}
return nil
})
}
// claimCatalogRecord reserves a catalog record for an in-flight delete or
// rename under one compare-checked write: the entry must still hold exactly
// expected. A record claimed by another kind of operation refuses the claim,
// so the loser backs out before any data or destination entry is written;
// a retry of the same kind may adopt its own earlier claim.
func (h *S3TablesHandler) claimCatalogRecord(
ctx context.Context,
client filer_pb.SeaweedFilerClient,
path string,
expected map[string][]byte,
claim string,
) error {
return h.mutateEntryExtended(ctx, client, path, func(extended map[string][]byte) error {
for key, want := range expected {
if key == ExtendedKeyDeleting {
continue
}
if !bytes.Equal(extended[key], want) {
return fmt.Errorf("%w: %s changed on %s", ErrConcurrentUpdate, key, path)
}
}
if cur, ok := extended[ExtendedKeyDeleting]; ok && string(cur) != claim {
return fmt.Errorf("%w: %s held on %s", ErrConcurrentUpdate, ExtendedKeyDeleting, path)
}
extended[ExtendedKeyDeleting] = []byte(claim)
return nil
})
}
// releaseCatalogRecord lifts a claimCatalogRecord reservation while the entry
// still holds expected — which includes the claim itself — restoring the
// record so the failed operation can be retried.
func (h *S3TablesHandler) releaseCatalogRecord(
ctx context.Context,
client filer_pb.SeaweedFilerClient,
path string,
expected map[string][]byte,
) error {
return h.mutateEntryExtended(ctx, client, path, func(extended map[string][]byte) error {
for key, want := range expected {
if !bytes.Equal(extended[key], want) {
return fmt.Errorf("%w: %s changed on %s", ErrConcurrentUpdate, key, path)
}
}
delete(extended, ExtendedKeyDeleting)
return nil
})
}
// updateExtendedAttribute applies mutate to the current value of key and writes
// the result back. mutate runs again on each retry, so it always sees the
// current value.
@@ -211,6 +281,7 @@ var s3tablesExtendedKeys = []string{
ExtendedKeyMaintenance,
ExtendedKeyMaintenanceStatus,
ExtendedKeyEntryType,
ExtendedKeyDeleting,
}
// SnapshotExtended captures an entry's attributes as an UpdateEntry
@@ -314,6 +385,27 @@ func (h *S3TablesHandler) deleteDirectory(ctx context.Context, client filer_pb.S
return filer_pb.DoRemove(ctx, client, dir, name, true, true, true, false, nil)
}
// deleteDirectoryIfMatches deletes a directory and all its contents only
// while the entry still carries the extended attributes the caller captured:
// the filer compares and removes in one operation under the path lock, so an
// attribute write that keeps Mtime or a delete+recreate in between refuses the
// delete with ErrConcurrentUpdate instead of losing the replacement.
func (h *S3TablesHandler) deleteDirectoryIfMatches(ctx context.Context, client filer_pb.SeaweedFilerClient, path string, expected *filer_pb.Entry) error {
dir, name := splitPath(path)
_, err := filer_pb.DoRemoveRequest(ctx, client, &filer_pb.DeleteEntryRequest{
Directory: dir,
Name: name,
IsDeleteData: true,
IsRecursive: true,
IgnoreRecursiveError: true,
ExpectedExtended: snapshotExtended(expected.Extended),
})
if status.Code(err) == codes.FailedPrecondition {
return fmt.Errorf("%w: %s", ErrConcurrentUpdate, path)
}
return err
}
// entryExists checks if an entry exists at the given path
func (h *S3TablesHandler) entryExists(ctx context.Context, client filer_pb.SeaweedFilerClient, path string) bool {
dir, name := splitPath(path)
+4
View File
@@ -32,6 +32,10 @@ const (
// contend on the same attribute.
ExtendedKeyMaintenanceStatus = s3_constants.ExtS3TablesPrefix + "maintenanceStatus"
ExtendedKeyEntryType = s3_constants.ExtS3TablesPrefix + "entryType"
// ExtendedKeyDeleting marks a catalog record claimed by an in-flight
// delete or rename, so the claim survives until the operation finishes
// or releases it.
ExtendedKeyDeleting = s3_constants.ExtS3TablesPrefix + "deleting"
// Entry-type marker values for ExtendedKeyEntryType. Absent or "table" means
// a table; views are stored like tables but tagged "view".
@@ -3,6 +3,7 @@ package s3tables
import (
"context"
"encoding/json"
"net/http"
"path"
"testing"
@@ -21,6 +22,86 @@ func runDeleteTable(t *testing.T, m *Manager, fs *s3tablestest.MemFiler, namespa
}, nil, "")
}
// A write that lands between the delete's read and its conditional commit —
// an xattr update keeps mtime — must turn a token delete into a conflict, not
// get erased by it.
func TestDeleteTableTokenDeleteLosesToConcurrentUpdate(t *testing.T) {
fs, m := startRenameManager(t)
token := "tok-ct"
meta, _ := json.Marshal(tableMetadataInternal{
Name: "ct",
Namespace: "ns",
Format: "ICEBERG",
OwnerAccountID: DefaultAccountID,
VersionToken: token,
})
fs.Put(GetNamespacePath(renameTestBucket, "ns"), "ct", map[string][]byte{
ExtendedKeyMetadata: meta,
})
tablePath := GetTablePath(renameTestBucket, "ns", "ct")
fs.Put(tablePath, "data", nil)
fs.BeforeDelete = func() {
fs.Put(GetNamespacePath(renameTestBucket, "ns"), "ct", map[string][]byte{
ExtendedKeyMetadata: meta,
ExtendedKeyTags: []byte(`{"k":"v"}`),
})
}
status, resp := runHandler(t, m, fs.Client, "DeleteTable", &DeleteTableRequest{
TableBucketARN: mustBucketARN(t),
Namespace: []string{"ns"},
Name: "ct",
VersionToken: token,
})
assert.Equal(t, http.StatusConflict, status)
assert.Equal(t, ErrCodeConflict, resp.Type)
assert.NotNil(t, fs.Get(GetNamespacePath(renameTestBucket, "ns"), "ct"), "the updated record must survive")
assert.NotNil(t, fs.Get(tablePath, "data"), "the table's data must survive")
}
// A create that checked the name while it was free must not write over a
// delete's restore: the claim loses instead of layering new metadata over the
// old tags, policy, and maintenance settings.
func TestCreateTableLosesToRestoredMarkers(t *testing.T) {
fs, m := startRenameManager(t)
nsPath := GetNamespacePath(renameTestBucket, "ns")
fs.Put(nsPath, "newt", nil)
oldMeta, _ := json.Marshal(tableMetadataInternal{
Name: "newt",
Namespace: "ns",
Format: "ICEBERG",
OwnerAccountID: DefaultAccountID,
})
restored := map[string][]byte{
ExtendedKeyMetadata: oldMeta,
ExtendedKeyEntryType: []byte(EntryTypeTable),
ExtendedKeyTags: []byte(`{"old":"tag"}`),
ExtendedKeyMaintenance: []byte(`{"old":"maintenance"}`),
}
fs.BeforeUpdate = func() {
fs.Put(nsPath, "newt", restored)
}
status, resp := runHandler(t, m, fs.Client, "CreateTable", &CreateTableRequest{
TableBucketARN: mustBucketARN(t),
Namespace: []string{"ns"},
Name: "newt",
Format: FormatIceberg,
})
assert.Equal(t, http.StatusConflict, status)
assert.Equal(t, ErrCodeTableAlreadyExists, resp.Type)
got := fs.Get(nsPath, "newt")
require.NotNil(t, got)
assert.Equal(t, restored[ExtendedKeyTags], got.Extended[ExtendedKeyTags], "restored markers must not be overwritten")
assert.Equal(t, restored[ExtendedKeyMetadata], got.Extended[ExtendedKeyMetadata])
}
// A table whose data was decoupled from its name (created over a leftover from a
// rename): catalog marker at ns/newt, data at ns/newt-x, and ns/newt still holds
// another table's leftover data. Dropping it must purge only its own data and
@@ -168,3 +249,77 @@ func TestDeleteTableRefusesNestedSiblingDataPath(t *testing.T) {
assert.NotNil(t, fs.Get(GetNamespacePath(renameTestBucket, "nsa"), "vtab"),
"the victim table's catalog entry must survive")
}
// A rename that completes in the token delete's check-to-claim window moves
// the record to another name: the stale delete must lose on the claim instead
// of purging the data the moved record still points at.
func TestDeleteTableTokenDeleteLosesToCompletedRename(t *testing.T) {
fs, m := startRenameManager(t)
token := "tok-rn"
meta, _ := json.Marshal(tableMetadataInternal{
Name: "rn",
Namespace: "ns",
Format: "ICEBERG",
OwnerAccountID: DefaultAccountID,
VersionToken: token,
MetadataLocation: "s3://" + renameTestBucket + "/ns/old-x/metadata/v1.metadata.json",
})
fs.Put(GetNamespacePath(renameTestBucket, "ns"), "rn", map[string][]byte{
ExtendedKeyMetadata: meta,
ExtendedKeyEntryType: []byte(EntryTypeTable),
})
dataPath := GetTablePath(renameTestBucket, "ns", "old-x")
fs.Put(dataPath, "data", nil)
fs.BeforeUpdate = func() {
// The rename's source clear has already run by the time the delete's
// claim write lands.
fs.Put(GetNamespacePath(renameTestBucket, "ns"), "rn", nil)
}
status, resp := runHandler(t, m, fs.Client, "DeleteTable", &DeleteTableRequest{
TableBucketARN: mustBucketARN(t),
Namespace: []string{"ns"},
Name: "rn",
VersionToken: token,
})
assert.Equal(t, http.StatusConflict, status)
assert.Equal(t, ErrCodeConflict, resp.Type)
assert.NotNil(t, fs.Get(dataPath, "data"), "the moved table's data must survive the stale delete")
}
// A delete's claim on the source must turn a rename into a conflict before
// any destination entry exists: the claim is what keeps a losing rename from
// leaving a destination marker pointing at data the delete went on to purge.
func TestRenameTableLosesToDeleteClaim(t *testing.T) {
fs, m := startRenameManager(t)
nsPath := GetNamespacePath(renameTestBucket, "ns")
fs.BeforeUpdate = func() {
e := fs.Get(nsPath, "t")
claimed := map[string][]byte{}
for k, v := range e.Extended {
claimed[k] = v
}
claimed[ExtendedKeyDeleting] = []byte("delete")
fs.Put(nsPath, "t", claimed)
}
status, resp := runHandler(t, m, fs.Client, "RenameTable", &RenameTableRequest{
TableBucketARN: mustBucketARN(t),
SourceNamespace: []string{"ns"},
SourceName: "t",
DestNamespace: []string{"ns"},
DestName: "t2",
})
assert.Equal(t, http.StatusConflict, status)
assert.Equal(t, ErrCodeConflict, resp.Type)
assert.Nil(t, fs.Get(nsPath, "t2"), "a losing rename must not publish the destination")
src := fs.Get(nsPath, "t")
require.NotNil(t, src)
assert.NotNil(t, src.Extended[ExtendedKeyMetadata], "the claimed source record must survive")
assert.NotNil(t, fs.Get(GetTablePath(renameTestBucket, "ns", "t"), "data"))
}
+153 -59
View File
@@ -13,6 +13,7 @@ import (
"strings"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
)
@@ -36,7 +37,7 @@ func (h *S3TablesHandler) handleCreateTable(w http.ResponseWriter, r *http.Reque
return fmt.Errorf("invalid format")
}
target, err := h.authorizeCreateTable(w, r, filerClient, req.TableBucketARN, req.Namespace, req.Name, req.Tags)
target, err := h.authorizeCreateTable(w, r, filerClient, req.TableBucketARN, req.Namespace, req.Name, req.Tags, false)
if err != nil {
return err
}
@@ -129,6 +130,14 @@ func (h *S3TablesHandler) handleCreateTable(w http.ResponseWriter, r *http.Reque
return fmt.Errorf("failed to marshal metadata: %w", err)
}
var tagsBytes []byte
if len(req.Tags) > 0 {
if tagsBytes, err = json.Marshal(req.Tags); err != nil {
h.writeError(w, http.StatusInternalServerError, ErrCodeInternalError, "failed to marshal tags")
return fmt.Errorf("failed to marshal tags: %w", err)
}
}
err = filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
// Ensure table directory exists (may already be created by object storage clients)
if err := h.ensureDirectory(r.Context(), client, tablePath); err != nil {
@@ -141,31 +150,21 @@ func (h *S3TablesHandler) handleCreateTable(w http.ResponseWriter, r *http.Reque
return err
}
// Set metadata as extended attribute
if err := h.setExtendedAttribute(r.Context(), client, tablePath, ExtendedKeyMetadata, metadataBytes); err != nil {
return err
attrs := map[string][]byte{
ExtendedKeyMetadata: metadataBytes,
ExtendedKeyEntryType: []byte(EntryTypeTable),
}
// Tag the entry as a table so view listings can exclude it.
if err := h.setExtendedAttribute(r.Context(), client, tablePath, ExtendedKeyEntryType, []byte(EntryTypeTable)); err != nil {
return err
if len(tagsBytes) > 0 {
attrs[ExtendedKeyTags] = tagsBytes
}
// Set tags if provided
if len(req.Tags) > 0 {
tagsBytes, err := json.Marshal(req.Tags)
if err != nil {
return fmt.Errorf("failed to marshal tags: %w", err)
}
if err := h.setExtendedAttribute(r.Context(), client, tablePath, ExtendedKeyTags, tagsBytes); err != nil {
return err
}
}
return nil
return h.claimCatalogEntry(r.Context(), client, tablePath, attrs)
})
if err != nil {
if errors.Is(err, ErrNameTaken) {
h.writeError(w, http.StatusConflict, ErrCodeTableAlreadyExists, fmt.Sprintf("table %s already exists", tableName))
return err
}
h.writeError(w, http.StatusInternalServerError, ErrCodeInternalError, "failed to create table")
return err
}
@@ -228,7 +227,7 @@ func (h *S3TablesHandler) handleRegisterTable(w http.ResponseWriter, r *http.Req
return fmt.Errorf("metadataLocation is required")
}
target, err := h.authorizeCreateTable(w, r, filerClient, req.TableBucketARN, req.Namespace, req.Name, nil)
target, err := h.authorizeCreateTable(w, r, filerClient, req.TableBucketARN, req.Namespace, req.Name, nil, false)
if err != nil {
return err
}
@@ -282,9 +281,15 @@ func (h *S3TablesHandler) handleRegisterTable(w http.ResponseWriter, r *http.Req
if err := h.ensureDirectory(r.Context(), client, tablePath); err != nil {
return err
}
return h.setExtendedAttribute(r.Context(), client, tablePath, ExtendedKeyMetadata, metadataBytes)
return h.claimCatalogEntry(r.Context(), client, tablePath, map[string][]byte{
ExtendedKeyMetadata: metadataBytes,
})
})
if err != nil {
if errors.Is(err, ErrNameTaken) {
h.writeError(w, http.StatusConflict, ErrCodeTableAlreadyExists, fmt.Sprintf("table %s already exists", tableName))
return err
}
h.writeError(w, http.StatusInternalServerError, ErrCodeInternalError, "failed to register table")
return err
}
@@ -896,8 +901,16 @@ func (h *S3TablesHandler) handleDeleteTable(w http.ResponseWriter, r *http.Reque
var bucketTags map[string]string
var tableTags map[string]string
var bucketMetadata tableBucketMetadata
var tableEntry *filer_pb.Entry
err = filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
data, err := h.readTableMetadata(r.Context(), client, tablePath)
// One lookup feeds both the version-token check and the conditional
// delete below: a second read could land on a replacement entry while
// the token was validated against the old one.
tableEntry, err = h.lookupEntry(r.Context(), client, tablePath)
if err != nil {
return err
}
data, err := tableMetadata(tableEntry)
if err != nil {
return err
}
@@ -1035,6 +1048,40 @@ func (h *S3TablesHandler) handleDeleteTable(w http.ResponseWriter, r *http.Reque
// lives elsewhere. Purge the data, then clear the catalog marker
// without deleting the name path -- it may still hold another
// table's data that was left when this name was reused.
if req.VersionToken != "" && tableEntry != nil {
// A token caller owns exactly the record it read. Claim it
// under the captured attributes before any data is removed:
// a rename or delete+recreate that moved the record aborts
// here, while it still points at live data. The claim holds
// the name through the purge, a failed purge releases it so
// the intact record can be retried, and the marker comes off
// under a compare on the claimed record.
snapshot := snapshotExtended(tableEntry.Extended)
if err := h.claimCatalogRecord(r.Context(), client, tablePath, snapshot, "delete"); err != nil {
if errors.Is(err, ErrConcurrentUpdate) {
return ErrVersionTokenMismatch
}
return err
}
claimed := snapshotExtended(tableEntry.Extended)
claimed[ExtendedKeyDeleting] = []byte("delete")
if err := h.deleteDirectory(r.Context(), client, dataPath); err != nil {
if relErr := h.releaseCatalogRecord(r.Context(), client, tablePath, claimed); relErr != nil {
glog.Warningf("delete table %s: release claim: %v", tablePath, relErr)
}
return err
}
err := h.removeExtendedAttributesIf(r.Context(), client, tablePath, claimed,
ExtendedKeyMetadata, ExtendedKeyMetadataVersion, ExtendedKeyPolicy, ExtendedKeyTags, ExtendedKeyEntryType,
ExtendedKeyMaintenance, ExtendedKeyMaintenanceStatus, ExtendedKeyDeleting)
if err != nil {
if errors.Is(err, ErrConcurrentUpdate) {
return ErrVersionTokenMismatch
}
return err
}
return nil
}
if err := h.deleteDirectory(r.Context(), client, dataPath); err != nil {
return err
}
@@ -1042,11 +1089,27 @@ func (h *S3TablesHandler) handleDeleteTable(w http.ResponseWriter, r *http.Reque
ExtendedKeyMetadata, ExtendedKeyMetadataVersion, ExtendedKeyPolicy, ExtendedKeyTags, ExtendedKeyEntryType,
ExtendedKeyMaintenance, ExtendedKeyMaintenanceStatus)
}
// Colocated table: the name path holds the data.
// Colocated table: the name path holds the data. A token caller's
// delete carries the captured attributes as a filer precondition, so
// compare and removal are one operation under the path lock: an xattr
// write that keeps Mtime, or a delete+recreate in between, refuses it.
if req.VersionToken != "" && tableEntry != nil {
if err := h.deleteDirectoryIfMatches(r.Context(), client, tablePath, tableEntry); err != nil {
if errors.Is(err, ErrConcurrentUpdate) {
return ErrVersionTokenMismatch
}
return err
}
return nil
}
return h.deleteDirectory(r.Context(), client, tablePath)
})
if err != nil {
if errors.Is(err, ErrVersionTokenMismatch) {
h.writeError(w, http.StatusConflict, ErrCodeConflict, "version token mismatch")
return err
}
h.writeError(w, http.StatusInternalServerError, ErrCodeInternalError, "failed to delete table")
return err
}
@@ -1123,6 +1186,13 @@ func (h *S3TablesHandler) readTableMetadata(ctx context.Context, client filer_pb
if err != nil {
return nil, err
}
return tableMetadata(entry)
}
// tableMetadata extracts the table-metadata xattr from an entry. A view stored
// at the same name is reported absent, the way GetTable and the renamer's
// entry-type check already treat kind mismatches.
func tableMetadata(entry *filer_pb.Entry) ([]byte, error) {
if EntryType(entry.Extended) == EntryTypeView {
return nil, filer_pb.ErrNotFound
}
@@ -1214,10 +1284,6 @@ func (h *S3TablesHandler) renameCatalogEntry(w http.ResponseWriter, r *http.Requ
var metadataVersionXattr []byte
var maintenanceXattr []byte
var maintenanceStatusXattr []byte
// The values the rename copies. The source is only cleared while it still
// holds exactly these, so a write that lands mid-rename is not deleted here
// after having missed the copy to the destination.
var copiedFromSource map[string][]byte
var tablePolicy string
var bucketPolicy string
var bucketTags map[string]string
@@ -1255,10 +1321,6 @@ func (h *S3TablesHandler) renameCatalogEntry(w http.ResponseWriter, r *http.Requ
return err
}
srcExtended = srcEntry.Extended
copiedFromSource = make(map[string][]byte, len(renamedTableAttributes))
for _, key := range renamedTableAttributes {
copiedFromSource[key] = srcEntry.Extended[key]
}
// The maintenance configuration and its last-run status belong to the
// table, so they move with it; leaving them behind re-enables
@@ -1421,52 +1483,69 @@ func (h *S3TablesHandler) renameCatalogEntry(w http.ResponseWriter, r *http.Requ
return fmt.Errorf("failed to marshal metadata: %w", err)
}
// Write the destination entry before deleting the source so a mid-rename
// failure can never lose the table.
// Claim the source record before publishing the destination: a delete or
// a second rename that wins the claim backs out here — while no
// destination entry exists — instead of leaving one behind pointing at
// data a delete already removed.
err = filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
if err := h.createDirectory(r.Context(), client, destPath); err != nil {
if err := h.claimCatalogRecord(r.Context(), client, srcPath, snapshotExtended(srcExtended), "rename"); err != nil {
return err
}
claimed := snapshotExtended(srcExtended)
claimed[ExtendedKeyDeleting] = []byte("rename")
releaseClaim := func(err error) error {
if relErr := h.releaseCatalogRecord(r.Context(), client, srcPath, claimed); relErr != nil {
glog.Warningf("rename %s: release claim: %v", srcPath, relErr)
}
return err
}
if err := h.createDirectory(r.Context(), client, destPath); err != nil {
return releaseClaim(err)
}
if err := h.setExtendedAttribute(r.Context(), client, destPath, ExtendedKeyMetadata, metadataBytes); err != nil {
return err
return releaseClaim(err)
}
if err := h.setExtendedAttribute(r.Context(), client, destPath, ExtendedKeyEntryType, []byte(kind.entryType)); err != nil {
return err
return releaseClaim(err)
}
if len(metadataVersionXattr) > 0 {
if err := h.setExtendedAttribute(r.Context(), client, destPath, ExtendedKeyMetadataVersion, metadataVersionXattr); err != nil {
return err
return releaseClaim(err)
}
}
if len(tableTags) > 0 {
tagsBytes, err := json.Marshal(tableTags)
if err != nil {
return fmt.Errorf("failed to marshal tags: %w", err)
return releaseClaim(fmt.Errorf("failed to marshal tags: %w", err))
}
if err := h.setExtendedAttribute(r.Context(), client, destPath, ExtendedKeyTags, tagsBytes); err != nil {
return err
return releaseClaim(err)
}
}
if tablePolicy != "" {
if err := h.setExtendedAttribute(r.Context(), client, destPath, ExtendedKeyPolicy, []byte(tablePolicy)); err != nil {
return err
return releaseClaim(err)
}
}
if len(maintenanceXattr) > 0 {
if err := h.setExtendedAttribute(r.Context(), client, destPath, ExtendedKeyMaintenance, maintenanceXattr); err != nil {
return err
return releaseClaim(err)
}
}
if len(maintenanceStatusXattr) > 0 {
if err := h.setExtendedAttribute(r.Context(), client, destPath, ExtendedKeyMaintenanceStatus, maintenanceStatusXattr); err != nil {
return err
return releaseClaim(err)
}
}
// Soft-delete the source catalog identity in place: drop its catalog xattrs
// so the name stops resolving while the metadata/ and data/ children stay put
// (manifests embed absolute paths, so the data must not move).
return h.removeExtendedAttributesIf(r.Context(), client, srcPath, copiedFromSource,
renamedTableAttributes...)
err := h.removeExtendedAttributesIf(r.Context(), client, srcPath, claimed,
append(append([]string{}, renamedTableAttributes...), ExtendedKeyDeleting)...)
if err != nil {
return releaseClaim(err)
}
return nil
})
if err != nil {
@@ -1750,7 +1829,7 @@ type createTableTarget struct {
// Deferred creates (Iceberg stage-create) write into the table bucket before any
// table is registered, so they run this same gate first through
// Manager.AuthorizeCreateTable.
func (h *S3TablesHandler) authorizeCreateTable(w http.ResponseWriter, r *http.Request, filerClient FilerClient, tableBucketARN string, namespace []string, name string, requestTags map[string]string) (*createTableTarget, error) {
func (h *S3TablesHandler) authorizeCreateTable(w http.ResponseWriter, r *http.Request, filerClient FilerClient, tableBucketARN string, namespace []string, name string, requestTags map[string]string, allowMissingNamespace bool) (*createTableTarget, error) {
if tableBucketARN == "" {
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, "tableBucketARN is required")
return nil, fmt.Errorf("tableBucketARN is required")
@@ -1781,14 +1860,21 @@ func (h *S3TablesHandler) authorizeCreateTable(w http.ResponseWriter, r *http.Re
// Check if namespace exists
namespacePath := GetNamespacePath(bucketName, namespaceName)
namespaceMetadata, err := h.loadNamespaceMetadata(r.Context(), filerClient, bucketName, namespaceName)
nsMetadata, err := h.loadNamespaceMetadata(r.Context(), filerClient, bucketName, namespaceName)
namespaceExists := err == nil
if err != nil {
if errors.Is(err, filer_pb.ErrNotFound) {
h.writeError(w, http.StatusNotFound, ErrCodeNoSuchNamespace, fmt.Sprintf("namespace %s not found", namespaceName))
if errors.Is(err, filer_pb.ErrNotFound) && allowMissingNamespace {
// An implicit namespace can be authorized before it is declared:
// evaluate the grant against the bucket alone.
err = nil
} else {
h.writeError(w, http.StatusInternalServerError, ErrCodeInternalError, fmt.Sprintf("failed to check namespace: %v", err))
if errors.Is(err, filer_pb.ErrNotFound) {
h.writeError(w, http.StatusNotFound, ErrCodeNoSuchNamespace, fmt.Sprintf("namespace %s not found", namespaceName))
} else {
h.writeError(w, http.StatusInternalServerError, ErrCodeInternalError, fmt.Sprintf("failed to check namespace: %v", err))
}
return nil, err
}
return nil, err
}
// Authorize table creation using policy framework (namespace + bucket policies)
@@ -1811,12 +1897,15 @@ func (h *S3TablesHandler) authorizeCreateTable(w http.ResponseWriter, r *http.Re
}
// Fetch namespace policy if it exists
policyData, err := h.getExtendedAttribute(r.Context(), client, namespacePath, ExtendedKeyPolicy)
if err == nil {
namespacePolicy = string(policyData)
} else if !errors.Is(err, ErrAttributeNotFound) {
return fmt.Errorf("failed to fetch namespace policy: %v", err)
if namespaceExists {
policyData, err := h.getExtendedAttribute(r.Context(), client, namespacePath, ExtendedKeyPolicy)
if err == nil {
namespacePolicy = string(policyData)
} else if !errors.Is(err, ErrAttributeNotFound) {
return fmt.Errorf("failed to fetch namespace policy: %v", err)
}
}
var policyData []byte
// Fetch bucket policy if it exists
policyData, err = h.getExtendedAttribute(r.Context(), client, bucketPath, ExtendedKeyPolicy)
@@ -1839,6 +1928,11 @@ func (h *S3TablesHandler) authorizeCreateTable(w http.ResponseWriter, r *http.Re
return nil, err
}
if !namespaceExists {
// The implicit namespace has no record yet: authorize it as the
// bucket owner's instead of dereferencing the absent metadata.
nsMetadata = &namespaceMetadata{OwnerAccountID: bucketMetadata.OwnerAccountID}
}
bucketARN := h.generateTableBucketARN(bucketMetadata.OwnerAccountID, bucketName)
identityActions := getIdentityActions(r)
policyContext := &PolicyContext{
@@ -1854,7 +1948,7 @@ func (h *S3TablesHandler) authorizeCreateTable(w http.ResponseWriter, r *http.Re
DefaultAllow: h.defaultAllowFor(r),
}
if !CheckTableOrBucketPermission("CreateTable", accountID,
namespaceMetadata.OwnerAccountID, namespacePolicy, bucketARN,
nsMetadata.OwnerAccountID, namespacePolicy, bucketARN,
bucketMetadata.OwnerAccountID, bucketPolicy, bucketARN, policyContext) {
h.writeError(w, http.StatusForbidden, ErrCodeAccessDenied, "not authorized to create table in this namespace")
return nil, ErrAccessDenied
@@ -1865,6 +1959,6 @@ func (h *S3TablesHandler) authorizeCreateTable(w http.ResponseWriter, r *http.Re
namespaceName: namespaceName,
tableName: tableName,
bucketFormat: bucketMetadata.Format,
ownerAccountID: namespaceMetadata.OwnerAccountID,
ownerAccountID: nsMetadata.OwnerAccountID,
}, nil
}
+5 -1
View File
@@ -151,12 +151,16 @@ func (h *S3TablesHandler) handleCreateView(w http.ResponseWriter, r *http.Reques
if err := h.ensureDirectory(r.Context(), client, viewPath); err != nil {
return err
}
return h.setExtendedAttributes(r.Context(), client, viewPath, map[string][]byte{
return h.claimCatalogEntry(r.Context(), client, viewPath, map[string][]byte{
ExtendedKeyMetadata: metadataBytes,
ExtendedKeyEntryType: []byte(EntryTypeView),
})
})
if err != nil {
if errors.Is(err, ErrNameTaken) {
h.writeError(w, http.StatusConflict, ErrCodeViewAlreadyExists, fmt.Sprintf("view %s already exists", viewName))
return err
}
h.writeError(w, http.StatusInternalServerError, ErrCodeInternalError, "failed to create view")
return err
}
+1 -1
View File
@@ -71,7 +71,7 @@ func (m *Manager) AuthorizeCreateTable(ctx context.Context, filerClient FilerCli
return err
}
recorder := httptest.NewRecorder()
_, authErr := m.handler.authorizeCreateTable(recorder, httpReq, filerClient, req.TableBucketARN, req.Namespace, req.Name, req.Tags)
_, authErr := m.handler.authorizeCreateTable(recorder, httpReq, filerClient, req.TableBucketARN, req.Namespace, req.Name, req.Tags, req.AllowMissingNamespace)
if authErr == nil {
return nil
}
@@ -34,6 +34,9 @@ type MemFiler struct {
// BeforeUpdate runs once, at the start of the next UpdateEntry, so a test
// can land a competing write in a handler's read-to-write window.
BeforeUpdate func()
// BeforeDelete runs once before the next DeleteEntry, so a test can land a
// competing write in a handler's check-to-delete window.
BeforeDelete func()
// RejectDelete returns the reason DeleteEntry refuses a path, answered the
// way the filer answers one: no transport error, the reason in the response.
RejectDelete func(dir, name string) string
@@ -170,6 +173,10 @@ func (f *MemFiler) UpdateEntry(_ context.Context, req *filer_pb.UpdateEntryReque
}
func (f *MemFiler) DeleteEntry(_ context.Context, req *filer_pb.DeleteEntryRequest) (*filer_pb.DeleteEntryResponse, error) {
if hook := f.BeforeDelete; hook != nil {
f.BeforeDelete = nil
hook()
}
if reject := f.RejectDelete; reject != nil {
if reason := reject(req.Directory, req.Name); reason != "" {
return &filer_pb.DeleteEntryResponse{Error: reason}, nil
@@ -177,6 +184,19 @@ func (f *MemFiler) DeleteEntry(_ context.Context, req *filer_pb.DeleteEntryReque
}
f.mu.Lock()
defer f.mu.Unlock()
// The real filer evaluates ExpectedExtended under the path lock and refuses
// the delete when the entry no longer matches what the caller captured.
for key, expected := range req.ExpectedExtended {
var actual []byte
if d, ok := f.entries[req.Directory]; ok {
if existing := d[req.Name]; existing != nil {
actual = existing.Extended[key]
}
}
if !bytes.Equal(actual, expected) {
return nil, status.Errorf(codes.FailedPrecondition, "extended attribute %q changed", key)
}
}
if d, ok := f.entries[req.Directory]; ok {
delete(d, req.Name)
}
+4
View File
@@ -195,6 +195,10 @@ type CreateTableRequest struct {
MetadataVersion int `json:"metadataVersion,omitempty"`
MetadataLocation string `json:"metadataLocation,omitempty"`
Tags map[string]string `json:"tags,omitempty"`
// AllowMissingNamespace lets an authorization gate evaluate a create whose
// namespace is implicit and not yet declared; the write path still
// validates existence. Never deserialized: internal callers set it.
AllowMissingNamespace bool `json:"-"`
}
type CreateTableResponse struct {
+398 -102
View File
@@ -10,8 +10,12 @@ import (
"strings"
"time"
"github.com/google/uuid"
"github.com/seaweedfs/seaweedfs/weed/glog"
"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"
)
@@ -66,6 +70,9 @@ func arnParts(v *vectorBucketARNs, name, arn string) error {
rest := arn[i+len("bucket/"):]
parts := strings.Split(rest, "/index/")
v.bucket = parts[0]
if v.bucket == "" {
return fmt.Errorf("malformed ARN %q", arn)
}
if len(parts) == 2 {
v.index = parts[1]
}
@@ -83,17 +90,16 @@ func resolveIndexARNs(bucketName, bucketArn, indexName, indexArn string) (vector
} else if indexArn != "" {
return v, fmt.Errorf("indexArn must be an ARN, got %q", indexArn)
}
if strings.HasPrefix(bucketArn, "arn:") {
if bucketArn != "" {
if !strings.HasPrefix(bucketArn, "arn:") {
return v, fmt.Errorf("vectorBucketArn must be an ARN, got %q", bucketArn)
}
if err := arnParts(&v, "", bucketArn); err != nil {
return v, err
}
}
if v.bucket == "" {
if bucketArn != "" {
v.bucket = bucketArn
} else {
v.bucket = bucketName
}
v.bucket = bucketName
}
if v.index == "" {
v.index = indexName
@@ -120,46 +126,121 @@ func (s *Server) catalog(ctx context.Context, operation string, req, resp interf
return s.tables.Execute(ctx, s.filerClient, operation, req, resp, identityOf(r))
}
// catalogErrCode returns the error code the in-process catalog reports;
// errors from other sources carry no code.
// 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 e *s3tables.S3TablesError
if errors.As(err, &e) {
return e.Type
var apiErr *s3tables.S3TablesError
if errors.As(err, &apiErr) {
return apiErr.Type
}
return ""
}
func isNotFound(err error) bool {
switch catalogErrCode(err) {
case s3tables.ErrCodeNoSuchBucket, s3tables.ErrCodeNoSuchTable, s3tables.ErrCodeNoSuchNamespace:
return true
}
return err != nil && (strings.Contains(err.Error(), "NoSuchTableBucket") ||
strings.Contains(err.Error(), "NoSuchTable") ||
strings.Contains(err.Error(), "NotFoundException") ||
strings.Contains(err.Error(), "NoSuchBucket"))
}
func isAlreadyExists(err error) bool {
code := catalogErrCode(err)
return strings.HasSuffix(code, "AlreadyExists") || code == s3tables.ErrCodeConflict ||
(err != nil && (strings.Contains(err.Error(), "AlreadyExists") || strings.Contains(err.Error(), "Conflict")))
}
func catalogErr(w http.ResponseWriter, err error) {
msg := err.Error()
switch {
case isNotFound(err):
writeError(w, http.StatusNotFound, "NotFoundException", msg)
case catalogErrCode(err) == s3tables.ErrCodeAccessDenied ||
strings.Contains(msg, "AccessDenied") || strings.Contains(msg, "PermissionDenied"):
writeError(w, http.StatusForbidden, "AccessDeniedException", "access denied")
case isAlreadyExists(err):
writeError(w, http.StatusConflict, "ConflictException", msg)
default:
writeError(w, http.StatusBadRequest, "ValidationException", msg)
var apiErr *s3tables.S3TablesError
if !errors.As(err, &apiErr) {
writeError(w, http.StatusInternalServerError, "InternalFailure", "internal error")
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)
}
}
// 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, 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:
writeError(w, http.StatusServiceUnavailable, "ServiceUnavailableException", "vector service unavailable")
default:
glog.V(1).Infof("S3 Vectors: engine error: %v", err)
writeError(w, http.StatusInternalServerError, "InternalFailure", "internal error")
}
}
// lanceBucket reports whether bucket is a table bucket that can hold vector
// indexes: a declared Lance bucket, or one made before formats existed. An
// explicit ICEBERG bucket is not a vector bucket and reads as absent. The
// lookup is internal so the call needs no read grant — the requested
// operation's own permission still gates it.
func (s *Server) lanceBucket(ctx context.Context, bucket string) error {
var resp struct {
Format string `json:"format"`
}
err := s.internalCatalog(ctx, "GetTableBucket",
&s3tables.GetTableBucketRequest{TableBucketARN: s.s3tablesArn(bucket)}, &resp)
if err != nil {
return err
}
if resp.Format != "" && resp.Format != s3tables.FormatLance {
return &s3tables.S3TablesError{
Type: s3tables.ErrCodeNoSuchBucket,
Message: fmt.Sprintf("vector bucket %s not found", bucket),
}
}
return nil
}
// 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 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.
if err != nil && catalogErrCode(err) != s3tables.ErrCodeNamespaceAlreadyExists {
return err
}
return nil
}
// authorizeIndexWrite requires the update grant on the index's catalog table
// — the same check credential vending uses — so a read-only GetTable grant
// never reaches a mutation.
func (s *Server) authorizeIndexWrite(ctx context.Context, r *http.Request, bucket, index string) error {
return s.tables.AuthorizeUpdateTable(ctx, s.filerClient, &s3tables.UpdateTableRequest{
TableBucketARN: s.s3tablesArn(bucket),
Namespace: []string{indexNamespace},
Name: index,
}, identityOf(r))
}
func (s *Server) handleCreateVectorBucket(w http.ResponseWriter, r *http.Request) {
@@ -172,6 +253,13 @@ func (s *Server) handleCreateVectorBucket(w http.ResponseWriter, r *http.Request
writeError(w, http.StatusBadRequest, "ValidationException", "vectorBucketName is required")
return
}
// The catalog has no encryption boundary to honor the configuration
// against; accepting it would silently promise semantics we do not keep.
if len(req.EncryptionConfiguration) > 0 {
writeError(w, http.StatusBadRequest, "ValidationException",
"encryptionConfiguration is not supported")
return
}
var resp map[string]interface{}
err := s.catalog(r.Context(), "CreateTableBucket",
&s3tables.CreateTableBucketRequest{Name: req.VectorBucketName, Format: s3tables.FormatLance}, &resp, r)
@@ -179,7 +267,7 @@ func (s *Server) handleCreateVectorBucket(w http.ResponseWriter, r *http.Request
catalogErr(w, err)
return
}
writeJSON(w, http.StatusOK, &CreateVectorBucketResponse{})
writeJSON(w, http.StatusOK, &CreateVectorBucketResponse{VectorBucketArn: s.vectorBucketArn(req.VectorBucketName)})
}
func (s *Server) handleGetVectorBucket(w http.ResponseWriter, r *http.Request) {
@@ -200,6 +288,12 @@ func (s *Server) handleGetVectorBucket(w http.ResponseWriter, r *http.Request) {
catalogErr(w, err)
return
}
// An explicit ICEBERG bucket is not a vector bucket.
if format, _ := resp["format"].(string); format != "" && format != s3tables.FormatLance {
writeError(w, http.StatusNotFound, "NotFoundException",
fmt.Sprintf("vector bucket %s not found", arns.bucket))
return
}
created, _ := time.Parse(time.RFC3339, fmt.Sprint(resp["createdAt"]))
writeJSON(w, http.StatusOK, &GetVectorBucketResponse{VectorBucket: &VectorBucket{
VectorBucketArn: s.vectorBucketArn(arns.bucket),
@@ -231,10 +325,10 @@ func (s *Server) handleListVectorBuckets(w http.ResponseWriter, r *http.Request)
catalogErr(w, err)
return
}
// 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}
for _, b := range resp.TableBuckets {
// Buckets pre-dating declared formats accept any format; only a bucket
// that declared another format is excluded here.
if b.Format != "" && b.Format != s3tables.FormatLance {
continue
}
@@ -259,15 +353,32 @@ func (s *Server) handleDeleteVectorBucket(w http.ResponseWriter, r *http.Request
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
// The format check needs no read grant — the delete below is the
// authorization boundary — but an ICEBERG bucket is not a vector bucket.
if err := s.lanceBucket(r.Context(), arns.bucket); err != nil {
catalogErr(w, err)
return
}
var resp map[string]interface{}
// Indexes live in an implicit namespace; dropping it best-effort lets an
// emptied bucket delete, while surviving indexes keep it non-empty.
_ = s.catalog(r.Context(), "DeleteNamespace", &s3tables.DeleteNamespaceRequest{
TableBucketARN: s.s3tablesArn(arns.bucket),
Namespace: []string{indexNamespace},
}, &resp, r)
err := s.catalog(r.Context(), "DeleteTableBucket",
&s3tables.DeleteTableBucketRequest{TableBucketARN: s.s3tablesArn(arns.bucket)}, &resp, r)
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.catalog(r.Context(), "DeleteTableBucket",
&s3tables.DeleteTableBucketRequest{TableBucketARN: s.s3tablesArn(arns.bucket)}, &resp, r)
} 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
@@ -315,42 +426,69 @@ func (s *Server) handleCreateIndex(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "ValidationException", "dimension must be 1..=4096")
return
}
if err := s.lanceBucket(r.Context(), arns.bucket); err != nil {
catalogErr(w, err)
return
}
// The create grant is exercised before any write: a denied caller must not
// leave even the implicit namespace behind. The namespace may not exist
// yet, so the check tolerates its absence.
if err := s.tables.AuthorizeCreateTable(r.Context(), s.filerClient, &s3tables.CreateTableRequest{
TableBucketARN: s.s3tablesArn(arns.bucket),
Namespace: []string{indexNamespace},
Name: arns.index,
AllowMissingNamespace: true,
}, identityOf(r)); 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": 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 {
if keys, err := json.Marshal(req.MetadataConfiguration.NonFilterableMetadataKeys); err == nil {
nonFilterable = req.MetadataConfiguration.NonFilterableMetadataKeys
if keys, err := json.Marshal(nonFilterable); err == nil {
metadata["nonFilterableMetadataKeys"] = string(keys)
}
}
metadataJSON, _ := json.Marshal(metadata)
var nsResp map[string]interface{}
// The index namespace is implicit on AWS; declare it, tolerating an
// already-existing namespace.
if err := s.catalog(r.Context(), "CreateNamespace", &s3tables.CreateNamespaceRequest{
TableBucketARN: s.s3tablesArn(arns.bucket),
Namespace: []string{indexNamespace},
}, &nsResp, r); err != nil && !isAlreadyExists(err) {
catalogErr(w, err)
return
}
var resp map[string]interface{}
err = s.catalog(r.Context(), "CreateTable", &s3tables.CreateTableRequest{
TableBucketARN: s.s3tablesArn(arns.bucket),
Namespace: []string{indexNamespace},
Name: arns.index,
Format: s3tables.FormatLance,
Metadata: &s3tables.TableMetadata{FullMetadata: metadataJSON},
}, &resp, r)
}, nil, r)
if err != nil {
catalogErr(w, err)
return
}
var nonFilterable []string
if req.MetadataConfiguration != nil {
nonFilterable = req.MetadataConfiguration.NonFilterableMetadataKeys
}
_, err = s.vectors.CreateIndex(r.Context(), &vector_pb.CreateIndexRequest{
Table: tableID(arns.bucket, arns.index),
Dimension: uint32(req.Dimension),
@@ -359,16 +497,48 @@ func (s *Server) handleCreateIndex(w http.ResponseWriter, r *http.Request) {
})
if err != nil {
// Roll the index record back so a half-created index does not linger.
var delResp map[string]interface{}
_ = s.catalog(r.Context(), "DeleteTable", &s3tables.DeleteTableRequest{
// 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,
}, &delResp, r)
writeError(w, http.StatusInternalServerError, "InternalFailure", err.Error())
}, &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, err)
return
}
writeJSON(w, http.StatusOK, &CreateIndexResponse{})
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.
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 {
@@ -423,7 +593,7 @@ func (s *Server) handleGetIndex(w http.ResponseWriter, r *http.Request) {
catalogErr(w, err)
return
}
writeJSON(w, http.StatusOK, &GetIndexResponse{Index: indexFromRecord(s, arns.bucket, req.IndexName, resp)})
writeJSON(w, http.StatusOK, &GetIndexResponse{Index: indexFromRecord(s, arns.bucket, arns.index, resp)})
}
func (s *Server) handleListIndexes(w http.ResponseWriter, r *http.Request) {
@@ -440,6 +610,7 @@ func (s *Server) handleListIndexes(w http.ResponseWriter, r *http.Request) {
var resp struct {
TableSummaries []struct {
Name string `json:"name"`
Format string `json:"format"`
CreatedAt string `json:"createdAt"`
} `json:"tables"`
ContinuationToken string `json:"continuationToken"`
@@ -455,9 +626,21 @@ func (s *Server) handleListIndexes(w http.ResponseWriter, r *http.Request) {
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}
for _, t := range resp.TableSummaries {
out.Indexes = append(out.Indexes, indexFromRecord(s, arns.bucket, t.Name, nil))
// 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)
}
@@ -473,9 +656,9 @@ func (s *Server) handleDeleteIndex(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
_, _ = s.vectors.DropIndex(r.Context(), &vector_pb.DropIndexRequest{
Table: tableID(arns.bucket, arns.index),
})
// Deleting the catalog record is the delete: the engine derives an index's
// existence from its table, and DropIndex is unimplemented on the shared
// service.
var resp map[string]interface{}
err = s.catalog(r.Context(), "DeleteTable", &s3tables.DeleteTableRequest{
TableBucketARN: s.s3tablesArn(arns.bucket),
@@ -518,12 +701,22 @@ func (s *Server) handlePutVectors(w http.ResponseWriter, r *http.Request) {
catalogErr(w, err)
return
}
// A read grant on the index does not carry a write: mutations require the
// catalog's update grant, the same check credential vending applies.
if err := s.authorizeIndexWrite(r.Context(), r, arns.bucket, arns.index); err != nil {
catalogErr(w, err)
return
}
vectors := make([]*vector_pb.Vector, 0, len(req.Vectors))
for _, v := range req.Vectors {
if v == nil {
writeError(w, http.StatusBadRequest, "ValidationException", "vectors contains a null element")
return
}
if v.Key == "" {
writeError(w, http.StatusBadRequest, "ValidationException", "vector key must not be empty")
return
}
metadata, err := metadataStruct(v.Metadata)
if err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
@@ -540,7 +733,7 @@ func (s *Server) handlePutVectors(w http.ResponseWriter, r *http.Request) {
Vectors: vectors,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "InternalFailure", err.Error())
vectorErr(w, err)
return
}
writeJSON(w, http.StatusOK, &PutVectorsResponse{})
@@ -583,7 +776,7 @@ func (s *Server) handleGetVectors(w http.ResponseWriter, r *http.Request) {
ReturnMetadata: req.ReturnMetadata,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "InternalFailure", err.Error())
vectorErr(w, err)
return
}
writeJSON(w, http.StatusOK, &GetVectorsResponse{
@@ -631,7 +824,7 @@ func (s *Server) handleListVectors(w http.ResponseWriter, r *http.Request) {
out, nextToken, err := s.listSegmentVectors(r.Context(), tableID(arns.bucket, arns.index),
offset, req.MaxResults, uint64(req.SegmentCount), uint64(req.SegmentIndex), req.ReturnData, req.ReturnMetadata)
if err != nil {
writeError(w, http.StatusInternalServerError, "InternalFailure", err.Error())
vectorErr(w, err)
return
}
writeJSON(w, http.StatusOK, &ListVectorsResponse{
@@ -648,7 +841,7 @@ func (s *Server) handleListVectors(w http.ResponseWriter, r *http.Request) {
ReturnMetadata: req.ReturnMetadata,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "InternalFailure", err.Error())
vectorErr(w, err)
return
}
writeJSON(w, http.StatusOK, &ListVectorsResponse{
@@ -727,12 +920,16 @@ func (s *Server) handleDeleteVectors(w http.ResponseWriter, r *http.Request) {
catalogErr(w, err)
return
}
if err := s.authorizeIndexWrite(r.Context(), r, 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 {
writeError(w, http.StatusInternalServerError, "InternalFailure", err.Error())
vectorErr(w, err)
return
}
writeJSON(w, http.StatusOK, &DeleteVectorsResponse{})
@@ -787,55 +984,123 @@ func filterExpr(filter map[string]interface{}) (string, error) {
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) {
col := fmt.Sprintf("json_get_string(`metadata`, '%s')", strings.ReplaceAll(field, "'", "''"))
switch c := cond.(type) {
case map[string]interface{}:
parts := []string{}
for op, operand := range c {
switch op {
case "$exists":
if operand == true {
parts = append(parts, fmt.Sprintf("json_exists(`metadata`, '%s')", strings.ReplaceAll(field, "'", "''")))
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 {
parts = append(parts, fmt.Sprintf("NOT json_exists(`metadata`, '%s')", strings.ReplaceAll(field, "'", "''")))
return "", fmt.Errorf("$exists expects a boolean")
}
case "$eq", "$ne":
lit, err := sqlLiteral(operand)
if err != nil {
return "", err
}
sym := "="
if op == "$ne" {
sym = "<>"
match, err := fieldMatch(field, operand, "= "+lit)
if err != nil {
return "", err
}
parts = append(parts, fmt.Sprintf("%s %s %s", col, sym, lit))
if op == "$ne" {
match = "NOT COALESCE(" + match + ", false)"
}
parts = append(parts, match)
case "$in", "$nin":
list, ok := operand.([]interface{})
if !ok {
return "", fmt.Errorf("%s expects a list", op)
}
vals := make([]string, 0, len(list))
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. A
// member whose typed extractor returns NULL must not let NOT
// invert NULL into NULL under $nin, so every group coalesces.
var groups []string
for _, item := range list {
lit, err := sqlLiteral(item)
if err != nil {
return "", err
}
vals = append(vals, lit)
g, err := fieldMatch(field, item, "in ("+lit+")")
if err != nil {
return "", err
}
groups = append(groups, fmt.Sprintf("COALESCE(%s, false)", g))
}
sym := "IN"
expr := "(" + strings.Join(groups, " OR ") + ")"
if op == "$nin" {
sym = "NOT IN"
expr = "NOT " + expr
}
parts = append(parts, fmt.Sprintf("%s %s (%s)", col, sym, strings.Join(vals, ", ")))
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]
numCol := fmt.Sprintf("json_get_float(`metadata`, '%s')", strings.ReplaceAll(field, "'", "''"))
parts = append(parts, fmt.Sprintf("%s %s %s", numCol, sym, lit))
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)
}
@@ -846,19 +1111,50 @@ func fieldClause(field string, cond interface{}) (string, error) {
if err != nil {
return "", err
}
return fmt.Sprintf("%s = %s", col, lit), nil
return fieldMatch(field, cond, "= "+lit)
}
}
// 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 "'" + strings.ReplaceAll(t, "'", "''") + "'", nil
return "'" + sqlEscape(jsonbString(t)) + "'", nil
case bool:
if t {
return "true", nil
return "'true'", nil
}
return "false", nil
return "'false'", nil
case float64:
return fmt.Sprintf("%v", t), nil
}
@@ -895,8 +1191,8 @@ func (s *Server) handleQueryVectors(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
if req.TopK < 0 {
writeError(w, http.StatusBadRequest, "ValidationException", "topK must be non-negative")
if req.TopK <= 0 {
writeError(w, http.StatusBadRequest, "ValidationException", "topK must be positive")
return
}
resp, err := s.vectors.QueryVectors(r.Context(), &vector_pb.QueryVectorsRequest{
@@ -908,7 +1204,7 @@ func (s *Server) handleQueryVectors(w http.ResponseWriter, r *http.Request) {
ReturnDistance: req.ReturnDistance,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "InternalFailure", err.Error())
vectorErr(w, err)
return
}
out := &QueryVectorsResponse{DistanceMetric: metric}
+105
View File
@@ -155,3 +155,108 @@ func TestListSegmentVectorsPageLimits(t *testing.T) {
t.Fatalf("capped page: %d rows token %q", len(out), next)
}
}
func TestArnParts(t *testing.T) {
var v vectorBucketARNs
if err := arnParts(&v, "plain", ""); err != nil || v.bucket != "plain" {
t.Fatalf("plain name: bucket=%q err=%v", v.bucket, err)
}
if err := arnParts(&v, "", "arn:aws:s3vectors:r:a:bucket/mybucket"); err != nil || v.bucket != "mybucket" || v.index != "" {
t.Fatalf("bucket ARN: %+v err=%v", v, err)
}
v = vectorBucketARNs{}
if err := arnParts(&v, "", "arn:aws:s3vectors:r:a:bucket/mybucket/index/myindex"); err != nil || v.bucket != "mybucket" || v.index != "myindex" {
t.Fatalf("index ARN: %+v err=%v", v, err)
}
for _, bad := range []string{
"arn:aws:s3vectors:r:a:bucket/",
"arn:aws:s3vectors:r:a:bucket//index/i",
"arn:aws:s3vectors:r:a:bucket",
} {
v = vectorBucketARNs{}
if err := arnParts(&v, "", bad); err == nil {
t.Fatalf("ARN %q should be rejected", bad)
}
}
}
func TestResolveIndexARNs(t *testing.T) {
arns, err := resolveIndexARNs("", "", "", "arn:aws:s3vectors:r:a:bucket/b/index/i")
if err != nil || arns.bucket != "b" || arns.index != "i" {
t.Fatalf("indexArn only: %+v err=%v", arns, err)
}
arns, err = resolveIndexARNs("b", "", "i", "")
if err != nil || arns.bucket != "b" || arns.index != "i" {
t.Fatalf("names only: %+v err=%v", arns, err)
}
if _, err := resolveIndexARNs("", "not-an-arn", "i", ""); err == nil {
t.Fatal("non-ARN vectorBucketArn should be rejected")
}
if _, err := resolveIndexARNs("", "", "", ""); err == nil {
t.Fatal("missing identifiers should be rejected")
}
if _, err := resolveIndexARNs("b", "", "", ""); err == nil {
t.Fatal("missing indexName should be rejected")
}
}
func TestTranslateFilter(t *testing.T) {
cases := []struct {
name string
filter map[string]interface{}
want string
}{
{"bare string equality", map[string]interface{}{"k": "v"},
"json_extract(`metadata`, '$[\"k\"]') = '\"v\"'"},
{"bare number equality", map[string]interface{}{"k": float64(3)},
"TRY_CAST(json_extract(`metadata`, '$[\"k\"]') AS DOUBLE) = 3"},
{"bare bool equality", map[string]interface{}{"k": true},
"json_extract(`metadata`, '$[\"k\"]') = 'true'"},
{"ne", map[string]interface{}{"k": map[string]interface{}{"$ne": "v"}},
"NOT COALESCE(json_extract(`metadata`, '$[\"k\"]') = '\"v\"', false)"},
{"exists true", map[string]interface{}{"k": map[string]interface{}{"$exists": true}},
"json_exists(`metadata`, '$[\"k\"]')"},
{"exists false", map[string]interface{}{"k": map[string]interface{}{"$exists": false}},
"NOT json_exists(`metadata`, '$[\"k\"]')"},
{"exists special key", map[string]interface{}{`a.b"c`: map[string]interface{}{"$exists": true}},
"json_exists(`metadata`, '$[\"a.b\\\"c\"]')"},
{"range", map[string]interface{}{"k": map[string]interface{}{"$gt": float64(1)}},
"TRY_CAST(json_extract(`metadata`, '$[\"k\"]') AS DOUBLE) > 1"},
{"in strings", map[string]interface{}{"k": map[string]interface{}{"$in": []interface{}{"a", "b"}}},
"(COALESCE(json_extract(`metadata`, '$[\"k\"]') in ('\"a\"'), false) OR COALESCE(json_extract(`metadata`, '$[\"k\"]') in ('\"b\"'), false))"},
{"nin numbers", map[string]interface{}{"k": map[string]interface{}{"$nin": []interface{}{float64(1)}}},
"NOT (COALESCE(TRY_CAST(json_extract(`metadata`, '$[\"k\"]') AS DOUBLE) in (1), false))"},
{"and", map[string]interface{}{"$and": []interface{}{
map[string]interface{}{"a": "1"}, map[string]interface{}{"b": float64(2)},
}},
"(json_extract(`metadata`, '$[\"a\"]') = '\"1\"' AND TRY_CAST(json_extract(`metadata`, '$[\"b\"]') AS DOUBLE) = 2)"},
{"sql injection", map[string]interface{}{"a'b": "v'1"},
"json_extract(`metadata`, '$[\"a''b\"]') = '\"v''1\"'"},
}
for _, tc := range cases {
got, err := translateFilter(tc.filter)
if err != nil {
t.Fatalf("%s: %v", tc.name, err)
}
if got != tc.want {
t.Fatalf("%s:\n got %s\nwant %s", tc.name, got, tc.want)
}
}
for name, filter := range map[string]map[string]interface{}{
"unknown operator": {"k": map[string]interface{}{"$bogus": "v"}},
"exists non-bool": {"k": map[string]interface{}{"$exists": "yes"}},
"range non-number": {"k": map[string]interface{}{"$gt": "v"}},
"in non-list": {"k": map[string]interface{}{"$in": "v"}},
"top-level bad op": {"$bogus": []interface{}{}},
"nested bad member": {"$and": []interface{}{"x"}},
} {
if _, err := translateFilter(filter); err == nil {
t.Fatalf("%s should fail", name)
}
}
if got, err := translateFilter(nil); err != nil || got != "" {
t.Fatalf("empty filter: %q %v", got, err)
}
}
+8 -1
View File
@@ -24,10 +24,11 @@ type S3Authenticator interface {
DefaultAllow() bool
}
// Server implements the AWS S3 Vectors JSON API.
// Server implements the AWS S3 Vectors API.
type Server struct {
filerClient FilerClient
tables *s3tables.Manager
internal *s3tables.Manager
vectors vector_pb.VectorServiceClient
authenticator S3Authenticator
region string
@@ -40,9 +41,15 @@ func NewServer(filerClient FilerClient, authenticator S3Authenticator, vectors v
if authenticator != nil {
manager.SetDefaultAllow(authenticator.DefaultAllow())
}
// Bookkeeping calls that ride along an operation the catalog already
// authorizes — format lookups, the implicit index namespace — must not
// demand read grants the API does not require.
internal := s3tables.NewManager()
internal.SetTrusted(true)
return &Server{
filerClient: filerClient,
tables: manager,
internal: internal,
vectors: vectors,
authenticator: authenticator,
}
+17 -4
View File
@@ -57,7 +57,9 @@ type CreateVectorBucketRequest struct {
MetadataResourceTags map[string]string `json:"-"`
}
type CreateVectorBucketResponse struct{}
type CreateVectorBucketResponse struct {
VectorBucketArn string `json:"vectorBucketArn"`
}
type GetVectorBucketRequest struct {
VectorBucketName string `json:"vectorBucketName"`
@@ -107,7 +109,9 @@ type MetadataConfiguration struct {
NonFilterableMetadataKeys []string `json:"nonFilterableMetadataKeys"`
}
type CreateIndexResponse struct{}
type CreateIndexResponse struct {
IndexArn string `json:"indexArn"`
}
type GetIndexRequest struct {
IndexArn string `json:"indexArn"`
@@ -139,9 +143,18 @@ type ListIndexesRequest struct {
VectorBucketArn string `json:"vectorBucketArn"`
}
// IndexSummary is the listing shape: the catalog knows the name, ARN, and
// creation time without a per-index metadata read.
type IndexSummary struct {
CreationTime time.Time `json:"creationTime"`
IndexArn string `json:"indexArn"`
IndexName string `json:"indexName"`
VectorBucketName string `json:"vectorBucketName"`
}
type ListIndexesResponse struct {
Indexes []*Index `json:"indexes"`
NextToken string `json:"nextToken,omitempty"`
Indexes []*IndexSummary `json:"indexes"`
NextToken string `json:"nextToken,omitempty"`
}
type DeleteIndexRequest struct {