|
46 | 46 | from aws_lambda_powertools.utilities.idempotency.serialization.dataclass import ( |
47 | 47 | DataclassSerializer, |
48 | 48 | ) |
| 49 | +from aws_lambda_powertools.utilities.typing import DurableContext |
49 | 50 | from aws_lambda_powertools.utilities.validation import envelopes, validator |
50 | 51 | from aws_lambda_powertools.warnings import PowertoolsUserWarning |
51 | 52 | from tests.functional.idempotency.utils import ( |
@@ -2136,3 +2137,189 @@ def lambda_handler(record, context): |
2136 | 2137 | result = lambda_handler(mock_event, lambda_context) |
2137 | 2138 | # THEN we expect the function to execute successfully |
2138 | 2139 | assert result == expected_result |
| 2140 | + |
| 2141 | + |
| 2142 | +# Tests: Durable Functions Integration |
| 2143 | + |
| 2144 | + |
| 2145 | +@pytest.fixture |
| 2146 | +def durable_context_single_operation(lambda_context): |
| 2147 | + """DurableContext with single operation (execution mode, is_replay=False)""" |
| 2148 | + durable_ctx = DurableContext() |
| 2149 | + durable_ctx._lambda_context = lambda_context |
| 2150 | + durable_ctx._state = Mock(operations=[{"id": "op1"}]) |
| 2151 | + return durable_ctx |
| 2152 | + |
| 2153 | + |
| 2154 | +@pytest.fixture |
| 2155 | +def durable_context_multiple_operations(lambda_context): |
| 2156 | + """DurableContext with multiple operations (replay mode, is_replay=True)""" |
| 2157 | + durable_ctx = DurableContext() |
| 2158 | + durable_ctx._lambda_context = lambda_context |
| 2159 | + durable_ctx._state = Mock(operations=[{"id": "op1"}, {"id": "op2"}]) |
| 2160 | + return durable_ctx |
| 2161 | + |
| 2162 | + |
| 2163 | +@pytest.mark.parametrize("idempotency_config", [{"use_local_cache": False}], indirect=True) |
| 2164 | +def test_idempotent_lambda_with_durable_context_first_execution( |
| 2165 | + idempotency_config: IdempotencyConfig, |
| 2166 | + persistence_store: DynamoDBPersistenceLayer, |
| 2167 | + lambda_apigw_event, |
| 2168 | + durable_context_single_operation, |
| 2169 | + lambda_response, |
| 2170 | +): |
| 2171 | + """ |
| 2172 | + Test idempotent decorator with DurableContext during first execution (execution mode). |
| 2173 | +
|
| 2174 | + When a durable function executes for the first time (single operation in state), |
| 2175 | + is_replay=False, and the function should execute normally, saving the result. |
| 2176 | + """ |
| 2177 | + # GIVEN |
| 2178 | + stubber = stub.Stubber(persistence_store.client) |
| 2179 | + stubber.add_response("put_item", {}) |
| 2180 | + stubber.add_response("update_item", {}) |
| 2181 | + stubber.activate() |
| 2182 | + |
| 2183 | + # WHEN |
| 2184 | + @idempotent(config=idempotency_config, persistence_store=persistence_store) |
| 2185 | + def lambda_handler(event, context): |
| 2186 | + return lambda_response |
| 2187 | + |
| 2188 | + result = lambda_handler(lambda_apigw_event, durable_context_single_operation) |
| 2189 | + |
| 2190 | + # THEN |
| 2191 | + assert result == lambda_response |
| 2192 | + stubber.assert_no_pending_responses() |
| 2193 | + stubber.deactivate() |
| 2194 | + |
| 2195 | + |
| 2196 | +@pytest.mark.parametrize("idempotency_config", [{"use_local_cache": False}], indirect=True) |
| 2197 | +def test_idempotent_lambda_with_durable_context_during_replay( |
| 2198 | + idempotency_config: IdempotencyConfig, |
| 2199 | + persistence_store: DynamoDBPersistenceLayer, |
| 2200 | + lambda_apigw_event, |
| 2201 | + durable_context_multiple_operations, |
| 2202 | + timestamp_future, |
| 2203 | + lambda_response, |
| 2204 | + serialized_lambda_response, |
| 2205 | +): |
| 2206 | + """ |
| 2207 | + Test idempotent decorator with DurableContext during workflow replay (replay mode). |
| 2208 | +
|
| 2209 | + When a durable function replays (multiple operations in state), is_replay=True. |
| 2210 | + The function should execute once to get the response and save it, even when |
| 2211 | + an INPROGRESS record exists from a previous execution. |
| 2212 | + """ |
| 2213 | + # GIVEN |
| 2214 | + hashed_key = hash_idempotency_key(data=lambda_apigw_event) |
| 2215 | + |
| 2216 | + stubber = stub.Stubber(persistence_store.client) |
| 2217 | + ddb_response = { |
| 2218 | + "Item": { |
| 2219 | + "id": {"S": hashed_key}, |
| 2220 | + "expiration": {"N": timestamp_future}, |
| 2221 | + "data": {"S": serialized_lambda_response}, |
| 2222 | + "status": {"S": "INPROGRESS"}, |
| 2223 | + }, |
| 2224 | + } |
| 2225 | + stubber.add_client_error("put_item", "ConditionalCheckFailedException", modeled_fields=ddb_response) |
| 2226 | + # In replay mode, function still executes once to get response, then saves it |
| 2227 | + stubber.add_response("update_item", {}) |
| 2228 | + stubber.activate() |
| 2229 | + |
| 2230 | + # WHEN |
| 2231 | + @idempotent(config=idempotency_config, persistence_store=persistence_store) |
| 2232 | + def lambda_handler(event, context): |
| 2233 | + return lambda_response |
| 2234 | + |
| 2235 | + result = lambda_handler(lambda_apigw_event, durable_context_multiple_operations) |
| 2236 | + |
| 2237 | + # THEN - Should return result in replay mode |
| 2238 | + assert result == lambda_response |
| 2239 | + stubber.assert_no_pending_responses() |
| 2240 | + stubber.deactivate() |
| 2241 | + |
| 2242 | + |
| 2243 | +@pytest.mark.parametrize("idempotency_config", [{"use_local_cache": False}], indirect=True) |
| 2244 | +def test_idempotent_lambda_extracts_lambda_context_from_durable_context( |
| 2245 | + idempotency_config: IdempotencyConfig, |
| 2246 | + persistence_store: DynamoDBPersistenceLayer, |
| 2247 | + lambda_apigw_event, |
| 2248 | + durable_context_single_operation, |
| 2249 | + lambda_response, |
| 2250 | +): |
| 2251 | + """ |
| 2252 | + Test that idempotency properly extracts LambdaContext from DurableContext. |
| 2253 | +
|
| 2254 | + The @idempotent decorator should extract the wrapped lambda_context from |
| 2255 | + DurableContext for tracking remaining time and other Lambda-specific features. |
| 2256 | + """ |
| 2257 | + # GIVEN |
| 2258 | + stubber = stub.Stubber(persistence_store.client) |
| 2259 | + stubber.add_response("put_item", {}) |
| 2260 | + stubber.add_response("update_item", {}) |
| 2261 | + stubber.activate() |
| 2262 | + |
| 2263 | + # WHEN |
| 2264 | + @idempotent(config=idempotency_config, persistence_store=persistence_store) |
| 2265 | + def lambda_handler(event, context): |
| 2266 | + # Verify we can access lambda_context properties |
| 2267 | + assert hasattr(context, "lambda_context") |
| 2268 | + assert context.lambda_context.function_name == "test-func" |
| 2269 | + return lambda_response |
| 2270 | + |
| 2271 | + result = lambda_handler(lambda_apigw_event, durable_context_single_operation) |
| 2272 | + |
| 2273 | + # THEN |
| 2274 | + assert result == lambda_response |
| 2275 | + stubber.assert_no_pending_responses() |
| 2276 | + stubber.deactivate() |
| 2277 | + |
| 2278 | + |
| 2279 | +@pytest.mark.parametrize("idempotency_config", [{"use_local_cache": False}], indirect=True) |
| 2280 | +def test_idempotent_lambda_concurrent_durable_executions_raise_in_progress_error( |
| 2281 | + idempotency_config: IdempotencyConfig, |
| 2282 | + persistence_store: DynamoDBPersistenceLayer, |
| 2283 | + lambda_apigw_event, |
| 2284 | + durable_context_single_operation, |
| 2285 | + lambda_response, |
| 2286 | +): |
| 2287 | + """ |
| 2288 | + Test that concurrent durable executions are prevented by IdempotencyAlreadyInProgressError. |
| 2289 | +
|
| 2290 | + Scenario: Two different durable function executions attempt to process the same |
| 2291 | + idempotent operation concurrently: |
| 2292 | + 1. First execution creates an INPROGRESS record |
| 2293 | + 2. Second execution (in execution mode, is_replay=False) finds the INPROGRESS record |
| 2294 | + 3. Second execution should raise IdempotencyAlreadyInProgressError to prevent duplicate work |
| 2295 | +
|
| 2296 | + This ensures data consistency when multiple durable function instances execute concurrently. |
| 2297 | + """ |
| 2298 | + # GIVEN |
| 2299 | + hashed_key = hash_idempotency_key(data=lambda_apigw_event) |
| 2300 | + |
| 2301 | + stubber = stub.Stubber(persistence_store.client) |
| 2302 | + # Simulate existing INPROGRESS record with far future timestamps |
| 2303 | + ddb_response = { |
| 2304 | + "Item": { |
| 2305 | + "id": {"S": hashed_key}, |
| 2306 | + "expiration": {"N": "9999999999"}, |
| 2307 | + "in_progress_expiration": {"N": "9999999999999"}, # Far future in milliseconds |
| 2308 | + "status": {"S": "INPROGRESS"}, |
| 2309 | + }, |
| 2310 | + } |
| 2311 | + stubber.add_client_error("put_item", "ConditionalCheckFailedException", modeled_fields=ddb_response) |
| 2312 | + stubber.activate() |
| 2313 | + |
| 2314 | + # WHEN / THEN - Should raise IdempotencyAlreadyInProgressError in execution mode |
| 2315 | + @idempotent(config=idempotency_config, persistence_store=persistence_store) |
| 2316 | + def lambda_handler(event, context): |
| 2317 | + return lambda_response |
| 2318 | + |
| 2319 | + with pytest.raises(IdempotencyAlreadyInProgressError) as exc_info: |
| 2320 | + lambda_handler(lambda_apigw_event, durable_context_single_operation) |
| 2321 | + |
| 2322 | + # Verify error message contains the idempotency key |
| 2323 | + assert hashed_key in str(exc_info.value) |
| 2324 | + stubber.assert_no_pending_responses() |
| 2325 | + stubber.deactivate() |
0 commit comments