Skip to content
Merged
2 changes: 1 addition & 1 deletion aws_lambda_powertools/utilities/idempotency/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ def __init__(
self.fn_kwargs = function_kwargs
self.config = config

persistence_store.configure(config, self.function.__name__)
persistence_store.configure(config, f"{self.function.__module__}.{self.function.__qualname__}")
self.persistence_store = persistence_store

def handle(self) -> Any:
Expand Down
2 changes: 1 addition & 1 deletion docs/utilities/idempotency.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ If you're not [changing the default configuration for the DynamoDB persistence l
| TTL attribute name | `expiration` | This can only be configured after your table is created if you're using AWS Console |

???+ tip "Tip: You can share a single state table for all functions"
You can reuse the same DynamoDB table to store idempotency state. We add your `function_name` in addition to the idempotency key as a hash key.
You can reuse the same DynamoDB table to store idempotency state. We add `module_name` and [qualified name for classes and functions](https://peps.python.org/pep-3155/) in addition to the idempotency key as a hash key.

```yaml hl_lines="5-13 21-23" title="AWS Serverless Application Model (SAM) example"
Resources:
Expand Down
Empty file.
19 changes: 19 additions & 0 deletions tests/e2e/idempotency/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import pytest

from tests.e2e.idempotency.infrastructure import IdempotencyDynamoDBStack


@pytest.fixture(autouse=True, scope="module")
def infrastructure(tmp_path_factory, worker_id):
"""Setup and teardown logic for E2E test infrastructure

Yields
------
Dict[str, str]
CloudFormation Outputs from deployed infrastructure
"""
stack = IdempotencyDynamoDBStack()
try:
yield stack.deploy()
finally:
stack.delete()
11 changes: 11 additions & 0 deletions tests/e2e/idempotency/handlers/basic_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from aws_lambda_powertools.utilities.idempotency import DynamoDBPersistenceLayer, idempotent

persistence_layer = DynamoDBPersistenceLayer(table_name="IdempotencyTable")


@idempotent(persistence_store=persistence_layer)
def lambda_handler(event, context):
return {
"message": "success",
"statusCode": 200,
}
23 changes: 23 additions & 0 deletions tests/e2e/idempotency/infrastructure.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from aws_cdk import CfnOutput, RemovalPolicy
from aws_cdk import aws_dynamodb as dynamodb

from tests.e2e.utils.infrastructure import BaseInfrastructure


class IdempotencyDynamoDBStack(BaseInfrastructure):
def create_resources(self):
self.create_lambda_functions()
self._create_dynamodb_table()

def _create_dynamodb_table(self):
table = dynamodb.Table(
self.stack,
"Idempotency",
table_name="IdempotencyTable",
removal_policy=RemovalPolicy.DESTROY,
partition_key=dynamodb.Attribute(name="id", type=dynamodb.AttributeType.STRING),
time_to_live_attribute="expiration",
billing_mode=dynamodb.BillingMode.PAY_PER_REQUEST,
)

CfnOutput(self.stack, "DynamoDBTable", value=table.table_name)
30 changes: 30 additions & 0 deletions tests/e2e/idempotency/test_idempotency_dynamodb.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import json

import pytest

from tests.e2e.utils import data_fetcher


@pytest.fixture
def basic_handler_fn(infrastructure: dict) -> str:
return infrastructure.get("BasicHandler", "")


@pytest.fixture
def basic_handler_fn_arn(infrastructure: dict) -> str:
return infrastructure.get("BasicHandlerArn", "")


def test_basic_idempotency_record(basic_handler_fn_arn: str, basic_handler_fn: str):
# GIVEN
function_name = "basic_handler.lambda_handler"
table_name = "IdempotencyTable"
payload = json.dumps({"message": "Lambda Powertools"})

# WHEN
data_fetcher.get_lambda_response(lambda_arn=basic_handler_fn_arn, payload=payload)

# THEN
ddb_records = data_fetcher.get_ddb_idempotency_record(function_name=function_name, table_name=table_name)

assert (ddb_records.get_records()) == 1
1 change: 1 addition & 0 deletions tests/e2e/utils/data_fetcher/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from tests.e2e.utils.data_fetcher.common import get_http_response, get_lambda_response
from tests.e2e.utils.data_fetcher.idempotency import get_ddb_idempotency_record
from tests.e2e.utils.data_fetcher.logs import get_logs
from tests.e2e.utils.data_fetcher.metrics import get_metrics
from tests.e2e.utils.data_fetcher.traces import get_traces
57 changes: 57 additions & 0 deletions tests/e2e/utils/data_fetcher/idempotency.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import boto3
from retry import retry


class DynamoDB:
def __init__(
self,
function_name: str,
table_name: str,
):
"""Fetch and expose Powertools Idempotency key from DynamoDB

Parameters
----------
function_name : str
Name of Lambda function to fetch dynamodb record
table_name : str
Name of DynamoDB table
"""
self.function_name = function_name
self.table_name = table_name
self.ddb_client = boto3.resource("dynamodb")

def get_records(self) -> int:

table = self.ddb_client.Table(self.table_name)
ret = table.scan(
FilterExpression="contains (id, :functionName)",
ExpressionAttributeValues={":functionName": f"{self.function_name}#"},
)

if not ret["Items"]:
raise ValueError("Empty response from DynamoDB Repeating...")

return ret["Count"]


@retry(ValueError, delay=2, jitter=1.5, tries=10)
def get_ddb_idempotency_record(
function_name: str,
table_name: str,
) -> DynamoDB:
"""_summary_

Parameters
----------
function_name : str
Name of Lambda function to fetch dynamodb record
table_name : str
Name of DynamoDB table

Returns
-------
DynamoDB
DynamoDB instance with dynamodb record
"""
return DynamoDB(function_name=function_name, table_name=table_name)
14 changes: 10 additions & 4 deletions tests/functional/idempotency/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,18 +172,24 @@ def expected_params_put_item_with_validation(hashed_idempotency_key, hashed_vali


@pytest.fixture
def hashed_idempotency_key(lambda_apigw_event, default_jmespath, lambda_context):
def hashed_idempotency_key(request, lambda_apigw_event, default_jmespath, lambda_context):
compiled_jmespath = jmespath.compile(default_jmespath)
data = compiled_jmespath.search(lambda_apigw_event)
return "test-func.lambda_handler#" + hash_idempotency_key(data)
return (
f"test-func.{request.function.__module__}.{request.function.__qualname__}.<locals>.lambda_handler#"
+ hash_idempotency_key(data)
)


@pytest.fixture
def hashed_idempotency_key_with_envelope(lambda_apigw_event):
def hashed_idempotency_key_with_envelope(request, lambda_apigw_event):
event = extract_data_from_envelope(
data=lambda_apigw_event, envelope=envelopes.API_GATEWAY_HTTP, jmespath_options={}
)
return "test-func.lambda_handler#" + hash_idempotency_key(event)
return (
f"test-func.{request.function.__module__}.{request.function.__qualname__}.<locals>.lambda_handler#"
+ hash_idempotency_key(event)
)


@pytest.fixture
Expand Down
28 changes: 18 additions & 10 deletions tests/functional/idempotency/test_idempotency.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from tests.functional.utils import json_serialize, load_event

TABLE_NAME = "TEST_TABLE"
TESTS_MODULE_PREFIX = "test-func.functional.idempotency.test_idempotency"


def get_dataclasses_lib():
Expand Down Expand Up @@ -770,7 +771,7 @@ def lambda_handler(event, context):

def test_idempotent_lambda_expires_in_progress_unavailable_remaining_time():
mock_event = {"data": "value"}
idempotency_key = "test-func.function#" + hash_idempotency_key(mock_event)
idempotency_key = f"{TESTS_MODULE_PREFIX}.test_idempotent_lambda_expires_in_progress_unavailable_remaining_time.<locals>.function#{hash_idempotency_key(mock_event)}" # noqa E501
persistence_layer = MockPersistenceLayer(expected_idempotency_key=idempotency_key)
expected_result = {"message": "Foo"}

Expand Down Expand Up @@ -1109,7 +1110,8 @@ def _delete_record(self, data_record: DataRecord) -> None:
def test_idempotent_lambda_event_source(lambda_context):
# Scenario to validate that we can use the event_source decorator before or after the idempotent decorator
mock_event = load_event("apiGatewayProxyV2Event.json")
persistence_layer = MockPersistenceLayer("test-func.lambda_handler#" + hash_idempotency_key(mock_event))
idempotency_key = f"{TESTS_MODULE_PREFIX}.test_idempotent_lambda_event_source.<locals>.lambda_handler#{hash_idempotency_key(mock_event)}" # noqa E501
persistence_layer = MockPersistenceLayer(idempotency_key)
expected_result = {"message": "Foo"}

# GIVEN an event_source decorator
Expand All @@ -1129,7 +1131,9 @@ def lambda_handler(event, _):
def test_idempotent_function():
# Scenario to validate we can use idempotent_function with any function
mock_event = {"data": "value"}
idempotency_key = "test-func.record_handler#" + hash_idempotency_key(mock_event)
idempotency_key = (
f"{TESTS_MODULE_PREFIX}.test_idempotent_function.<locals>.record_handler#{hash_idempotency_key(mock_event)}"
)
persistence_layer = MockPersistenceLayer(expected_idempotency_key=idempotency_key)
expected_result = {"message": "Foo"}

Expand All @@ -1147,7 +1151,7 @@ def test_idempotent_function_arbitrary_args_kwargs():
# Scenario to validate we can use idempotent_function with a function
# with an arbitrary number of args and kwargs
mock_event = {"data": "value"}
idempotency_key = "test-func.record_handler#" + hash_idempotency_key(mock_event)
idempotency_key = f"{TESTS_MODULE_PREFIX}.test_idempotent_function_arbitrary_args_kwargs.<locals>.record_handler#{hash_idempotency_key(mock_event)}" # noqa E501
persistence_layer = MockPersistenceLayer(expected_idempotency_key=idempotency_key)
expected_result = {"message": "Foo"}

Expand All @@ -1163,7 +1167,7 @@ def record_handler(arg_one, arg_two, record, is_record):

def test_idempotent_function_invalid_data_kwarg():
mock_event = {"data": "value"}
idempotency_key = "test-func.record_handler#" + hash_idempotency_key(mock_event)
idempotency_key = f"{TESTS_MODULE_PREFIX}.test_idempotent_function_invalid_data_kwarg.<locals>.record_handler#{hash_idempotency_key(mock_event)}" # noqa E501
persistence_layer = MockPersistenceLayer(expected_idempotency_key=idempotency_key)
expected_result = {"message": "Foo"}
keyword_argument = "payload"
Expand Down Expand Up @@ -1200,15 +1204,17 @@ def record_handler(record):
def test_idempotent_function_and_lambda_handler(lambda_context):
# Scenario to validate we can use both idempotent_function and idempotent decorators
mock_event = {"data": "value"}
idempotency_key = "test-func.record_handler#" + hash_idempotency_key(mock_event)
idempotency_key = f"{TESTS_MODULE_PREFIX}.test_idempotent_function_and_lambda_handler.<locals>.record_handler#{hash_idempotency_key(mock_event)}" # noqa E501
persistence_layer = MockPersistenceLayer(expected_idempotency_key=idempotency_key)
expected_result = {"message": "Foo"}

@idempotent_function(persistence_store=persistence_layer, data_keyword_argument="record")
def record_handler(record):
return expected_result

persistence_layer = MockPersistenceLayer("test-func.lambda_handler#" + hash_idempotency_key(mock_event))
persistence_layer = MockPersistenceLayer(
f"{TESTS_MODULE_PREFIX}.test_idempotent_function_and_lambda_handler.<locals>.lambda_handler#{hash_idempotency_key(mock_event)}" # noqa E501
)

@idempotent(persistence_store=persistence_layer)
def lambda_handler(event, _):
Expand All @@ -1229,7 +1235,9 @@ def test_idempotent_data_sorting():
# Scenario to validate same data in different order hashes to the same idempotency key
data_one = {"data": "test message 1", "more_data": "more data 1"}
data_two = {"more_data": "more data 1", "data": "test message 1"}
idempotency_key = "test-func.dummy#" + hash_idempotency_key(data_one)
idempotency_key = (
f"{TESTS_MODULE_PREFIX}.test_idempotent_data_sorting.<locals>.dummy#{hash_idempotency_key(data_one)}"
)
# Assertion will happen in MockPersistenceLayer
persistence_layer = MockPersistenceLayer(expected_idempotency_key=idempotency_key)

Expand Down Expand Up @@ -1337,7 +1345,7 @@ def test_idempotent_function_dataclass_with_jmespath():
dataclasses = get_dataclasses_lib()
config = IdempotencyConfig(event_key_jmespath="transaction_id", use_local_cache=True)
mock_event = {"customer_id": "fake", "transaction_id": "fake-id"}
idempotency_key = "test-func.collect_payment#" + hash_idempotency_key(mock_event["transaction_id"])
idempotency_key = f"{TESTS_MODULE_PREFIX}.test_idempotent_function_dataclass_with_jmespath.<locals>.collect_payment#{hash_idempotency_key(mock_event['transaction_id'])}" # noqa E501
persistence_layer = MockPersistenceLayer(expected_idempotency_key=idempotency_key)

@dataclasses.dataclass
Expand All @@ -1362,7 +1370,7 @@ def test_idempotent_function_pydantic_with_jmespath():
# GIVEN
config = IdempotencyConfig(event_key_jmespath="transaction_id", use_local_cache=True)
mock_event = {"customer_id": "fake", "transaction_id": "fake-id"}
idempotency_key = "test-func.collect_payment#" + hash_idempotency_key(mock_event["transaction_id"])
idempotency_key = f"{TESTS_MODULE_PREFIX}.test_idempotent_function_pydantic_with_jmespath.<locals>.collect_payment#{hash_idempotency_key(mock_event['transaction_id'])}" # noqa E501
persistence_layer = MockPersistenceLayer(expected_idempotency_key=idempotency_key)

class Payment(BaseModel):
Expand Down
12 changes: 10 additions & 2 deletions tests/functional/idempotency/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,13 @@ def hash_idempotency_key(data: Any):
def build_idempotency_put_item_stub(
data: Dict,
function_name: str = "test-func",
function_qualified_name: str = "test_idempotent_lambda_first_execution_event_mutation.<locals>",
module_name: str = "functional.idempotency.test_idempotency",
handler_name: str = "lambda_handler",
) -> Dict:
idempotency_key_hash = f"{function_name}.{handler_name}#{hash_idempotency_key(data)}"
idempotency_key_hash = (
f"{function_name}.{module_name}.{function_qualified_name}.{handler_name}#{hash_idempotency_key(data)}"
)
return {
"ConditionExpression": (
"attribute_not_exists(#id) OR #expiry < :now OR "
Expand All @@ -43,9 +47,13 @@ def build_idempotency_update_item_stub(
data: Dict,
handler_response: Dict,
function_name: str = "test-func",
function_qualified_name: str = "test_idempotent_lambda_first_execution_event_mutation.<locals>",
module_name: str = "functional.idempotency.test_idempotency",
handler_name: str = "lambda_handler",
) -> Dict:
idempotency_key_hash = f"{function_name}.{handler_name}#{hash_idempotency_key(data)}"
idempotency_key_hash = (
f"{function_name}.{module_name}.{function_qualified_name}.{handler_name}#{hash_idempotency_key(data)}"
)
serialized_lambda_response = json_serialize(handler_response)
return {
"ExpressionAttributeNames": {
Expand Down