Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Idempotency helper utility #245

Merged
merged 47 commits into from
Feb 19, 2021
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
d503fb0
feat: initial commit for idempotency utility
Dec 16, 2020
e564c63
fix: ensure region is configured in botocore for tests
Dec 16, 2020
c4d19ba
chore: ignore security warning for md5 usage
Dec 16, 2020
45d384b
chore: add debug logging
Jan 4, 2021
13e5b09
feat: add local caching for idempotency lookups
Jan 11, 2021
1efc27d
feat: replace simple dict cache with LRU
Jan 14, 2021
546e879
feat: remove idempotent exception handling
Jan 14, 2021
60fd336
feat: remove unused logic to create ddb table - will handle in docume…
Jan 14, 2021
ee46124
fix: remove redundant code from table creation logic
Jan 15, 2021
acab091
chore: move tests to own dir
Jan 18, 2021
2a364fd
chore: remove redundant code for exception handling
Jan 18, 2021
4f5d52b
feat: add payload validation logic and functionality to use different…
Jan 18, 2021
a19d955
feat: optimization to reduce number of database calls, reorganize per…
Jan 19, 2021
0ef52f9
chore: type corrections
Jan 20, 2021
d128b0a
chore: add more logging statements
Jan 21, 2021
4caa52c
fix: Use variable for ddb attribute name
Jan 26, 2021
d89fcee
chore: clarify docstring for abstract method
Jan 26, 2021
ed9e0c2
feat: Refactor to cover corner cases where state changes between call…
Jan 26, 2021
7000927
chore: correct stubbed ddb responses for test case
Jan 26, 2021
c4856fd
docs: add first of a few seq diagrams to support documentation
Jan 26, 2021
aed4a7b
feat: use boto3 session for constructing clients to allow customizati…
Jan 28, 2021
3b6c2e3
Merge branch 'develop' into feat/idempotency_helper
Jan 28, 2021
2047d34
chore: move cache dict implementation to shared dir
Jan 28, 2021
8a054cb
chore: refactor with improvements for readability, variable names, an…
Jan 28, 2021
523535f
chore: remove dead code, rename variable for clarity, change args to …
Feb 9, 2021
834db1c
chore: improve test coverage, refactor fixtures
Feb 9, 2021
24f6187
Merge branch 'develop' into feat/idempotency_helper
Feb 9, 2021
41d559e
chore: skip tests using pytest-mock's spy for python < 3.8 due to iss…
Feb 9, 2021
dca02ee
Merge branch 'develop' into feat/idempotency_helper
Feb 12, 2021
b4490b9
chore: update test fixtures to use jmespath
Feb 12, 2021
43b72e7
docs: first draft of docs for idempotency util
Feb 13, 2021
88e983e
fix: Allow event_key_jmespath to be left empty to use entire event as…
Feb 14, 2021
d17275d
docs: add section for compatibility with other utils
Feb 14, 2021
83d78ce
chore: improvements to func tests
Feb 15, 2021
4bdfdf6
chore: add unit tests for lru cache
Feb 15, 2021
9de6e29
feat: add support for decimals in json serializer
Feb 19, 2021
a4cc61a
chore: Add docstring for LRU cache dict
Feb 19, 2021
978a6bb
chore: Remove unused status constants
Feb 19, 2021
1fd8b5a
chore: Rename method for clarity
Feb 19, 2021
022739e
chore: Correct example in docstring
Feb 19, 2021
8a2d4fe
fix: make data attribute of data record optional in get_record so we …
Feb 19, 2021
e6f2d98
docs: clarify behaviour for concurrent executions and DDB behaviour f…
Feb 19, 2021
4aa8145
Update aws_lambda_powertools/shared/cache_dict.py
Feb 19, 2021
c54952c
Update aws_lambda_powertools/shared/cache_dict.py
Feb 19, 2021
7832e56
Update aws_lambda_powertools/utilities/idempotency/persistence/base.py
Feb 19, 2021
fed58fe
chore: add test for invalid status on data record
Feb 19, 2021
b079387
Update aws_lambda_powertools/utilities/idempotency/persistence/base.py
Feb 19, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
8 changes: 8 additions & 0 deletions aws_lambda_powertools/utilities/idempotency/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
"""
Utility for adding idempotency to lambda functions
"""

from .idempotency import idempotent
from .persistence import BasePersistenceLayer, DynamoDBPersistenceLayer

