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, 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)