boto3-assist 0.32.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- boto3_assist/__init__.py +0 -0
- boto3_assist/aws_config.py +199 -0
- boto3_assist/aws_lambda/event_info.py +414 -0
- boto3_assist/aws_lambda/mock_context.py +5 -0
- boto3_assist/boto3session.py +87 -0
- boto3_assist/cloudwatch/cloudwatch_connection.py +84 -0
- boto3_assist/cloudwatch/cloudwatch_connection_tracker.py +17 -0
- boto3_assist/cloudwatch/cloudwatch_log_connection.py +62 -0
- boto3_assist/cloudwatch/cloudwatch_logs.py +39 -0
- boto3_assist/cloudwatch/cloudwatch_query.py +191 -0
- boto3_assist/cognito/cognito_authorizer.py +169 -0
- boto3_assist/cognito/cognito_connection.py +59 -0
- boto3_assist/cognito/cognito_utility.py +514 -0
- boto3_assist/cognito/jwks_cache.py +21 -0
- boto3_assist/cognito/user.py +27 -0
- boto3_assist/connection.py +146 -0
- boto3_assist/connection_tracker.py +120 -0
- boto3_assist/dynamodb/dynamodb.py +1206 -0
- boto3_assist/dynamodb/dynamodb_connection.py +113 -0
- boto3_assist/dynamodb/dynamodb_helpers.py +333 -0
- boto3_assist/dynamodb/dynamodb_importer.py +102 -0
- boto3_assist/dynamodb/dynamodb_index.py +507 -0
- boto3_assist/dynamodb/dynamodb_iservice.py +29 -0
- boto3_assist/dynamodb/dynamodb_key.py +130 -0
- boto3_assist/dynamodb/dynamodb_model_base.py +382 -0
- boto3_assist/dynamodb/dynamodb_model_base_interfaces.py +34 -0
- boto3_assist/dynamodb/dynamodb_re_indexer.py +165 -0
- boto3_assist/dynamodb/dynamodb_reindexer.py +165 -0
- boto3_assist/dynamodb/dynamodb_reserved_words.py +52 -0
- boto3_assist/dynamodb/dynamodb_reserved_words.txt +573 -0
- boto3_assist/dynamodb/readme.md +68 -0
- boto3_assist/dynamodb/troubleshooting.md +7 -0
- boto3_assist/ec2/ec2_connection.py +57 -0
- boto3_assist/environment_services/__init__.py +0 -0
- boto3_assist/environment_services/environment_loader.py +128 -0
- boto3_assist/environment_services/environment_variables.py +219 -0
- boto3_assist/erc/__init__.py +64 -0
- boto3_assist/erc/ecr_connection.py +57 -0
- boto3_assist/errors/custom_exceptions.py +46 -0
- boto3_assist/http_status_codes.py +80 -0
- boto3_assist/models/serializable_model.py +9 -0
- boto3_assist/role_assumption_mixin.py +38 -0
- boto3_assist/s3/s3.py +64 -0
- boto3_assist/s3/s3_bucket.py +67 -0
- boto3_assist/s3/s3_connection.py +76 -0
- boto3_assist/s3/s3_event_data.py +168 -0
- boto3_assist/s3/s3_object.py +695 -0
- boto3_assist/securityhub/securityhub.py +150 -0
- boto3_assist/securityhub/securityhub_connection.py +57 -0
- boto3_assist/session_setup_mixin.py +70 -0
- boto3_assist/ssm/connection.py +57 -0
- boto3_assist/ssm/parameter_store/parameter_store.py +116 -0
- boto3_assist/utilities/datetime_utility.py +349 -0
- boto3_assist/utilities/decimal_conversion_utility.py +140 -0
- boto3_assist/utilities/dictionary_utility.py +32 -0
- boto3_assist/utilities/file_operations.py +135 -0
- boto3_assist/utilities/http_utility.py +48 -0
- boto3_assist/utilities/logging_utility.py +0 -0
- boto3_assist/utilities/numbers_utility.py +329 -0
- boto3_assist/utilities/serialization_utility.py +664 -0
- boto3_assist/utilities/string_utility.py +337 -0
- boto3_assist/version.py +1 -0
- boto3_assist-0.32.0.dist-info/METADATA +76 -0
- boto3_assist-0.32.0.dist-info/RECORD +67 -0
- boto3_assist-0.32.0.dist-info/WHEEL +4 -0
- boto3_assist-0.32.0.dist-info/licenses/LICENSE-EXPLAINED.txt +11 -0
- boto3_assist-0.32.0.dist-info/licenses/LICENSE.txt +21 -0
|
@@ -0,0 +1,1206 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Geek Cafe, LLC
|
|
3
|
+
Maintainers: Eric Wilson
|
|
4
|
+
MIT License. See Project Root for the license information.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
from typing import List, Optional, overload, Dict, Any
|
|
9
|
+
from botocore.exceptions import ClientError
|
|
10
|
+
from boto3.dynamodb.conditions import Attr
|
|
11
|
+
|
|
12
|
+
from aws_lambda_powertools import Logger
|
|
13
|
+
from boto3.dynamodb.conditions import (
|
|
14
|
+
Key,
|
|
15
|
+
# And,
|
|
16
|
+
# Equals,
|
|
17
|
+
ComparisonCondition,
|
|
18
|
+
ConditionBase,
|
|
19
|
+
)
|
|
20
|
+
from .dynamodb_connection import DynamoDBConnection
|
|
21
|
+
from .dynamodb_helpers import DynamoDBHelpers
|
|
22
|
+
from .dynamodb_model_base import DynamoDBModelBase
|
|
23
|
+
from ..utilities.string_utility import StringUtility
|
|
24
|
+
from ..utilities.decimal_conversion_utility import DecimalConversionUtility
|
|
25
|
+
from .dynamodb_index import DynamoDBIndex
|
|
26
|
+
|
|
27
|
+
logger = Logger()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class DynamoDB(DynamoDBConnection):
|
|
31
|
+
"""
|
|
32
|
+
DynamoDB. Wrapper for basic DynamoDB Connection and Actions
|
|
33
|
+
|
|
34
|
+
Inherits:
|
|
35
|
+
DynamoDBConnection
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
*,
|
|
41
|
+
aws_profile: Optional[str] = None,
|
|
42
|
+
aws_region: Optional[str] = None,
|
|
43
|
+
aws_end_point_url: Optional[str] = None,
|
|
44
|
+
aws_access_key_id: Optional[str] = None,
|
|
45
|
+
aws_secret_access_key: Optional[str] = None,
|
|
46
|
+
assume_role_arn: Optional[str] = None,
|
|
47
|
+
assume_role_chain: Optional[List[str]] = None,
|
|
48
|
+
assume_role_duration_seconds: Optional[int] = 3600,
|
|
49
|
+
) -> None:
|
|
50
|
+
super().__init__(
|
|
51
|
+
aws_profile=aws_profile,
|
|
52
|
+
aws_region=aws_region,
|
|
53
|
+
aws_end_point_url=aws_end_point_url,
|
|
54
|
+
aws_access_key_id=aws_access_key_id,
|
|
55
|
+
assume_role_arn=assume_role_arn,
|
|
56
|
+
assume_role_chain=assume_role_chain,
|
|
57
|
+
assume_role_duration_seconds=assume_role_duration_seconds,
|
|
58
|
+
)
|
|
59
|
+
self.helpers: DynamoDBHelpers = DynamoDBHelpers()
|
|
60
|
+
self.log_dynamodb_item_size: bool = bool(
|
|
61
|
+
os.getenv("LOG_DYNAMODB_ITEM_SIZE", "False").lower() == "true"
|
|
62
|
+
)
|
|
63
|
+
self.convert_decimals: bool = bool(
|
|
64
|
+
os.getenv("DYNAMODB_CONVERT_DECIMALS", "True").lower() == "true"
|
|
65
|
+
)
|
|
66
|
+
logger.setLevel(os.getenv("LOG_LEVEL", "INFO"))
|
|
67
|
+
|
|
68
|
+
def _apply_decimal_conversion(self, response: Dict[str, Any]) -> Dict[str, Any]:
|
|
69
|
+
"""
|
|
70
|
+
Apply decimal conversion to DynamoDB response if enabled.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
response: The DynamoDB response dictionary
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
The response with decimal conversion applied if enabled
|
|
77
|
+
"""
|
|
78
|
+
if not self.convert_decimals:
|
|
79
|
+
return response
|
|
80
|
+
|
|
81
|
+
return DecimalConversionUtility.convert_decimals_to_native_types(response)
|
|
82
|
+
|
|
83
|
+
def save(
|
|
84
|
+
self,
|
|
85
|
+
item: dict | DynamoDBModelBase,
|
|
86
|
+
table_name: str,
|
|
87
|
+
source: Optional[str] = None,
|
|
88
|
+
fail_if_exists: bool = False,
|
|
89
|
+
condition_expression: Optional[str] = None,
|
|
90
|
+
expression_attribute_names: Optional[dict] = None,
|
|
91
|
+
expression_attribute_values: Optional[dict] = None,
|
|
92
|
+
) -> dict:
|
|
93
|
+
"""
|
|
94
|
+
Save an item to the database with optional conditional expressions.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
item (dict): DynamoDB Dictionary Object or DynamoDBModelBase.
|
|
98
|
+
Supports the "client" or "resource" syntax
|
|
99
|
+
table_name (str): The DynamoDb Table Name
|
|
100
|
+
source (str, optional): The source of the call, used for logging. Defaults to None.
|
|
101
|
+
fail_if_exists (bool, optional): Only allow it to insert once.
|
|
102
|
+
Fail if it already exits. This is useful for loggers, historical records,
|
|
103
|
+
tasks, etc. that should only be created once
|
|
104
|
+
condition_expression (str, optional): Custom condition expression.
|
|
105
|
+
Example: "attribute_not_exists(#pk)" or "#version = :expected_version"
|
|
106
|
+
expression_attribute_names (dict, optional): Attribute name mappings.
|
|
107
|
+
Example: {"#version": "version", "#status": "status"}
|
|
108
|
+
expression_attribute_values (dict, optional): Attribute value mappings.
|
|
109
|
+
Example: {":expected_version": 1, ":active": "active"}
|
|
110
|
+
|
|
111
|
+
Raises:
|
|
112
|
+
ClientError: Client specific errors
|
|
113
|
+
RuntimeError: Conditional check failed
|
|
114
|
+
Exception: Any Error Raised
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
dict: The Response from DynamoDB's put_item actions.
|
|
118
|
+
It does not return the saved object, only the response.
|
|
119
|
+
|
|
120
|
+
Examples:
|
|
121
|
+
>>> # Simple save
|
|
122
|
+
>>> db.save(item=user, table_name="users")
|
|
123
|
+
|
|
124
|
+
>>> # Prevent duplicates
|
|
125
|
+
>>> db.save(item=user, table_name="users", fail_if_exists=True)
|
|
126
|
+
|
|
127
|
+
>>> # Optimistic locking with version check
|
|
128
|
+
>>> db.save(
|
|
129
|
+
... item=user,
|
|
130
|
+
... table_name="users",
|
|
131
|
+
... condition_expression="#version = :expected_version",
|
|
132
|
+
... expression_attribute_names={"#version": "version"},
|
|
133
|
+
... expression_attribute_values={":expected_version": 5}
|
|
134
|
+
... )
|
|
135
|
+
"""
|
|
136
|
+
response: Dict[str, Any] = {}
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
if not isinstance(item, dict):
|
|
140
|
+
# attempt to convert it
|
|
141
|
+
if not isinstance(item, DynamoDBModelBase):
|
|
142
|
+
raise RuntimeError(
|
|
143
|
+
f"Item is not a dictionary or DynamoDBModelBase. Type: {type(item).__name__}. "
|
|
144
|
+
"In order to prep the model for saving, it needs to already be dictionary or support "
|
|
145
|
+
"the to_resource_dictionary() method, which is available when you inherit from DynamoDBModelBase. "
|
|
146
|
+
"Unable to save item to DynamoDB. The entry was not saved."
|
|
147
|
+
)
|
|
148
|
+
try:
|
|
149
|
+
item = item.to_resource_dictionary()
|
|
150
|
+
except Exception as e: # pylint: disable=w0718
|
|
151
|
+
logger.exception(e)
|
|
152
|
+
raise RuntimeError(
|
|
153
|
+
"An error occurred during model conversion. The entry was not saved. "
|
|
154
|
+
) from e
|
|
155
|
+
|
|
156
|
+
if isinstance(item, dict):
|
|
157
|
+
self.__log_item_size(item=item)
|
|
158
|
+
|
|
159
|
+
# Convert native numeric types to Decimal for DynamoDB
|
|
160
|
+
# (DynamoDB doesn't accept float, requires Decimal)
|
|
161
|
+
item = DecimalConversionUtility.convert_native_types_to_decimals(item)
|
|
162
|
+
|
|
163
|
+
if isinstance(item, dict) and isinstance(next(iter(item.values())), dict):
|
|
164
|
+
# Use boto3.client syntax
|
|
165
|
+
# client API style
|
|
166
|
+
params = {
|
|
167
|
+
"TableName": table_name,
|
|
168
|
+
"Item": item,
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
# Handle conditional expressions
|
|
172
|
+
if condition_expression:
|
|
173
|
+
# Custom condition provided
|
|
174
|
+
params["ConditionExpression"] = condition_expression
|
|
175
|
+
if expression_attribute_names:
|
|
176
|
+
params["ExpressionAttributeNames"] = expression_attribute_names
|
|
177
|
+
if expression_attribute_values:
|
|
178
|
+
params["ExpressionAttributeValues"] = expression_attribute_values
|
|
179
|
+
elif fail_if_exists:
|
|
180
|
+
# only insert if the item does *not* already exist
|
|
181
|
+
params["ConditionExpression"] = (
|
|
182
|
+
"attribute_not_exists(#pk) AND attribute_not_exists(#sk)"
|
|
183
|
+
)
|
|
184
|
+
params["ExpressionAttributeNames"] = {"#pk": "pk", "#sk": "sk"}
|
|
185
|
+
|
|
186
|
+
response = dict(self.dynamodb_client.put_item(**params))
|
|
187
|
+
|
|
188
|
+
else:
|
|
189
|
+
# Use boto3.resource syntax
|
|
190
|
+
table = self.dynamodb_resource.Table(table_name)
|
|
191
|
+
|
|
192
|
+
# Build put_item parameters
|
|
193
|
+
put_params = {"Item": item}
|
|
194
|
+
|
|
195
|
+
# Handle conditional expressions
|
|
196
|
+
if condition_expression:
|
|
197
|
+
# Custom condition provided
|
|
198
|
+
# Convert string condition to boto3 condition object if needed
|
|
199
|
+
put_params["ConditionExpression"] = condition_expression
|
|
200
|
+
if expression_attribute_names:
|
|
201
|
+
put_params["ExpressionAttributeNames"] = expression_attribute_names
|
|
202
|
+
if expression_attribute_values:
|
|
203
|
+
put_params["ExpressionAttributeValues"] = expression_attribute_values
|
|
204
|
+
elif fail_if_exists:
|
|
205
|
+
put_params["ConditionExpression"] = (
|
|
206
|
+
Attr("pk").not_exists() & Attr("sk").not_exists()
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
response = dict(table.put_item(**put_params))
|
|
210
|
+
|
|
211
|
+
except ClientError as e:
|
|
212
|
+
error_code = e.response["Error"]["Code"]
|
|
213
|
+
|
|
214
|
+
if error_code == "ConditionalCheckFailedException":
|
|
215
|
+
# Enhanced error message for conditional check failures
|
|
216
|
+
if fail_if_exists:
|
|
217
|
+
raise RuntimeError(
|
|
218
|
+
f"Item with pk={item['pk']} already exists in {table_name}"
|
|
219
|
+
) from e
|
|
220
|
+
elif condition_expression:
|
|
221
|
+
raise RuntimeError(
|
|
222
|
+
f"Conditional check failed for item in {table_name}. "
|
|
223
|
+
f"Condition: {condition_expression}"
|
|
224
|
+
) from e
|
|
225
|
+
else:
|
|
226
|
+
raise RuntimeError(
|
|
227
|
+
f"Conditional check failed for item in {table_name}"
|
|
228
|
+
) from e
|
|
229
|
+
|
|
230
|
+
logger.exception(
|
|
231
|
+
{"source": f"{source}", "metric_filter": "put_item", "error": str(e)}
|
|
232
|
+
)
|
|
233
|
+
raise
|
|
234
|
+
|
|
235
|
+
except Exception as e: # pylint: disable=w0718
|
|
236
|
+
logger.exception(
|
|
237
|
+
{"source": f"{source}", "metric_filter": "put_item", "error": str(e)}
|
|
238
|
+
)
|
|
239
|
+
raise
|
|
240
|
+
|
|
241
|
+
return response
|
|
242
|
+
|
|
243
|
+
def __log_item_size(self, item: dict):
|
|
244
|
+
if not isinstance(item, dict):
|
|
245
|
+
warning = f"Item is not a dictionary. Type: {type(item).__name__}"
|
|
246
|
+
logger.warning(warning)
|
|
247
|
+
return
|
|
248
|
+
|
|
249
|
+
if self.log_dynamodb_item_size:
|
|
250
|
+
size_bytes: int = StringUtility.get_size_in_bytes(item)
|
|
251
|
+
size_kb: float = StringUtility.get_size_in_kb(item)
|
|
252
|
+
logger.info({"item_size": {"bytes": size_bytes, "kb": f"{size_kb:.2f}kb"}})
|
|
253
|
+
|
|
254
|
+
if size_kb > 390:
|
|
255
|
+
logger.warning(
|
|
256
|
+
{
|
|
257
|
+
"item_size": {
|
|
258
|
+
"bytes": size_bytes,
|
|
259
|
+
"kb": f"{size_kb:.2f}kb",
|
|
260
|
+
},
|
|
261
|
+
"warning": "approaching limit",
|
|
262
|
+
}
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
@overload
|
|
266
|
+
def get(
|
|
267
|
+
self,
|
|
268
|
+
*,
|
|
269
|
+
table_name: str,
|
|
270
|
+
model: DynamoDBModelBase,
|
|
271
|
+
do_projections: bool = False,
|
|
272
|
+
strongly_consistent: bool = False,
|
|
273
|
+
return_consumed_capacity: Optional[str] = None,
|
|
274
|
+
projection_expression: Optional[str] = None,
|
|
275
|
+
expression_attribute_names: Optional[dict] = None,
|
|
276
|
+
source: Optional[str] = None,
|
|
277
|
+
call_type: str = "resource",
|
|
278
|
+
) -> Dict[str, Any]: ...
|
|
279
|
+
|
|
280
|
+
@overload
|
|
281
|
+
def get(
|
|
282
|
+
self,
|
|
283
|
+
key: dict,
|
|
284
|
+
table_name: str,
|
|
285
|
+
*,
|
|
286
|
+
strongly_consistent: bool = False,
|
|
287
|
+
return_consumed_capacity: Optional[str] = None,
|
|
288
|
+
projection_expression: Optional[str] = None,
|
|
289
|
+
expression_attribute_names: Optional[dict] = None,
|
|
290
|
+
source: Optional[str] = None,
|
|
291
|
+
call_type: str = "resource",
|
|
292
|
+
) -> Dict[str, Any]: ...
|
|
293
|
+
|
|
294
|
+
def get(
|
|
295
|
+
self,
|
|
296
|
+
key: Optional[dict] = None,
|
|
297
|
+
table_name: Optional[str] = None,
|
|
298
|
+
model: Optional[DynamoDBModelBase] = None,
|
|
299
|
+
do_projections: bool = False,
|
|
300
|
+
strongly_consistent: bool = False,
|
|
301
|
+
return_consumed_capacity: Optional[str] = None,
|
|
302
|
+
projection_expression: Optional[str] = None,
|
|
303
|
+
expression_attribute_names: Optional[dict] = None,
|
|
304
|
+
source: Optional[str] = None,
|
|
305
|
+
call_type: str = "resource",
|
|
306
|
+
) -> Dict[str, Any]:
|
|
307
|
+
"""
|
|
308
|
+
Description:
|
|
309
|
+
generic get_item dynamoDb call
|
|
310
|
+
Parameters:
|
|
311
|
+
key: a dictionary object representing the primary key
|
|
312
|
+
model: a model instance of DynamoDBModelBase
|
|
313
|
+
"""
|
|
314
|
+
|
|
315
|
+
if model is not None:
|
|
316
|
+
if table_name is None:
|
|
317
|
+
raise ValueError("table_name must be provided when model is used.")
|
|
318
|
+
if key is not None:
|
|
319
|
+
raise ValueError(
|
|
320
|
+
"key cannot be provided when model is used. "
|
|
321
|
+
"When using the model, we'll automatically use the key defined."
|
|
322
|
+
)
|
|
323
|
+
key = model.indexes.primary.key()
|
|
324
|
+
if do_projections:
|
|
325
|
+
projection_expression = model.projection_expression
|
|
326
|
+
expression_attribute_names = model.projection_expression_attribute_names
|
|
327
|
+
elif key is None and model is None:
|
|
328
|
+
raise ValueError("Either 'key' or 'model' must be provided.")
|
|
329
|
+
|
|
330
|
+
response = None
|
|
331
|
+
try:
|
|
332
|
+
kwargs = {
|
|
333
|
+
"ConsistentRead": strongly_consistent,
|
|
334
|
+
"ReturnConsumedCapacity": return_consumed_capacity,
|
|
335
|
+
"ProjectionExpression": projection_expression,
|
|
336
|
+
"ExpressionAttributeNames": expression_attribute_names,
|
|
337
|
+
}
|
|
338
|
+
# only pass in args that aren't none
|
|
339
|
+
valid_kwargs = {k: v for k, v in kwargs.items() if v is not None}
|
|
340
|
+
|
|
341
|
+
if table_name is None:
|
|
342
|
+
raise ValueError("table_name must be provided.")
|
|
343
|
+
if call_type == "resource":
|
|
344
|
+
table = self.dynamodb_resource.Table(table_name)
|
|
345
|
+
response = dict(table.get_item(Key=key, **valid_kwargs)) # type: ignore[arg-type]
|
|
346
|
+
elif call_type == "client":
|
|
347
|
+
response = dict(
|
|
348
|
+
self.dynamodb_client.get_item(
|
|
349
|
+
Key=key,
|
|
350
|
+
TableName=table_name,
|
|
351
|
+
**valid_kwargs, # type: ignore[arg-type]
|
|
352
|
+
)
|
|
353
|
+
)
|
|
354
|
+
else:
|
|
355
|
+
raise ValueError(
|
|
356
|
+
f"Unknown call_type of {call_type}. Supported call_types [resource | client]"
|
|
357
|
+
)
|
|
358
|
+
except Exception as e: # pylint: disable=w0718
|
|
359
|
+
logger.exception(
|
|
360
|
+
{"source": f"{source}", "metric_filter": "get_item", "error": str(e)}
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
response = {"exception": str(e)}
|
|
364
|
+
if self.raise_on_error:
|
|
365
|
+
raise e
|
|
366
|
+
|
|
367
|
+
# Apply decimal conversion to the response
|
|
368
|
+
return self._apply_decimal_conversion(response)
|
|
369
|
+
|
|
370
|
+
def update_item(
|
|
371
|
+
self,
|
|
372
|
+
table_name: str,
|
|
373
|
+
key: dict,
|
|
374
|
+
update_expression: str,
|
|
375
|
+
expression_attribute_values: Optional[dict] = None,
|
|
376
|
+
expression_attribute_names: Optional[dict] = None,
|
|
377
|
+
condition_expression: Optional[str] = None,
|
|
378
|
+
return_values: str = "NONE",
|
|
379
|
+
) -> dict:
|
|
380
|
+
"""
|
|
381
|
+
Update an item in DynamoDB with an update expression.
|
|
382
|
+
|
|
383
|
+
Update expressions allow you to modify specific attributes without replacing
|
|
384
|
+
the entire item. Supports SET, ADD, REMOVE, and DELETE operations.
|
|
385
|
+
|
|
386
|
+
Args:
|
|
387
|
+
table_name: The DynamoDB table name
|
|
388
|
+
key: Primary key dict, e.g., {"pk": "user#123", "sk": "user#123"}
|
|
389
|
+
update_expression: Update expression string, e.g., "SET #name = :name, age = age + :inc"
|
|
390
|
+
expression_attribute_values: Value mappings, e.g., {":name": "Alice", ":inc": 1}
|
|
391
|
+
expression_attribute_names: Attribute name mappings for reserved words, e.g., {"#name": "name"}
|
|
392
|
+
condition_expression: Optional condition that must be met, e.g., "attribute_exists(pk)"
|
|
393
|
+
return_values: What to return after update:
|
|
394
|
+
- "NONE" (default): Nothing
|
|
395
|
+
- "ALL_OLD": All attributes before update
|
|
396
|
+
- "UPDATED_OLD": Only updated attributes before update
|
|
397
|
+
- "ALL_NEW": All attributes after update
|
|
398
|
+
- "UPDATED_NEW": Only updated attributes after update
|
|
399
|
+
|
|
400
|
+
Returns:
|
|
401
|
+
dict: DynamoDB response with optional Attributes based on return_values
|
|
402
|
+
|
|
403
|
+
Raises:
|
|
404
|
+
RuntimeError: If condition expression fails
|
|
405
|
+
ClientError: For other DynamoDB errors
|
|
406
|
+
|
|
407
|
+
Examples:
|
|
408
|
+
>>> # Simple SET operation
|
|
409
|
+
>>> db.update_item(
|
|
410
|
+
... table_name="users",
|
|
411
|
+
... key={"pk": "user#123", "sk": "user#123"},
|
|
412
|
+
... update_expression="SET email = :email",
|
|
413
|
+
... expression_attribute_values={":email": "new@example.com"}
|
|
414
|
+
... )
|
|
415
|
+
|
|
416
|
+
>>> # Atomic counter
|
|
417
|
+
>>> db.update_item(
|
|
418
|
+
... table_name="users",
|
|
419
|
+
... key={"pk": "user#123", "sk": "user#123"},
|
|
420
|
+
... update_expression="ADD view_count :inc",
|
|
421
|
+
... expression_attribute_values={":inc": 1}
|
|
422
|
+
... )
|
|
423
|
+
|
|
424
|
+
>>> # Multiple operations with reserved word
|
|
425
|
+
>>> db.update_item(
|
|
426
|
+
... table_name="users",
|
|
427
|
+
... key={"pk": "user#123", "sk": "user#123"},
|
|
428
|
+
... update_expression="SET #status = :status, updated_at = :now REMOVE temp_field",
|
|
429
|
+
... expression_attribute_names={"#status": "status"},
|
|
430
|
+
... expression_attribute_values={":status": "active", ":now": "2024-10-15"}
|
|
431
|
+
... )
|
|
432
|
+
|
|
433
|
+
>>> # Conditional update with return value
|
|
434
|
+
>>> response = db.update_item(
|
|
435
|
+
... table_name="users",
|
|
436
|
+
... key={"pk": "user#123", "sk": "user#123"},
|
|
437
|
+
... update_expression="SET email = :email",
|
|
438
|
+
... expression_attribute_values={":email": "new@example.com"},
|
|
439
|
+
... condition_expression="attribute_exists(pk)",
|
|
440
|
+
... return_values="ALL_NEW"
|
|
441
|
+
... )
|
|
442
|
+
>>> updated_user = response['Attributes']
|
|
443
|
+
"""
|
|
444
|
+
table = self.dynamodb_resource.Table(table_name)
|
|
445
|
+
|
|
446
|
+
# Build update parameters
|
|
447
|
+
params = {
|
|
448
|
+
"Key": key,
|
|
449
|
+
"UpdateExpression": update_expression,
|
|
450
|
+
"ReturnValues": return_values
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if expression_attribute_values:
|
|
454
|
+
params["ExpressionAttributeValues"] = expression_attribute_values
|
|
455
|
+
|
|
456
|
+
if expression_attribute_names:
|
|
457
|
+
params["ExpressionAttributeNames"] = expression_attribute_names
|
|
458
|
+
|
|
459
|
+
if condition_expression:
|
|
460
|
+
params["ConditionExpression"] = condition_expression
|
|
461
|
+
|
|
462
|
+
try:
|
|
463
|
+
response = dict(table.update_item(**params))
|
|
464
|
+
|
|
465
|
+
# Apply decimal conversion if response contains attributes
|
|
466
|
+
return self._apply_decimal_conversion(response)
|
|
467
|
+
|
|
468
|
+
except ClientError as e:
|
|
469
|
+
error_code = e.response["Error"]["Code"]
|
|
470
|
+
|
|
471
|
+
if error_code == "ConditionalCheckFailedException":
|
|
472
|
+
raise RuntimeError(
|
|
473
|
+
f"Conditional check failed for update in {table_name}. "
|
|
474
|
+
f"Condition: {condition_expression}"
|
|
475
|
+
) from e
|
|
476
|
+
|
|
477
|
+
logger.exception(f"Error in update_item: {str(e)}")
|
|
478
|
+
raise
|
|
479
|
+
|
|
480
|
+
def query(
|
|
481
|
+
self,
|
|
482
|
+
key: dict | Key | ConditionBase | ComparisonCondition | DynamoDBIndex,
|
|
483
|
+
table_name: str,
|
|
484
|
+
*,
|
|
485
|
+
index_name: Optional[str] = None,
|
|
486
|
+
ascending: bool = False,
|
|
487
|
+
source: Optional[str] = None,
|
|
488
|
+
strongly_consistent: bool = False,
|
|
489
|
+
projection_expression: Optional[str] = None,
|
|
490
|
+
expression_attribute_names: Optional[dict] = None,
|
|
491
|
+
start_key: Optional[dict] = None,
|
|
492
|
+
limit: Optional[int] = None,
|
|
493
|
+
) -> dict:
|
|
494
|
+
"""
|
|
495
|
+
Run a query and return a list of items
|
|
496
|
+
Args:
|
|
497
|
+
key (Key): _description_
|
|
498
|
+
index_name (str, optional): _description_. Defaults to None.
|
|
499
|
+
ascending (bool, optional): _description_. Defaults to False.
|
|
500
|
+
table_name (str, optional): _description_. Defaults to None.
|
|
501
|
+
source (str, optional): The source of the query. Used for logging. Defaults to None.
|
|
502
|
+
|
|
503
|
+
Returns:
|
|
504
|
+
dict: dynamodb response dictionary
|
|
505
|
+
"""
|
|
506
|
+
|
|
507
|
+
logger.debug({"action": "query", "source": source})
|
|
508
|
+
if not key:
|
|
509
|
+
raise ValueError("Query failed: key must be provided.")
|
|
510
|
+
|
|
511
|
+
if not table_name:
|
|
512
|
+
raise ValueError("Query failed: table_name must be provided.")
|
|
513
|
+
|
|
514
|
+
if isinstance(key, DynamoDBIndex):
|
|
515
|
+
if not index_name:
|
|
516
|
+
index_name = key.name
|
|
517
|
+
# turn it into a key expected by dynamodb
|
|
518
|
+
key = key.key(query_key=True)
|
|
519
|
+
|
|
520
|
+
kwargs: dict = {}
|
|
521
|
+
|
|
522
|
+
if index_name and index_name != "primary":
|
|
523
|
+
# only include the index_name if we are not using our "primary" pk/sk
|
|
524
|
+
kwargs["IndexName"] = f"{index_name}"
|
|
525
|
+
kwargs["TableName"] = f"{table_name}"
|
|
526
|
+
kwargs["KeyConditionExpression"] = key
|
|
527
|
+
kwargs["ScanIndexForward"] = ascending
|
|
528
|
+
kwargs["ConsistentRead"] = strongly_consistent
|
|
529
|
+
|
|
530
|
+
if projection_expression:
|
|
531
|
+
kwargs["ProjectionExpression"] = projection_expression
|
|
532
|
+
|
|
533
|
+
if expression_attribute_names:
|
|
534
|
+
kwargs["ExpressionAttributeNames"] = expression_attribute_names
|
|
535
|
+
|
|
536
|
+
if start_key:
|
|
537
|
+
kwargs["ExclusiveStartKey"] = start_key
|
|
538
|
+
|
|
539
|
+
if limit:
|
|
540
|
+
kwargs["Limit"] = limit
|
|
541
|
+
|
|
542
|
+
if table_name is None:
|
|
543
|
+
raise ValueError("Query failed: table_name must be provided.")
|
|
544
|
+
|
|
545
|
+
table = self.dynamodb_resource.Table(table_name)
|
|
546
|
+
response: dict = {}
|
|
547
|
+
try:
|
|
548
|
+
response = dict(table.query(**kwargs))
|
|
549
|
+
except Exception as e: # pylint: disable=w0718
|
|
550
|
+
logger.exception(
|
|
551
|
+
{"source": f"{source}", "metric_filter": "query", "error": str(e)}
|
|
552
|
+
)
|
|
553
|
+
response = {"exception": str(e)}
|
|
554
|
+
if self.raise_on_error:
|
|
555
|
+
raise e
|
|
556
|
+
|
|
557
|
+
# Apply decimal conversion to the response
|
|
558
|
+
return self._apply_decimal_conversion(response)
|
|
559
|
+
|
|
560
|
+
@overload
|
|
561
|
+
def delete(self, *, table_name: str, model: DynamoDBModelBase) -> dict:
|
|
562
|
+
pass
|
|
563
|
+
|
|
564
|
+
@overload
|
|
565
|
+
def delete(
|
|
566
|
+
self,
|
|
567
|
+
*,
|
|
568
|
+
table_name: str,
|
|
569
|
+
primary_key: dict,
|
|
570
|
+
) -> dict:
|
|
571
|
+
pass
|
|
572
|
+
|
|
573
|
+
def delete(
|
|
574
|
+
self,
|
|
575
|
+
*,
|
|
576
|
+
primary_key: Optional[dict] = None,
|
|
577
|
+
table_name: Optional[str] = None,
|
|
578
|
+
model: Optional[DynamoDBModelBase] = None,
|
|
579
|
+
):
|
|
580
|
+
"""deletes an item from the database"""
|
|
581
|
+
|
|
582
|
+
if model is not None:
|
|
583
|
+
if table_name is None:
|
|
584
|
+
raise ValueError("table_name must be provided when model is used.")
|
|
585
|
+
if primary_key is not None:
|
|
586
|
+
raise ValueError("primary_key cannot be provided when model is used.")
|
|
587
|
+
primary_key = model.indexes.primary.key()
|
|
588
|
+
|
|
589
|
+
response = None
|
|
590
|
+
|
|
591
|
+
if table_name is None or primary_key is None:
|
|
592
|
+
raise ValueError("table_name and primary_key must be provided.")
|
|
593
|
+
|
|
594
|
+
table = self.dynamodb_resource.Table(table_name)
|
|
595
|
+
response = table.delete_item(Key=primary_key)
|
|
596
|
+
|
|
597
|
+
return response
|
|
598
|
+
|
|
599
|
+
def list_tables(self) -> List[str]:
|
|
600
|
+
"""Get a list of tables from the current connection"""
|
|
601
|
+
tables = list(self.dynamodb_resource.tables.all())
|
|
602
|
+
table_list: List[str] = []
|
|
603
|
+
if len(tables) > 0:
|
|
604
|
+
for table in tables:
|
|
605
|
+
table_list.append(table.name)
|
|
606
|
+
|
|
607
|
+
return table_list
|
|
608
|
+
|
|
609
|
+
def query_by_criteria(
|
|
610
|
+
self,
|
|
611
|
+
*,
|
|
612
|
+
model: DynamoDBModelBase,
|
|
613
|
+
table_name: str,
|
|
614
|
+
index_name: str,
|
|
615
|
+
key: dict | Key | ConditionBase | ComparisonCondition,
|
|
616
|
+
start_key: Optional[dict] = None,
|
|
617
|
+
do_projections: bool = False,
|
|
618
|
+
ascending: bool = False,
|
|
619
|
+
strongly_consistent: bool = False,
|
|
620
|
+
limit: Optional[int] = None,
|
|
621
|
+
) -> dict:
|
|
622
|
+
"""Helper function to list by criteria"""
|
|
623
|
+
|
|
624
|
+
projection_expression: str | None = None
|
|
625
|
+
expression_attribute_names: dict | None = None
|
|
626
|
+
|
|
627
|
+
if do_projections:
|
|
628
|
+
projection_expression = model.projection_expression
|
|
629
|
+
expression_attribute_names = model.projection_expression_attribute_names
|
|
630
|
+
|
|
631
|
+
response = self.query(
|
|
632
|
+
key=key,
|
|
633
|
+
index_name=index_name,
|
|
634
|
+
table_name=table_name,
|
|
635
|
+
start_key=start_key,
|
|
636
|
+
projection_expression=projection_expression,
|
|
637
|
+
expression_attribute_names=expression_attribute_names,
|
|
638
|
+
ascending=ascending,
|
|
639
|
+
strongly_consistent=strongly_consistent,
|
|
640
|
+
limit=limit,
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
return response
|
|
644
|
+
|
|
645
|
+
def has_more_records(self, response: dict) -> bool:
|
|
646
|
+
"""
|
|
647
|
+
Check if there are more records to process.
|
|
648
|
+
This based on the existance of the LastEvaluatedKey in the response.
|
|
649
|
+
Parameters:
|
|
650
|
+
response (dict): dynamodb response dictionary
|
|
651
|
+
|
|
652
|
+
Returns:
|
|
653
|
+
bool: True if there are more records, False otherwise
|
|
654
|
+
"""
|
|
655
|
+
|
|
656
|
+
return "LastEvaluatedKey" in response
|
|
657
|
+
|
|
658
|
+
def last_key(self, response: dict) -> dict | None:
|
|
659
|
+
"""
|
|
660
|
+
Get the LastEvaluatedKey, which can be used to continue processing the results
|
|
661
|
+
Parameters:
|
|
662
|
+
response (dict): dynamodb response dictionary
|
|
663
|
+
|
|
664
|
+
Returns:
|
|
665
|
+
dict | None: The last key or None if not found
|
|
666
|
+
"""
|
|
667
|
+
|
|
668
|
+
return response.get("LastEvaluatedKey")
|
|
669
|
+
|
|
670
|
+
def items(self, response: dict) -> list:
|
|
671
|
+
"""
|
|
672
|
+
Get the Items from the dynamodb response
|
|
673
|
+
Parameters:
|
|
674
|
+
response (dict): dynamodb response dictionary
|
|
675
|
+
|
|
676
|
+
Returns:
|
|
677
|
+
list: A list or empty array/list if no items found
|
|
678
|
+
"""
|
|
679
|
+
|
|
680
|
+
return response.get("Items", [])
|
|
681
|
+
|
|
682
|
+
def item(self, response: dict) -> dict:
|
|
683
|
+
"""
|
|
684
|
+
Get the Item from the dynamodb response
|
|
685
|
+
Parameters:
|
|
686
|
+
response (dict): dynamodb response dictionary
|
|
687
|
+
|
|
688
|
+
Returns:
|
|
689
|
+
dict: A dictionary or empty dictionary if no item found
|
|
690
|
+
"""
|
|
691
|
+
|
|
692
|
+
return response.get("Item", {})
|
|
693
|
+
|
|
694
|
+
def batch_get_item(
|
|
695
|
+
self,
|
|
696
|
+
keys: list[dict],
|
|
697
|
+
table_name: str,
|
|
698
|
+
*,
|
|
699
|
+
projection_expression: Optional[str] = None,
|
|
700
|
+
expression_attribute_names: Optional[dict] = None,
|
|
701
|
+
consistent_read: bool = False,
|
|
702
|
+
) -> dict:
|
|
703
|
+
"""
|
|
704
|
+
Retrieve multiple items from DynamoDB in a single request.
|
|
705
|
+
|
|
706
|
+
DynamoDB allows up to 100 items per batch_get_item call. This method
|
|
707
|
+
automatically chunks larger requests and handles unprocessed keys with
|
|
708
|
+
exponential backoff retry logic.
|
|
709
|
+
|
|
710
|
+
Args:
|
|
711
|
+
keys: List of key dictionaries. Each dict must contain the primary key
|
|
712
|
+
(and sort key if applicable) for the items to retrieve.
|
|
713
|
+
Example: [{"pk": "user#1", "sk": "user#1"}, {"pk": "user#2", "sk": "user#2"}]
|
|
714
|
+
table_name: The DynamoDB table name
|
|
715
|
+
projection_expression: Optional comma-separated list of attributes to retrieve
|
|
716
|
+
expression_attribute_names: Optional dict mapping attribute name placeholders to actual names
|
|
717
|
+
consistent_read: If True, uses strongly consistent reads (costs more RCUs)
|
|
718
|
+
|
|
719
|
+
Returns:
|
|
720
|
+
dict: Response containing:
|
|
721
|
+
- 'Items': List of retrieved items (with Decimal conversion applied)
|
|
722
|
+
- 'UnprocessedKeys': Any keys that couldn't be processed after retries
|
|
723
|
+
- 'ConsumedCapacity': Capacity units consumed (if available)
|
|
724
|
+
|
|
725
|
+
Example:
|
|
726
|
+
>>> keys = [
|
|
727
|
+
... {"pk": "user#user-001", "sk": "user#user-001"},
|
|
728
|
+
... {"pk": "user#user-002", "sk": "user#user-002"},
|
|
729
|
+
... {"pk": "user#user-003", "sk": "user#user-003"}
|
|
730
|
+
... ]
|
|
731
|
+
>>> response = db.batch_get_item(keys=keys, table_name="users")
|
|
732
|
+
>>> items = response['Items']
|
|
733
|
+
>>> print(f"Retrieved {len(items)} items")
|
|
734
|
+
|
|
735
|
+
Note:
|
|
736
|
+
- Maximum 100 items per request (automatically chunked)
|
|
737
|
+
- Each item can be up to 400 KB
|
|
738
|
+
- Maximum 16 MB total response size
|
|
739
|
+
- Unprocessed keys are automatically retried with exponential backoff
|
|
740
|
+
"""
|
|
741
|
+
import time
|
|
742
|
+
|
|
743
|
+
all_items = []
|
|
744
|
+
unprocessed_keys = []
|
|
745
|
+
|
|
746
|
+
# DynamoDB limit: 100 items per batch_get_item call
|
|
747
|
+
BATCH_SIZE = 100
|
|
748
|
+
|
|
749
|
+
# Chunk keys into batches of 100
|
|
750
|
+
for i in range(0, len(keys), BATCH_SIZE):
|
|
751
|
+
batch_keys = keys[i:i + BATCH_SIZE]
|
|
752
|
+
|
|
753
|
+
# Build request parameters
|
|
754
|
+
request_items = {
|
|
755
|
+
table_name: {
|
|
756
|
+
'Keys': batch_keys,
|
|
757
|
+
'ConsistentRead': consistent_read
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
# Add projection if provided
|
|
762
|
+
if projection_expression:
|
|
763
|
+
request_items[table_name]['ProjectionExpression'] = projection_expression
|
|
764
|
+
if expression_attribute_names:
|
|
765
|
+
request_items[table_name]['ExpressionAttributeNames'] = expression_attribute_names
|
|
766
|
+
|
|
767
|
+
# Retry logic for unprocessed keys
|
|
768
|
+
max_retries = 5
|
|
769
|
+
retry_count = 0
|
|
770
|
+
backoff_time = 0.1 # Start with 100ms
|
|
771
|
+
|
|
772
|
+
while retry_count <= max_retries:
|
|
773
|
+
try:
|
|
774
|
+
response = self.dynamodb_resource.meta.client.batch_get_item(
|
|
775
|
+
RequestItems=request_items
|
|
776
|
+
)
|
|
777
|
+
|
|
778
|
+
# Collect items from this batch
|
|
779
|
+
if 'Responses' in response and table_name in response['Responses']:
|
|
780
|
+
batch_items = response['Responses'][table_name]
|
|
781
|
+
all_items.extend(batch_items)
|
|
782
|
+
|
|
783
|
+
# Check for unprocessed keys
|
|
784
|
+
if 'UnprocessedKeys' in response and response['UnprocessedKeys']:
|
|
785
|
+
if table_name in response['UnprocessedKeys']:
|
|
786
|
+
unprocessed = response['UnprocessedKeys'][table_name]
|
|
787
|
+
|
|
788
|
+
if retry_count < max_retries:
|
|
789
|
+
# Retry with exponential backoff
|
|
790
|
+
logger.warning(
|
|
791
|
+
f"Batch get has {len(unprocessed['Keys'])} unprocessed keys. "
|
|
792
|
+
f"Retrying in {backoff_time}s (attempt {retry_count + 1}/{max_retries})"
|
|
793
|
+
)
|
|
794
|
+
time.sleep(backoff_time)
|
|
795
|
+
request_items = {table_name: unprocessed}
|
|
796
|
+
backoff_time *= 2 # Exponential backoff
|
|
797
|
+
retry_count += 1
|
|
798
|
+
continue
|
|
799
|
+
else:
|
|
800
|
+
# Max retries reached, collect remaining unprocessed keys
|
|
801
|
+
logger.error(
|
|
802
|
+
f"Max retries reached. {len(unprocessed['Keys'])} keys remain unprocessed"
|
|
803
|
+
)
|
|
804
|
+
unprocessed_keys.extend(unprocessed['Keys'])
|
|
805
|
+
break
|
|
806
|
+
else:
|
|
807
|
+
# No unprocessed keys, we're done with this batch
|
|
808
|
+
break
|
|
809
|
+
|
|
810
|
+
except ClientError as e:
|
|
811
|
+
error_code = e.response['Error']['Code']
|
|
812
|
+
if error_code == 'ProvisionedThroughputExceededException' and retry_count < max_retries:
|
|
813
|
+
logger.warning(
|
|
814
|
+
f"Throughput exceeded. Retrying in {backoff_time}s (attempt {retry_count + 1}/{max_retries})"
|
|
815
|
+
)
|
|
816
|
+
time.sleep(backoff_time)
|
|
817
|
+
backoff_time *= 2
|
|
818
|
+
retry_count += 1
|
|
819
|
+
continue
|
|
820
|
+
else:
|
|
821
|
+
logger.exception(f"Error in batch_get_item: {str(e)}")
|
|
822
|
+
raise
|
|
823
|
+
|
|
824
|
+
# Apply decimal conversion to all items
|
|
825
|
+
result = {
|
|
826
|
+
'Items': all_items,
|
|
827
|
+
'Count': len(all_items),
|
|
828
|
+
'UnprocessedKeys': unprocessed_keys
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
return self._apply_decimal_conversion(result)
|
|
832
|
+
|
|
833
|
+
def batch_write_item(
|
|
834
|
+
self,
|
|
835
|
+
items: list[dict],
|
|
836
|
+
table_name: str,
|
|
837
|
+
*,
|
|
838
|
+
operation: str = "put"
|
|
839
|
+
) -> dict:
|
|
840
|
+
"""
|
|
841
|
+
Write or delete multiple items in a single request.
|
|
842
|
+
|
|
843
|
+
DynamoDB allows up to 25 write operations per batch_write_item call.
|
|
844
|
+
This method automatically chunks larger requests and handles unprocessed
|
|
845
|
+
items with exponential backoff retry logic.
|
|
846
|
+
|
|
847
|
+
Args:
|
|
848
|
+
items: List of items to write or delete
|
|
849
|
+
- For 'put': Full item dictionaries
|
|
850
|
+
- For 'delete': Key-only dictionaries (pk, sk)
|
|
851
|
+
table_name: The DynamoDB table name
|
|
852
|
+
operation: Either 'put' (default) or 'delete'
|
|
853
|
+
|
|
854
|
+
Returns:
|
|
855
|
+
dict: Response containing:
|
|
856
|
+
- 'UnprocessedItems': Items that couldn't be processed after retries
|
|
857
|
+
- 'ProcessedCount': Number of successfully processed items
|
|
858
|
+
- 'UnprocessedCount': Number of unprocessed items
|
|
859
|
+
|
|
860
|
+
Example (Put):
|
|
861
|
+
>>> items = [
|
|
862
|
+
... {"pk": "user#1", "sk": "user#1", "name": "Alice"},
|
|
863
|
+
... {"pk": "user#2", "sk": "user#2", "name": "Bob"},
|
|
864
|
+
... {"pk": "user#3", "sk": "user#3", "name": "Charlie"}
|
|
865
|
+
... ]
|
|
866
|
+
>>> response = db.batch_write_item(items=items, table_name="users")
|
|
867
|
+
>>> print(f"Processed {response['ProcessedCount']} items")
|
|
868
|
+
|
|
869
|
+
Example (Delete):
|
|
870
|
+
>>> keys = [
|
|
871
|
+
... {"pk": "user#1", "sk": "user#1"},
|
|
872
|
+
... {"pk": "user#2", "sk": "user#2"}
|
|
873
|
+
... ]
|
|
874
|
+
>>> response = db.batch_write_item(
|
|
875
|
+
... items=keys,
|
|
876
|
+
... table_name="users",
|
|
877
|
+
... operation="delete"
|
|
878
|
+
... )
|
|
879
|
+
|
|
880
|
+
Note:
|
|
881
|
+
- Maximum 25 operations per request (automatically chunked)
|
|
882
|
+
- Each item can be up to 400 KB
|
|
883
|
+
- Maximum 16 MB total request size
|
|
884
|
+
- No conditional writes in batch operations
|
|
885
|
+
- Unprocessed items are automatically retried with exponential backoff
|
|
886
|
+
"""
|
|
887
|
+
import time
|
|
888
|
+
|
|
889
|
+
if operation not in ['put', 'delete']:
|
|
890
|
+
raise ValueError(f"Invalid operation '{operation}'. Must be 'put' or 'delete'")
|
|
891
|
+
|
|
892
|
+
# DynamoDB limit: 25 operations per batch_write_item call
|
|
893
|
+
BATCH_SIZE = 25
|
|
894
|
+
|
|
895
|
+
total_processed = 0
|
|
896
|
+
all_unprocessed = []
|
|
897
|
+
|
|
898
|
+
# Chunk items into batches of 25
|
|
899
|
+
for i in range(0, len(items), BATCH_SIZE):
|
|
900
|
+
batch_items = items[i:i + BATCH_SIZE]
|
|
901
|
+
|
|
902
|
+
# Build request items
|
|
903
|
+
write_requests = []
|
|
904
|
+
for item in batch_items:
|
|
905
|
+
if operation == 'put':
|
|
906
|
+
write_requests.append({'PutRequest': {'Item': item}})
|
|
907
|
+
else: # delete
|
|
908
|
+
write_requests.append({'DeleteRequest': {'Key': item}})
|
|
909
|
+
|
|
910
|
+
request_items = {table_name: write_requests}
|
|
911
|
+
|
|
912
|
+
# Retry logic for unprocessed items
|
|
913
|
+
max_retries = 5
|
|
914
|
+
retry_count = 0
|
|
915
|
+
backoff_time = 0.1 # Start with 100ms
|
|
916
|
+
|
|
917
|
+
while retry_count <= max_retries:
|
|
918
|
+
try:
|
|
919
|
+
response = self.dynamodb_resource.meta.client.batch_write_item(
|
|
920
|
+
RequestItems=request_items
|
|
921
|
+
)
|
|
922
|
+
|
|
923
|
+
# Count processed items from this batch
|
|
924
|
+
processed_in_batch = len(batch_items)
|
|
925
|
+
|
|
926
|
+
# Check for unprocessed items
|
|
927
|
+
if 'UnprocessedItems' in response and response['UnprocessedItems']:
|
|
928
|
+
if table_name in response['UnprocessedItems']:
|
|
929
|
+
unprocessed = response['UnprocessedItems'][table_name]
|
|
930
|
+
unprocessed_count = len(unprocessed)
|
|
931
|
+
processed_in_batch -= unprocessed_count
|
|
932
|
+
|
|
933
|
+
if retry_count < max_retries:
|
|
934
|
+
# Retry with exponential backoff
|
|
935
|
+
logger.warning(
|
|
936
|
+
f"Batch write has {unprocessed_count} unprocessed items. "
|
|
937
|
+
f"Retrying in {backoff_time}s (attempt {retry_count + 1}/{max_retries})"
|
|
938
|
+
)
|
|
939
|
+
time.sleep(backoff_time)
|
|
940
|
+
request_items = {table_name: unprocessed}
|
|
941
|
+
backoff_time *= 2 # Exponential backoff
|
|
942
|
+
retry_count += 1
|
|
943
|
+
continue
|
|
944
|
+
else:
|
|
945
|
+
# Max retries reached
|
|
946
|
+
logger.error(
|
|
947
|
+
f"Max retries reached. {unprocessed_count} items remain unprocessed"
|
|
948
|
+
)
|
|
949
|
+
all_unprocessed.extend(unprocessed)
|
|
950
|
+
break
|
|
951
|
+
|
|
952
|
+
# Successfully processed this batch
|
|
953
|
+
total_processed += processed_in_batch
|
|
954
|
+
break
|
|
955
|
+
|
|
956
|
+
except ClientError as e:
|
|
957
|
+
error_code = e.response['Error']['Code']
|
|
958
|
+
if error_code == 'ProvisionedThroughputExceededException' and retry_count < max_retries:
|
|
959
|
+
logger.warning(
|
|
960
|
+
f"Throughput exceeded. Retrying in {backoff_time}s (attempt {retry_count + 1}/{max_retries})"
|
|
961
|
+
)
|
|
962
|
+
time.sleep(backoff_time)
|
|
963
|
+
backoff_time *= 2
|
|
964
|
+
retry_count += 1
|
|
965
|
+
continue
|
|
966
|
+
else:
|
|
967
|
+
logger.exception(f"Error in batch_write_item: {str(e)}")
|
|
968
|
+
raise
|
|
969
|
+
|
|
970
|
+
return {
|
|
971
|
+
'ProcessedCount': total_processed,
|
|
972
|
+
'UnprocessedCount': len(all_unprocessed),
|
|
973
|
+
'UnprocessedItems': all_unprocessed
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
def transact_write_items(
|
|
977
|
+
self,
|
|
978
|
+
operations: list[dict],
|
|
979
|
+
*,
|
|
980
|
+
client_request_token: Optional[str] = None,
|
|
981
|
+
return_consumed_capacity: str = "NONE",
|
|
982
|
+
return_item_collection_metrics: str = "NONE"
|
|
983
|
+
) -> dict:
|
|
984
|
+
"""
|
|
985
|
+
Execute multiple write operations as an atomic transaction.
|
|
986
|
+
|
|
987
|
+
All operations succeed or all fail together. This is critical for
|
|
988
|
+
maintaining data consistency across multiple items. Supports up to
|
|
989
|
+
100 operations per transaction (increased from 25 in 2023).
|
|
990
|
+
|
|
991
|
+
Args:
|
|
992
|
+
operations: List of transaction operation dictionaries. Each dict must
|
|
993
|
+
have one of: 'Put', 'Update', 'Delete', or 'ConditionCheck'
|
|
994
|
+
Example:
|
|
995
|
+
[
|
|
996
|
+
{
|
|
997
|
+
'Put': {
|
|
998
|
+
'TableName': 'users',
|
|
999
|
+
'Item': {'pk': 'user#1', 'sk': 'user#1', 'name': 'Alice'}
|
|
1000
|
+
}
|
|
1001
|
+
},
|
|
1002
|
+
{
|
|
1003
|
+
'Update': {
|
|
1004
|
+
'TableName': 'accounts',
|
|
1005
|
+
'Key': {'pk': 'account#1', 'sk': 'account#1'},
|
|
1006
|
+
'UpdateExpression': 'SET balance = balance - :amount',
|
|
1007
|
+
'ExpressionAttributeValues': {':amount': 100}
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
]
|
|
1011
|
+
client_request_token: Optional idempotency token for retry safety
|
|
1012
|
+
return_consumed_capacity: 'INDEXES', 'TOTAL', or 'NONE' (default)
|
|
1013
|
+
return_item_collection_metrics: 'SIZE' or 'NONE' (default)
|
|
1014
|
+
|
|
1015
|
+
Returns:
|
|
1016
|
+
dict: Transaction response containing:
|
|
1017
|
+
- 'ConsumedCapacity': Capacity consumed (if requested)
|
|
1018
|
+
- 'ItemCollectionMetrics': Metrics (if requested)
|
|
1019
|
+
|
|
1020
|
+
Raises:
|
|
1021
|
+
TransactionCanceledException: If transaction fails due to:
|
|
1022
|
+
- Conditional check failure
|
|
1023
|
+
- Item size too large
|
|
1024
|
+
- Throughput exceeded
|
|
1025
|
+
- Duplicate request
|
|
1026
|
+
|
|
1027
|
+
Example:
|
|
1028
|
+
>>> # Transfer money between accounts atomically
|
|
1029
|
+
>>> operations = [
|
|
1030
|
+
... {
|
|
1031
|
+
... 'Update': {
|
|
1032
|
+
... 'TableName': 'accounts',
|
|
1033
|
+
... 'Key': {'pk': 'account#123', 'sk': 'account#123'},
|
|
1034
|
+
... 'UpdateExpression': 'SET balance = balance - :amount',
|
|
1035
|
+
... 'ExpressionAttributeValues': {':amount': 100},
|
|
1036
|
+
... 'ConditionExpression': 'balance >= :amount'
|
|
1037
|
+
... }
|
|
1038
|
+
... },
|
|
1039
|
+
... {
|
|
1040
|
+
... 'Update': {
|
|
1041
|
+
... 'TableName': 'accounts',
|
|
1042
|
+
... 'Key': {'pk': 'account#456', 'sk': 'account#456'},
|
|
1043
|
+
... 'UpdateExpression': 'SET balance = balance + :amount',
|
|
1044
|
+
... 'ExpressionAttributeValues': {':amount': 100}
|
|
1045
|
+
... }
|
|
1046
|
+
... }
|
|
1047
|
+
... ]
|
|
1048
|
+
>>> response = db.transact_write_items(operations=operations)
|
|
1049
|
+
|
|
1050
|
+
Note:
|
|
1051
|
+
- Maximum 100 operations per transaction (AWS limit as of 2023)
|
|
1052
|
+
- Each item can be up to 400 KB
|
|
1053
|
+
- Maximum 4 MB total transaction size
|
|
1054
|
+
- Cannot target same item multiple times in one transaction
|
|
1055
|
+
- All operations must succeed or all fail (atomic)
|
|
1056
|
+
- Uses strongly consistent reads for condition checks
|
|
1057
|
+
"""
|
|
1058
|
+
if not operations:
|
|
1059
|
+
raise ValueError("At least one operation is required")
|
|
1060
|
+
|
|
1061
|
+
if len(operations) > 100:
|
|
1062
|
+
raise ValueError(
|
|
1063
|
+
f"Transaction supports maximum 100 operations, got {len(operations)}. "
|
|
1064
|
+
"Consider splitting into multiple transactions."
|
|
1065
|
+
)
|
|
1066
|
+
|
|
1067
|
+
params = {
|
|
1068
|
+
'TransactItems': operations,
|
|
1069
|
+
'ReturnConsumedCapacity': return_consumed_capacity,
|
|
1070
|
+
'ReturnItemCollectionMetrics': return_item_collection_metrics
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
if client_request_token:
|
|
1074
|
+
params['ClientRequestToken'] = client_request_token
|
|
1075
|
+
|
|
1076
|
+
try:
|
|
1077
|
+
response = self.dynamodb_resource.meta.client.transact_write_items(**params)
|
|
1078
|
+
return response
|
|
1079
|
+
|
|
1080
|
+
except ClientError as e:
|
|
1081
|
+
error_code = e.response['Error']['Code']
|
|
1082
|
+
|
|
1083
|
+
if error_code == 'TransactionCanceledException':
|
|
1084
|
+
# Parse cancellation reasons
|
|
1085
|
+
reasons = e.response.get('CancellationReasons', [])
|
|
1086
|
+
logger.error(f"Transaction cancelled. Reasons: {reasons}")
|
|
1087
|
+
|
|
1088
|
+
# Enhance error message with specific reason
|
|
1089
|
+
if reasons:
|
|
1090
|
+
reason_messages = []
|
|
1091
|
+
for idx, reason in enumerate(reasons):
|
|
1092
|
+
if reason.get('Code'):
|
|
1093
|
+
reason_messages.append(
|
|
1094
|
+
f"Operation {idx}: {reason['Code']} - {reason.get('Message', '')}"
|
|
1095
|
+
)
|
|
1096
|
+
|
|
1097
|
+
raise RuntimeError(
|
|
1098
|
+
f"Transaction failed: {'; '.join(reason_messages)}"
|
|
1099
|
+
) from e
|
|
1100
|
+
|
|
1101
|
+
logger.exception(f"Error in transact_write_items: {str(e)}")
|
|
1102
|
+
raise
|
|
1103
|
+
|
|
1104
|
+
def transact_get_items(
|
|
1105
|
+
self,
|
|
1106
|
+
keys: list[dict],
|
|
1107
|
+
*,
|
|
1108
|
+
return_consumed_capacity: str = "NONE"
|
|
1109
|
+
) -> dict:
|
|
1110
|
+
"""
|
|
1111
|
+
Retrieve multiple items with strong consistency as a transaction.
|
|
1112
|
+
|
|
1113
|
+
Unlike batch_get_item, this provides a consistent snapshot across all items
|
|
1114
|
+
using strongly consistent reads. Maximum 100 items per transaction.
|
|
1115
|
+
|
|
1116
|
+
Args:
|
|
1117
|
+
keys: List of get operation dictionaries. Each dict must specify:
|
|
1118
|
+
- 'Key': The item's primary key
|
|
1119
|
+
- 'TableName': The table name
|
|
1120
|
+
- 'ProjectionExpression': Optional projection
|
|
1121
|
+
- 'ExpressionAttributeNames': Optional attribute names
|
|
1122
|
+
Example:
|
|
1123
|
+
[
|
|
1124
|
+
{
|
|
1125
|
+
'Key': {'pk': 'user#1', 'sk': 'user#1'},
|
|
1126
|
+
'TableName': 'users'
|
|
1127
|
+
},
|
|
1128
|
+
{
|
|
1129
|
+
'Key': {'pk': 'order#123', 'sk': 'order#123'},
|
|
1130
|
+
'TableName': 'orders',
|
|
1131
|
+
'ProjectionExpression': 'id,total,#status',
|
|
1132
|
+
'ExpressionAttributeNames': {'#status': 'status'}
|
|
1133
|
+
}
|
|
1134
|
+
]
|
|
1135
|
+
return_consumed_capacity: 'INDEXES', 'TOTAL', or 'NONE' (default)
|
|
1136
|
+
|
|
1137
|
+
Returns:
|
|
1138
|
+
dict: Response containing:
|
|
1139
|
+
- 'Items': List of retrieved items (with Decimal conversion)
|
|
1140
|
+
- 'ConsumedCapacity': Capacity consumed (if requested)
|
|
1141
|
+
|
|
1142
|
+
Example:
|
|
1143
|
+
>>> keys = [
|
|
1144
|
+
... {
|
|
1145
|
+
... 'Key': {'pk': 'user#123', 'sk': 'user#123'},
|
|
1146
|
+
... 'TableName': 'users'
|
|
1147
|
+
... },
|
|
1148
|
+
... {
|
|
1149
|
+
... 'Key': {'pk': 'account#123', 'sk': 'account#123'},
|
|
1150
|
+
... 'TableName': 'accounts'
|
|
1151
|
+
... }
|
|
1152
|
+
... ]
|
|
1153
|
+
>>> response = db.transact_get_items(keys=keys)
|
|
1154
|
+
>>> items = response['Items']
|
|
1155
|
+
|
|
1156
|
+
Note:
|
|
1157
|
+
- Maximum 100 items per transaction
|
|
1158
|
+
- Always uses strongly consistent reads
|
|
1159
|
+
- More expensive than batch_get_item (2x RCUs)
|
|
1160
|
+
- Provides snapshot isolation across items
|
|
1161
|
+
- Cannot be combined with transact_write_items
|
|
1162
|
+
"""
|
|
1163
|
+
if not keys:
|
|
1164
|
+
raise ValueError("At least one key is required")
|
|
1165
|
+
|
|
1166
|
+
if len(keys) > 100:
|
|
1167
|
+
raise ValueError(
|
|
1168
|
+
f"Transaction supports maximum 100 items, got {len(keys)}. "
|
|
1169
|
+
"Use batch_get_item for larger requests."
|
|
1170
|
+
)
|
|
1171
|
+
|
|
1172
|
+
# Build transaction get items
|
|
1173
|
+
transact_items = []
|
|
1174
|
+
for key_spec in keys:
|
|
1175
|
+
get_item = {'Get': key_spec}
|
|
1176
|
+
transact_items.append(get_item)
|
|
1177
|
+
|
|
1178
|
+
params = {
|
|
1179
|
+
'TransactItems': transact_items,
|
|
1180
|
+
'ReturnConsumedCapacity': return_consumed_capacity
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
try:
|
|
1184
|
+
response = self.dynamodb_resource.meta.client.transact_get_items(**params)
|
|
1185
|
+
|
|
1186
|
+
# Extract items from response
|
|
1187
|
+
items = []
|
|
1188
|
+
if 'Responses' in response:
|
|
1189
|
+
for item_response in response['Responses']:
|
|
1190
|
+
if 'Item' in item_response:
|
|
1191
|
+
items.append(item_response['Item'])
|
|
1192
|
+
|
|
1193
|
+
result = {
|
|
1194
|
+
'Items': items,
|
|
1195
|
+
'Count': len(items)
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
if 'ConsumedCapacity' in response:
|
|
1199
|
+
result['ConsumedCapacity'] = response['ConsumedCapacity']
|
|
1200
|
+
|
|
1201
|
+
# Apply decimal conversion
|
|
1202
|
+
return self._apply_decimal_conversion(result)
|
|
1203
|
+
|
|
1204
|
+
except ClientError as e:
|
|
1205
|
+
logger.exception(f"Error in transact_get_items: {str(e)}")
|
|
1206
|
+
raise
|