__all__ = ("DynamoDBPersistenceLayer", "BasePersistenceLayer", "idempotent")
26 changes: 26 additions & 0 deletions aws_lambda_powertools/utilities/idempotency/cache_dict.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from collections import OrderedDict


class LRUDict(OrderedDict):
def __init__(self, max_size=1024, *args, **kwds):
self.max_size = max_size
super().__init__(*args, **kwds)

def __getitem__(self, key):
value = super().__getitem__(key)
self.move_to_end(key)
return value

def __setitem__(self, key, value):
if key in self:
self.move_to_end(key)
super().__setitem__(key, value)
if len(self) > self.max_size:
oldest = next(iter(self))
del self[oldest]

def get(self, key, *args, **kwargs):
item = super(LRUDict, self).get(key, *args, **kwargs)
if item:
self.move_to_end(key=key)
return item
33 changes: 33 additions & 0 deletions aws_lambda_powertools/utilities/idempotency/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""
Idempotency errors
"""


class ItemAlreadyExistsError(Exception):
"""
Item attempting to be inserted into persistence store already exists
"""


class ItemNotFoundError(Exception):
"""
Item does not exist in persistence store
"""


class AlreadyInProgressError(Exception):
"""
Execution with idempotency key is already in progress
"""


class InvalidStatusError(Exception):
"""
An invalid status was provided
"""


class IdempotencyValidationerror(Exception):
"""
Payload does not match stored idempotency record
"""
96 changes: 96 additions & 0 deletions aws_lambda_powertools/utilities/idempotency/idempotency.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
"""
Primary interface for idempotent Lambda functions utility
"""
import logging
from typing import Any, Callable, Dict

from aws_lambda_powertools.middleware_factory import lambda_handler_decorator

from ..typing import LambdaContext
from .exceptions import AlreadyInProgressError, ItemNotFoundError
from .persistence import STATUS_CONSTANTS, BasePersistenceLayer

logger = logging.getLogger(__name__)


def default_error_callback():
raise


@lambda_handler_decorator
def idempotent(
handler: Callable[[Any, LambdaContext], Any],
event: Dict[str, Any],
context: LambdaContext,
persistence: BasePersistenceLayer,
) -> Any:
"""
Middleware to handle idempotency

Parameters
----------
handler: Callable
Lambda's handler
event: Dict
Lambda's Event
context: Dict
Lambda's Context
persistence: BasePersistenceLayer
Instance of BasePersistenceLayer to store data

Examples
--------
**Processes Lambda's event in an idempotent manner**
>>> from aws_lambda_powertools.utilities.idempotency import idempotent, DynamoDBPersistenceLayer
>>>
>>> persistence_store = DynamoDBPersistenceLayer(event_key="body", table_name="idempotency_store")
to-mc marked this conversation as resolved.
Show resolved Hide resolved
>>>
>>> @idempotent(persistence=persistence_store)
>>> def handler(event, context):
>>> return {"StatusCode": 200}
"""

persistence_instance = persistence
try:
to-mc marked this conversation as resolved.
Show resolved Hide resolved
event_record = persistence_instance.get_record(event)
to-mc marked this conversation as resolved.
Show resolved Hide resolved
except ItemNotFoundError:
persistence_instance.save_inprogress(event=event)
to-mc marked this conversation as resolved.
Show resolved Hide resolved
return _call_lambda(handler=handler, persistence_instance=persistence_instance, event=event, context=context)

if event_record.status == STATUS_CONSTANTS["EXPIRED"]:
return _call_lambda(handler=handler, persistence_instance=persistence_instance, event=event, context=context)

if event_record.status == STATUS_CONSTANTS["INPROGRESS"]:
raise AlreadyInProgressError(
f"Execution already in progress with idempotency key: "
f"{persistence_instance.event_key}={event_record.idempotency_key}"
)

if event_record.status == STATUS_CONSTANTS["COMPLETED"]:
return event_record.response_json_as_dict()


def _call_lambda(
handler: Callable, persistence_instance: BasePersistenceLayer, event: Dict[str, Any], context: LambdaContext
) -> Any:
"""

Parameters
----------
handler: Callable
Lambda handler
persistence_instance: BasePersistenceLayer
Instance of persistence layer
event
Lambda event
context
Lambda context
"""
try:
handler_response = handler(event, context)
to-mc marked this conversation as resolved.
Show resolved Hide resolved
except Exception as ex:
persistence_instance.save_error(event=event, exception=ex)
raise
else:
persistence_instance.save_success(event=event, result=handler_response)
return handler_response