Skip to content
This repository was archived by the owner on Mar 23, 2026. It is now read-only.
Merged
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
24 changes: 24 additions & 0 deletions localstack-core/localstack/services/dynamodb/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -748,6 +748,9 @@ def create_table(
if "NumberOfDecreasesToday" not in table_description["ProvisionedThroughput"]:
table_description["ProvisionedThroughput"]["NumberOfDecreasesToday"] = 0

if "WarmThroughput" in table_description:
table_description["WarmThroughput"]["Status"] = "UPDATING"

tags = table_definitions.pop("Tags", [])
if tags:
get_store(context.account_id, context.region).TABLE_TAGS[table_arn] = {
Expand All @@ -765,6 +768,13 @@ def delete_table(
) -> DeleteTableOutput:
global_table_region = self.get_global_table_region(context, table_name)

self.ensure_table_exists(
context.account_id,
global_table_region,
table_name,
error_message=f"Requested resource not found: Table: {table_name} not found",
)

# Limitation note: On AWS, for a replicated table, if the source table is deleted, the replicated tables continue to exist.
# This is not the case for LocalStack, where all replicated tables will also be removed if source is deleted.

Expand Down Expand Up @@ -825,6 +835,9 @@ def describe_table(
table_description["TableClassSummary"] = {
"TableClass": table_definitions["TableClass"]
}
if warm_throughput := table_definitions.get("WarmThroughput"):
table_description["WarmThroughput"] = warm_throughput.copy()
table_description["WarmThroughput"].setdefault("Status", "ACTIVE")

if "GlobalSecondaryIndexes" in table_description:
for gsi in table_description["GlobalSecondaryIndexes"]:
Expand All @@ -837,6 +850,17 @@ def describe_table(
# Terraform depends on this parity for update operations
gsi["ProvisionedThroughput"] = default_values | gsi.get("ProvisionedThroughput", {})

# Set defaults for warm throughput
if "WarmThroughput" not in table_description:
billing_mode = table_definitions.get("BillingMode") if table_definitions else None
table_description["WarmThroughput"] = {
"ReadUnitsPerSecond": 12000 if billing_mode == "PAY_PER_REQUEST" else 5,
"WriteUnitsPerSecond": 4000 if billing_mode == "PAY_PER_REQUEST" else 5,
}
table_description["WarmThroughput"]["Status"] = (
table_description.get("TableStatus") or "ACTIVE"
)

return DescribeTableOutput(
Table=select_from_typed_dict(TableDescription, table_description)
)
Expand Down
24 changes: 24 additions & 0 deletions localstack-core/localstack/services/dynamodb/v2/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,9 @@ def create_table(
if "NumberOfDecreasesToday" not in table_description["ProvisionedThroughput"]:
table_description["ProvisionedThroughput"]["NumberOfDecreasesToday"] = 0

if "WarmThroughput" in table_description:
table_description["WarmThroughput"]["Status"] = "UPDATING"

tags = table_definitions.pop("Tags", [])
if tags:
get_store(context.account_id, context.region).TABLE_TAGS[table_arn] = {
Expand All @@ -577,6 +580,13 @@ def delete_table(
) -> DeleteTableOutput:
global_table_region = self.get_global_table_region(context, table_name)

self.ensure_table_exists(
context.account_id,
global_table_region,
table_name,
error_message=f"Requested resource not found: Table: {table_name} not found",
)

# Limitation note: On AWS, for a replicated table, if the source table is deleted, the replicated tables continue to exist.
# This is not the case for LocalStack, where all replicated tables will also be removed if source is deleted.

Expand Down Expand Up @@ -636,6 +646,9 @@ def describe_table(
table_description["TableClassSummary"] = {
"TableClass": table_definitions["TableClass"]
}
if warm_throughput := table_definitions.get("WarmThroughput"):
table_description["WarmThroughput"] = warm_throughput.copy()
table_description["WarmThroughput"].setdefault("Status", "ACTIVE")

if "GlobalSecondaryIndexes" in table_description:
for gsi in table_description["GlobalSecondaryIndexes"]:
Expand All @@ -648,6 +661,17 @@ def describe_table(
# Terraform depends on this parity for update operations
gsi["ProvisionedThroughput"] = default_values | gsi.get("ProvisionedThroughput", {})

# Set defaults for warm throughput
if "WarmThroughput" not in table_description:
billing_mode = table_definitions.get("BillingMode") if table_definitions else None
table_description["WarmThroughput"] = {
"ReadUnitsPerSecond": 12000 if billing_mode == "PAY_PER_REQUEST" else 5,
"WriteUnitsPerSecond": 4000 if billing_mode == "PAY_PER_REQUEST" else 5,
}
table_description["WarmThroughput"]["Status"] = (
table_description.get("TableStatus") or "ACTIVE"
)

return DescribeTableOutput(
Table=select_from_typed_dict(TableDescription, table_description)
)
Expand Down
117 changes: 100 additions & 17 deletions tests/aws/services/dynamodb/test_dynamodb.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from botocore.config import Config
from botocore.exceptions import ClientError
from localstack_snapshot.snapshots.transformer import SortingTransformer
from localstack_snapshot.snapshots.transformer_utility import TransformerUtility

from localstack import config
from localstack.aws.api.dynamodb import (
Expand Down Expand Up @@ -1490,32 +1491,101 @@ def test_create_duplicate_table(self, dynamodb_create_table_with_parameters, sna
)
snapshot.match("Error", ctx.value)

@markers.aws.only_localstack(
reason="timing issues - needs a check to see if table is successfully deleted"
@markers.aws.validated
@markers.snapshot.skip_snapshot_verify(
paths=[
"$..TableDescription.TableStatus", # DDBLocal directly goes to ACTIVE status
"$..Table.ProvisionedThroughput.LastDecreaseDateTime", # Not returned by DDBLocal
"$..Table.ProvisionedThroughput.LastIncreaseDateTime", # Not returned by DDBLocal
# The following attributes (prefixed TableDescription) are returned by DDBLocal DeleteTable but not in parity with AWS
"$..TableDescription.AttributeDefinitions",
"$..TableDescription.CreationDateTime",
"$..TableDescription.KeySchema",
"$..TableDescription.ProvisionedThroughput.LastDecreaseDateTime",
"$..TableDescription.ProvisionedThroughput.LastIncreaseDateTime",
"$..TableDescription.TableId",
]
)
def test_delete_table(self, dynamodb_create_table, aws_client):
def test_table_crud(self, aws_client, cleanups, snapshot, dynamodb_wait_for_table_active):
snapshot.add_transformer(
[
TransformerUtility.key_value("TableName"),
TransformerUtility.key_value("TableArn"),
]
)

table_name = f"test-ddb-table-{short_uid()}"

tables_before = len(aws_client.dynamodb.list_tables()["TableNames"])
# CreateTable
response = aws_client.dynamodb.create_table(
TableName=table_name,
KeySchema=[{"AttributeName": "id", "KeyType": "HASH"}],
AttributeDefinitions=[{"AttributeName": "id", "AttributeType": "S"}],
ProvisionedThroughput={"ReadCapacityUnits": 5, "WriteCapacityUnits": 5},
)
cleanups.append(lambda: aws_client.dynamodb.delete_table(TableName=table_name))
snapshot.match("create-table", response)

dynamodb_wait_for_table_active(table_name=table_name)

dynamodb_create_table(
table_name=table_name,
partition_key=PARTITION_KEY,
# ListTables
assert table_name in aws_client.dynamodb.list_tables()["TableNames"]

# DescribeTable
response = aws_client.dynamodb.describe_table(TableName=table_name)
snapshot.match("describe-table", response)

# DeleteTable
response = aws_client.dynamodb.delete_table(TableName=table_name)
snapshot.match("delete-table", response)

# ListTable: after DeleteTable
retry(
lambda: table_name not in aws_client.dynamodb.list_tables()["TableNames"],
sleep=1,
retries=30,
)

table_list = aws_client.dynamodb.list_tables()
# TODO: fix assertion, to enable parallel test execution!
assert tables_before + 1 == len(table_list["TableNames"])
assert table_name in table_list["TableNames"]
# DeleteTable: delete non-existent table
with pytest.raises(ClientError) as exc:
aws_client.dynamodb.delete_table(TableName="non-existent")
snapshot.match("delete-non-existent-table", exc.value.response)

aws_client.dynamodb.delete_table(TableName=table_name)
@markers.aws.validated
@markers.snapshot.skip_snapshot_verify(
paths=[
"$..TableDescription.TableStatus", # DDBLocal directly goes to ACTIVE status
"$..Table.ProvisionedThroughput.LastDecreaseDateTime", # Not returned by DDBLocal
"$..Table.ProvisionedThroughput.LastIncreaseDateTime", # Not returned by DDBLocal
]
)
def test_table_warm_throughput(
self, dynamodb_create_table_with_parameters, snapshot, aws_client
):
"""
This test ensures that WarmThroughput params provided to CreateTable are reflected in DescribeTable
"""

table_list = aws_client.dynamodb.list_tables()
assert tables_before == len(table_list["TableNames"])
snapshot.add_transformer(
[
TransformerUtility.key_value("TableName"),
TransformerUtility.key_value("TableArn"),
]
)

with pytest.raises(Exception) as ctx:
aws_client.dynamodb.delete_table(TableName=table_name)
assert ctx.match("ResourceNotFoundException")
table_name = f"test-ddb-table-{short_uid()}"

response = dynamodb_create_table_with_parameters(
TableName=table_name,
KeySchema=[{"AttributeName": "id", "KeyType": "HASH"}],
AttributeDefinitions=[{"AttributeName": "id", "AttributeType": "S"}],
ProvisionedThroughput={"ReadCapacityUnits": 5, "WriteCapacityUnits": 5},
WarmThroughput={"ReadUnitsPerSecond": 1000, "WriteUnitsPerSecond": 1200},
)
snapshot.match("create-table", response)

response = aws_client.dynamodb.describe_table(TableName=table_name)
snapshot.match("describe-table", response)

@markers.aws.validated
def test_transaction_write_items(
Expand Down Expand Up @@ -1947,9 +2017,14 @@ def test_dynamodb_create_table_with_sse_specification(
assert result["TableDescription"]["SSEDescription"]["KMSMasterKeyArn"] == kms_master_key_arn

@markers.aws.validated
@markers.snapshot.skip_snapshot_verify(
paths=["$..KeyMetadata.CurrentKeyMaterialId"] # Not supported by LS
)
def test_dynamodb_create_table_with_partial_sse_specification(
self, dynamodb_create_table_with_parameters, snapshot, aws_client
):
snapshot.add_transformer(TransformerUtility.key_value("CurrentKeyMaterialId"))

table_name = f"test_table_{short_uid()}"
sse_specification = {"Enabled": True}

Expand Down Expand Up @@ -1979,9 +2054,14 @@ def test_dynamodb_create_table_with_partial_sse_specification(
assert "SSESpecification" not in result["Table"]

@markers.aws.validated
@markers.snapshot.skip_snapshot_verify(
paths=["$..KeyMetadata.CurrentKeyMaterialId"] # Not supported by LS
)
def test_dynamodb_update_table_without_sse_specification_change(
self, dynamodb_create_table_with_parameters, snapshot, aws_client
):
snapshot.add_transformer(TransformerUtility.key_value("CurrentKeyMaterialId"))

table_name = f"test_table_{short_uid()}"

sse_specification = {"Enabled": True}
Expand Down Expand Up @@ -2284,6 +2364,7 @@ def test_data_encoding_consistency(
paths=[
"$..PointInTimeRecoveryDescription..EarliestRestorableDateTime",
"$..PointInTimeRecoveryDescription..LatestRestorableDateTime",
"$..ContinuousBackupsDescription.PointInTimeRecoveryDescription.RecoveryPeriodInDays",
]
)
@markers.aws.validated
Expand Down Expand Up @@ -2638,6 +2719,8 @@ def _get_records_amount(record_amount: int):
@pytest.mark.parametrize("billing_mode", ["PAY_PER_REQUEST", "PROVISIONED"])
@markers.snapshot.skip_snapshot_verify(
paths=[
# Warm throughput for GSI is not implemented in LS. DDB Local doesn't support it either.
"$..Table.GlobalSecondaryIndexes..WarmThroughput",
# LS returns those and not AWS, probably because no changes happened there yet
"$..ProvisionedThroughput.LastDecreaseDateTime",
"$..ProvisionedThroughput.LastIncreaseDateTime",
Expand Down
Loading
Loading