Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
1bf49d1
test(core): pin the measured vector write-path validation strings
yesyayen Aug 19, 2026
9cc5210
fix(core): emit the measured vector write-path validation strings
yesyayen Aug 19, 2026
25c7171
feat(core): report VectorSearchUnits alongside VectorSearchRequestBytes
yesyayen Aug 19, 2026
c0702d9
feat(storage): add ResourceInUse and map both refusal kinds on Update…
yesyayen Aug 19, 2026
7cdc686
refactor(core): use the vector message constants on the create path
yesyayen Aug 19, 2026
ca48d69
fix(sqlite): refuse a vector index create when the net billing mode i…
yesyayen Aug 19, 2026
4050101
test: wire coverage for the measured vector conformance gaps
yesyayen Aug 19, 2026
b6c4c3e
test: pin the transaction cancellation reason for an invalid vector
yesyayen Aug 19, 2026
75e5adb
test(core): pin the single-sentence envelope for an element-type error
yesyayen Aug 19, 2026
09b995c
fix(core): use the single-sentence envelope for an element-type error
yesyayen Aug 19, 2026
ddc3b66
feat(storage): shared vector index-build lifecycle module (ADR-0005)
yesyayen Aug 19, 2026
1517b55
refactor(sqlite): delegate the vector index-build lifecycle to shared…
yesyayen Aug 19, 2026
570cd2a
docs(adr): record the lifecycle-extraction outcome in ADR-0005
yesyayen Aug 20, 2026
d4c2a97
docs: track the unenforced allocation-phase delete refusal as F-18
yesyayen Aug 20, 2026
77999f7
refactor(sqlite): read the stored billing mode once in update_table
yesyayen Aug 20, 2026
8b5de7b
docs: clarify run_backfill's export, the rebuild status exception, an…
yesyayen Aug 20, 2026
bc05f5b
chore: stop tracking the local wire-test instance log
yesyayen Aug 20, 2026
e5af651
fix(core): pin the vector write-path errors to the region-uniform env…
yesyayen Aug 26, 2026
85b8f60
refactor(storage): one in-use variant carrying the whole wire message
yesyayen Aug 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,6 @@ extenddb.toml.bak
extenddb-*.toml
extenddb-*.toml.bak
extenddb-*.toml.keep

