Skip to content

feat(engine): Vector index and SearchVectors contract for storage backends - #243

Draft
LeeroyHannigan wants to merge 1 commit into
mainfrom
feat/vector-search-contract
Draft

feat(engine): Vector index and SearchVectors contract for storage backends#243
LeeroyHannigan wants to merge 1 commit into
mainfrom
feat/vector-search-contract

Conversation

@LeeroyHannigan

Copy link
Copy Markdown
Collaborator

What this does

Models DynamoDB's vector index surface in extenddb-core and makes it implementable by a storage backend, without implementing it for any. Both in-tree backends still refuse every vector operation, and those refusals are proven over the wire rather than by calling the validator directly.

CreateTable and UpdateTable accept VectorIndexes / VectorIndexUpdates, DescribeTable reports them, SearchVectors is a first-class operation with its own consumed-capacity shape, and item writes validate the vector attribute and the search-schema attributes against the index definition.

Design docs: docs/adr/0004-vector-search-exact-scan.md (added on the stacked implementation branch), RFC #236.

Vector search is an optional capability

This is the first optional feature on a trait surface where every other method is mandatory, so the shape is worth a look:

// A separate trait, with NO default bodies.
pub trait VectorSearchEngine: Send + Sync {
    fn search_vectors(&self, req: VectorSearch<'_>) -> BoxedFuture<'_, VectorSearchResult>;
}

// One accessor on DataEngine, defaulting to None.
fn as_vector_search(&self) -> Option<&dyn VectorSearchEngine> { None }

Declaring support therefore requires handing over an implementation. An earlier revision used a supports_vector_indexes() -> bool, which let a backend return true and never implement the method: an honour system where the type system should be doing the work.

The accessor deliberately sits on DataEngine, not StorageEngine. StorageEngine comes from a blanket impl over the six focused traits, so a defaulted method there could never be overridden by a backend. It would have looked correct and been unoverridable.

StorageError::Unsupported is added so a backend declining a feature is not reported as an internal fault. If we ever want a backend that declines transactions or streams, this is the template: peel the feature into its own trait with no defaults, add one accessor to a trait backends already implement, gate in core so the refusal never reaches storage.

Measured against the live service

Most specifics were measured against real DynamoDB rather than inferred, and in nearly every case the measurement contradicted the inference:

Fact Inferred Measured
DistanceFunction order alphabetical, or declaration order [DOT_PRODUCT, COSINE, EUCLIDEAN], neither
Inline filter cap 20, from the query-side limit 18
SearchSchema HASH elements exactly one, required at most one, and optional
SearchConditionExpression always required required only when a HASH is declared
Component range - representable as f32; f32::MAX exactly is accepted
Excess decimal precision rejected accepted
N output form may use an exponent never; the 38-digit limit bounds significant digits
TableThroughputMode a CreateTable member not a member at all

That last one mattered: an alias for it made ExtendDB accept a request AWS ignores.

Error messages are pinned by whole-string equality, not by fragment. Fragment assertions that began after the One or more parameter values were invalid prefix hid three divergences: a colon where the service uses a full stop (twice), and one message missing the prefix entirely.

Deliberately absent

Please read these as scope, not omissions:

  • No backend implements this, so no vector index can be created in tree yet. The SQLite implementation is a stacked branch and will follow.
  • Backfill state and the UpdateTable create/delete paths are modelled but unvalidated, because validating them needs a backend that actually performs a backfill. This is the portion most likely to move, and reviewer time is probably better spent elsewhere.
  • Async propagation of index writes is not modelled.
  • Errors report the bare field name where the service reports a positional path such as vectorIndexes.1.member.distanceFunction. A serde deserializer for the enum cannot know its position, so closing this means deserialising permissively and validating positionally, as the Projection check already does. Its own change.

Verification

Refusals are asserted over the wire against a live server, using a SigV4 raw-request helper because no SDK version carries the vector types. That suite immediately earned its place: it found that UpdateTable's at-least-one check omitted VectorIndexUpdates, so a request carrying only vector index changes was rejected as empty, contradicting what the live service accepts. The unit tests call the validator directly and never reach that check, so it was invisible to them.

Both vector suites self-skip on the wrong kind of backend, and the expectation is pinned per CI job (EXTENDDB_EXPECT_VECTORS), so a backend that silently lost or gained the capability fails rather than skipping green.

  • 427 Rust integration tests, 0 filtered out
  • 735 workspace tests, 0 filtered out
  • cargo fmt --check clean, clippy --all-targets -D warnings clean on both feature sets

What I would most like reviewed

