Skip to content

Commit a19d955

Browse files
author
Tom McCarthy
committed
feat: optimization to reduce number of database calls, reorganize persistence layer modules
1 parent 4f5d52b commit a19d955

File tree

7 files changed

+223
-207
lines changed

7 files changed

+223
-207
lines changed

aws_lambda_powertools/utilities/idempotency/__init__.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
Utility for adding idempotency to lambda functions
33
"""
44

5+
from aws_lambda_powertools.utilities.idempotency.persistence.base import BasePersistenceLayer
6+
from aws_lambda_powertools.utilities.idempotency.persistence.dynamodb import DynamoDBPersistenceLayer
7+
58
from .idempotency import idempotent
6-
from .persistence import BasePersistenceLayer, DynamoDBPersistenceLayer
79

810
__all__ = ("DynamoDBPersistenceLayer", "BasePersistenceLayer", "idempotent")

aws_lambda_powertools/utilities/idempotency/idempotency.py

+28-23
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@
55
from typing import Any, Callable, Dict
66

77
from aws_lambda_powertools.middleware_factory import lambda_handler_decorator
8+
from aws_lambda_powertools.utilities.idempotency.persistence.base import STATUS_CONSTANTS, BasePersistenceLayer
89

910
from ..typing import LambdaContext
10-
from .exceptions import AlreadyInProgressError, ItemNotFoundError
11-
from .persistence import STATUS_CONSTANTS, BasePersistenceLayer
11+
from .exceptions import AlreadyInProgressError, ItemAlreadyExistsError, ItemNotFoundError
1212

1313
logger = logging.getLogger(__name__)
1414

@@ -22,7 +22,7 @@ def idempotent(
2222
handler: Callable[[Any, LambdaContext], Any],
2323
event: Dict[str, Any],
2424
context: LambdaContext,
25-
persistence: BasePersistenceLayer,
25+
persistence_store: BasePersistenceLayer,
2626
) -> Any:
2727
"""
2828
Middleware to handle idempotency
@@ -35,7 +35,7 @@ def idempotent(
3535
Lambda's Event
3636
context: Dict
3737
Lambda's Context
38-
persistence: BasePersistenceLayer
38+
persistence_store: BasePersistenceLayer
3939
Instance of BasePersistenceLayer to store data
4040
4141
Examples
@@ -45,41 +45,46 @@ def idempotent(
4545
>>>
4646
>>> persistence_store = DynamoDBPersistenceLayer(event_key="body", table_name="idempotency_store")
4747
>>>
48-
>>> @idempotent(persistence=persistence_store)
48+
>>> @idempotent(persistence_store=persistence_store)
4949
>>> def handler(event, context):
5050
>>> return {"StatusCode": 200}
5151
"""
5252

53-
persistence_instance = persistence
5453
try:
55-
event_record = persistence_instance.get_record(event)
56-
except ItemNotFoundError:
57-
persistence_instance.save_inprogress(event=event)
58-
return _call_lambda(handler=handler, persistence_instance=persistence_instance, event=event, context=context)
54+
# We call save_inprogress first as an optimization for the most common case where no idempotent record already
55+
# exists. If it succeeds, there's no need to call get_record.
56+
persistence_store.save_inprogress(event=event)
57+
except ItemAlreadyExistsError:
58+
try:
59+
event_record = persistence_store.get_record(event)
60+
except ItemNotFoundError:
61+
return _call_lambda(handler=handler, persistence_store=persistence_store, event=event, context=context)
5962

60-
if event_record.status == STATUS_CONSTANTS["EXPIRED"]:
61-
return _call_lambda(handler=handler, persistence_instance=persistence_instance, event=event, context=context)
63+
if event_record.status == STATUS_CONSTANTS["EXPIRED"]:
64+
return _call_lambda(handler=handler, persistence_store=persistence_store, event=event, context=context)
6265

63-
if event_record.status == STATUS_CONSTANTS["INPROGRESS"]:
64-
raise AlreadyInProgressError(
65-
f"Execution already in progress with idempotency key: "
66-
f"{persistence_instance.event_key}={event_record.idempotency_key}"
67-
)
66+
if event_record.status == STATUS_CONSTANTS["INPROGRESS"]:
67+
raise AlreadyInProgressError(
68+
f"Execution already in progress with idempotency key: "
69+
f"{persistence_store.event_key}={event_record.idempotency_key}"
70+
)
6871

69-
if event_record.status == STATUS_CONSTANTS["COMPLETED"]:
70-
return event_record.response_json_as_dict()
72+
if event_record.status == STATUS_CONSTANTS["COMPLETED"]:
73+
return event_record.response_json_as_dict()
74+
75+
return _call_lambda(handler=handler, persistence_store=persistence_store, event=event, context=context)
7176

7277

7378
def _call_lambda(
74-
handler: Callable, persistence_instance: BasePersistenceLayer, event: Dict[str, Any], context: LambdaContext
79+
handler: Callable, persistence_store: BasePersistenceLayer, event: Dict[str, Any], context: LambdaContext
7580
) -> Any:
7681
"""
7782
7883
Parameters
7984
----------
8085
handler: Callable
8186
Lambda handler
82-
persistence_instance: BasePersistenceLayer
87+
persistence_store: BasePersistenceLayer
8388
Instance of persistence layer
8489
event
8590
Lambda event
@@ -89,8 +94,8 @@ def _call_lambda(
8994
try:
9095
handler_response = handler(event, context)
9196
except Exception as ex:
92-
persistence_instance.save_error(event=event, exception=ex)
97+
persistence_store.save_error(event=event, exception=ex)
9398
raise
9499
else:
95-
persistence_instance.save_success(event=event, result=handler_response)
100+
persistence_store.save_success(event=event, result=handler_response)
96101
return handler_response

aws_lambda_powertools/utilities/idempotency/persistence/__init__.py

Whitespace-only changes.

aws_lambda_powertools/utilities/idempotency/persistence.py renamed to aws_lambda_powertools/utilities/idempotency/persistence/base.py

+19-147
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,16 @@
77
import json
88
import logging
99
from abc import ABC, abstractmethod
10-
from typing import Any, Dict, Optional, Union
10+
from typing import Any, Dict
1111

12-
import boto3
1312
import jmespath
14-
from botocore.config import Config
1513

16-
from .cache_dict import LRUDict
17-
from .exceptions import IdempotencyValidationerror, InvalidStatusError, ItemAlreadyExistsError, ItemNotFoundError
14+
from aws_lambda_powertools.utilities.idempotency.cache_dict import LRUDict
15+
from aws_lambda_powertools.utilities.idempotency.exceptions import (
16+
IdempotencyValidationerror,
17+
InvalidStatusError,
18+
ItemAlreadyExistsError,
19+
)
1820

1921
logger = logging.getLogger(__name__)
2022

@@ -297,8 +299,16 @@ def save_inprogress(self, event: Dict[str, Any]) -> None:
297299
)
298300

299301
logger.debug(f"Saving in progress record for idempotency key: {data_record.idempotency_key}")
302+
303+
if self.use_local_cache:
304+
record = self._retrieve_from_cache(idempotency_key=data_record.idempotency_key)
305+
if record:
306+
raise ItemAlreadyExistsError
307+
300308
self._put_record(data_record)
301309

310+
# This has to come after _put_record. If _put_record call raises ItemAlreadyExists we shouldn't populate the
311+
# cache with an "INPROGRESS" record as we don't know the status in the data store at this point.
302312
if self.use_local_cache:
303313
self._save_to_cache(data_record)
304314

@@ -354,6 +364,10 @@ def get_record(self, lambda_event) -> DataRecord:
354364
return cached_record
355365

356366
record = self._get_record(idempotency_key)
367+
368+
if self.use_local_cache:
369+
self._save_to_cache(data_record=record)
370+
357371
self._validate_payload(lambda_event, record)
358372
return record
359373

@@ -416,145 +430,3 @@ def _delete_record(self, data_record: DataRecord) -> None:
416430
"""
417431

418432
raise NotImplementedError
419-
420-
421-
class DynamoDBPersistenceLayer(BasePersistenceLayer):
422-
def __init__(
423-
self,
424-
table_name: str, # Can we use the lambda function name?
425-
key_attr: Optional[str] = "id",
426-
expiry_attr: Optional[str] = "expiration",
427-
status_attr: Optional[str] = "status",
428-
data_attr: Optional[str] = "data",
429-
validation_key_attr: Optional[str] = "validation",
430-
boto_config: Optional[Config] = None,
431-
*args,
432-
**kwargs,
433-
):
434-
"""
435-
Initialize the DynamoDB client
436-
437-
Parameters
438-
----------
439-
table_name: str
440-
Name of the table to use for storing execution records
441-
key_attr: str, optional
442-
DynamoDB attribute name for key, by default "id"
443-
expiry_attr: str, optional
444-
DynamoDB attribute name for expiry timestamp, by default "expiration"
445-
status_attr: str, optional
446-
DynamoDB attribute name for status, by default "status"
447-
data_attr: str, optional
448-
DynamoDB attribute name for response data, by default "data"
449-
boto_config: botocore.config.Config, optional
450-
Botocore configuration to pass during client initialization
451-
args
452-
kwargs
453-
454-
Examples
455-
--------
456-
**Create a DynamoDB persistence layer with custom settings**
457-
>>> from aws_lambda_powertools.utilities.idempotency import idempotent, DynamoDBPersistenceLayer
458-
>>>
459-
>>> persistence_store = DynamoDBPersistenceLayer(event_key="body", table_name="idempotency_store")
460-
>>>
461-
>>> @idempotent(persistence=persistence_store)
462-
>>> def handler(event, context):
463-
>>> return {"StatusCode": 200}
464-
"""
465-
466-
boto_config = boto_config or Config()
467-
self._ddb_resource = boto3.resource("dynamodb", config=boto_config)
468-
self.table_name = table_name
469-
self.table = self._ddb_resource.Table(self.table_name)
470-
self.key_attr = key_attr
471-
self.expiry_attr = expiry_attr
472-
self.status_attr = status_attr
473-
self.data_attr = data_attr
474-
self.validation_key_attr = validation_key_attr
475-
super(DynamoDBPersistenceLayer, self).__init__(*args, **kwargs)
476-
477-
def _item_to_data_record(self, item: Dict[str, Union[str, int]]) -> DataRecord:
478-
"""
479-
Translate raw item records from DynamoDB to DataRecord
480-
481-
Parameters
482-
----------
483-
item: Dict[str, Union[str, int]]
484-
Item format from dynamodb response
485-
486-
Returns
487-
-------
488-
DataRecord
489-
representation of item
490-
491-
"""
492-
return DataRecord(
493-
idempotency_key=item[self.key_attr],
494-
status=item[self.status_attr],
495-
expiry_timestamp=item[self.expiry_attr],
496-
response_data=item.get(self.data_attr),
497-
payload_hash=item.get(self.validation_key_attr),
498-
)
499-
500-
def _get_record(self, idempotency_key) -> DataRecord:
501-
response = self.table.get_item(Key={self.key_attr: idempotency_key}, ConsistentRead=True)
502-
503-
try:
504-
item = response["Item"]
505-
except KeyError:
506-
raise ItemNotFoundError
507-
return self._item_to_data_record(item)
508-
509-
def _put_record(self, data_record: DataRecord) -> None:
510-
511-
item = {
512-
self.key_attr: data_record.idempotency_key,
513-
self.expiry_attr: data_record.expiry_timestamp,
514-
self.status_attr: STATUS_CONSTANTS["INPROGRESS"],
515-
}
516-
517-
if self.payload_validation_enabled:
518-
item[self.validation_key_attr] = data_record.payload_hash
519-
520-
now = datetime.datetime.now()
521-
try:
522-
logger.debug(f"Putting record for idempotency key: {data_record.idempotency_key}")
523-
self.table.put_item(
524-
Item=item,
525-
ConditionExpression=f"attribute_not_exists({self.key_attr}) OR expiration < :now",
526-
ExpressionAttributeValues={":now": int(now.timestamp())},
527-
)
528-
except self._ddb_resource.meta.client.exceptions.ConditionalCheckFailedException:
529-
logger.debug(f"Failed to put record for already existing idempotency key: {data_record.idempotency_key}")
530-
raise ItemAlreadyExistsError
531-
532-
def _update_record(self, data_record: DataRecord):
533-
logger.debug(f"Updating record for idempotency key: {data_record.idempotency_key}")
534-
update_expression = "SET #response_data = :response_data, #expiry = :expiry, #status = :status"
535-
expression_attr_values = {
536-
":expiry": data_record.expiry_timestamp,
537-
":response_data": data_record.response_data,
538-
":status": data_record.status,
539-
}
540-
expression_attr_names = {
541-
"#response_data": self.data_attr,
542-
"#expiry": self.expiry_attr,
543-
"#status": self.status_attr,
544-
}
545-
546-
if self.payload_validation_enabled:
547-
update_expression += ", #validation_key = :validation_key"
548-
expression_attr_values[":validation_key"] = data_record.payload_hash
549-
expression_attr_names["#validation_key"] = self.validation_key_attr
550-
551-
self.table.update_item(
552-
Key={self.key_attr: data_record.idempotency_key},
553-
UpdateExpression=update_expression,
554-
ExpressionAttributeValues=expression_attr_values,
555-
ExpressionAttributeNames=expression_attr_names,
556-
)
557-
558-
def _delete_record(self, data_record: DataRecord) -> None:
559-
logger.debug(f"Deleting record for idempotency key: {data_record.idempotency_key}")
560-
self.table.delete_item(Key={self.key_attr: data_record.idempotency_key},)

0 commit comments

Comments
 (0)