Skip to content

Commit a67dd59

Browse files
committed
feat(idempotency): Clean up on lambda timeout
Changes: - Initial draft on an option to clean up on function timeout close aws-powertools#1038
1 parent 8ca082f commit a67dd59

File tree

6 files changed

+79
-21
lines changed

6 files changed

+79
-21
lines changed

aws_lambda_powertools/utilities/idempotency/base.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,10 @@ def _process_idempotency(self):
101101
try:
102102
# We call save_inprogress first as an optimization for the most common case where no idempotent record
103103
# already exists. If it succeeds, there's no need to call get_record.
104-
self.persistence_store.save_inprogress(data=self.data)
104+
self.persistence_store.save_inprogress(
105+
data=self.data,
106+
function_timeout=self._get_remaining_time_in_seconds(),
107+
)
105108
except IdempotencyKeyError:
106109
raise
107110
except IdempotencyItemAlreadyExistsError:
@@ -113,6 +116,11 @@ def _process_idempotency(self):
113116

114117
return self._get_function_response()
115118

119+
def _get_remaining_time_in_seconds(self) -> Optional[int]:
120+
if self.fn_args and len(self.fn_args) == 2 and getattr(self.fn_args[1], "get_remaining_time_in_millis", None):
121+
return self.fn_args[1].get_remaining_time_in_millis() / 1000
122+
return None
123+
116124
def _get_idempotency_record(self) -> DataRecord:
117125
"""
118126
Retrieve the idempotency record from the persistence layer.

aws_lambda_powertools/utilities/idempotency/config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ def __init__(
99
jmespath_options: Optional[Dict] = None,
1010
raise_on_no_idempotency_key: bool = False,
1111
expires_after_seconds: int = 60 * 60, # 1 hour default
12+
function_timeout_clean_up: bool = False,
1213
use_local_cache: bool = False,
1314
local_cache_max_items: int = 256,
1415
hash_function: str = "md5",
@@ -26,6 +27,8 @@ def __init__(
2627
Raise exception if no idempotency key was found in the request, by default False
2728
expires_after_seconds: int
2829
The number of seconds to wait before a record is expired
30+
function_timeout_clean_up: bool
31+
Whether to clean up in progress record after a function timeouts
2932
use_local_cache: bool, optional
3033
Whether to locally cache idempotency results, by default False
3134
local_cache_max_items: int, optional
@@ -38,6 +41,7 @@ def __init__(
3841
self.jmespath_options = jmespath_options
3942
self.raise_on_no_idempotency_key = raise_on_no_idempotency_key
4043
self.expires_after_seconds = expires_after_seconds
44+
self.function_timeout_clean_up = function_timeout_clean_up
4145
self.use_local_cache = use_local_cache
4246
self.local_cache_max_items = local_cache_max_items
4347
self.hash_function = hash_function

aws_lambda_powertools/utilities/idempotency/persistence/base.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ def __init__(
4040
idempotency_key,
4141
status: str = "",
4242
expiry_timestamp: Optional[int] = None,
43+
function_timeout: Optional[int] = None,
4344
response_data: Optional[str] = "",
4445
payload_hash: Optional[str] = None,
4546
) -> None:
@@ -61,6 +62,7 @@ def __init__(
6162
self.idempotency_key = idempotency_key
6263
self.payload_hash = payload_hash
6364
self.expiry_timestamp = expiry_timestamp
65+
self.function_timeout = function_timeout
6466
self._status = status
6567
self.response_data = response_data
6668

@@ -120,6 +122,7 @@ def __init__(self):
120122
self.validation_key_jmespath = None
121123
self.raise_on_no_idempotency_key = False
122124
self.expires_after_seconds: int = 60 * 60 # 1 hour default
125+
self.function_timeout_clean_up = False
123126
self.use_local_cache = False
124127
self.hash_function = None
125128

@@ -152,6 +155,7 @@ def configure(self, config: IdempotencyConfig, function_name: Optional[str] = No
152155
self.payload_validation_enabled = True
153156
self.raise_on_no_idempotency_key = config.raise_on_no_idempotency_key
154157
self.expires_after_seconds = config.expires_after_seconds
158+
self.function_timeout_clean_up = config.function_timeout_clean_up
155159
self.use_local_cache = config.use_local_cache
156160
if self.use_local_cache:
157161
self._cache = LRUDict(max_items=config.local_cache_max_items)
@@ -257,9 +261,21 @@ def _get_expiry_timestamp(self) -> int:
257261
int
258262
unix timestamp of expiry date for idempotency record
259263
264+
"""
265+
return self._get_timestamp_after_seconds(self.expires_after_seconds)
266+
267+
@staticmethod
268+
def _get_timestamp_after_seconds(seconds: int) -> int:
269+
"""
270+
271+
Returns
272+
-------
273+
int
274+
unix timestamp after the specified seconds
275+
260276
"""
261277
now = datetime.datetime.now()
262-
period = datetime.timedelta(seconds=self.expires_after_seconds)
278+
period = datetime.timedelta(seconds=seconds)
263279
return int((now + period).timestamp())
264280