  1. The optional-capability shape, since it becomes the precedent for every future optional feature.
  2. Whether the accessor belongs on DataEngine or somewhere better.
  3. Wire parity: anything modelled that the service does differently.

@LeeroyHannigan
LeeroyHannigan force-pushed the feat/vector-search-contract branch 3 times, most recently from bf34f6e to d184b1b Compare August 7, 2026 12:13
@LeeroyHannigan LeeroyHannigan changed the title Vector index and SearchVectors contract for storage backends feat(engine): Vector index and SearchVectors contract for storage backends Aug 7, 2026
…kends

Models DynamoDB's vector index surface in core and makes it implementable
by a storage backend, without implementing it for any: both in-tree
backends still refuse, and the refusals are proven over the wire.

The contract

CreateTable and UpdateTable accept VectorIndexes and VectorIndexUpdates,
DescribeTable reports them, and SearchVectors is a first-class operation
with its own consumed-capacity shape (VectorSearchRequestBytes, a byte
count with no units field). Item writes validate the vector attribute and
the search-schema attributes against the index definition.

Vector search is an OPTIONAL capability, expressed as a separate
VectorSearchEngine trait with no default bodies, reached through one
accessor on DataEngine that returns Option<&dyn VectorSearchEngine> and
defaults to None. Declaring support therefore requires handing over an
implementation: a backend cannot claim the capability and omit the method,
which an earlier boolean flag allowed. The accessor deliberately sits on
DataEngine rather than StorageEngine, because StorageEngine comes from a
blanket impl over the six focused traits and a defaulted method there
could never be overridden by a backend.

This is the first optional feature on a trait surface where every other
method is mandatory, so it is also the template: peel the feature into its
own trait with no defaults, add one accessor to a trait backends already
implement, gate in core so the refusal never reaches storage, and return
StorageError::Unsupported, added here, so declining is not reported as an
internal fault.

Capability is not the same as having acted

The capability gate proves a backend CAN serve vector indexes. It cannot
prove the backend acted on a given request, and that gap is not
theoretical: measured against the SQLite backend before it implemented the
UpdateTable path, a Create returned 200 and created nothing, discoverable
only on the first search, and a Delete returned 200 while the index stayed
ACTIVE, stayed in DescribeTable, and kept returning hits. A backend that
declares the capability and never reads the field is indistinguishable from
one that succeeded.

Both paths now check a post-condition against the description the backend
itself returned, so no backend can opt out: an index asked to be created
must be present, and one asked to be deleted must not be present and
ACTIVE. Deliberately tolerant about which post-state is correct, since that
is unmeasured, so doing nothing is caught without asserting a lifecycle
this contract has not observed. Reported as an internal fault rather than a
validation error, because it is a bug in the backend and not something the
caller did wrong.

The delete direction is the one that motivated this. Deleting a vector
index to stop serving a set of embeddings is something people do for
reasons that are not performance, and being told it worked while the
vectors remain queryable is the kind of failure that is discovered by
someone else.

Measured against the live service

Most of the specifics were measured against real DynamoDB rather than
inferred, and in nearly every case the measurement contradicted the
inference:

  - the DistanceFunction enum order is [DOT_PRODUCT, COSINE, EUCLIDEAN],
    which is neither alphabetical nor declaration order
  - a search schema accepts at most one HASH element, and the inline
    filter cap is 18, not the 20 that follows from the query-side limit
  - a HASH element is OPTIONAL; SearchConditionExpression becomes
    required only when one is declared
  - a component must be representable as a 32-bit float; f32::MAX exactly
    is accepted, and excess decimal precision is accepted, not rejected
  - N never comes back in scientific notation, and the 38-digit limit
    bounds significant digits rather than characters
  - an absent vector attribute is accepted; an empty list draws the size
    message with Actual: 0
  - TableThroughputMode is not a member of CreateTable at all, so an
    alias for it made ExtendDB accept a request AWS ignores

Error messages are pinned by whole-string equality, not by fragment. That
matters because fragment assertions beginning after the "One or more
parameter values were invalid" prefix hid three divergences: a colon where
the service uses a full stop, twice, and one message missing the prefix
entirely.

The refusal suite cannot silently stop running

Because the capability is optional, a suite that adapts to whatever the
backend reports never asserts WHICH backend is under test. The refusal
tests would then self-skip the moment any backend gained vector support,
and the contract that non-participating backends refuse would stop being
checked anywhere while still reporting green.

EXTENDDB_EXPECT_VECTORS lets a run state its expectation, with three states
rather than two: 0 means the backend must refuse, so a skipped refusal
suite is an error; 1 means it must support, so a backend that refuses is an
error; unset stays adaptive so a plain local cargo test works without
ceremony. An unrecognised value panics. The Rust integration job pins 0,
since no in-tree backend implements vector search, and the first backend
that does sets 1 in its own job.

The mechanism lives here rather than with the first implementation on
purpose. Whoever implements vector search inherits the guard instead of
inventing one, which is where the hole would otherwise be introduced.

All four states were exercised against a live backend, which found two
defects in the guard itself. The expectation was read inside a
short-circuiting &&, so an invalid value was never validated on a backend
without vector support, meaning the typo guard was dead exactly where it
was needed. And only one direction of the contradiction was asserted, so a
run claiming support the backend does not have passed unchecked, there
being no positive suite yet to notice.

Only the tests asserting a REFUSAL are gated on the capability. Three tests
in the same file assert behaviour true of every backend, that a plain
CreateTable still succeeds, that an empty vector list is not a vector
request, and that SearchVectors is a known operation requiring auth, and
they stay on the endpoint guard so they keep running once a backend
implements vector search.

Deliberately absent

No backend implements this, so no vector index can yet be created in tree.
The backfill state and the UpdateTable create and delete paths are modelled
but unvalidated, because validating them requires a backend that performs a
backfill; expect that portion to move. Async propagation of index writes is
not modelled. Errors report the bare field name where the service reports a
positional path such as vectorIndexes.1.member.distanceFunction, which
cannot be closed by a serde deserializer that does not know its own
position.

Verification

Refusals are asserted over the wire against a live server rather than by
calling the validator, using a SigV4 raw-request helper because no SDK
version carries the vector types. That suite immediately found a real
defect the unit tests could not see: UpdateTable's at-least-one check
omitted VectorIndexUpdates, so a request carrying only vector index
changes was rejected as empty, contradicting what the live service accepts.

The post-condition guard was verified over the wire too, against a backend
that declared the capability and dropped the field: both the create and the
delete now fail where they previously returned 200, and an ordinary
UpdateTable and a CreateTable carrying vector indexes are unaffected.

427 Rust integration tests and 742 workspace tests, 0 filtered out, fmt
and clippy -D warnings clean on both feature sets.
@LeeroyHannigan
LeeroyHannigan force-pushed the feat/vector-search-contract branch from d184b1b to 7857db7 Compare August 10, 2026 20:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant