Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
189 changes: 173 additions & 16 deletions crates/core/src/types/table.rs
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,34 @@ pub struct BillingModeSummary {
pub last_update_to_pay_per_request_date_time: Option<f64>,
}

/// 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<f64>,
}

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 {
Expand Down Expand Up @@ -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`,
Expand Down Expand Up @@ -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<BillingModeSummary>,
/// 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<TableThroughputModeSummary>,
#[serde(
rename = "GlobalSecondaryIndexes",
skip_serializing_if = "Option::is_none"
Expand Down Expand Up @@ -818,6 +864,8 @@ pub struct CreateTableInput {
pub attribute_definitions: Vec<AttributeDefinition>,
#[serde(rename = "BillingMode")]
pub billing_mode: Option<BillingMode>,
#[serde(rename = "TableThroughputMode")]
pub table_throughput_mode: Option<BillingMode>,
#[serde(rename = "ProvisionedThroughput")]
pub provisioned_throughput: Option<ProvisionedThroughput>,
#[serde(rename = "GlobalSecondaryIndexes")]
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -949,6 +1012,8 @@ pub struct UpdateTableInput {
pub table_name: String,
#[serde(rename = "BillingMode")]
pub billing_mode: Option<BillingMode>,
#[serde(rename = "TableThroughputMode")]
pub table_throughput_mode: Option<BillingMode>,
#[serde(rename = "ProvisionedThroughput")]
pub provisioned_throughput: Option<ProvisionedThroughput>,
#[serde(rename = "DeletionProtectionEnabled")]
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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]
Expand All @@ -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]
Expand Down
1 change: 1 addition & 0 deletions crates/core/src/validation/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions crates/engine/src/backup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 }))
}

Expand Down
21 changes: 15 additions & 6 deletions crates/engine/src/create_table.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,18 @@ pub async fn handle_create_table(
) -> Result<Value, DynamoDbError> {
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| {
Expand All @@ -41,6 +48,7 @@ pub async fn handle_create_table(
))
}
})?;
input.resolve_table_throughput_mode();

validate_create_table(&input, &ctx.limits)?;

Expand All @@ -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
Expand Down Expand Up @@ -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,
};
Expand Down
3 changes: 2 additions & 1 deletion crates/engine/src/delete_table.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
};
Expand Down
3 changes: 2 additions & 1 deletion crates/engine/src/describe_table.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
3 changes: 3 additions & 0 deletions crates/engine/src/import_export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading