Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
e3cb3a0
feat(sqlite): vector index storage, exact-scan search, and the declar…
LeeroyHannigan Aug 6, 2026
0ac4716
feat(sqlite): maintain vector indexes on writes, and prove search ove…
LeeroyHannigan Aug 6, 2026
8640355
ci: run the Rust integration suite against SQLite, and prove the vect…
LeeroyHannigan Aug 6, 2026
a270e3d
docs(adr): record the vector search decision with measured evidence
LeeroyHannigan Aug 6, 2026
4b07a9a
test(vector): close the silent-green hole an independent review found…
LeeroyHannigan Aug 6, 2026
46e407f
fix(vector): close the contract gaps an independent review found, thr…
LeeroyHannigan Aug 6, 2026
f6bad22
fix(vector): apply the index projection and stop billing for bytes th…
LeeroyHannigan Aug 6, 2026
6f2cad1
fix(vector): withhold the vector attribute unless ProjectionExpressio…
LeeroyHannigan Aug 7, 2026
2ebe67b
feat(vector): return the stored f32 rather than a second verbatim copy
LeeroyHannigan Aug 7, 2026
fcbc27d
fix(vector): correct four write-path facts measured against real Dyna…
LeeroyHannigan Aug 7, 2026
21abd09
feat(sqlite): UpdateTable vector index create and delete, with backfill
LeeroyHannigan Aug 7, 2026
6031daf
test(vector): assert SearchVectors returns no Count field
LeeroyHannigan Aug 10, 2026
05649e2
fix(vector): project SearchSchema attributes, and enforce the on-dema…
LeeroyHannigan Aug 10, 2026
f10b53a
feat(vector): propagate vector index maintenance asynchronously
LeeroyHannigan Aug 11, 2026
0964366
refactor(settings): rename the propagation delay for both index kinds…
LeeroyHannigan Aug 11, 2026
583fd10
fix(vector): close seven divergences found by differential testing ag…
LeeroyHannigan Aug 11, 2026
4d1e095
feat(vector): build a vector index asynchronously, and refuse searche…
LeeroyHannigan Aug 11, 2026
a15e388
ci: run the rust integration suite through the harness, so ten authz …
LeeroyHannigan Aug 11, 2026
a8d5850
Merge main and adapt the MongoDB backend to the vector index contract
LeeroyHannigan Aug 11, 2026
d49c844
test(vector): add a scan timing harness, and record that the scan is …
LeeroyHannigan Aug 11, 2026
5ce36b3
ci: interpolate the admin password, and make the refusal suite mandat…
LeeroyHannigan Aug 11, 2026
a6c063e
fix(sqlite): begins_with on a binary sort key silently dropped high keys
LeeroyHannigan Aug 11, 2026
08e57c7
fix(vector): paginate the backfill by rowid, so a composite-key parti…
LeeroyHannigan Aug 12, 2026
a8c885f
fix(vector): recover a dead index build at runtime, not only at restart
LeeroyHannigan Aug 12, 2026
d5439b4
test(vector): close the wire-coverage gaps an independent review found
LeeroyHannigan Aug 12, 2026
a099910
Merge branch 'tmp-contract' into probe-sqlite
LeeroyHannigan Aug 12, 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
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,8 @@ Stream cleanup worker: <error>
GSI query returns stale data after a write
```

**Cause:** GSI updates are applied asynchronously with a configurable propagation delay (default 10ms). This matches real DynamoDB's eventually consistent GSI behavior. Each GSI can have its own `propagation_delay_ms` setting; the system-wide default is controlled by the `gsi_propagation_delay_ms` runtime setting.
**Cause:** GSI updates are applied asynchronously with a configurable propagation delay (default 10ms). This matches real DynamoDB's eventually consistent GSI behavior. Each GSI can have its own `propagation_delay_ms` setting; the system-wide default is controlled by the `index_propagation_delay_ms` runtime setting.

**Fix:** This is expected behavior. For tests that query GSIs after writes, poll/retry the GSI query until the expected data appears. To make all GSIs synchronous for testing, set `extenddb settings set gsi_propagation_delay_ms 0`. For production-like testing, keep the default async delay.
**Fix:** This is expected behavior. For tests that query GSIs after writes, poll/retry the GSI query until the expected data appears. To make all GSIs synchronous for testing, set `extenddb settings set index_propagation_delay_ms 0`. For production-like testing, keep the default async delay.

**Source:** `docs/troubleshooting.md`, section "GSI Async Update Behavior", last synced 2026-05-12.
110 changes: 78 additions & 32 deletions .github/workflows/integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -232,44 +232,88 @@ jobs:
echo "Server failed to start"
exit 1

- name: Provision IAM test user and access key
id: creds
# Driven through devtools/run-tests rather than a bare `cargo test`, because the
# harness provisions what several suites require and a raw invocation does not:
# devtools/provision-test-credentials creates account 123456789012 with an IAM
# user, access key and full-access policy, and exports the credentials.
#
# batch_transact_authz targets that account and skips itself when
# EXTENDDB_ADMIN_PASSWORD is absent, so under a bare `cargo test` all ten of its
# tests reported ok WITHOUT EXECUTING, in every run since they landed in #232.
# The vector backfill tests need the same access to set the batch delay.
- name: Run Rust integration tests
env:
EXTENDDB_PASSWORD: ${{ steps.init.outputs.admin_password }}
EXTENDDB_TEST_ENDPOINT: https://127.0.0.1:18443
AWS_DEFAULT_REGION: us-east-1
EXTENDDB_ADMIN_USER: admin
EXTENDDB_ADMIN_PASSWORD: ${{ steps.init.outputs.admin_password }}
# PostgreSQL implements no vector search, so this is the job where the
# wire refusal tests must actually run. Both vector suites adapt to
# whatever the backend reports, so the refusal suite could skip every
# assertion here and still report green. "0" asserts the backend
# refuses vector indexes, making those tests mandatory rather than
# optional, and turns a silent skip into a failure.
EXTENDDB_EXPECT_VECTORS: "0"
run: devtools/run-tests --extenddb --rust-integration --release
run-rust-integration-sqlite:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v6

- uses: dtolnay/rust-toolchain@stable

- uses: Swatinem/rust-cache@v2
with:
cache-on-failure: true

- name: Build release (SQLite backend)
run: cargo build --release -p extenddb --no-default-features --features sqlite

- name: Initialize ExtendDB
id: init
run: |
acc='${{ steps.init.outputs.account_id }}'
./target/release/extenddb manage --user admin create-user \
--account-id "$acc" --user-name tester
./target/release/extenddb manage --user admin put-user-policy \
--account-id "$acc" --user-name tester --policy-name ddbfull \
--policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"dynamodb:*","Resource":"*"}]}'
./target/release/extenddb manage --user admin create-access-key \
--account-id "$acc" --user-name tester > /tmp/key.json
echo "akid=$(jq -r .access_key_id /tmp/key.json)" >> "$GITHUB_OUTPUT"
secret=$(jq -r .secret_access_key /tmp/key.json)
echo "::add-mask::$secret"
echo "secret=$secret" >> "$GITHUB_OUTPUT"
output=$(./target/release/extenddb init --backend sqlite --config extenddb.toml 2>&1)
echo "$output"
echo "admin_password=$(echo "$output" | grep -oP 'Password: \K\S+')" >> "$GITHUB_OUTPUT"

- name: Enable provisioned-capacity throttling enforcement
run: ./target/release/extenddb settings set throttling_enabled true

- name: Start ExtendDB
run: |
./target/release/extenddb serve --config extenddb.toml --foreground --write-pid-file &
for i in $(seq 1 30); do
if curl -sk https://127.0.0.1:18443/health | grep -q healthy; then
echo "Server ready"
exit 0
fi
sleep 1
done
echo "Server failed to start"
exit 1

# Driven through devtools/run-tests rather than a bare `cargo test`, because the
# harness provisions what several suites require and a raw invocation does not:
# devtools/provision-test-credentials creates account 123456789012 with an IAM
# user, access key and full-access policy, and exports the credentials.
#
# batch_transact_authz targets that account and skips itself when
# EXTENDDB_ADMIN_PASSWORD is absent, so under a bare `cargo test` all ten of its
# tests reported ok WITHOUT EXECUTING, in every run since they landed in #232.
# The vector backfill tests need the same access to set the batch delay.
- name: Run Rust integration tests
env:
EXTENDDB_TEST_ENDPOINT: https://127.0.0.1:18443
AWS_DEFAULT_REGION: us-east-1
AWS_ACCESS_KEY_ID: ${{ steps.creds.outputs.akid }}
AWS_SECRET_ACCESS_KEY: ${{ steps.creds.outputs.secret }}
# No in-tree backend implements vector search, so every vector request
# must be refused and the refusal suite must actually run. Without this
# the suite self-skips the moment a backend gains vector support, and the
# contract that non-participating backends refuse would stop being
# checked anywhere while still reporting green. Pinning the expectation
# turns that skip into a failure. The first backend to implement vector
# search sets this to 1 in its own job.
EXTENDDB_EXPECT_VECTORS: "0"
run: |
# Self-signed cert generated by init; trust it for the SDK client.
export EXTENDDB_CA_CERT="$(grep -oP 'cert_path\s*=\s*"\K[^"]+' extenddb.toml)"
# The suite shares one SDK client across tests; the hyper connection
# pool is bound to the first test's runtime, so run serially.
cd tests/rust && cargo test -- --test-threads=1
EXTENDDB_ADMIN_USER: admin
EXTENDDB_ADMIN_PASSWORD: ${{ steps.init.outputs.admin_password }}
# Both vector suites self-skip when the backend is the wrong kind, so without
# this the positive suite could skip every assertion and still report green,
# which is what would happen if the backend lost the capability. Pinning the
# expectation turns that skip into a failure.
EXTENDDB_EXPECT_VECTORS: "1"
run: devtools/run-tests --extenddb --rust-integration --release

integration:
runs-on: ubuntu-latest
Expand All @@ -279,13 +323,15 @@ jobs:
run-integration-sqlite,
run-integration-dev-mode,
run-rust-integration,
run-rust-integration-sqlite,
]
if: always()
steps:
- run: |
if [ "${{ needs.run-integration.result }}" != "success" ] || \
[ "${{ needs.run-integration-sqlite.result }}" != "success" ] || \
[ "${{ needs.run-integration-dev-mode.result }}" != "success" ] || \
[ "${{ needs.run-rust-integration.result }}" != "success" ]; then
[ "${{ needs.run-rust-integration.result }}" != "success" ] || \
[ "${{ needs.run-rust-integration-sqlite.result }}" != "success" ]; then
exit 1
fi
3 changes: 3 additions & 0 deletions crates/app/src/cmd_settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,9 @@ async fn set(store: &dyn SettingsStore, key: &str, value: &str) -> anyhow::Resul
);
}

// Write under the canonical name so the deprecated alias updates the row the read
// path consults, rather than adding a second one that is silently ignored.
let key = extenddb_core::settings_keys::canonical_key(key);
store
.set_setting(key, value)
.await
Expand Down
95 changes: 91 additions & 4 deletions crates/core/src/expression/search_condition.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,21 @@ pub fn validate_search_condition_expression(
))
})?
} else {
// A bare identifier must not be a reserved word. This check existed for
// ProjectionExpression, where its message is byte-identical to the
// service, but was never wired into this expression type, so
// `cat = :c AND bucket = :b` was accepted and returned results where the
// service refuses it. Measured on 2026-08-11.
//
// Applied to the bare form only, which is the point of the rule: the
// `#alias` branch above exists precisely so a reserved word can be used
// by aliasing it.
if crate::expression::reserved_words::is_reserved(lhs) {
return Err(invalid(format!(
"Invalid SearchConditionExpression: Attribute name is a reserved \
keyword; reserved keyword: {lhs}"
)));
}
(*lhs).to_owned()
};
if attr_name.contains('.') || attr_name.contains('[') {
Expand Down Expand Up @@ -220,15 +235,22 @@ pub fn validate_conditions_against_search_schema(
let schema = search_schema.unwrap_or(&[]);

// Every referenced attribute must be part of the index search schema.
//
// Wording measured against the service on 2026-08-11. It names the offending
// attribute, which the previous text did not, so a caller with several
// conditions had to work out which one was at fault. The service's own grammar
// slip ("attributes that is not") is reproduced deliberately: parity means
// matching what clients actually receive, not correcting it.
for condition in conditions {
let in_schema = schema
.iter()
.any(|element| element.attribute_name == condition.attribute_name);
if !in_schema {
return Err(invalid(
"SearchConditionExpression must not contain any attributes outside the vector \
index search schema",
));
return Err(invalid(format!(
"SearchConditionExpression must not contain any attributes that is not in \
SearchSchema. Invalid attribute: {}",
condition.attribute_name
)));
}
}

Expand Down Expand Up @@ -299,6 +321,71 @@ mod tests {
}
}

/// A bare reserved word is refused, with the service's wording.
///
/// The check already existed for `ProjectionExpression`, where its message is
/// byte-identical to the service, but was never applied here, so
/// `bucket = :b` was accepted and returned results for a request the service
/// refuses. `bucket` is the specific word that exposed it against the live
/// service on 2026-08-11.
#[test]
fn a_bare_reserved_keyword_is_refused() {
let v = values(&[(":b", "4")]);
let message = err(validate_search_condition_expression(
"bucket = :b",
None,
Some(&v),
));
assert_eq!(
message,
"Invalid SearchConditionExpression: Attribute name is a reserved keyword; \
reserved keyword: bucket"
);
}

/// Aliasing is the documented escape hatch, so the rule must apply to the bare
/// form only. Without this, the fix above would make reserved-word attributes
/// unusable rather than merely requiring an alias.
#[test]
fn an_aliased_reserved_keyword_is_accepted() {
let n = names(&[("#b", "bucket")]);
let v = values(&[(":b", "4")]);
let conditions = validate_search_condition_expression("#b = :b", Some(&n), Some(&v))
.expect("an aliased reserved word must be allowed");
assert_eq!(conditions.len(), 1);
assert_eq!(conditions[0].attribute_name, "bucket");
}

/// The condition is rejected when it names an attribute outside the schema, and
/// the message names WHICH attribute, as the service's does.
#[test]
fn an_attribute_outside_the_search_schema_is_named_in_the_error() {
let conditions = vec![
SearchCondition {
attribute_name: "cat".to_owned(),
value: AttributeValue::S("alpha".to_owned()),
},
SearchCondition {
attribute_name: "payload".to_owned(),
value: AttributeValue::S("nope".to_owned()),
},
];
let schema = [SearchSchemaElement {
attribute_name: "cat".to_owned(),
element_type: SearchSchemaElementType::Hash,
}];
let message = err(validate_conditions_against_search_schema(
&conditions,
Some(&schema),
&[],
));
assert_eq!(
message,
"SearchConditionExpression must not contain any attributes that is not in \
SearchSchema. Invalid attribute: payload"
);
}

#[test]
fn single_literal_equality_resolves() {
let v = values(&[(":cat", "Electronics")]);
Expand Down
1 change: 1 addition & 0 deletions crates/core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ pub mod expression;
pub mod limits;
pub mod metrics;
pub mod serde_helpers;
pub mod settings_keys;
pub mod throttle;
pub mod types;
pub mod validation;
Expand Down
81 changes: 81 additions & 0 deletions crates/core/src/settings_keys.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// Copyright 2026 ExtendDB contributors
// SPDX-License-Identifier: Apache-2.0

//! Canonical names for runtime settings keys.
//!
//! These strings live in one place because they are read by both storage backends,
//! written by the management API, seeded by both schemas, and documented. Scattering
//! the literal is what let one of them drift out of step with its own meaning.

/// Propagation delay applied to asynchronous secondary-index maintenance, in
/// milliseconds. `0` means maintenance is applied synchronously in the write's own
/// transaction.
///
/// Governs GSIs and vector indexes alike, which is why it is not named for either.
/// Real DynamoDB exposes no such knob: this exists so a test can choose between
/// asserting eventual-consistency behaviour and asserting steady state without
/// waiting.
pub const INDEX_PROPAGATION_DELAY_MS: &str = "index_propagation_delay_ms";

/// The pre-rename name of [`INDEX_PROPAGATION_DELAY_MS`], still honoured.
///
/// Two reasons this cannot simply be deleted. A catalog created before the rename
/// holds the operator's value under the old name, and the server refuses to start on
/// a catalog-version mismatch rather than migrating, so there is no upgrade step in
/// which the row could be rewritten. Silently reading past that row would reset a
/// deliberately configured delay to the default, and a delay of 0 means synchronous,
/// so the silent change would be from strict to eventually consistent.
///
/// Reads therefore prefer the canonical key and fall back to this one; writes to this
/// name are redirected to the canonical key so a deployment converges on one row
/// rather than accumulating two that disagree.
pub const LEGACY_GSI_PROPAGATION_DELAY_MS: &str = "gsi_propagation_delay_ms";

/// Milliseconds to pause between batches of a vector index backfill.
///
/// Zero, and meant to stay zero outside tests. It exists because the correctness
/// property that matters during a backfill is an ordering one: a write that lands
/// while the index is building must end up in the index with its NEW value, not be
/// overwritten by the backfill's older snapshot of the same item. Proving that needs
/// a write to land mid-backfill, and a backfill over a test-sized table finishes far
/// too quickly for a test to hit that window reliably.
///
/// Without this the test would be a race against the backfill and would pass whether
/// or not the ordering is correct, which is worse than having no test.
pub const VECTOR_BACKFILL_BATCH_DELAY_MS: &str = "vector_backfill_batch_delay_ms";

/// Resolve a caller-supplied settings key to its canonical name.
///
/// Accepting the old name keeps `extenddb settings set gsi_propagation_delay_ms 0`
/// working for anyone with it in a script or runbook, while ensuring the value lands
/// where the read path looks first.
#[must_use]
pub fn canonical_key(key: &str) -> &str {
if key == LEGACY_GSI_PROPAGATION_DELAY_MS {
INDEX_PROPAGATION_DELAY_MS
} else {
key
}
}

#[cfg(test)]
mod tests {
use super::{INDEX_PROPAGATION_DELAY_MS, LEGACY_GSI_PROPAGATION_DELAY_MS, canonical_key};

#[test]
fn the_legacy_name_resolves_to_the_canonical_one() {
assert_eq!(
canonical_key(LEGACY_GSI_PROPAGATION_DELAY_MS),
INDEX_PROPAGATION_DELAY_MS
);
}

#[test]
fn an_unrelated_key_is_returned_unchanged() {
assert_eq!(canonical_key("throttling_enabled"), "throttling_enabled");
assert_eq!(
canonical_key(INDEX_PROPAGATION_DELAY_MS),
INDEX_PROPAGATION_DELAY_MS
);
}
}
Loading
Loading