Skip to content

Commit 8e762fd

Browse files
committed
fix: convert Unix timestamps in Lambda input
Fix TypeError when parsing Lambda invocation events containing Unix millisecond timestamps. The backend sends timestamps as integers, but the code expected datetime objects for comparison operations. Note this problem does NOT occur on Checkpoint and Get Execution State, because these benefit from the boto deserializer creating DateTime objects. It only impacts the input to the lambda. The bug manifested in concurrent execution (map/parallel) when operations had PENDING status with NextAttemptTimestamp. The code attempted to compare the integer timestamp with datetime.now(), causing: TypeError: '<' not supported between instances of 'int' and 'datetime.datetime' Changed execution.py to use from_json_dict() instead of from_dict() when parsing Lambda events. The from_json_dict() method properly converts Unix millisecond timestamps to datetime objects using TimestampConverter. Added regression tests covering all timestamp fields: - StartTimestamp - EndTimestamp - StepDetails.NextAttemptTimestamp - WaitDetails.ScheduledEndTimestamp The bug only affected Lambda invocations, not unit tests, because tests inject pre-constructed objects that bypass event parsing. closes #269
1 parent 228ae59 commit 8e762fd

2 files changed

Lines changed: 144 additions & 1 deletion

File tree

src/aws_durable_execution_sdk_python/execution.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,7 @@ def wrapper(event: Any, context: LambdaContext) -> MutableMapping[str, Any]:
260260
logger.debug(
261261
"durableExecutionArn: %s", event.get("DurableExecutionArn")
262262
)
263-
invocation_input = DurableExecutionInvocationInput.from_dict(event)
263+
invocation_input = DurableExecutionInvocationInput.from_json_dict(event)
264264
except (KeyError, TypeError, AttributeError) as e:
265265
msg = (
266266
"Unexpected payload provided to start the durable execution. "

tests/execution_test.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2551,3 +2551,146 @@ def test_durable_execution_invocation_input_json_dict_preserves_non_timestamp_fi
25512551
assert result["DurableExecutionArn"] == "arn:test:execution"
25522552
assert result["CheckpointToken"] == "token123"
25532553
assert result["InitialExecutionState"]["NextMarker"] == "marker123"
2554+
2555+
2556+
def test_event_parsing_with_unix_millis_timestamps():
2557+
"""Test that event parsing converts Unix millis timestamps to datetime objects.
2558+
2559+
This reproduces the production bug where NextAttemptTimestamp was sent as
2560+
Unix milliseconds (integer) and caused TypeError when comparing with datetime.now().
2561+
2562+
Regression test for: TypeError: '<' not supported between instances of 'int' and 'datetime.datetime'
2563+
2564+
Tests all timestamp fields handled by from_json_dict:
2565+
- StartTimestamp
2566+
- EndTimestamp
2567+
- StepDetails.NextAttemptTimestamp
2568+
- WaitDetails.ScheduledEndTimestamp
2569+
"""
2570+
# Real event structure from Lambda backend with Unix millis timestamps
2571+
event = {
2572+
"DurableExecutionArn": "arn:aws:lambda:us-east-1:123456789:function:test:$LATEST/durable-execution/e/o",
2573+
"CheckpointToken": "test-token",
2574+
"InitialExecutionState": {
2575+
"Operations": [
2576+
{
2577+
"Id": "exec-op",
2578+
"Type": "EXECUTION",
2579+
"StartTimestamp": 1769481309631, # Unix millis (int)
2580+
"EndTimestamp": 1769481319631, # Unix millis (int)
2581+
"Status": "STARTED",
2582+
"ExecutionDetails": {"InputPayload": "{}"},
2583+
},
2584+
{
2585+
"Id": "step-with-retry",
2586+
"Type": "STEP",
2587+
"SubType": "WaitForCondition",
2588+
"StartTimestamp": 1769481309631, # Unix millis (int)
2589+
"Status": "PENDING",
2590+
"StepDetails": {
2591+
"Attempt": 1,
2592+
"NextAttemptTimestamp": 1769481369631, # Unix millis (int) - THE BUG!
2593+
},
2594+
},
2595+
{
2596+
"Id": "wait-op",
2597+
"Type": "WAIT",
2598+
"StartTimestamp": 1769481309631, # Unix millis (int)
2599+
"Status": "PENDING",
2600+
"WaitDetails": {
2601+
"ScheduledEndTimestamp": 1769481399631 # Unix millis (int)
2602+
},
2603+
},
2604+
]
2605+
},
2606+
}
2607+
2608+
# Parse using from_json_dict (the fix)
2609+
invocation_input = DurableExecutionInvocationInput.from_json_dict(event)
2610+
operations = invocation_input.initial_execution_state.operations
2611+
2612+
# Verify EXECUTION operation timestamps
2613+
assert isinstance(operations[0].start_timestamp, datetime.datetime)
2614+
assert isinstance(operations[0].end_timestamp, datetime.datetime)
2615+
assert operations[0].start_timestamp.tzinfo == datetime.UTC
2616+
assert operations[0].end_timestamp.tzinfo == datetime.UTC
2617+
2618+
# Verify STEP operation with NextAttemptTimestamp (the critical one!)
2619+
assert operations[1].step_details is not None
2620+
next_attempt = operations[1].step_details.next_attempt_timestamp
2621+
assert isinstance(next_attempt, datetime.datetime)
2622+
assert next_attempt.tzinfo == datetime.UTC
2623+
2624+
# Verify WAIT operation with ScheduledEndTimestamp
2625+
assert operations[2].wait_details is not None
2626+
scheduled_end = operations[2].wait_details.scheduled_end_timestamp
2627+
assert isinstance(scheduled_end, datetime.datetime)
2628+
assert scheduled_end.tzinfo == datetime.UTC
2629+
2630+
# Verify timestamps can be compared with datetime.now() without TypeError
2631+
now = datetime.datetime.now(tz=datetime.UTC)
2632+
assert isinstance(next_attempt < now or next_attempt >= now, bool)
2633+
assert isinstance(scheduled_end < now or scheduled_end >= now, bool)
2634+
2635+
2636+
def test_from_dict_leaves_timestamps_as_integers():
2637+
"""Test that from_dict (the bug) leaves timestamps as integers.
2638+
2639+
This demonstrates the bug behavior for documentation purposes.
2640+
"""
2641+
event = {
2642+
"DurableExecutionArn": "arn:test",
2643+
"CheckpointToken": "token",
2644+
"InitialExecutionState": {
2645+
"Operations": [
2646+
{
2647+
"Id": "step-id",
2648+
"Type": "STEP",
2649+
"SubType": "WaitForCondition",
2650+
"StartTimestamp": 1769481309631,
2651+
"EndTimestamp": 1769481319631,
2652+
"Status": "PENDING",
2653+
"StepDetails": {
2654+
"Attempt": 1,
2655+
"NextAttemptTimestamp": 1769481369631, # Unix millis (int)
2656+
},
2657+
},
2658+
{
2659+
"Id": "wait-id",
2660+
"Type": "WAIT",
2661+
"StartTimestamp": 1769481309631,
2662+
"Status": "PENDING",
2663+
"WaitDetails": {
2664+
"ScheduledEndTimestamp": 1769481399631 # Unix millis (int)
2665+
},
2666+
},
2667+
]
2668+
},
2669+
}
2670+
2671+
# Using from_dict leaves timestamps as integers
2672+
invocation_input = DurableExecutionInvocationInput.from_dict(event)
2673+
operations = invocation_input.initial_execution_state.operations
2674+
2675+
# All timestamps remain as integers (the bug)
2676+
assert isinstance(operations[0].start_timestamp, int)
2677+
assert isinstance(operations[0].end_timestamp, int)
2678+
assert isinstance(operations[0].step_details.next_attempt_timestamp, int)
2679+
assert isinstance(operations[1].wait_details.scheduled_end_timestamp, int)
2680+
2681+
# These comparisons would cause TypeError
2682+
with pytest.raises(
2683+
TypeError,
2684+
match="'<' not supported between instances of 'int' and 'datetime.datetime'",
2685+
):
2686+
_ = operations[0].step_details.next_attempt_timestamp < datetime.datetime.now(
2687+
tz=datetime.UTC
2688+
)
2689+
2690+
with pytest.raises(
2691+
TypeError,
2692+
match="'<' not supported between instances of 'int' and 'datetime.datetime'",
2693+
):
2694+
_ = operations[1].wait_details.scheduled_end_timestamp < datetime.datetime.now(
2695+
tz=datetime.UTC
2696+
)

0 commit comments

Comments
 (0)