Skip to content

feat(idempotency): support methods with the same name (ABCs) by including fully qualified name in v2 #1535

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

Merged
merged 10 commits into from
Sep 27, 2022
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