From 322c2c2291583914d718b5bca73945293ace8b1d Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Mon, 26 Oct 2026 01:38:50 +0000 Subject: [PATCH 1/4] fix(table): check each key schema's definitions as the service does CreateTable and an UpdateTable GSI Create refused an undefined key attribute with one sentence that named only the first missing key. Amazon DynamoDB (measured 2026-10-25 and 2026-10-26 at 01:30 and 01:36 UTC, each request twice) applies these rules: - A name defined twice in AttributeDefinitions answers `Attribute Name is duplicated: `, naming the first name whose second definition comes first, before any key check. ExtendDB accepted duplicates. - The table's key schema is checked first, then each LSI's, then each GSI's. A schema with more keys than there are definitions answers `Invalid KeySchema: Some index key attribute have no definition`; otherwise a key with no definition answers `Some index key attributes are not defined in AttributeDefinitions`, listing every key of that schema and every definition. The two checks live in the new validation/key_definitions.rs and both operations call them. The service orders the two lists in a way that follows neither the request nor the alphabet; ExtendDB lists them in request order. The UpdateTable unit test row that expected the listing sentence for one definition and two keys now expects the key schema sentence, as measured. --- crates/core/src/validation/key_definitions.rs | 165 ++++++++++++++++++ crates/core/src/validation/mod.rs | 27 ++- crates/engine/src/update_table.rs | 47 +---- crates/engine/src/update_table_tests.rs | 23 ++- 4 files changed, 202 insertions(+), 60 deletions(-) create mode 100644 crates/core/src/validation/key_definitions.rs diff --git a/crates/core/src/validation/key_definitions.rs b/crates/core/src/validation/key_definitions.rs new file mode 100644 index 00000000..18f71d43 --- /dev/null +++ b/crates/core/src/validation/key_definitions.rs @@ -0,0 +1,165 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Checks that a key schema's attributes are defined in `AttributeDefinitions`, +//! shared by `CreateTable` and an `UpdateTable` GSI `Create`. +//! +//! The service's rules, measured on 2026-10-25 and 2026-10-26 (us-east-1): +//! +//! - A name defined twice answers `Attribute Name is duplicated: `, +//! naming the first name whose second definition comes first in the list. +//! This comes before every key check. +//! - Each key schema is then checked in turn: the table's, each LSI's, each +//! GSI's. A schema with more keys than there are definitions answers +//! `Invalid KeySchema: Some index key attribute have no definition`. A +//! schema with a key that has no definition answers `Some index key +//! attributes are not defined in AttributeDefinitions`, listing every key of +//! that schema and every definition. +//! +//! The service lists the keys and definitions in an order that follows neither +//! the request nor the alphabet; ExtendDB lists them in request order. + +use crate::error::DynamoDbError; +use crate::types::{AttributeDefinition, KeySchemaElement}; + +/// Refuse a name defined twice in `defs`. +/// +/// # Errors +/// +/// `ValidationException` `Attribute Name is duplicated: `. +pub fn validate_unique_attribute_definitions( + defs: &[AttributeDefinition], +) -> Result<(), DynamoDbError> { + for (i, def) in defs.iter().enumerate() { + if defs[..i] + .iter() + .any(|earlier| earlier.attribute_name == def.attribute_name) + { + return Err(DynamoDbError::ValidationException(format!( + "Attribute Name is duplicated: {}", + def.attribute_name + ))); + } + } + Ok(()) +} + +/// Refuse a key schema whose keys are not all defined in `defs`. +/// +/// # Errors +/// +/// `ValidationException` with the `Invalid KeySchema` sentence when the schema +/// has more keys than `defs` has entries, otherwise with the `Some index key +/// attributes are not defined` sentence when any key has no definition. +pub fn validate_key_schema_defined( + key_schema: &[KeySchemaElement], + defs: &[AttributeDefinition], +) -> Result<(), DynamoDbError> { + if defs.len() < key_schema.len() { + return Err(DynamoDbError::ValidationException( + "Invalid KeySchema: Some index key attribute have no definition".to_owned(), + )); + } + let defined = |name: &str| defs.iter().any(|d| d.attribute_name == name); + if key_schema.iter().all(|k| defined(&k.attribute_name)) { + return Ok(()); + } + let keys: Vec<&str> = key_schema + .iter() + .map(|k| k.attribute_name.as_str()) + .collect(); + let names: Vec<&str> = defs.iter().map(|d| d.attribute_name.as_str()).collect(); + Err(DynamoDbError::ValidationException(format!( + "One or more parameter values were invalid: Some index key attributes are not defined in \ + AttributeDefinitions. Keys: [{}], AttributeDefinitions: [{}]", + keys.join(", "), + names.join(", ") + ))) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::{KeyType, ScalarAttributeType}; + + fn defs(names: &[&str]) -> Vec { + names + .iter() + .map(|n| AttributeDefinition { + attribute_name: (*n).to_owned(), + attribute_type: ScalarAttributeType::S, + }) + .collect() + } + + fn ks(hash: &str, range: Option<&str>) -> Vec { + let mut k = vec![KeySchemaElement { + attribute_name: hash.to_owned(), + key_type: KeyType::Hash, + }]; + if let Some(r) = range { + k.push(KeySchemaElement { + attribute_name: r.to_owned(), + key_type: KeyType::Range, + }); + } + k + } + + fn message(r: Result<(), DynamoDbError>) -> String { + match r { + Err(DynamoDbError::ValidationException(m)) => m, + other => panic!("expected a ValidationException, got {other:?}"), + } + } + + const INVALID: &str = "Invalid KeySchema: Some index key attribute have no definition"; + + #[test] + fn the_first_repeated_name_is_named() { + assert_eq!( + message(validate_unique_attribute_definitions(&defs(&[ + "b", "a", "b", "a" + ]))), + "Attribute Name is duplicated: b" + ); + assert!(validate_unique_attribute_definitions(&defs(&["a", "b"])).is_ok()); + } + + #[test] + fn fewer_definitions_than_keys_is_the_key_schema_sentence() { + assert_eq!( + message(validate_key_schema_defined( + &ks("g", Some("r")), + &defs(&["pk"]) + )), + INVALID + ); + assert_eq!( + message(validate_key_schema_defined(&ks("g", None), &[])), + INVALID + ); + } + + #[test] + fn a_missing_key_lists_every_key_and_every_definition() { + assert_eq!( + message(validate_key_schema_defined( + &ks("g", Some("r")), + &defs(&["pk", "g"]) + )), + "One or more parameter values were invalid: Some index key attributes are not defined \ + in AttributeDefinitions. Keys: [g, r], AttributeDefinitions: [pk, g]" + ); + // As many definitions as keys, none of them a key. + assert_eq!( + message(validate_key_schema_defined( + &ks("g", Some("r")), + &defs(&["pk", "z"]) + )), + "One or more parameter values were invalid: Some index key attributes are not defined \ + in AttributeDefinitions. Keys: [g, r], AttributeDefinitions: [pk, z]" + ); + assert!(validate_key_schema_defined(&ks("g", Some("r")), &defs(&["r", "g"])).is_ok()); + } +} diff --git a/crates/core/src/validation/mod.rs b/crates/core/src/validation/mod.rs index c144b46b..82ce6f40 100755 --- a/crates/core/src/validation/mod.rs +++ b/crates/core/src/validation/mod.rs @@ -1,8 +1,10 @@ // Copyright 2026 ExtendDB contributors // SPDX-License-Identifier: Apache-2.0 +mod key_definitions; pub mod number; pub mod vector_item; +pub use key_definitions::{validate_key_schema_defined, validate_unique_attribute_definitions}; pub use vector_item::{ MAX_HASH_KEY_SIZE, MAX_INLINE_FILTER_SIZE, validate_vector_write, validate_vector_write_changed, vector_components, vector_norm, @@ -943,14 +945,16 @@ fn validate_attribute_definitions(input: &CreateTableInput) -> Result<(), Dynamo } } - // Every key attribute must have a definition - for attr in &key_attrs { - if !def_names.contains(attr) { - return Err(DynamoDbError::ValidationException(format!( - "One or more parameter values were invalid: Some index key attributes are not defined in AttributeDefinitions. Keys: [{attr}], AttributeDefinitions: [{}]", - format_attr_defs(&input.attribute_definitions) - ))); - } + // Every key attribute must have a definition: the table's key schema, then + // each LSI's, then each GSI's (measured order, see key_definitions.rs). + validate_unique_attribute_definitions(&input.attribute_definitions)?; + let defs = &input.attribute_definitions; + validate_key_schema_defined(&input.key_schema, defs)?; + for lsi in input.local_secondary_indexes.iter().flatten() { + validate_key_schema_defined(&lsi.key_schema, defs)?; + } + for gsi in input.global_secondary_indexes.iter().flatten() { + validate_key_schema_defined(&gsi.key_schema, defs)?; } // Every definition must be used by a key @@ -966,13 +970,6 @@ fn validate_attribute_definitions(input: &CreateTableInput) -> Result<(), Dynamo Ok(()) } -fn format_attr_defs(defs: &[AttributeDefinition]) -> String { - defs.iter() - .map(|d| d.attribute_name.clone()) - .collect::>() - .join(", ") -} - /// Validate table-level throughput against the effective billing mode. /// /// # Errors diff --git a/crates/engine/src/update_table.rs b/crates/engine/src/update_table.rs index 14970ac8..9b965b3c 100755 --- a/crates/engine/src/update_table.rs +++ b/crates/engine/src/update_table.rs @@ -251,46 +251,17 @@ fn validate_gsi_updates(input: &UpdateTableInput) -> Result<(), DynamoDbError> { } // Every key attribute must be in AttributeDefinitions. The // service's sentence depends on the member's shape (measured - // 2026-10-25): absent names the index; present but lacking - // the key lists the keys and the definitions (the CreateTable - // sentence, whose CreateTable copy lives in core's validation - // and is not shared with this path); an empty list has its - // own sentence. - let missing: Vec<&str> = match &input.attribute_definitions { - None => { - return Err(DynamoDbError::ValidationException(format!( - "One or more parameter values were invalid: AttributeDefinitions is not specified for index: {}", - create.index_name - ))); - } - Some(defs) if defs.is_empty() => { - return Err(DynamoDbError::ValidationException( - "Invalid KeySchema: Some index key attribute have no definition" - .to_owned(), - )); - } - Some(defs) => create - .key_schema - .iter() - .filter(|ks| !defs.iter().any(|ad| ad.attribute_name == ks.attribute_name)) - .map(|ks| ks.attribute_name.as_str()) - .collect(), - }; - if let Some(first_missing) = missing.first() { - let defined: Vec<&str> = input - .attribute_definitions - .as_deref() - .unwrap_or(&[]) - .iter() - .map(|ad| ad.attribute_name.as_str()) - .collect(); + // 2026-10-25 and 2026-10-26): absent names the index; a name + // defined twice is refused first; then the CreateTable rules + // for the index's key schema (core's key_definitions.rs). + let Some(defs) = &input.attribute_definitions else { return Err(DynamoDbError::ValidationException(format!( - "One or more parameter values were invalid: Some index key attributes are not defined in AttributeDefinitions. \ - Keys: [{}], AttributeDefinitions: [{}]", - first_missing, - defined.join(", ") + "One or more parameter values were invalid: AttributeDefinitions is not specified for index: {}", + create.index_name ))); - } + }; + extenddb_core::validation::validate_unique_attribute_definitions(defs)?; + extenddb_core::validation::validate_key_schema_defined(&create.key_schema, defs)?; } if let Some(delete) = &update.delete { extenddb_core::validation::validate_index_name(&delete.index_name)?; diff --git a/crates/engine/src/update_table_tests.rs b/crates/engine/src/update_table_tests.rs index aa4cfda3..cd882838 100644 --- a/crates/engine/src/update_table_tests.rs +++ b/crates/engine/src/update_table_tests.rs @@ -35,8 +35,9 @@ fn enabling_a_stream_without_a_view_type_is_refused() { /// A created index whose key attribute is not in AttributeDefinitions is /// refused with the sentence the service picks by the member's shape -/// (measured 2026-10-25): absent names the index, present but lacking the -/// key lists the key and the definitions, empty has its own sentence. +/// (measured 2026-10-25 and 2026-10-26): absent names the index; fewer +/// definitions than keys, including none, has the key schema sentence; +/// otherwise the sentence lists every key and every definition. #[test] fn a_created_gsi_on_an_undefined_key_attribute_is_refused_by_member_shape() { let input = |defs: Option| -> UpdateTableInput { @@ -72,13 +73,21 @@ fn a_created_gsi_on_an_undefined_key_attribute_is_refused_by_member_shape() { message(Some( serde_json::json!([{ "AttributeName": "g", "AttributeType": "S" }]) )), - "One or more parameter values were invalid: Some index key attributes are not defined in AttributeDefinitions. Keys: [r], AttributeDefinitions: [g]" + "Invalid KeySchema: Some index key attribute have no definition" ); assert_eq!( - message(Some( - serde_json::json!([{ "AttributeName": "other", "AttributeType": "S" }]) - )), - "One or more parameter values were invalid: Some index key attributes are not defined in AttributeDefinitions. Keys: [g], AttributeDefinitions: [other]" + message(Some(serde_json::json!([ + { "AttributeName": "pk", "AttributeType": "S" }, + { "AttributeName": "g", "AttributeType": "S" }, + ]))), + "One or more parameter values were invalid: Some index key attributes are not defined in AttributeDefinitions. Keys: [g, r], AttributeDefinitions: [pk, g]" + ); + assert_eq!( + message(Some(serde_json::json!([ + { "AttributeName": "g", "AttributeType": "S" }, + { "AttributeName": "g", "AttributeType": "S" }, + ]))), + "Attribute Name is duplicated: g" ); validate_gsi_updates(&input(Some(serde_json::json!([ { "AttributeName": "g", "AttributeType": "S" }, From cbe6f07f63f97d0fdc422b4d345d642061fb0ba7 Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Mon, 26 Oct 2026 01:38:50 +0000 Subject: [PATCH 2/4] test(table): undefined and duplicated key attribute definitions Dual-target wire tests from the 2026-10-25 and 2026-10-26 measurements: UpdateTable GSI Create with a missing hash or range key, both keys, fewer definitions than keys and names defined twice; CreateTable with missing keys on the table, an LSI and GSIs in each order, fewer definitions than keys and names defined twice; and an UpdateTable that redefines the table's own key with another type, which keeps the stored type. The lists in the sentence are compared as sets, since the service's order is not the request's. --- tests/test_key_schema_definitions.py | 198 +++++++++++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 tests/test_key_schema_definitions.py diff --git a/tests/test_key_schema_definitions.py b/tests/test_key_schema_definitions.py new file mode 100644 index 00000000..87d6225d --- /dev/null +++ b/tests/test_key_schema_definitions.py @@ -0,0 +1,198 @@ +# Copyright 2026 ExtendDB contributors +# SPDX-License-Identifier: Apache-2.0 + +"""Key schemas whose attributes are missing from AttributeDefinitions. + +Amazon DynamoDB, measured on 2026-10-25 and 2026-10-26 at 01:30 UTC +(us-east-1), each request twice, on CreateTable and on an UpdateTable GSI +Create: + +- A name defined twice answers ``Attribute Name is duplicated: ``, + naming the first name whose second definition comes first, before every + key check. +- The table's key schema is checked first, then each LSI's, then each GSI's. + A schema with more keys than there are definitions answers ``Invalid + KeySchema: Some index key attribute have no definition``. Otherwise a key + with no definition answers ``Some index key attributes are not defined in + AttributeDefinitions`` listing every key of that schema and every + definition. + +The service orders both lists in a way that follows neither the request nor +the alphabet, and ExtendDB lists them in request order, so these tests compare +the lists as sets. + +On UpdateTable a definition that redefines the table's own key with another +type is accepted beside a valid Create, and the table keeps its type +(measured 2026-10-25). + +Dual-target against Amazon DynamoDB and extenddb. +""" + +from __future__ import annotations + +import re + +import pytest +from botocore.exceptions import ClientError + +from conftest import scoped_table + +INVALID = "Invalid KeySchema: Some index key attribute have no definition" +LISTED = re.compile( + r"^One or more parameter values were invalid: Some index key attributes are not defined in " + r"AttributeDefinitions\. Keys: \[([^\]]*)\], AttributeDefinitions: \[([^\]]*)\]$" +) + + +def defs(spec: str) -> list[dict]: + return [{"AttributeName": p.split(":")[0], "AttributeType": p.split(":")[1]} for p in spec.split()] + + +def ks(hash_key: str, range_key: str | None = None) -> list[dict]: + out = [{"AttributeName": hash_key, "KeyType": "HASH"}] + if range_key: + out.append({"AttributeName": range_key, "KeyType": "RANGE"}) + return out + + +def _message(call) -> str: + with pytest.raises(ClientError) as exc_info: + call() + err = exc_info.value.response["Error"] + assert err["Code"] == "ValidationException", err + return err["Message"] + + +def _listed(message: str) -> tuple[set[str], set[str]]: + m = LISTED.match(message) + assert m, message + return set(m.group(1).split(", ")), set(m.group(2).split(", ")) + + +@pytest.fixture(scope="module") +def table(dynamodb_client): + with scoped_table(dynamodb_client) as name: + yield name + + +def _update(client, table, spec, hash_key, range_key=None): + return lambda: client.update_table( + TableName=table, + AttributeDefinitions=defs(spec), + GlobalSecondaryIndexUpdates=[{"Create": { + "IndexName": "never-created", + "KeySchema": ks(hash_key, range_key), + "Projection": {"ProjectionType": "KEYS_ONLY"}, + }}], + ) + + +def _create(client, spec, table_keys, gsis=(), lsis=()): + kwargs = { + "TableName": "never-created-key-definitions", + "AttributeDefinitions": defs(spec), + "KeySchema": table_keys, + "BillingMode": "PAY_PER_REQUEST", + } + if gsis: + kwargs["GlobalSecondaryIndexes"] = [ + {"IndexName": f"gsi{i}", "KeySchema": k, "Projection": {"ProjectionType": "KEYS_ONLY"}} + for i, k in enumerate(gsis) + ] + if lsis: + kwargs["LocalSecondaryIndexes"] = [ + {"IndexName": f"lsi{i}", "KeySchema": k, "Projection": {"ProjectionType": "KEYS_ONLY"}} + for i, k in enumerate(lsis) + ] + return lambda: client.create_table(**kwargs) + + +# UpdateTable GSI Create: (definitions, index hash, index range, keys listed, definitions listed) +UPDATE_LISTED = [ + ("range_missing", "pk:S g:S", "g", "r", {"g", "r"}, {"pk", "g"}), + ("hash_missing", "pk:S r:S", "g", "r", {"g", "r"}, {"pk", "r"}), + ("both_missing_two_defs", "pk:S z:S", "g", "r", {"g", "r"}, {"pk", "z"}), + ("both_missing_three_defs", "pk:S y:S z:S", "g", "r", {"g", "r"}, {"pk", "y", "z"}), + ("hash_only_missing", "pk:S", "g", None, {"g"}, {"pk"}), + ("other_types", "pk:S n:N b:B", "n", "x", {"n", "x"}, {"pk", "n", "b"}), +] + + +@pytest.mark.parametrize(("case", "spec", "h", "r", "keys", "names"), UPDATE_LISTED, + ids=[c[0] for c in UPDATE_LISTED]) +def test_update_table_lists_every_key_and_definition(dynamodb_client, table, case, spec, h, r, keys, names): + message = _message(_update(dynamodb_client, table, spec, h, r)) + assert _listed(message) == (keys, names) + + +@pytest.mark.parametrize(("spec", "h", "r"), [("pk:S", "g", "r"), ("pk:S", "pk", "r")], + ids=["both_missing_one_def", "range_missing_one_def"]) +def test_update_table_with_fewer_definitions_than_keys(dynamodb_client, table, spec, h, r): + assert _message(_update(dynamodb_client, table, spec, h, r)) == INVALID + + +@pytest.mark.parametrize(("spec", "h", "r", "name"), [ + ("h:S h:S", "h", None, "h"), + ("h:S h:S", "h", "r", "h"), + ("b:S a:S b:S a:S h:S", "h", "r", "b"), + ("h:S h:N", "h", "r", "h"), +], ids=["index_key", "and_a_missing_key", "first_repeated", "two_types"]) +def test_update_table_refuses_a_name_defined_twice(dynamodb_client, table, spec, h, r, name): + assert _message(_update(dynamodb_client, table, spec, h, r)) == f"Attribute Name is duplicated: {name}" + + +# CreateTable: (definitions, table keys, GSIs, LSIs, keys listed or None for the +# key schema sentence, definitions listed) +CREATE = [ + ("gsi_range_missing", "pk:S g:S", ks("pk"), [ks("g", "r")], [], {"g", "r"}, {"pk", "g"}), + ("gsi_both_missing", "pk:S", ks("pk"), [ks("g", "r")], [], None, None), + ("table_sort_missing_one_def", "pk:S", ks("pk", "sk"), [], [], None, None), + ("table_sort_missing", "pk:S x:S", ks("pk", "sk"), [], [], {"pk", "sk"}, {"pk", "x"}), + ("table_pk_missing", "x:S", ks("pk"), [], [], {"pk"}, {"x"}), + ("table_before_gsi", "pk:S g:S", ks("pk", "sk"), [ks("g", "r")], [], {"pk", "sk"}, {"pk", "g"}), + ("table_before_lsi", "pk:S x:S", ks("pk", "sk"), [], [ks("pk", "l")], {"pk", "sk"}, {"pk", "x"}), + ("lsi_missing", "pk:S sk:S", ks("pk", "sk"), [], [ks("pk", "l")], {"pk", "l"}, {"pk", "sk"}), + ("lsi_before_gsi", "pk:S sk:S g:S", ks("pk", "sk"), [ks("g", "r")], [ks("pk", "l")], + {"pk", "l"}, {"pk", "sk", "g"}), + ("first_gsi_first", "pk:S g:S h:S", ks("pk"), [ks("g", "r"), ks("h", "q")], [], + {"g", "r"}, {"pk", "g", "h"}), +] + + +@pytest.mark.parametrize(("case", "spec", "table_keys", "gsis", "lsis", "keys", "names"), CREATE, + ids=[c[0] for c in CREATE]) +def test_create_table_checks_each_key_schema_in_turn(dynamodb_client, case, spec, table_keys, gsis, lsis, + keys, names): + message = _message(_create(dynamodb_client, spec, table_keys, gsis, lsis)) + if keys is None: + assert message == INVALID + else: + assert _listed(message) == (keys, names) + + +@pytest.mark.parametrize(("spec", "table_keys", "gsis", "name"), [ + ("pk:S pk:S", ks("pk"), [], "pk"), + ("pk:S pk:N", ks("pk"), [], "pk"), + ("pk:S pk:S", ks("pk", "sk"), [], "pk"), + ("pk:S g:S g:S", ks("pk"), [ks("g")], "g"), + ("b:S a:S b:S a:S pk:S", ks("pk"), [], "b"), +], ids=["table_key", "two_types", "and_a_missing_key", "index_key", "first_repeated"]) +def test_create_table_refuses_a_name_defined_twice(dynamodb_client, spec, table_keys, gsis, name): + message = _message(_create(dynamodb_client, spec, table_keys, gsis)) + assert message == f"Attribute Name is duplicated: {name}" + + +def test_update_table_keeps_the_type_of_a_redefined_table_key(dynamodb_client): + with scoped_table(dynamodb_client) as name: + dynamodb_client.update_table( + TableName=name, + AttributeDefinitions=defs("pk:N g:S"), + GlobalSecondaryIndexUpdates=[{"Create": { + "IndexName": "by-g", + "KeySchema": ks("g"), + "Projection": {"ProjectionType": "KEYS_ONLY"}, + }}], + ) + desc = dynamodb_client.describe_table(TableName=name)["Table"] + stored = {d["AttributeName"]: d["AttributeType"] for d in desc["AttributeDefinitions"]} + assert stored == {"pk": "S", "g": "S"} From c80996e588e8c27006475c5fc02f560637129959 Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Mon, 26 Oct 2026 03:24:12 +0000 Subject: [PATCH 3/4] fix(table): billing faults first, a linear duplicate check, the list order documented Amazon DynamoDB reports a CreateTable billing fault (throughput with PAY_PER_REQUEST, or none with PROVISIONED) before a duplicated or missing definition (measured 2026-10-26 at 02:27 and 02:34 UTC, each case twice). The billing check now runs before the definition checks. The duplicate check compared every definition with every earlier one, and UpdateTable runs it once per GSI Create: 8,000 definitions with 200 Creates took 22.9 s. It now makes one pass with a set of seen names and still names the first name whose second definition comes first. The module comment states where the service checks a duplicate (after the billing checks, the one-name-twice rule and the length rule) and where ExtendDB does (after the key schema shape checks). docs/differences-from-dynamodb.md records the order of the two lists in the listing sentence. --- crates/core/src/validation/key_definitions.rs | 20 +++++++++++-------- crates/core/src/validation/mod.rs | 4 +++- docs/differences-from-dynamodb.md | 1 + 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/crates/core/src/validation/key_definitions.rs b/crates/core/src/validation/key_definitions.rs index 18f71d43..ee14e246 100644 --- a/crates/core/src/validation/key_definitions.rs +++ b/crates/core/src/validation/key_definitions.rs @@ -8,7 +8,9 @@ //! //! - A name defined twice answers `Attribute Name is duplicated: `, //! naming the first name whose second definition comes first in the list. -//! This comes before every key check. +//! The service checks it after the table billing checks, the one-name-twice +//! rule and the length rule below, and before the other key schema checks; +//! ExtendDB checks it after all key schema shape checks. //! - Each key schema is then checked in turn: the table's, each LSI's, each //! GSI's. A schema with more keys than there are definitions answers //! `Invalid KeySchema: Some index key attribute have no definition`. A @@ -19,6 +21,8 @@ //! The service lists the keys and definitions in an order that follows neither //! the request nor the alphabet; ExtendDB lists them in request order. +use std::collections::HashSet; + use crate::error::DynamoDbError; use crate::types::{AttributeDefinition, KeySchemaElement}; @@ -30,11 +34,9 @@ use crate::types::{AttributeDefinition, KeySchemaElement}; pub fn validate_unique_attribute_definitions( defs: &[AttributeDefinition], ) -> Result<(), DynamoDbError> { - for (i, def) in defs.iter().enumerate() { - if defs[..i] - .iter() - .any(|earlier| earlier.attribute_name == def.attribute_name) - { + let mut seen = HashSet::with_capacity(defs.len()); + for def in defs { + if !seen.insert(def.attribute_name.as_str()) { return Err(DynamoDbError::ValidationException(format!( "Attribute Name is duplicated: {}", def.attribute_name @@ -60,8 +62,10 @@ pub fn validate_key_schema_defined( "Invalid KeySchema: Some index key attribute have no definition".to_owned(), )); } - let defined = |name: &str| defs.iter().any(|d| d.attribute_name == name); - if key_schema.iter().all(|k| defined(&k.attribute_name)) { + if key_schema + .iter() + .all(|k| defs.iter().any(|d| d.attribute_name == k.attribute_name)) + { return Ok(()); } let keys: Vec<&str> = key_schema diff --git a/crates/core/src/validation/mod.rs b/crates/core/src/validation/mod.rs index 82ce6f40..f7221ac2 100755 --- a/crates/core/src/validation/mod.rs +++ b/crates/core/src/validation/mod.rs @@ -103,8 +103,10 @@ pub fn validate_create_table( validate_key_schema(input, limits.allow_multipart_table_keys)?; validate_gsi_key_schemas(input)?; validate_lsi_key_schemas(input)?; - validate_attribute_definitions(input)?; + // The service reports a table billing fault before any definition fault + // (measured 2026-10-26). validate_provisioned_throughput(input)?; + validate_attribute_definitions(input)?; validate_gsi_provisioned_throughput(input)?; validate_gsi_throughput_minimum(input)?; validate_gsi_count(input, limits)?; diff --git a/docs/differences-from-dynamodb.md b/docs/differences-from-dynamodb.md index 2d6469b7..feecf83a 100755 --- a/docs/differences-from-dynamodb.md +++ b/docs/differences-from-dynamodb.md @@ -48,6 +48,7 @@ adaptation when switching between ExtendDB and the real service. |------|----------|------| | Table creation delay | Returns `CREATING` immediately; transitions to `ACTIVE` typically within seconds. Same behavior for on-demand and provisioned | Configurable via `control_plane_delay_seconds` runtime setting (default: 5s) | | DeletionProtectionEnabled | Enforced | Enforced (accepted and stored, DeleteTable rejects when enabled) | +| Order of the lists in `Some index key attributes are not defined in AttributeDefinitions. Keys: [...], AttributeDefinitions: [...]` (CreateTable, UpdateTable GSI Create) | Every key of the schema and every definition, in an order that follows neither the request nor the alphabet and does not depend on the attribute types (measured 2026-10-26: definitions `[pk, r]` render `[r, pk]`, keys `hk, rk` render `[rk, hk]`) | The same keys and definitions, the keys in key schema order and the definitions in request order. A client that reads the first element of `Keys` can see a different name | ## Time to Live (TTL) From ea5045e756fa8ff35af2488dd21eccb98740648e Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Mon, 26 Oct 2026 03:24:12 +0000 Subject: [PATCH 4/4] test(table): billing faults before definition faults; wait for the index CreateTable with PAY_PER_REQUEST and throughput beside a duplicated name, a missing key, or fewer definitions than keys answers the billing sentence (measured 2026-10-26 at 02:27 and 02:34 UTC). The redefinition test waits for its new index to be active before the table is deleted, since the service refuses DeleteTable while an index is being created, and writes an item with the stored key type. The measurement times in the docstring are corrected to 01:27 and 01:29 UTC, plus the two later runs. --- tests/test_key_schema_definitions.py | 43 ++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/tests/test_key_schema_definitions.py b/tests/test_key_schema_definitions.py index 87d6225d..29da06e5 100644 --- a/tests/test_key_schema_definitions.py +++ b/tests/test_key_schema_definitions.py @@ -3,13 +3,14 @@ """Key schemas whose attributes are missing from AttributeDefinitions. -Amazon DynamoDB, measured on 2026-10-25 and 2026-10-26 at 01:30 UTC -(us-east-1), each request twice, on CreateTable and on an UpdateTable GSI -Create: +Amazon DynamoDB, measured on 2026-10-25 and 2026-10-26 at 01:27, 01:29, +02:27 and 02:34 UTC (us-east-1), each request twice, on CreateTable and on +an UpdateTable GSI Create: +- A table billing fault is reported first. - A name defined twice answers ``Attribute Name is duplicated: ``, - naming the first name whose second definition comes first, before every - key check. + naming the first name whose second definition comes first, before a + missing key. - The table's key schema is checked first, then each LSI's, then each GSI's. A schema with more keys than there are definitions answers ``Invalid KeySchema: Some index key attribute have no definition``. Otherwise a key @@ -31,6 +32,7 @@ from __future__ import annotations import re +import time import pytest from botocore.exceptions import ClientError @@ -170,6 +172,23 @@ def test_create_table_checks_each_key_schema_in_turn(dynamodb_client, case, spec assert _listed(message) == (keys, names) +BILLING = ("One or more parameter values were invalid: Neither ReadCapacityUnits nor WriteCapacityUnits " + "can be specified when BillingMode is PAY_PER_REQUEST") + + +@pytest.mark.parametrize(("spec", "table_keys"), [ + ("pk:S pk:S", ks("pk")), + ("pk:S x:S", ks("pk", "sk")), + ("pk:S", ks("pk", "sk")), +], ids=["duplicate", "missing_key", "fewer_definitions"]) +def test_create_table_reports_a_billing_fault_first(dynamodb_client, spec, table_keys): + def call(): + dynamodb_client.create_table( + TableName="never-created-key-definitions", AttributeDefinitions=defs(spec), KeySchema=table_keys, + BillingMode="PAY_PER_REQUEST", ProvisionedThroughput={"ReadCapacityUnits": 1, "WriteCapacityUnits": 1}) + assert _message(call) == BILLING + + @pytest.mark.parametrize(("spec", "table_keys", "gsis", "name"), [ ("pk:S pk:S", ks("pk"), [], "pk"), ("pk:S pk:N", ks("pk"), [], "pk"), @@ -182,6 +201,17 @@ def test_create_table_refuses_a_name_defined_twice(dynamodb_client, spec, table_ assert message == f"Attribute Name is duplicated: {name}" +def _wait_for_index(client, table, index, timeout=120.0): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + desc = client.describe_table(TableName=table)["Table"] + status = {i["IndexName"]: i["IndexStatus"] for i in desc.get("GlobalSecondaryIndexes", [])} + if desc["TableStatus"] == "ACTIVE" and status.get(index) == "ACTIVE": + return + time.sleep(0.5) + raise TimeoutError(f"{index} on {table} not ACTIVE after {timeout} s") + + def test_update_table_keeps_the_type_of_a_redefined_table_key(dynamodb_client): with scoped_table(dynamodb_client) as name: dynamodb_client.update_table( @@ -196,3 +226,6 @@ def test_update_table_keeps_the_type_of_a_redefined_table_key(dynamodb_client): desc = dynamodb_client.describe_table(TableName=name)["Table"] stored = {d["AttributeName"]: d["AttributeType"] for d in desc["AttributeDefinitions"]} assert stored == {"pk": "S", "g": "S"} + # The table cannot be deleted while the index is being created. + _wait_for_index(dynamodb_client, name, "by-g") + dynamodb_client.put_item(TableName=name, Item={"pk": {"S": "a"}, "g": {"S": "b"}})