feat(engine): Vector index and SearchVectors contract for storage backends - #243
Draft
LeeroyHannigan wants to merge 1 commit into
Draft
feat(engine): Vector index and SearchVectors contract for storage backends#243LeeroyHannigan wants to merge 1 commit into
LeeroyHannigan wants to merge 1 commit into
Conversation
LeeroyHannigan
force-pushed
the
feat/vector-search-contract
branch
3 times, most recently
from
August 7, 2026 12:13
bf34f6e to
d184b1b
Compare
8 tasks
…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
force-pushed
the
feat/vector-search-contract
branch
from
August 10, 2026 20:44
d184b1b to
7857db7
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this does
Models DynamoDB's vector index surface in
extenddb-coreand 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.CreateTableandUpdateTableacceptVectorIndexes/VectorIndexUpdates,DescribeTablereports them,SearchVectorsis 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:
Declaring support therefore requires handing over an implementation. An earlier revision used a
supports_vector_indexes() -> bool, which let a backend returntrueand never implement the method: an honour system where the type system should be doing the work.The accessor deliberately sits on
DataEngine, notStorageEngine.StorageEnginecomes 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::Unsupportedis 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:
DistanceFunctionorder[DOT_PRODUCT, COSINE, EUCLIDEAN], neitherSearchSchemaHASH elementsSearchConditionExpressionf32;f32::MAXexactly is acceptedNoutput formTableThroughputModeCreateTablememberThat 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 invalidprefix 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:
UpdateTablecreate/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.vectorIndexes.1.member.distanceFunction. A serde deserializer for the enum cannot know its position, so closing this means deserialising permissively and validating positionally, as theProjectioncheck 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 omittedVectorIndexUpdates, 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.cargo fmt --checkclean,clippy --all-targets -D warningsclean on both feature setsWhat I would most like reviewed
DataEngineor somewhere better.