# Local wire-test instance
.gw-instance/
98 changes: 85 additions & 13 deletions crates/core/src/types/capacity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,16 @@ pub struct Capacity {
/// Capacity consumed by a vector index.
///
/// Vector indexes meter in their own units, separate from table read and write
/// capacity: `VectorSearchRequestBytes` for `SearchVectors`, and
/// `VectorWriteRequestBytes` for writes replicated into the index. Both are
/// byte figures, not unit figures, and each is omitted rather than reported as
/// zero when the operation does not consume it.
/// capacity. A `SearchVectors` charge is reported twice, as
/// `VectorSearchRequestBytes` and `VectorSearchUnits` with the same value; a
/// write replicated into the index is reported as `VectorWriteRequestBytes`.
/// Each member is omitted rather than reported as zero when the operation does
/// not consume it.
///
/// The duplicated search member is measured, not a guess: probe P8 against real
/// Amazon DynamoDB on 2026-08-19 captured both members on every search, always
/// equal, under both `INDEXES` and `TOTAL`. The write side has no such capture,
/// so it carries the bytes member alone until one exists.
#[derive(Debug, Clone, Default, Serialize)]
pub struct VectorCapacity {
/// Bytes consumed by a `SearchVectors` operation.
Expand All @@ -65,6 +71,10 @@ pub struct VectorCapacity {
skip_serializing_if = "Option::is_none"
)]
pub vector_search_request_bytes: Option<f64>,
/// The same figure as `VectorSearchRequestBytes`, under the name a client
/// reading units expects.
#[serde(rename = "VectorSearchUnits", skip_serializing_if = "Option::is_none")]
pub vector_search_units: Option<f64>,
/// Bytes consumed replicating a write into the index.
#[serde(
rename = "VectorWriteRequestBytes",
Expand All @@ -73,6 +83,33 @@ pub struct VectorCapacity {
pub vector_write_request_bytes: Option<f64>,
}

impl VectorCapacity {
/// A search charge, which the service reports under both search member names
/// with the same value.
///
/// A constructor rather than a struct literal at the call site, so the two
/// members cannot drift apart: there is no way to set one and forget the
/// other.
#[must_use]
pub const fn search(bytes: f64) -> Self {
Self {
vector_search_request_bytes: Some(bytes),
vector_search_units: Some(bytes),
vector_write_request_bytes: None,
}
}

/// A write-replication charge.
#[must_use]
pub const fn write(bytes: f64) -> Self {
Self {
vector_search_request_bytes: None,
vector_search_units: None,
vector_write_request_bytes: Some(bytes),
}
}
}

/// Consumed capacity information returned when requested.
#[derive(Debug, Clone, Serialize)]
pub struct ConsumedCapacity {
Expand Down Expand Up @@ -201,15 +238,7 @@ impl ConsumedCapacity {
}
let map: HashMap<String, VectorCapacity> = charges
.into_iter()
.map(|(name, bytes)| {
(
name,
VectorCapacity {
vector_search_request_bytes: None,
vector_write_request_bytes: Some(bytes),
},
)
})
.map(|(name, bytes)| (name, VectorCapacity::write(bytes)))
.collect();
if !map.is_empty() {
self.vector_indexes = Some(map);
Expand Down Expand Up @@ -490,4 +519,47 @@ mod tests {
.is_none()
);
}

/// A search charge serialises both measured members and nothing else.
///
/// Probe P8 (2026-08-19, real Amazon DynamoDB) captured the whole
/// `SearchVectors` shape as
/// `{"VectorSearchRequestBytes": 1024.0, "VectorSearchUnits": 1024.0}` under
/// both `INDEXES` and `TOTAL`. A client reading the units member got `null`
/// from ExtendDB and a number from the service.
#[test]
fn a_search_charge_carries_both_measured_members() {
let capacity = VectorCapacity::search(2048.0);
let Ok(value) = serde_json::to_value(capacity) else {
panic!("vector capacity should serialize");
};
let members: Vec<&str> = value
.as_object()
.expect("object")
.keys()
.map(String::as_str)
.collect();
assert_eq!(members, ["VectorSearchRequestBytes", "VectorSearchUnits"]);
assert_eq!(value["VectorSearchRequestBytes"], 2048.0);
assert_eq!(value["VectorSearchUnits"], 2048.0);
}

/// The write charge is untouched by the search-side addition. The service's
/// write-side units member is NOT measured, so nothing may be invented for
/// it: a write charge still carries exactly one member.
#[test]
fn a_write_charge_carries_only_the_measured_bytes_member() {
let capacity = ConsumedCapacity::write("table", 1.0, true)
.with_vector_writes([("vidx".to_owned(), 512.0)], true);
let Ok(value) = serde_json::to_value(capacity) else {
panic!("indexed capacity should serialize");
};
let members: Vec<&str> = value["VectorIndexes"]["vidx"]
.as_object()
.expect("object")
.keys()
.map(String::as_str)
.collect();
assert_eq!(members, ["VectorWriteRequestBytes"]);
}
}
1 change: 1 addition & 0 deletions crates/core/src/types/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ pub use table::{
VECTOR_SEARCH_SCHEMA_UNDECLARED, VectorAttribute, VectorIndexDescription,
VectorIndexSpecification, VectorIndexUpdate, vector_attribute_conflicting_definition,
vector_attribute_redefines_key, vector_attribute_redefines_vector,
vector_index_delete_in_allocation_phase,
};
pub use transaction::{
CancellationReason, ItemResponse, TransactConditionCheck, TransactDelete, TransactGet,
Expand Down
21 changes: 21 additions & 0 deletions crates/core/src/types/table.rs
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,27 @@ pub const VECTOR_INDEX_ALREADY_EXISTS: &str = "Attempting to create an index whi
pub const VECTOR_INDEX_CREATE_IN_USE_PREFIX: &str =
"Attempt to change a resource which is still in use: Index is being created.";

/// Message the service returns when a vector index is deleted while its creation
/// is still in the resource-allocation phase.
///
/// Measured byte-exact on 2026-08-19 (probe P2), carried as a
/// `ResourceInUseException` with HTTP 400. Deleting a `CREATING` vector index is
/// phase-dependent: refused while the index reports `Backfilling: false`,
/// accepted once it reports `Backfilling: true`, which is why the text tells the
/// caller to retry rather than that the request was wrong.
///
/// A function rather than a bare constant because the service names both
/// resources, separated by a single space and with no comma:
/// "... is active. Table: t Index: i".
#[must_use]
pub fn vector_index_delete_in_allocation_phase(table_name: &str, index_name: &str) -> String {
format!(
"Attempt to change a resource which is still in use: Index creation is in resource \
allocation phase. Retry deletion during backfilling phase or when the index is active. \
Table: {table_name} Index: {index_name}"
)
}

impl VectorIndexDescription {
/// Reject a description whose reported state the service would never produce.
///
Expand Down
31 changes: 21 additions & 10 deletions crates/core/src/validation/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ use crate::limits::LimitsConfig;
use crate::types::{
AttributeDefinition, AttributeValue, BillingMode, CreateTableInput, DeleteItemInput,
GetItemInput, Item, KeySchemaElement, KeyType, MAX_VECTOR_INDEXES_PER_TABLE, PutItemInput,
ReturnValues, ScalarAttributeType, Select, UpdateItemInput, item_size_bytes,
ReturnValues, ScalarAttributeType, Select, UpdateItemInput, VECTOR_INDEX_COUNT_LIMIT_CREATE,
VECTOR_INDEX_REQUIRES_PAY_PER_REQUEST, item_size_bytes,
};

/// Validate a table name per Virtual `DynamoDB` rules.
Expand Down Expand Up @@ -389,26 +390,24 @@ fn validate_vector_indexes(input: &CreateTableInput) -> Result<(), DynamoDbError
// vector index capacity mode as on-demand only. `BillingMode` defaults to
// PROVISIONED when absent, so an omitted BillingMode is a rejection too.
//
// Wording measured against the service on 2026-08-11; the earlier text was a
// reasonable paraphrase but not what the service says.
// Wording measured against the service on 2026-08-11 and held in the
// constant, which the UpdateTable paths share: the service returns one
// string for every direction of this rule.
if !vis.is_empty()
&& input.billing_mode.unwrap_or(BillingMode::Provisioned) != BillingMode::PayPerRequest
{
return Err(DynamoDbError::ValidationException(
"One or more parameter values were invalid: Vector indexes are only supported \
for PAY_PER_REQUEST tables"
.to_owned(),
VECTOR_INDEX_REQUIRES_PAY_PER_REQUEST.to_owned(),
));
}

// Wording measured against the service on 2026-08-11. Note the service does not
// echo the offending count here, unlike its SearchSchema messages, so neither
// does this.
if vis.len() > MAX_VECTOR_INDEXES_PER_TABLE {
return Err(DynamoDbError::ValidationException(format!(
"One or more parameter values were invalid: VectorIndex count exceeds the \
per-table limit of {MAX_VECTOR_INDEXES_PER_TABLE}"
)));
return Err(DynamoDbError::ValidationException(
VECTOR_INDEX_COUNT_LIMIT_CREATE.to_owned(),
));
}

// Several indexes may share one vector attribute, but they must agree on its
Expand Down Expand Up @@ -2341,6 +2340,18 @@ mod tests {
per-table limit of {MAX_VECTOR_INDEXES_PER_TABLE}"
)
);

// Both messages are now taken from the shared constants rather than
// inlined here, and the count constant spells its limit out as a literal.
// Assert the two agree, so raising the limit cannot leave the message
// stating the old one.
assert_eq!(
VECTOR_INDEX_COUNT_LIMIT_CREATE,
format!(
"One or more parameter values were invalid: VectorIndex count exceeds the \
per-table limit of {MAX_VECTOR_INDEXES_PER_TABLE}"
)
);
}

#[test]
Expand Down
Loading
Loading