265281
def _save_to_cache(self, data_record: DataRecord):
@@ -317,6 +333,7 @@ def save_success(self, data: Dict[str, Any], result: dict) -> None:
317333
idempotency_key=self._get_hashed_idempotency_key(data=data),
318334
status=STATUS_CONSTANTS["COMPLETED"],
319335
expiry_timestamp=self._get_expiry_timestamp(),
336+
function_timeout=None,
320337
response_data=response_data,
321338
payload_hash=self._get_hashed_payload(data=data),
322339
)
@@ -328,19 +345,26 @@ def save_success(self, data: Dict[str, Any], result: dict) -> None:
328345

329346
self._save_to_cache(data_record=data_record)
330347

331-
def save_inprogress(self, data: Dict[str, Any]) -> None:
348+
def save_inprogress(self, data: Dict[str, Any], function_timeout: Optional[int] = None) -> None:
332349
"""
333350
Save record of function's execution being in progress
334351
335352
Parameters
336353
----------
337354
data: Dict[str, Any]
338355
Payload
356+
function_timeout: int, optional
339357
"""
358+
function_timeout = (
359+
self._get_timestamp_after_seconds(function_timeout)
360+
if function_timeout and self.function_timeout_clean_up
361+
else None
362+
)
340363
data_record = DataRecord(
341364
idempotency_key=self._get_hashed_idempotency_key(data=data),
342365
status=STATUS_CONSTANTS["INPROGRESS"],
343366
expiry_timestamp=self._get_expiry_timestamp(),
367+
function_timeout=function_timeout,
344368
payload_hash=self._get_hashed_payload(data=data),
345369
)
346370

