Skip to content

Commit 1922cdd

Browse files
authored
Speed up DynamoDB query deepcopy while preserving isolation (getmoto#9670)
1 parent 7be8f0d commit 1922cdd

2 files changed

Lines changed: 130 additions & 2 deletions

File tree

moto/dynamodb/models/dynamo_type.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import base64
22
import copy
33
from decimal import Decimal
4-
from typing import Any, Optional, Union
4+
from typing import Any, Optional, Union, cast
55

66
from boto3.dynamodb.types import TypeDeserializer, TypeSerializer
77
from botocore.utils import merge_dicts
@@ -97,6 +97,25 @@ def __ge__(self, other: "DynamoType") -> bool:
9797
def __repr__(self) -> str:
9898
return f"DynamoType: {self.to_json()}"
9999

100+
def __deepcopy__(self, memo: dict[int, Any]) -> "DynamoType":
101+
"""Custom deepcopy that bypasses the expensive default pickle-based
102+
__reduce_ex__ protocol. Safe for DynamoDB AttributeValue trees,
103+
which are acyclic."""
104+
if id(self) in memo: # pragma: no cover
105+
return memo[id(self)] # Circular refs can't occur in DynamoDB data
106+
result = self.__class__.__new__(self.__class__)
107+
memo[id(self)] = result
108+
result.type = self.type
109+
if self.is_list():
110+
result.value = [copy.deepcopy(v, memo) for v in self.value]
111+
elif self.is_map():
112+
result.value = {k: copy.deepcopy(v, memo) for k, v in self.value.items()}
113+
elif self.is_set():
114+
result.value = list(self.value)
115+
else:
116+
result.value = self.value
117+
return result
118+
100119
def __add__(self, other: "DynamoType") -> "DynamoType":
101120
if self.type != other.type:
102121
raise TypeError("Different types of operandi is not allowed.")
@@ -323,6 +342,26 @@ def __eq__(self, other: "Item") -> bool: # type: ignore[override]
323342
def __repr__(self) -> str:
324343
return f"Item: {self.to_json()}"
325344

345+
def __deepcopy__(self, memo: dict[int, Any]) -> "Item":
346+
"""Custom deepcopy that bypasses the expensive default pickle-based
347+
__reduce_ex__ protocol, and skips LimitedSizeDict size validation
348+
during copy (the source item already passed validation on write)."""
349+
if id(self) in memo: # pragma: no cover
350+
return memo[id(self)] # Circular refs can't occur in DynamoDB data
351+
result = cast(Item, self.__class__.__new__(self.__class__))
352+
memo[id(self)] = result
353+
result.hash_key = copy.deepcopy(self.hash_key, memo)
354+
result.range_key = copy.deepcopy(self.range_key, memo)
355+
# Bypass LimitedSizeDict.__setitem__ which runs O(n) size validation on
356+
# every insert. The source item already passed validation, so re-checking
357+
# during copy is unnecessary and would make the overall copy O(n²).
358+
attrs_copy = LimitedSizeDict.__new__(LimitedSizeDict)
359+
dict.__init__(attrs_copy)
360+
for key, value in self.attrs.items():
361+
dict.__setitem__(attrs_copy, key, copy.deepcopy(value, memo))
362+
result.attrs = attrs_copy
363+
return result
364+
326365
def size(self) -> int:
327366
return sum(bytesize(key) + value.size() for key, value in self.attrs.items())
328367

tests/test_dynamodb/test_dynamodb_query.py

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
1+
import copy
12
from decimal import Decimal
23
from uuid import uuid4
34

45
import boto3
56
import pytest
67
from boto3.dynamodb.conditions import Attr, Key
78

8-
from moto import mock_aws
9+
from moto import mock_aws, settings
10+
from moto.core import DEFAULT_ACCOUNT_ID as ACCOUNT_ID
11+
from moto.dynamodb import dynamodb_backends
12+
from moto.dynamodb.models import DynamoType
913

1014
from . import dynamodb_aws_verified
1115

@@ -153,6 +157,91 @@ def test_key_condition_expressions():
153157
assert results["Count"] == 1
154158

155159

160+
@mock_aws
161+
@pytest.mark.requires_clean_slate
162+
def test_query_returns_detached_items():
163+
if settings.TEST_SERVER_MODE:
164+
pytest.skip("Can't access backend directly in server mode")
165+
dynamodb = boto3.resource("dynamodb", region_name="us-east-1")
166+
table_name = f"T{uuid4()}"
167+
table = dynamodb.create_table(
168+
TableName=table_name,
169+
KeySchema=[
170+
{"AttributeName": "pk", "KeyType": "HASH"},
171+
{"AttributeName": "sk", "KeyType": "RANGE"},
172+
],
173+
AttributeDefinitions=[
174+
{"AttributeName": "pk", "AttributeType": "S"},
175+
{"AttributeName": "sk", "AttributeType": "S"},
176+
],
177+
ProvisionedThroughput={"ReadCapacityUnits": 5, "WriteCapacityUnits": 5},
178+
)
179+
180+
table.put_item(
181+
Item={
182+
"pk": "hash",
183+
"sk": "1",
184+
"payload": {"nested": "original"},
185+
"tags": ["a", "b"],
186+
}
187+
)
188+
189+
# Use backend API to verify Item isolation (boto3 returns plain dicts).
190+
backend = dynamodb_backends[ACCOUNT_ID]["us-east-1"]
191+
items, _, _ = backend.query(
192+
table_name,
193+
{"S": "hash"},
194+
None,
195+
[],
196+
limit=10,
197+
exclusive_start_key=None,
198+
scan_index_forward=True,
199+
projection_expressions=None,
200+
index_name=None,
201+
consistent_read=False,
202+
expr_names=None,
203+
expr_values=None,
204+
filter_expression=None,
205+
)
206+
207+
item = items[0]
208+
item.attrs["payload"].value["nested"].value = "changed"
209+
item.attrs["tags"].value[0].value = "changed"
210+
211+
stored_item = backend.get_item(table_name, {"pk": {"S": "hash"}, "sk": {"S": "1"}})
212+
assert stored_item is not None
213+
assert stored_item is not item
214+
assert stored_item.attrs["payload"].value["nested"].value == "original"
215+
assert stored_item.attrs["tags"].value[0].value == "a"
216+
217+
218+
@pytest.mark.parametrize(
219+
"attr_value",
220+
[
221+
pytest.param({"S": "hello"}, id="string"),
222+
pytest.param({"N": "123"}, id="number"),
223+
pytest.param({"B": "aGVsbG8="}, id="binary"),
224+
pytest.param({"BOOL": True}, id="boolean"),
225+
pytest.param({"NULL": True}, id="null"),
226+
pytest.param({"SS": ["a", "b", "c"]}, id="string_set"),
227+
pytest.param({"NS": ["1", "2", "3"]}, id="number_set"),
228+
pytest.param({"BS": ["aGVsbG8=", "d29ybGQ="]}, id="binary_set"),
229+
pytest.param({"L": [{"S": "a"}, {"N": "1"}]}, id="list"),
230+
pytest.param({"M": {"key": {"S": "value"}}}, id="map"),
231+
],
232+
)
233+
def test_dynamotype_deepcopy_all_types(attr_value):
234+
original = DynamoType(attr_value)
235+
copied = copy.deepcopy(original)
236+
237+
assert copied == original
238+
assert copied is not original
239+
assert copied.type == original.type
240+
# For mutable containers, verify the value is a different object
241+
if original.is_list() or original.is_map() or original.is_set():
242+
assert copied.value is not original.value
243+
244+
156245
@pytest.mark.aws_verified
157246
@dynamodb_aws_verified(add_range=True)
158247
def test_query_pagination(table_name=None):

0 commit comments

Comments
 (0)