From 8a840a267ee1963c6a4061bc87bc1c2115cb9068 Mon Sep 17 00:00:00 2001 From: Anandh Somasundaram Date: Fri, 21 Aug 2026 02:26:59 +0000 Subject: [PATCH 1/2] test: TableThroughputMode member and TableThroughputModeSummary parity Dual-target coverage for the TableThroughputMode request member on CreateTable and UpdateTable (fallback alias of BillingMode, per-member enum validation, BillingMode precedence when both are present) and for TableThroughputModeSummary mirroring BillingModeSummary in table descriptions. Measured against Amazon DynamoDB; passes there, fails on ExtendDB before the fix. --- tests/test_table_throughput_mode.py | 513 ++++++++++++++++++++++++++++ 1 file changed, 513 insertions(+) create mode 100644 tests/test_table_throughput_mode.py diff --git a/tests/test_table_throughput_mode.py b/tests/test_table_throughput_mode.py new file mode 100644 index 00000000..8a97d7ea --- /dev/null +++ b/tests/test_table_throughput_mode.py @@ -0,0 +1,513 @@ +# Copyright 2026 ExtendDB contributors +# SPDX-License-Identifier: Apache-2.0 + +"""TableThroughputMode request member and TableThroughputModeSummary response member. + +Measured against Amazon DynamoDB (2026-08-21, raw signed JSON): + +- ``TableThroughputMode`` is accepted on CreateTable and UpdateTable as a + fallback alias of ``BillingMode``: when both members are present, + ``BillingMode`` decides and the other member is ignored. There is no + conflict refusal. +- Enum validation is per member and pre-semantic: an invalid value fails at + ``tableThroughputMode`` even when a valid ``BillingMode`` would win, before + required-member validation and before the table lookup on UpdateTable. + Multiple invalid members join under "N validation errors detected:". +- Downstream throughput validation phrases the mode as ``BillingMode`` even + when only ``TableThroughputMode`` was sent. +- Table descriptions carry ``TableThroughputModeSummary`` as an exact mirror + of ``BillingModeSummary``: present if and only if the sibling is present, + same mode, identical ``LastUpdateToPayPerRequestDateTime``. + +The botocore DynamoDB model (checked at botocore 1.33.13) does not yet +include either member: boto3 refuses to send ``TableThroughputMode`` and +silently drops ``TableThroughputModeSummary`` when parsing responses. All +requests and response assertions in this module therefore go over raw +SigV4-signed JSON (same approach as ``test_attribute_value_validation.py``), +against either target. boto3 fixtures are used only for cleanup. +""" + +from __future__ import annotations + +import json +import os +import time +import uuid + +import boto3 +import pytest +import requests +from botocore.auth import SigV4Auth +from botocore.awsrequest import AWSRequest + +ENUM_SET = "[PROVISIONED, PAY_PER_REQUEST]" + +REGION = os.environ.get("AWS_DEFAULT_REGION", "us-east-1") + +_EXTENDDB_ENDPOINT = os.environ.get("EXTENDDB_TEST_ENDPOINT", "").strip() +ENDPOINT = _EXTENDDB_ENDPOINT or f"https://dynamodb.{REGION}.amazonaws.com/" + + +def _signed_post(operation: str, body: dict) -> requests.Response: + """POST ``body`` under the DynamoDB JSON-1.0 protocol to the active target. + + Signs with the default credential chain, so it follows the same + credentials the boto3 fixtures use for either target. + """ + body_bytes = json.dumps(body).encode("utf-8") + headers = { + "X-Amz-Target": f"DynamoDB_20120810.{operation}", + "Content-Type": "application/x-amz-json-1.0", + } + creds = boto3.Session().get_credentials() + if creds is not None: + aws_req = AWSRequest( + method="POST", url=ENDPOINT, data=body_bytes, headers=headers + ) + SigV4Auth(creds.get_frozen_credentials(), "dynamodb", REGION).add_auth(aws_req) + headers = dict(aws_req.headers) + return requests.post( + ENDPOINT, + data=body_bytes, + headers=headers, + # The default `extenddb init` cert is self-signed; real endpoints verify. + verify=not bool(_EXTENDDB_ENDPOINT), + ) + + +def _raw_ok(operation: str, body: dict) -> dict: + resp = _signed_post(operation, body) + assert resp.status_code == 200, f"{operation} failed: {resp.text}" + return resp.json() + + +def _wait_active_raw(name: str, timeout: float = 120.0) -> None: + interval = 0.2 if not _EXTENDDB_ENDPOINT else 0.02 + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + table = _raw_ok("DescribeTable", {"TableName": name})["Table"] + if table["TableStatus"] == "ACTIVE": + return + time.sleep(interval) + raise TimeoutError(f"Table {name} did not become ACTIVE within {timeout}s") + + +def _key_shape() -> dict: + return { + "KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}], + "AttributeDefinitions": [{"AttributeName": "pk", "AttributeType": "S"}], + } + + +PT = {"ReadCapacityUnits": 1, "WriteCapacityUnits": 1} + + +def _unique_name() -> str: + return f"extenddb-test-ttm-{uuid.uuid4().hex[:12]}" + + +def _assert_validation_error(resp: requests.Response, expected_message: str) -> None: + assert resp.status_code == 400, f"expected 400, got {resp.status_code}: {resp.text}" + payload = resp.json() + assert "ValidationException" in payload.get("__type", ""), payload + message = payload.get("message", payload.get("Message", "")) + assert message == expected_message, f"got: {message!r}" + + +def _assert_summaries_mirror(desc: dict) -> None: + """Assert the measured invariant: TableThroughputModeSummary mirrors + BillingModeSummary exactly (presence, mode, timestamp).""" + bms = desc.get("BillingModeSummary") + ttms = desc.get("TableThroughputModeSummary") + assert (bms is None) == (ttms is None), ( + f"summaries must be emitted together: BMS={bms}, TTMS={ttms}" + ) + if bms is not None: + assert ttms["TableThroughputMode"] == bms["BillingMode"] + assert ttms.get("LastUpdateToPayPerRequestDateTime") == bms.get( + "LastUpdateToPayPerRequestDateTime" + ) + + +@pytest.fixture() +def raw_table_cleanup(dynamodb_client): + """Delete raw-created tables after the test, tolerating absence.""" + names: list[str] = [] + yield names + for name in names: + try: + _wait_active_raw(name) + dynamodb_client.delete_table(TableName=name) + except Exception: + pass + + +class TestCreateTableThroughputMode: + def test_member_alone_creates_pay_per_request_table(self, raw_table_cleanup): + name = _unique_name() + raw_table_cleanup.append(name) + out = _raw_ok( + "CreateTable", + {"TableName": name, **_key_shape(), "TableThroughputMode": "PAY_PER_REQUEST"}, + ) + desc = out["TableDescription"] + assert desc["BillingModeSummary"]["BillingMode"] == "PAY_PER_REQUEST" + _assert_summaries_mirror(desc) + _wait_active_raw(name) + table = _raw_ok("DescribeTable", {"TableName": name})["Table"] + assert table["BillingModeSummary"]["BillingMode"] == "PAY_PER_REQUEST" + assert ( + table["TableThroughputModeSummary"]["TableThroughputMode"] + == "PAY_PER_REQUEST" + ) + _assert_summaries_mirror(table) + + def test_billing_mode_wins_when_both_present(self, raw_table_cleanup): + # Conflict is not refused: BillingMode decides. + name = _unique_name() + raw_table_cleanup.append(name) + _raw_ok( + "CreateTable", + { + "TableName": name, + **_key_shape(), + "BillingMode": "PROVISIONED", + "ProvisionedThroughput": PT, + "TableThroughputMode": "PAY_PER_REQUEST", + }, + ) + _wait_active_raw(name) + table = _raw_ok("DescribeTable", {"TableName": name})["Table"] + assert table["ProvisionedThroughput"]["ReadCapacityUnits"] == 1 + assert "BillingModeSummary" not in table + assert "TableThroughputModeSummary" not in table + + def test_billing_mode_pay_per_request_wins_and_rejects_throughput(self): + # BillingMode=PAY_PER_REQUEST beats TableThroughputMode=PROVISIONED, + # so the supplied throughput is rejected against PAY_PER_REQUEST. + resp = _signed_post( + "CreateTable", + { + "TableName": _unique_name(), + **_key_shape(), + "BillingMode": "PAY_PER_REQUEST", + "TableThroughputMode": "PROVISIONED", + "ProvisionedThroughput": PT, + }, + ) + _assert_validation_error( + resp, + "One or more parameter values were invalid: Neither ReadCapacityUnits " + "nor WriteCapacityUnits can be specified when BillingMode is " + "PAY_PER_REQUEST", + ) + + def test_member_provisioned_with_throughput(self, raw_table_cleanup): + name = _unique_name() + raw_table_cleanup.append(name) + _raw_ok( + "CreateTable", + { + "TableName": name, + **_key_shape(), + "TableThroughputMode": "PROVISIONED", + "ProvisionedThroughput": PT, + }, + ) + _wait_active_raw(name) + table = _raw_ok("DescribeTable", {"TableName": name})["Table"] + assert table["ProvisionedThroughput"]["ReadCapacityUnits"] == 1 + assert "BillingModeSummary" not in table + assert "TableThroughputModeSummary" not in table + + def test_member_provisioned_without_throughput_matches_billing_mode_path(self): + # The member resolves into the same billing-mode logic, so the missing + # throughput failure is byte-identical whichever member carried the mode. + # Deliberately a relative assertion: the absolute wording of the + # missing-throughput message differs between targets (a pre-existing, + # separately tracked divergence), while the TTM-path/BM-path equality + # holds on both. + via_ttm = _signed_post( + "CreateTable", + { + "TableName": _unique_name(), + **_key_shape(), + "TableThroughputMode": "PROVISIONED", + }, + ) + via_bm = _signed_post( + "CreateTable", + {"TableName": _unique_name(), **_key_shape(), "BillingMode": "PROVISIONED"}, + ) + assert via_ttm.status_code == 400, via_ttm.text + assert via_bm.status_code == 400, via_bm.text + assert "ValidationException" in via_ttm.json().get("__type", "") + assert via_ttm.json()["message"] == via_bm.json()["message"] + + def test_member_pay_per_request_with_throughput_rejected(self): + resp = _signed_post( + "CreateTable", + { + "TableName": _unique_name(), + **_key_shape(), + "TableThroughputMode": "PAY_PER_REQUEST", + "ProvisionedThroughput": PT, + }, + ) + _assert_validation_error( + resp, + "One or more parameter values were invalid: Neither ReadCapacityUnits " + "nor WriteCapacityUnits can be specified when BillingMode is " + "PAY_PER_REQUEST", + ) + + def test_invalid_enum_value(self): + resp = _signed_post( + "CreateTable", + {"TableName": _unique_name(), **_key_shape(), "TableThroughputMode": "BOGUS"}, + ) + _assert_validation_error( + resp, + "1 validation error detected: Value 'BOGUS' at 'tableThroughputMode' " + f"failed to satisfy constraint: Member must satisfy enum value set: {ENUM_SET}", + ) + + def test_invalid_enum_reported_even_when_billing_mode_present(self): + # Enum validation is per member and precedes resolution: the losing + # member still fails validation. + resp = _signed_post( + "CreateTable", + { + "TableName": _unique_name(), + **_key_shape(), + "BillingMode": "PAY_PER_REQUEST", + "TableThroughputMode": "BOGUS", + }, + ) + _assert_validation_error( + resp, + "1 validation error detected: Value 'BOGUS' at 'tableThroughputMode' " + f"failed to satisfy constraint: Member must satisfy enum value set: {ENUM_SET}", + ) + + def test_both_invalid_enums_joined(self): + resp = _signed_post( + "CreateTable", + { + "TableName": _unique_name(), + **_key_shape(), + "BillingMode": "BOGUS1", + "TableThroughputMode": "BOGUS2", + }, + ) + _assert_validation_error( + resp, + "2 validation errors detected: Value 'BOGUS1' at 'billingMode' failed " + f"to satisfy constraint: Member must satisfy enum value set: {ENUM_SET}; " + "Value 'BOGUS2' at 'tableThroughputMode' failed to satisfy constraint: " + f"Member must satisfy enum value set: {ENUM_SET}", + ) + + +class TestUpdateTableThroughputMode: + def test_switch_to_provisioned_via_member(self, raw_table_cleanup): + name = _unique_name() + raw_table_cleanup.append(name) + _raw_ok( + "CreateTable", + {"TableName": name, **_key_shape(), "BillingMode": "PAY_PER_REQUEST"}, + ) + _wait_active_raw(name) + out = _raw_ok( + "UpdateTable", + { + "TableName": name, + "TableThroughputMode": "PROVISIONED", + "ProvisionedThroughput": { + "ReadCapacityUnits": 5, + "WriteCapacityUnits": 5, + }, + }, + ) + _assert_summaries_mirror(out["TableDescription"]) + _wait_active_raw(name) + table = _raw_ok("DescribeTable", {"TableName": name})["Table"] + assert table["ProvisionedThroughput"]["ReadCapacityUnits"] == 5 + _assert_summaries_mirror(table) + + def test_switch_to_pay_per_request_via_member(self, raw_table_cleanup): + name = _unique_name() + raw_table_cleanup.append(name) + _raw_ok( + "CreateTable", + { + "TableName": name, + **_key_shape(), + "BillingMode": "PROVISIONED", + "ProvisionedThroughput": PT, + }, + ) + _wait_active_raw(name) + _raw_ok( + "UpdateTable", + {"TableName": name, "TableThroughputMode": "PAY_PER_REQUEST"}, + ) + _wait_active_raw(name) + table = _raw_ok("DescribeTable", {"TableName": name})["Table"] + assert table["BillingModeSummary"]["BillingMode"] == "PAY_PER_REQUEST" + assert ( + table["TableThroughputModeSummary"]["TableThroughputMode"] + == "PAY_PER_REQUEST" + ) + _assert_summaries_mirror(table) + + def test_billing_mode_wins_when_both_present(self, raw_table_cleanup): + name = _unique_name() + raw_table_cleanup.append(name) + _raw_ok( + "CreateTable", + {"TableName": name, **_key_shape(), "BillingMode": "PAY_PER_REQUEST"}, + ) + _wait_active_raw(name) + _raw_ok( + "UpdateTable", + { + "TableName": name, + "BillingMode": "PROVISIONED", + "ProvisionedThroughput": { + "ReadCapacityUnits": 2, + "WriteCapacityUnits": 2, + }, + "TableThroughputMode": "PAY_PER_REQUEST", + }, + ) + _wait_active_raw(name) + table = _raw_ok("DescribeTable", {"TableName": name})["Table"] + assert table["ProvisionedThroughput"]["ReadCapacityUnits"] == 2 + + def test_member_provisioned_without_throughput_matches_billing_mode_path( + self, raw_table_cleanup + ): + # UpdateTable's downstream missing-throughput message phrases the mode + # as BillingMode whichever member carried it (measured: both paths + # return the identical string). Deliberately a relative assertion: the + # absolute wording differs between targets (a pre-existing, separately + # tracked divergence), while the TTM-path/BM-path equality holds on + # both. + name = _unique_name() + raw_table_cleanup.append(name) + _raw_ok( + "CreateTable", + {"TableName": name, **_key_shape(), "BillingMode": "PAY_PER_REQUEST"}, + ) + _wait_active_raw(name) + via_ttm = _signed_post( + "UpdateTable", + {"TableName": name, "TableThroughputMode": "PROVISIONED"}, + ) + via_bm = _signed_post( + "UpdateTable", + {"TableName": name, "BillingMode": "PROVISIONED"}, + ) + assert via_ttm.status_code == 400, via_ttm.text + assert via_bm.status_code == 400, via_bm.text + assert "ValidationException" in via_ttm.json().get("__type", "") + assert via_ttm.json()["message"] == via_bm.json()["message"] + assert "ProvisionedThroughput must be specified" in via_ttm.json()["message"] + + def test_invalid_enum_precedes_table_lookup(self): + # Enum validation fires before the table lookup, so a nonexistent + # table still reports the enum failure. + resp = _signed_post( + "UpdateTable", + { + "TableName": f"extenddb-test-nonexistent-{uuid.uuid4().hex[:8]}", + "TableThroughputMode": "BOGUS", + }, + ) + _assert_validation_error( + resp, + "1 validation error detected: Value 'BOGUS' at 'tableThroughputMode' " + f"failed to satisfy constraint: Member must satisfy enum value set: {ENUM_SET}", + ) + + def test_both_invalid_enums_joined(self): + resp = _signed_post( + "UpdateTable", + { + "TableName": f"extenddb-test-nonexistent-{uuid.uuid4().hex[:8]}", + "BillingMode": "B1", + "TableThroughputMode": "B2", + }, + ) + _assert_validation_error( + resp, + "2 validation errors detected: Value 'B1' at 'billingMode' failed to " + f"satisfy constraint: Member must satisfy enum value set: {ENUM_SET}; " + "Value 'B2' at 'tableThroughputMode' failed to satisfy constraint: " + f"Member must satisfy enum value set: {ENUM_SET}", + ) + + +class TestTableThroughputModeSummary: + def test_pay_per_request_lifecycle_carries_mirrored_summaries( + self, raw_table_cleanup + ): + name = _unique_name() + raw_table_cleanup.append(name) + created = _raw_ok( + "CreateTable", + {"TableName": name, **_key_shape(), "BillingMode": "PAY_PER_REQUEST"}, + ) + desc = created["TableDescription"] + assert desc["BillingModeSummary"]["BillingMode"] == "PAY_PER_REQUEST" + _assert_summaries_mirror(desc) + _wait_active_raw(name) + table = _raw_ok("DescribeTable", {"TableName": name})["Table"] + assert ( + table["TableThroughputModeSummary"]["TableThroughputMode"] + == "PAY_PER_REQUEST" + ) + _assert_summaries_mirror(table) + deleted = _raw_ok("DeleteTable", {"TableName": name}) + _assert_summaries_mirror(deleted["TableDescription"]) + + def test_provisioned_table_omits_both_summaries(self, raw_table_cleanup): + name = _unique_name() + raw_table_cleanup.append(name) + created = _raw_ok( + "CreateTable", + { + "TableName": name, + **_key_shape(), + "BillingMode": "PROVISIONED", + "ProvisionedThroughput": PT, + }, + ) + desc = created["TableDescription"] + assert "BillingModeSummary" not in desc + assert "TableThroughputModeSummary" not in desc + _wait_active_raw(name) + table = _raw_ok("DescribeTable", {"TableName": name})["Table"] + assert "BillingModeSummary" not in table + assert "TableThroughputModeSummary" not in table + deleted = _raw_ok("DeleteTable", {"TableName": name}) + desc = deleted["TableDescription"] + assert "BillingModeSummary" not in desc + assert "TableThroughputModeSummary" not in desc + + def test_update_table_response_mirrors_summaries(self, raw_table_cleanup): + name = _unique_name() + raw_table_cleanup.append(name) + _raw_ok( + "CreateTable", + {"TableName": name, **_key_shape(), "BillingMode": "PAY_PER_REQUEST"}, + ) + _wait_active_raw(name) + out = _raw_ok( + "UpdateTable", + {"TableName": name, "DeletionProtectionEnabled": False}, + ) + desc = out["TableDescription"] + assert desc["BillingModeSummary"]["BillingMode"] == "PAY_PER_REQUEST" + _assert_summaries_mirror(desc) From a444e491ba9cc723366cd1562caf1caeaac88ba9 Mon Sep 17 00:00:00 2001 From: Anandh Somasundaram Date: Fri, 21 Aug 2026 02:47:12 +0000 Subject: [PATCH 2/2] fix: honour TableThroughputMode and emit TableThroughputModeSummary Amazon DynamoDB accepts TableThroughputMode on CreateTable and UpdateTable as a fallback alias of BillingMode: BillingMode wins when both members are present (a conflict is not refused), enum validation is per member and pre-semantic, and every downstream message phrases the mode as BillingMode. Measured 2026-08-21; the earlier decision to ignore the member was based on the SDK model, which lags the service. Table descriptions now also carry TableThroughputModeSummary, an exact mirror of BillingModeSummary (same mode, identical timestamp, emitted together or not at all), populated centrally in the engine so both members cannot disagree and every backend inherits it. --- crates/core/src/types/table.rs | 189 ++++++++++++++++-- crates/core/src/validation/mod.rs | 1 + crates/engine/src/backup.rs | 3 + crates/engine/src/create_table.rs | 21 +- crates/engine/src/delete_table.rs | 3 +- crates/engine/src/describe_table.rs | 3 +- crates/engine/src/import_export.rs | 3 + crates/engine/src/update_table.rs | 22 +- crates/storage-mongodb/src/table_engine.rs | 2 + crates/storage-postgres/src/create_table.rs | 1 + crates/storage-postgres/src/table_helpers.rs | 1 + .../tests/vector_control_plane.rs | 1 + crates/storage-sqlite/src/create_table.rs | 1 + crates/storage-sqlite/src/table_helpers.rs | 1 + 14 files changed, 227 insertions(+), 25 deletions(-) diff --git a/crates/core/src/types/table.rs b/crates/core/src/types/table.rs index b895b2fa..9baf1c9a 100755 --- a/crates/core/src/types/table.rs +++ b/crates/core/src/types/table.rs @@ -218,6 +218,34 @@ pub struct BillingModeSummary { pub last_update_to_pay_per_request_date_time: Option, } +/// Summary of the table's throughput mode, a mirror of [`BillingModeSummary`]. +/// +/// Measured 2026-08-21 against Amazon DynamoDB: table descriptions carry this +/// member exactly when they carry `BillingModeSummary`, with the same mode and +/// an identical `LastUpdateToPayPerRequestDateTime`. Derive it from the +/// billing-mode summary rather than assembling it independently, so the two +/// members cannot disagree. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct TableThroughputModeSummary { + #[serde(rename = "TableThroughputMode")] + pub table_throughput_mode: BillingMode, + #[serde( + rename = "LastUpdateToPayPerRequestDateTime", + skip_serializing_if = "Option::is_none" + )] + pub last_update_to_pay_per_request_date_time: Option, +} + +impl From<&BillingModeSummary> for TableThroughputModeSummary { + fn from(summary: &BillingModeSummary) -> Self { + Self { + table_throughput_mode: summary.billing_mode, + last_update_to_pay_per_request_date_time: summary + .last_update_to_pay_per_request_date_time, + } + } +} + /// A key-value tag attached to a Virtual `DynamoDB` resource. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Tag { @@ -725,6 +753,16 @@ impl TableDescription { } Ok(()) } + + /// Populate `TableThroughputModeSummary` as an exact mirror of + /// `BillingModeSummary`. + /// + /// Called on every path that hands a description to a client, so the + /// measured invariant (both members together, always agreeing) holds + /// centrally instead of each backend assembling the mirror itself. + pub fn populate_table_throughput_mode_summary(&mut self) { + self.table_throughput_mode_summary = self.billing_mode_summary.as_ref().map(Into::into); + } } /// Full description of a Virtual `DynamoDB` table, returned by `CreateTable`, @@ -758,6 +796,14 @@ pub struct TableDescription { pub provisioned_throughput: ProvisionedThroughputDescription, #[serde(rename = "BillingModeSummary", skip_serializing_if = "Option::is_none")] pub billing_mode_summary: Option, + /// Populated by the engine via `populate_table_throughput_mode_summary()` + /// on every path that emits a description. Backends leave this `None` and + /// must not assemble it independently. + #[serde( + rename = "TableThroughputModeSummary", + skip_serializing_if = "Option::is_none" + )] + pub table_throughput_mode_summary: Option, #[serde( rename = "GlobalSecondaryIndexes", skip_serializing_if = "Option::is_none" @@ -818,6 +864,8 @@ pub struct CreateTableInput { pub attribute_definitions: Vec, #[serde(rename = "BillingMode")] pub billing_mode: Option, + #[serde(rename = "TableThroughputMode")] + pub table_throughput_mode: Option, #[serde(rename = "ProvisionedThroughput")] pub provisioned_throughput: Option, #[serde(rename = "GlobalSecondaryIndexes")] @@ -847,6 +895,21 @@ pub struct CreateTableOutput { pub table_description: TableDescription, } +impl CreateTableInput { + /// Resolve `TableThroughputMode` into the billing-mode slot. + /// + /// Measured 2026-08-21 against Amazon DynamoDB: `BillingMode` wins when + /// both members are present (a conflict is not refused), and + /// `TableThroughputMode` applies only when `BillingMode` is absent. Every + /// downstream validation message phrases the mode as `BillingMode` either + /// way, which resolving here reproduces without further changes. The + /// member is consumed, so the storage layer only ever sees the resolved + /// `billing_mode`. + pub fn resolve_table_throughput_mode(&mut self) { + self.billing_mode = self.billing_mode.or(self.table_throughput_mode.take()); + } +} + /// `DeleteTable` request body. #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct DeleteTableInput { @@ -949,6 +1012,8 @@ pub struct UpdateTableInput { pub table_name: String, #[serde(rename = "BillingMode")] pub billing_mode: Option, + #[serde(rename = "TableThroughputMode")] + pub table_throughput_mode: Option, #[serde(rename = "ProvisionedThroughput")] pub provisioned_throughput: Option, #[serde(rename = "DeletionProtectionEnabled")] @@ -977,6 +1042,18 @@ pub struct UpdateTableOutput { pub table_description: TableDescription, } +impl UpdateTableInput { + /// Resolve `TableThroughputMode` into the billing-mode slot. + /// + /// Same measured rule as [`CreateTableInput::resolve_table_throughput_mode`]: + /// `BillingMode` wins when both members are present, no conflict refusal. + /// The member is consumed, so the storage layer only ever sees the + /// resolved `billing_mode`. + pub fn resolve_table_throughput_mode(&mut self) { + self.billing_mode = self.billing_mode.or(self.table_throughput_mode.take()); + } +} + // --- TTL --- /// TTL status for a table. @@ -1103,27 +1180,56 @@ pub struct DescribeLimitsOutput { mod tests { use super::*; - /// `TableThroughputMode` is not a member of DynamoDB's CreateTable request: - /// the model has `BillingMode` only (verified against aws-sdk-dynamodb 1.119.0, - /// where the field does not appear at all). An earlier version of this type - /// accepted it as an alias, which meant a request that produced a - /// PAY_PER_REQUEST table here would be ignored by AWS and produce a - /// PROVISIONED table there: an accept-direction divergence, where code written - /// against ExtendDB breaks against the real service. Under AWS JSON 1.0 an - /// unknown member is ignored, which is what must happen here. + /// `TableThroughputMode` is accepted and honoured by Amazon DynamoDB as a + /// fallback alias of `BillingMode` (measured 2026-08-21: CreateTable with + /// only `TableThroughputMode: PAY_PER_REQUEST` returns 200 with a + /// PAY_PER_REQUEST `BillingModeSummary`, on plain and vector-indexed tables + /// alike). An earlier version of this type deliberately ignored the member + /// because it does not exist in the SDK model (checked against + /// aws-sdk-dynamodb 1.119.0, where it still does not appear); that reasoning + /// was sound, but the SDK model lagged the service, and the measurement + /// supersedes it. Do not re-remove the member on SDK-model grounds. #[test] - fn create_table_ignores_the_unknown_table_throughput_mode_member() { + fn create_table_honours_the_table_throughput_mode_member() { let json = r#"{ "TableName": "t", "KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}], "AttributeDefinitions": [{"AttributeName": "pk", "AttributeType": "S"}], "TableThroughputMode": "PAY_PER_REQUEST" }"#; - let input: CreateTableInput = serde_json::from_str(json).unwrap(); + let mut input: CreateTableInput = serde_json::from_str(json).unwrap(); assert_eq!( - input.billing_mode, None, - "an unknown member must be ignored, not treated as BillingMode" + input.table_throughput_mode, + Some(BillingMode::PayPerRequest) ); + assert_eq!(input.billing_mode, None, "the member is not BillingMode"); + input.resolve_table_throughput_mode(); + assert_eq!( + input.billing_mode, + Some(BillingMode::PayPerRequest), + "alone, the member must resolve into the billing-mode slot" + ); + assert_eq!( + input.table_throughput_mode, None, + "resolution consumes the member; storage sees only billing_mode" + ); + } + + /// Measured 2026-08-21: when both members are present, `BillingMode` wins + /// whatever the other member says. A conflict is not refused. + #[test] + fn create_table_billing_mode_wins_over_table_throughput_mode() { + let json = r#"{ + "TableName": "t", + "KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}], + "AttributeDefinitions": [{"AttributeName": "pk", "AttributeType": "S"}], + "BillingMode": "PROVISIONED", + "TableThroughputMode": "PAY_PER_REQUEST" + }"#; + let mut input: CreateTableInput = serde_json::from_str(json).unwrap(); + input.resolve_table_throughput_mode(); + assert_eq!(input.billing_mode, Some(BillingMode::Provisioned)); + assert_eq!(input.table_throughput_mode, None); } #[test] @@ -1138,14 +1244,65 @@ mod tests { assert_eq!(input.billing_mode, Some(BillingMode::PayPerRequest)); } + /// The UpdateTable twin of the CreateTable test above: the member is + /// honoured as a fallback alias, resolved by the same measured rule. #[test] - fn update_table_ignores_the_unknown_table_throughput_mode_member() { + fn update_table_honours_the_table_throughput_mode_member() { let json = r#"{"TableName": "t", "TableThroughputMode": "PROVISIONED"}"#; - let input: UpdateTableInput = serde_json::from_str(json).unwrap(); + let mut input: UpdateTableInput = serde_json::from_str(json).unwrap(); + assert_eq!(input.billing_mode, None); + input.resolve_table_throughput_mode(); + assert_eq!(input.billing_mode, Some(BillingMode::Provisioned)); + assert_eq!(input.table_throughput_mode, None); + } + + #[test] + fn update_table_billing_mode_wins_over_table_throughput_mode() { + let json = r#"{"TableName": "t", "BillingMode": "PAY_PER_REQUEST", + "TableThroughputMode": "PROVISIONED"}"#; + let mut input: UpdateTableInput = serde_json::from_str(json).unwrap(); + input.resolve_table_throughput_mode(); + assert_eq!(input.billing_mode, Some(BillingMode::PayPerRequest)); + assert_eq!( + input.table_throughput_mode, None, + "the losing member is still consumed" + ); + } + + /// The mirror is a pure function of the billing-mode summary: same mode, + /// same timestamp, absent when the sibling is absent. + #[test] + fn table_throughput_mode_summary_mirrors_billing_mode_summary() { + let mut desc = TableDescription { + billing_mode_summary: Some(BillingModeSummary { + billing_mode: BillingMode::PayPerRequest, + last_update_to_pay_per_request_date_time: Some(1787276378.446), + }), + ..Default::default() + }; + desc.populate_table_throughput_mode_summary(); + let ttms = desc.table_throughput_mode_summary.as_ref().unwrap(); + assert_eq!(ttms.table_throughput_mode, BillingMode::PayPerRequest); assert_eq!( - input.billing_mode, None, - "an unknown member must be ignored" + ttms.last_update_to_pay_per_request_date_time, + Some(1787276378.446) ); + + desc.billing_mode_summary = None; + desc.populate_table_throughput_mode_summary(); + assert_eq!(desc.table_throughput_mode_summary, None); + } + + /// Wire shape: the timestamp member is omitted, not null, when absent, + /// matching the sibling summary's serialisation. + #[test] + fn table_throughput_mode_summary_omits_absent_timestamp() { + let json = serde_json::to_string(&TableThroughputModeSummary { + table_throughput_mode: BillingMode::PayPerRequest, + last_update_to_pay_per_request_date_time: None, + }) + .unwrap(); + assert_eq!(json, r#"{"TableThroughputMode":"PAY_PER_REQUEST"}"#); } #[test] diff --git a/crates/core/src/validation/mod.rs b/crates/core/src/validation/mod.rs index 87077676..ba2c46b2 100755 --- a/crates/core/src/validation/mod.rs +++ b/crates/core/src/validation/mod.rs @@ -2159,6 +2159,7 @@ mod tests { key_schema, attribute_definitions: attr_defs, billing_mode: Some(BillingMode::PayPerRequest), + table_throughput_mode: None, provisioned_throughput: None, global_secondary_indexes: None, local_secondary_indexes: None, diff --git a/crates/engine/src/backup.rs b/crates/engine/src/backup.rs index 7c6f117b..510190d1 100755 --- a/crates/engine/src/backup.rs +++ b/crates/engine/src/backup.rs @@ -184,6 +184,9 @@ pub(crate) async fn handle_restore_table_from_backup( // cover exactly this class of path, so the omission was a latent inconsistency // rather than a deliberate exception. desc.validate_vector_index_readiness()?; + // Same rule as every other path that emits a table description: the + // throughput-mode summary mirrors the billing-mode summary. + desc.populate_table_throughput_mode_summary(); serialize_output(&json!({ "TableDescription": desc })) } diff --git a/crates/engine/src/create_table.rs b/crates/engine/src/create_table.rs index eeaa86e6..257cd229 100755 --- a/crates/engine/src/create_table.rs +++ b/crates/engine/src/create_table.rs @@ -15,11 +15,18 @@ pub async fn handle_create_table( ) -> Result { crate::validate_enum_fields( &body, - &[crate::EnumField { - json_name: "BillingMode", - valid: &["PROVISIONED", "PAY_PER_REQUEST"], - clause: crate::EnumClause::Named("billingMode"), - }], + &[ + crate::EnumField { + json_name: "BillingMode", + valid: &["PROVISIONED", "PAY_PER_REQUEST"], + clause: crate::EnumClause::Named("billingMode"), + }, + crate::EnumField { + json_name: "TableThroughputMode", + valid: &["PROVISIONED", "PAY_PER_REQUEST"], + clause: crate::EnumClause::Named("tableThroughputMode"), + }, + ], )?; let mut input: CreateTableInput = serde_json::from_value(body).map_err(|e| { @@ -41,6 +48,7 @@ pub async fn handle_create_table( )) } })?; + input.resolve_table_throughput_mode(); validate_create_table(&input, &ctx.limits)?; @@ -61,7 +69,7 @@ pub async fn handle_create_table( // Kept for the post-condition check below, since `input` is moved into the // backend call. let requested_vector_indexes = input.vector_indexes.clone(); - let table_desc = ctx + let mut table_desc = ctx .storage .create_table(&ctx.account_id, input) .await @@ -95,6 +103,7 @@ pub async fn handle_create_table( ); ctx.auth_cache.invalidate_resource_tags(&arn).await; + table_desc.populate_table_throughput_mode_summary(); let output = CreateTableOutput { table_description: table_desc, }; diff --git a/crates/engine/src/delete_table.rs b/crates/engine/src/delete_table.rs index 1d5590ca..8b2de008 100755 --- a/crates/engine/src/delete_table.rs +++ b/crates/engine/src/delete_table.rs @@ -19,7 +19,7 @@ pub async fn handle_delete_table( validate_table_name(&input.table_name, &ctx.limits)?; let table_name = input.table_name.clone(); - let table_desc = ctx + let mut table_desc = ctx .storage .delete_table(&ctx.account_id, input) .await @@ -39,6 +39,7 @@ pub async fn handle_delete_table( ); ctx.auth_cache.invalidate_resource_tags(&arn).await; + table_desc.populate_table_throughput_mode_summary(); let output = DeleteTableOutput { table_description: table_desc, }; diff --git a/crates/engine/src/describe_table.rs b/crates/engine/src/describe_table.rs index 449e8611..ea6a69f4 100755 --- a/crates/engine/src/describe_table.rs +++ b/crates/engine/src/describe_table.rs @@ -19,7 +19,7 @@ pub async fn handle_describe_table( validate_table_name(&input.table_name, &ctx.limits)?; - let table_desc = ctx + let mut table_desc = ctx .storage .describe_table(&ctx.account_id, input) .await @@ -29,6 +29,7 @@ pub async fn handle_describe_table( // populated; the first search would silently undercount. table_desc.validate_vector_index_readiness()?; + table_desc.populate_table_throughput_mode_summary(); let output = DescribeTableOutput { table: table_desc }; serialize_output(&output) } diff --git a/crates/engine/src/import_export.rs b/crates/engine/src/import_export.rs index b6500ee3..d7994042 100755 --- a/crates/engine/src/import_export.rs +++ b/crates/engine/src/import_export.rs @@ -344,6 +344,9 @@ fn create_table_input_from_params(tcp: &TableCreationParameters) -> CreateTableI attribute_definitions: tcp.attribute_definitions.clone(), key_schema: tcp.key_schema.clone(), billing_mode: tcp.billing_mode, + // ImportTable's TableCreationParameters has no TableThroughputMode + // member; nothing to resolve. + table_throughput_mode: None, provisioned_throughput: tcp.provisioned_throughput.clone(), global_secondary_indexes: tcp.global_secondary_indexes.clone(), local_secondary_indexes: None, diff --git a/crates/engine/src/update_table.rs b/crates/engine/src/update_table.rs index 416698ed..262becdf 100755 --- a/crates/engine/src/update_table.rs +++ b/crates/engine/src/update_table.rs @@ -27,8 +27,27 @@ pub async fn handle_update_table( body: Value, ctx: &OperationContext, ) -> Result { + // Per-member enum validation precedes everything else, including the table + // lookup, matching the measured service ordering. + crate::validate_enum_fields( + &body, + &[ + crate::EnumField { + json_name: "BillingMode", + valid: &["PROVISIONED", "PAY_PER_REQUEST"], + clause: crate::EnumClause::Named("billingMode"), + }, + crate::EnumField { + json_name: "TableThroughputMode", + valid: &["PROVISIONED", "PAY_PER_REQUEST"], + clause: crate::EnumClause::Named("tableThroughputMode"), + }, + ], + )?; + let mut input: UpdateTableInput = serde_json::from_value(body).map_err(crate::deserialize_error)?; + input.resolve_table_throughput_mode(); if input.table_name.is_empty() { return Err(DynamoDbError::ValidationException( @@ -179,7 +198,7 @@ pub async fn handle_update_table( // Kept for the post-condition check below, since `input` is moved into the // backend call. let vector_index_updates = input.vector_index_updates.clone(); - let desc = ctx + let mut desc = ctx .storage .update_table(&ctx.account_id, input) .await @@ -201,6 +220,7 @@ pub async fn handle_update_table( .invalidate_table_key_info(&ctx.account_id, &table_name) .await; + desc.populate_table_throughput_mode_summary(); let output = extenddb_core::types::UpdateTableOutput { table_description: desc, }; diff --git a/crates/storage-mongodb/src/table_engine.rs b/crates/storage-mongodb/src/table_engine.rs index 4513af71..51000f22 100644 --- a/crates/storage-mongodb/src/table_engine.rs +++ b/crates/storage-mongodb/src/table_engine.rs @@ -521,6 +521,7 @@ impl MongoEngine { table_id, provisioned_throughput: pt_desc, billing_mode_summary: billing_summary, + table_throughput_mode_summary: None, global_secondary_indexes: gsi_descriptions, local_secondary_indexes: lsi_descriptions, stream_specification: input.stream_specification, @@ -1435,6 +1436,7 @@ impl MongoEngine { table_id, provisioned_throughput: pt_desc, billing_mode_summary: billing_summary, + table_throughput_mode_summary: None, global_secondary_indexes: if gsis.is_empty() { None } else { Some(gsis) }, local_secondary_indexes: if lsis.is_empty() { None } else { Some(lsis) }, stream_specification, diff --git a/crates/storage-postgres/src/create_table.rs b/crates/storage-postgres/src/create_table.rs index 343e6fdb..a221a191 100755 --- a/crates/storage-postgres/src/create_table.rs +++ b/crates/storage-postgres/src/create_table.rs @@ -534,6 +534,7 @@ impl PostgresEngine { last_decrease_date_time: None, }, billing_mode_summary, + table_throughput_mode_summary: None, global_secondary_indexes: gsis, local_secondary_indexes: lsis, stream_specification: input.stream_specification, diff --git a/crates/storage-postgres/src/table_helpers.rs b/crates/storage-postgres/src/table_helpers.rs index c97777ea..d75049ba 100755 --- a/crates/storage-postgres/src/table_helpers.rs +++ b/crates/storage-postgres/src/table_helpers.rs @@ -395,6 +395,7 @@ impl PostgresEngine { last_decrease_date_time: None, }, billing_mode_summary, + table_throughput_mode_summary: None, global_secondary_indexes: if gsis.is_empty() { None } else { Some(gsis) }, local_secondary_indexes: if lsis.is_empty() { None } else { Some(lsis) }, stream_specification: stream_spec, diff --git a/crates/storage-postgres/tests/vector_control_plane.rs b/crates/storage-postgres/tests/vector_control_plane.rs index 265638c7..33601170 100644 --- a/crates/storage-postgres/tests/vector_control_plane.rs +++ b/crates/storage-postgres/tests/vector_control_plane.rs @@ -322,6 +322,7 @@ fn update_input(table: &str) -> UpdateTableInput { stream_specification: None, table_class: None, on_demand_throughput: None, + table_throughput_mode: None, vector_index_updates: None, } } diff --git a/crates/storage-sqlite/src/create_table.rs b/crates/storage-sqlite/src/create_table.rs index 754cac0c..85baf8f6 100644 --- a/crates/storage-sqlite/src/create_table.rs +++ b/crates/storage-sqlite/src/create_table.rs @@ -492,6 +492,7 @@ impl SqliteEngine { last_decrease_date_time: None, }, billing_mode_summary, + table_throughput_mode_summary: None, global_secondary_indexes: gsis, local_secondary_indexes: lsis, stream_specification: input.stream_specification, diff --git a/crates/storage-sqlite/src/table_helpers.rs b/crates/storage-sqlite/src/table_helpers.rs index f0735e0b..b8adfbf1 100644 --- a/crates/storage-sqlite/src/table_helpers.rs +++ b/crates/storage-sqlite/src/table_helpers.rs @@ -289,6 +289,7 @@ impl SqliteEngine { last_decrease_date_time: None, }, billing_mode_summary, + table_throughput_mode_summary: None, global_secondary_indexes: (!gsis.is_empty()).then_some(gsis), local_secondary_indexes: (!lsis.is_empty()).then_some(lsis), stream_specification: stream_spec,