aws_lambda_powertools/utilities/idempotency/persistence/dynamodb.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ def __init__(
2525
static_pk_value: Optional[str] = None,
2626
sort_key_attr: Optional[str] = None,
2727
expiry_attr: str = "expiration",
28+
function_timeout_attr: str = "function_timeout",
2829
status_attr: str = "status",
2930
data_attr: str = "data",
3031
validation_key_attr: str = "validation",
@@ -85,6 +86,7 @@ def __init__(
8586
self.static_pk_value = static_pk_value
8687
self.sort_key_attr = sort_key_attr
8788
self.expiry_attr = expiry_attr
89+
self.function_timeout_attr = function_timeout_attr
8890
self.status_attr = status_attr
8991
self.data_attr = data_attr
9092
self.validation_key_attr = validation_key_attr
@@ -150,6 +152,7 @@ def _put_record(self, data_record: DataRecord) -> None:
150152
item = {
151153
**self._get_key(data_record.idempotency_key),
152154
self.expiry_attr: data_record.expiry_timestamp,
155+
self.function_timeout_attr: data_record.function_timeout,
153156
self.status_attr: data_record.status,
154157
}
155158

@@ -161,8 +164,12 @@ def _put_record(self, data_record: DataRecord) -> None:
161164
logger.debug(f"Putting record for idempotency key: {data_record.idempotency_key}")
162165
self.table.put_item(
163166
Item=item,
164-
ConditionExpression="attribute_not_exists(#id) OR #now < :now",
165-
ExpressionAttributeNames={"#id": self.key_attr, "#now": self.expiry_attr},
167+
ConditionExpression="attribute_not_exists(#id) OR #now < :now OR #function_timeout < :now",
168+
ExpressionAttributeNames={
169+
"#id": self.key_attr,
170+
"#now": self.expiry_attr,
171+
"#function_timeout": self.function_timeout_attr,
172+
},
166173
ExpressionAttributeValues={":now": int(now.timestamp())},
167174
)
168175
except self.table.meta.client.exceptions.ConditionalCheckFailedException:

tests/functional/idempotency/conftest.py

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import datetime
22
import json
3-
from collections import namedtuple
43
from decimal import Decimal
54
from unittest import mock
65

@@ -32,14 +31,19 @@ def lambda_apigw_event():
3231

3332
@pytest.fixture
3433
def lambda_context():
35-
lambda_context = {
36-
"function_name": "test-func",
37-
"memory_limit_in_mb": 128,
38-
"invoked_function_arn": "arn:aws:lambda:eu-west-1:809313241234:function:test-func",
39-
"aws_request_id": "52fdfc07-2182-154f-163f-5f0f9a621d72",
40-
}
34+
class LambdaContext:
35+
def __init__(self):
36+
self.function_name = "test-func"
37+
self.memory_limit_in_mb = 128
38+
self.invoked_function_arn = "arn:aws:lambda:eu-west-1:809313241234:function:test-func"
39+
self.aws_request_id = "52fdfc07-2182-154f-163f-5f0f9a621d72"
40+
41+
@staticmethod
42+
def get_remaining_time_in_millis() -> int:
43+
"""Returns the number of milliseconds left before the execution times out."""
44+
return 0
4145

42-
return namedtuple("LambdaContext", lambda_context.keys())(*lambda_context.values())
46+
return LambdaContext()
4347

4448

4549
@pytest.fixture
@@ -117,25 +121,31 @@ def expected_params_update_item_with_validation(
117121
@pytest.fixture
118122
def expected_params_put_item(hashed_idempotency_key):
119123
return {
120-
"ConditionExpression": "attribute_not_exists(#id) OR #now < :now",
121-
"ExpressionAttributeNames": {"#id": "id", "#now": "expiration"},
124+
"ConditionExpression": "attribute_not_exists(#id) OR #now < :now OR #function_timeout < :now",
125+
"ExpressionAttributeNames": {"#id": "id", "#now": "expiration", "#function_timeout": "function_timeout"},
122126
"ExpressionAttributeValues": {":now": stub.ANY},
123-
"Item": {"expiration": stub.ANY, "id": hashed_idempotency_key, "status": "INPROGRESS"},
127+
"Item": {
128+
"expiration": stub.ANY,
129+
"id": hashed_idempotency_key,
130+
"status": "INPROGRESS",
131+
"function_timeout": None,
132+
},
124133
"TableName": "TEST_TABLE",
125134
}
126135

127136

128137
@pytest.fixture
129138
def expected_params_put_item_with_validation(hashed_idempotency_key, hashed_validation_key):
130139
return {
131-
"ConditionExpression": "attribute_not_exists(#id) OR #now < :now",
132-
"ExpressionAttributeNames": {"#id": "id", "#now": "expiration"},
140+
"ConditionExpression": "attribute_not_exists(#id) OR #now < :now OR #function_timeout < :now",
141+
"ExpressionAttributeNames": {"#id": "id", "#now": "expiration", "#function_timeout": "function_timeout"},
133142
"ExpressionAttributeValues": {":now": stub.ANY},
134143
"Item": {
135144
"expiration": stub.ANY,
136145
"id": hashed_idempotency_key,
137146
"status": "INPROGRESS",
138147
"validation": hashed_validation_key,
148+
"function_timeout": None,
139149
},
140150
"TableName": "TEST_TABLE",
141151
}

tests/functional/idempotency/utils.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,15 @@ def build_idempotency_put_item_stub(
1616
) -> Dict:
1717
idempotency_key_hash = f"{function_name}.{handler_name}#{hash_idempotency_key(data)}"
1818
return {
19-
"ConditionExpression": "attribute_not_exists(#id) OR #now < :now",
20-
"ExpressionAttributeNames": {"#id": "id", "#now": "expiration"},
19+
"ConditionExpression": "attribute_not_exists(#id) OR #now < :now OR #function_timeout < :now",
20+
"ExpressionAttributeNames": {"#id": "id", "#now": "expiration", "#function_timeout": "function_timeout"},
2121
"ExpressionAttributeValues": {":now": stub.ANY},
22-
"Item": {"expiration": stub.ANY, "id": idempotency_key_hash, "status": "INPROGRESS"},
22+
"Item": {
23+
"expiration": stub.ANY,
24+
"id": idempotency_key_hash,
25+
"status": "INPROGRESS",
26+
"function_timeout": None,
27+
},
2328
"TableName": "TEST_TABLE",
2429
}
2530

0 commit comments

Comments
 (0)