Skip to content

Commit a2b6a34

Browse files
committed
chore(idempotency): addressed review comments
1 parent 0ce0bb2 commit a2b6a34

File tree

6 files changed

+57
-28
lines changed

6 files changed

+57
-28
lines changed

aws_lambda_powertools/utilities/idempotency/base.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,8 +121,9 @@ def _get_remaining_time_in_millis(self) -> Optional[int]:
121121
"""
122122
Tries to determine the remaining time available for the current lambda invocation.
123123
124-
Currently, it only works if the idempotent handler decorator is used, since we need to acess the lambda context.
125-
However, this could be improved if we start storing the lambda context globally during the invocation.
124+
This only works if the idempotent handler decorator is used, since we need to access the lambda context.
125+
However, this could be improved if we start storing the lambda context globally during the invocation. One
126+
way to do this is to register the lambda context when configuring the IdempotencyConfig object.
126127
127128
Returns
128129
-------

aws_lambda_powertools/utilities/idempotency/config.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ def __init__(
1414
use_local_cache: bool = False,
1515
local_cache_max_items: int = 256,
1616
hash_function: str = "md5",
17+
lambda_context: Optional[LambdaContext] = None,
1718
):
1819
"""
1920
Initialize the base persistence layer
@@ -34,6 +35,8 @@ def __init__(
3435
Max number of items to store in local cache, by default 1024
3536
hash_function: str, optional
3637
Function to use for calculating hashes, by default md5.
38+
lambda_context: LambdaContext, optional
39+
Lambda Context containing information about the invocation, function and execution environment.
3740
"""
3841
self.event_key_jmespath = event_key_jmespath
3942
self.payload_validation_jmespath = payload_validation_jmespath
@@ -43,7 +46,7 @@ def __init__(
4346
self.use_local_cache = use_local_cache
4447
self.local_cache_max_items = local_cache_max_items
4548
self.hash_function = hash_function
46-
self.lambda_context: Optional[LambdaContext] = None
49+
self.lambda_context: Optional[LambdaContext] = lambda_context
4750

4851
def register_lambda_context(self, lambda_context: LambdaContext):
4952
self.lambda_context = lambda_context

aws_lambda_powertools/utilities/idempotency/persistence/base.py

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
import os
99
import warnings
1010
from abc import ABC, abstractmethod
11-
from math import ceil
1211
from types import MappingProxyType
1312
from typing import Any, Dict, Optional
1413

@@ -355,12 +354,7 @@ def save_inprogress(self, data: Dict[str, Any], remaining_time_in_millis: Option
355354
now = datetime.datetime.now()
356355
period = datetime.timedelta(milliseconds=remaining_time_in_millis)
357356

358-
# It's very important to use math.ceil here. Otherwise, we might return an integer that will be smaller
359-
# than the current time in milliseconds, due to rounding. This will create a scenario where the record
360-
# looks already expired in the store, but the invocation is still running.
361-
timestamp = ceil((now + period).timestamp())
362-
363-
data_record.in_progress_expiry_timestamp = timestamp
357+
data_record.in_progress_expiry_timestamp = int((now + period).timestamp() * 1000)
364358
else:
365359
warnings.warn("Expires in progress is enabled but we couldn't determine the remaining time left")
366360

aws_lambda_powertools/utilities/idempotency/persistence/dynamodb.py

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -168,19 +168,50 @@ def _put_record(self, data_record: DataRecord) -> None:
168168
try:
169169
logger.debug(f"Putting record for idempotency key: {data_record.idempotency_key}")
170170

171+
# | LOCKED | RETRY if status = "INPROGRESS" | RETRY
172+
# |----------------|-------------------------------------------------------|-------------> .... (time)
173+
# | Lambda Idempotency Record
174+
# | Timeout Timeout
175+
# | (in_progress_expiry) (expiry)
176+
177+
# Conditions to successfully save a record:
178+
179+
# The idempotency key does not exist:
180+
# - first time that this invocation key is used
181+
# - previous invocation with the same key was deleted due to TTL
182+
idempotency_key_not_exist = "attribute_not_exists(#id)"
183+
184+
# The idempotency record exists but it's expired:
185+
idempotency_expiry_expired = "#expiry < :now"
186+
187+
# The status of the record is "INPROGRESS", there is an in-progress expiry timestamp, but it's expired
188+
inprogress_expiry_expired = " AND ".join(
189+
[
190+
"#status = :inprogress",
191+
"attribute_exists(#in_progress_expiry)",
192+
"#in_progress_expiry < :now_in_millis",
193+
]
194+
)
195+
inprogress_expiry_expired = f"({inprogress_expiry_expired})"
196+
197+
condition_expression = " OR ".join(
198+
[idempotency_key_not_exist, idempotency_expiry_expired, inprogress_expiry_expired]
199+
)
200+
171201
self.table.put_item(
172202
Item=item,
173-
ConditionExpression=(
174-
"attribute_not_exists(#id) OR #now < :now OR "
175-
"(attribute_exists(#in_progress_expiry) AND #in_progress_expiry < :now AND #status = :inprogress)"
176-
),
203+
ConditionExpression=condition_expression,
177204
ExpressionAttributeNames={
178205
"#id": self.key_attr,
179-
"#now": self.expiry_attr,
206+
"#expiry": self.expiry_attr,
180207
"#in_progress_expiry": self.in_progress_expiry_attr,
181208
"#status": self.status_attr,
182209
},
183-
ExpressionAttributeValues={":now": int(now.timestamp()), ":inprogress": STATUS_CONSTANTS["INPROGRESS"]},
210+
ExpressionAttributeValues={
211+
":now": int(now.timestamp()),
212+
":now_in_millis": int(now.timestamp() * 1000),
213+
":inprogress": STATUS_CONSTANTS["INPROGRESS"],
214+
},
184215
)
185216
except self.table.meta.client.exceptions.ConditionalCheckFailedException:
186217
logger.debug(f"Failed to put record for already existing idempotency key: {data_record.idempotency_key}")

tests/functional/idempotency/conftest.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -134,16 +134,16 @@ def expected_params_update_item_with_validation(
134134
def expected_params_put_item(hashed_idempotency_key):
135135
return {
136136
"ConditionExpression": (
137-
"attribute_not_exists(#id) OR #now < :now OR "
138-
"(attribute_exists(#in_progress_expiry) AND #in_progress_expiry < :now AND #status = :inprogress)"
137+
"attribute_not_exists(#id) OR #expiry < :now OR "
138+
"(#status = :inprogress AND attribute_exists(#in_progress_expiry) AND #in_progress_expiry < :now_in_millis)"
139139
),
140140
"ExpressionAttributeNames": {
141141
"#id": "id",
142-
"#now": "expiration",
142+
"#expiry": "expiration",
143143
"#status": "status",
144144
"#in_progress_expiry": "in_progress_expiration",
145145
},
146-
"ExpressionAttributeValues": {":now": stub.ANY, ":inprogress": "INPROGRESS"},
146+
"ExpressionAttributeValues": {":now": stub.ANY, ":now_in_millis": stub.ANY, ":inprogress": "INPROGRESS"},
147147
"Item": {
148148
"expiration": stub.ANY,
149149
"id": hashed_idempotency_key,
@@ -158,16 +158,16 @@ def expected_params_put_item(hashed_idempotency_key):
158158
def expected_params_put_item_with_validation(hashed_idempotency_key, hashed_validation_key):
159159
return {
160160
"ConditionExpression": (
161-
"attribute_not_exists(#id) OR #now < :now OR "
162-
"(attribute_exists(#in_progress_expiry) AND #in_progress_expiry < :now AND #status = :inprogress)"
161+
"attribute_not_exists(#id) OR #expiry < :now OR "
162+
"(#status = :inprogress AND attribute_exists(#in_progress_expiry) AND #in_progress_expiry < :now_in_millis)"
163163
),
164164
"ExpressionAttributeNames": {
165165
"#id": "id",
166-
"#now": "expiration",
166+
"#expiry": "expiration",
167167
"#status": "status",
168168
"#in_progress_expiry": "in_progress_expiration",
169169
},
170-
"ExpressionAttributeValues": {":now": stub.ANY, ":inprogress": "INPROGRESS"},
170+
"ExpressionAttributeValues": {":now": stub.ANY, ":now_in_millis": stub.ANY, ":inprogress": "INPROGRESS"},
171171
"Item": {
172172
"expiration": stub.ANY,
173173
"in_progress_expiration": stub.ANY,

tests/functional/idempotency/utils.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,16 @@ def build_idempotency_put_item_stub(
1919
idempotency_key_hash = f"{function_name}.{handler_name}#{hash_idempotency_key(data)}"
2020
return {
2121
"ConditionExpression": (
22-
"attribute_not_exists(#id) OR #now < :now OR "
23-
"(attribute_exists(#in_progress_expiry) AND #in_progress_expiry < :now AND #status = :inprogress)"
22+
"attribute_not_exists(#id) OR #expiry < :now OR "
23+
"(#status = :inprogress AND attribute_exists(#in_progress_expiry) AND #in_progress_expiry < :now_in_millis)"
2424
),
2525
"ExpressionAttributeNames": {
2626
"#id": "id",
27-
"#now": "expiration",
27+
"#expiry": "expiration",
2828
"#status": "status",
2929
"#in_progress_expiry": "in_progress_expiration",
3030
},
31-
"ExpressionAttributeValues": {":now": stub.ANY, ":inprogress": "INPROGRESS"},
31+
"ExpressionAttributeValues": {":now": stub.ANY, ":now_in_millis": stub.ANY, ":inprogress": "INPROGRESS"},
3232
"Item": {
3333
"expiration": stub.ANY,
3434
"id": idempotency_key_hash,

0 commit comments

Comments
 (0)