Skip to content

Commit 0cdc2a2

Browse files
committed
fix: convert InvocationCompletedDetails to unix milliseconds
SQLite storage was failing with "Object of type datetime is not JSON serializable" when attempting to save execution state after Lambda invocation. The root cause was InvocationCompletedDetails.to_dict() returning raw datetime objects instead of JSON-serializable integers. This fix adds to_json_dict() and from_json_dict() methods to InvocationCompletedDetails that convert datetime objects to/from Unix milliseconds using TimestampConverter, matching the pattern already used by the SDK's Operation class. Changes: - Add InvocationCompletedDetails.to_json_dict() for serialization - Add InvocationCompletedDetails.from_json_dict() for deserialization - Update Execution.to_json_dict() to call completion.to_json_dict() - Update Execution.from_json_dict() to call from_json_dict() The to_dict() method is preserved for internal use where datetime objects are needed, while to_json_dict() is used for storage and JSON serialization paths. Fixes execution persistence failures in SQLite and filesystem stores. closes #193
1 parent 65a4d59 commit 0cdc2a2

6 files changed

Lines changed: 134 additions & 10 deletions

File tree

src/aws_durable_execution_sdk_python_testing/execution.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ def to_json_dict(self) -> dict[str, Any]:
102102
"Operations": [op.to_json_dict() for op in self.operations],
103103
"Updates": [update.to_dict() for update in self.updates],
104104
"InvocationCompletions": [
105-
completion.to_dict() for completion in self.invocation_completions
105+
completion.to_json_dict() for completion in self.invocation_completions
106106
],
107107
"UsedTokens": list(self.used_tokens),
108108
"TokenSequence": self._token_sequence,
@@ -135,7 +135,7 @@ def from_json_dict(cls, data: dict[str, Any]) -> Execution:
135135
OperationUpdate.from_dict(update_data) for update_data in data["Updates"]
136136
]
137137
execution.invocation_completions = [
138-
InvocationCompletedDetails.from_dict(item)
138+
InvocationCompletedDetails.from_json_dict(item)
139139
for item in data.get("InvocationCompletions", [])
140140
]
141141
execution.used_tokens = set(data["UsedTokens"])

src/aws_durable_execution_sdk_python_testing/executor.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ def start_execution(
115115
execution = Execution.new(input=input)
116116
execution.start()
117117
self._store.save(execution)
118+
logger.debug("Created execution with ARN: %s", execution.durable_execution_arn)
118119

119120
completion_event = self._scheduler.create_event()
120121
self._completion_events[execution.durable_execution_arn] = completion_event

src/aws_durable_execution_sdk_python_testing/invoker.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,12 @@
1616

1717
from aws_durable_execution_sdk_python_testing.exceptions import (
1818
DurableFunctionsTestError,
19+
InvalidParameterValueException,
20+
ResourceNotFoundException,
1921
)
2022
from aws_durable_execution_sdk_python_testing.model import LambdaContext
2123

24+
2225
if TYPE_CHECKING:
2326
from collections.abc import Callable
2427

@@ -217,10 +220,6 @@ def invoke(
217220
InvalidParameterValueException: If parameters are invalid
218221
DurableFunctionsTestError: For other invocation failures
219222
"""
220-
from aws_durable_execution_sdk_python_testing.exceptions import (
221-
ResourceNotFoundException,
222-
InvalidParameterValueException,
223-
)
224223

225224
# Parameter validation
226225
if not function_name or not function_name.strip():

src/aws_durable_execution_sdk_python_testing/model.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,10 @@
33
from __future__ import annotations
44

55
import datetime
6+
import json
67
from dataclasses import dataclass, replace
78
from enum import Enum
89
from typing import Any
9-
import json
10-
11-
from dateutil.tz import UTC
1210

1311
from aws_durable_execution_sdk_python.execution import DurableExecutionInvocationOutput
1412

@@ -30,12 +28,14 @@
3028
OperationUpdate,
3129
StepDetails,
3230
StepOptions,
31+
TimestampConverter,
3332
WaitDetails,
3433
WaitOptions,
3534
)
3635
from aws_durable_execution_sdk_python.types import (
3736
LambdaContext as LambdaContextProtocol,
3837
)
38+
from dateutil.tz import UTC
3939

4040
from aws_durable_execution_sdk_python_testing.exceptions import (
4141
InvalidParameterValueException,
@@ -1239,13 +1239,30 @@ def from_dict(cls, data: dict) -> InvocationCompletedDetails:
12391239
request_id=data["RequestId"],
12401240
)
12411241

1242+
@classmethod
1243+
def from_json_dict(cls, data: dict) -> InvocationCompletedDetails:
1244+
"""Deserialize from JSON dict with Unix millisecond timestamps."""
1245+
return cls(
1246+
start_timestamp=TimestampConverter.from_unix_millis(data["StartTimestamp"]), # type: ignore[arg-type]
1247+
end_timestamp=TimestampConverter.from_unix_millis(data["EndTimestamp"]), # type: ignore[arg-type]
1248+
request_id=data["RequestId"],
1249+
)
1250+
12421251
def to_dict(self) -> dict[str, Any]:
12431252
return {
12441253
"StartTimestamp": self.start_timestamp,
12451254
"EndTimestamp": self.end_timestamp,
12461255
"RequestId": self.request_id,
12471256
}
12481257

1258+
def to_json_dict(self) -> dict[str, Any]:
1259+
"""Convert to JSON-serializable dict with Unix millisecond timestamps."""
1260+
return {
1261+
"StartTimestamp": TimestampConverter.to_unix_millis(self.start_timestamp),
1262+
"EndTimestamp": TimestampConverter.to_unix_millis(self.end_timestamp),
1263+
"RequestId": self.request_id,
1264+
}
1265+
12491266

12501267
# endregion event_structures
12511268

src/aws_durable_execution_sdk_python_testing/web/handlers.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,16 +291,22 @@ def handle(self, parsed_route: Route, request: HTTPRequest) -> HTTPResponse: #
291291
Returns:
292292
HTTPResponse: The HTTP response to send to the client
293293
"""
294+
logger.debug("🌟 HANDLER: Received POST /start-durable-execution request")
294295
try:
295296
body_data: dict[str, Any] = self._parse_json_body(request)
297+
logger.debug("🌟 HANDLER: Parsed request body successfully")
296298

297299
start_input: StartDurableExecutionInput = (
298300
StartDurableExecutionInput.from_dict(body_data)
299301
)
302+
logger.debug(
303+
"🌟 HANDLER: Created StartDurableExecutionInput, calling executor.start_execution()"
304+
)
300305

301306
start_output: StartDurableExecutionOutput = self.executor.start_execution(
302307
start_input
303308
)
309+
logger.debug("🌟 HANDLER: executor.start_execution() returned successfully")
304310

305311
response_data: dict[str, Any] = start_output.to_dict()
306312

tests/model_test.py

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@
33
from __future__ import annotations
44

55
import datetime
6+
import json
67

78
import pytest
8-
99
from aws_durable_execution_sdk_python.lambda_service import (
1010
OperationStatus,
1111
OperationType,
1212
)
13+
1314
from aws_durable_execution_sdk_python_testing.exceptions import (
1415
InvalidParameterValueException,
1516
)
@@ -46,6 +47,7 @@
4647
GetDurableExecutionResponse,
4748
GetDurableExecutionStateRequest,
4849
GetDurableExecutionStateResponse,
50+
InvocationCompletedDetails,
4951
ListDurableExecutionsByFunctionRequest,
5052
ListDurableExecutionsByFunctionResponse,
5153
ListDurableExecutionsRequest,
@@ -3564,3 +3566,102 @@ def test_events_to_operations_invalid_sub_type():
35643566
match=f"'{invalid_sub_type}' is not a valid OperationSubType",
35653567
):
35663568
events_to_operations([event])
3569+
3570+
3571+
def test_invocation_completed_details_to_json_dict():
3572+
"""Test InvocationCompletedDetails.to_json_dict() converts datetime to Unix milliseconds."""
3573+
start_time = datetime.datetime(2023, 1, 1, 0, 0, 0, 123456, tzinfo=datetime.UTC)
3574+
end_time = datetime.datetime(2023, 1, 1, 0, 1, 0, 456789, tzinfo=datetime.UTC)
3575+
3576+
details = InvocationCompletedDetails(
3577+
start_timestamp=start_time, end_timestamp=end_time, request_id="req-123"
3578+
)
3579+
3580+
json_dict = details.to_json_dict()
3581+
3582+
# Verify timestamps are converted to Unix milliseconds (integers)
3583+
assert json_dict["StartTimestamp"] == 1672531200123
3584+
assert json_dict["EndTimestamp"] == 1672531260456
3585+
assert json_dict["RequestId"] == "req-123"
3586+
3587+
# Verify all values are JSON-serializable
3588+
json_str = json.dumps(json_dict)
3589+
assert json_str is not None
3590+
3591+
3592+
def test_invocation_completed_details_from_json_dict():
3593+
"""Test InvocationCompletedDetails.from_json_dict() converts Unix milliseconds to datetime."""
3594+
json_dict = {
3595+
"StartTimestamp": 1672531200123,
3596+
"EndTimestamp": 1672531260456,
3597+
"RequestId": "req-456",
3598+
}
3599+
3600+
details = InvocationCompletedDetails.from_json_dict(json_dict)
3601+
3602+
# Verify timestamps are converted to datetime objects
3603+
assert details.start_timestamp == datetime.datetime(
3604+
2023, 1, 1, 0, 0, 0, 123000, tzinfo=datetime.UTC
3605+
)
3606+
assert details.end_timestamp == datetime.datetime(
3607+
2023, 1, 1, 0, 1, 0, 456000, tzinfo=datetime.UTC
3608+
)
3609+
assert details.request_id == "req-456"
3610+
3611+
3612+
def test_invocation_completed_details_json_round_trip():
3613+
"""Test InvocationCompletedDetails to_json_dict/from_json_dict round-trip."""
3614+
original = InvocationCompletedDetails(
3615+
start_timestamp=datetime.datetime(
3616+
2023, 6, 15, 12, 30, 45, 678000, tzinfo=datetime.UTC
3617+
),
3618+
end_timestamp=datetime.datetime(
3619+
2023, 6, 15, 12, 31, 50, 123000, tzinfo=datetime.UTC
3620+
),
3621+
request_id="round-trip-test",
3622+
)
3623+
3624+
# Serialize to JSON dict
3625+
json_dict = original.to_json_dict()
3626+
3627+
# Deserialize back
3628+
restored = InvocationCompletedDetails.from_json_dict(json_dict)
3629+
3630+
# Verify round-trip preserves data
3631+
assert restored.start_timestamp == original.start_timestamp
3632+
assert restored.end_timestamp == original.end_timestamp
3633+
assert restored.request_id == original.request_id
3634+
3635+
3636+
def test_invocation_completed_details_to_dict_preserves_datetime():
3637+
"""Test InvocationCompletedDetails.to_dict() preserves datetime objects (not converted)."""
3638+
start_time = datetime.datetime(2023, 1, 1, 0, 0, 0, tzinfo=datetime.UTC)
3639+
end_time = datetime.datetime(2023, 1, 1, 0, 1, 0, tzinfo=datetime.UTC)
3640+
3641+
details = InvocationCompletedDetails(
3642+
start_timestamp=start_time, end_timestamp=end_time, request_id="req-789"
3643+
)
3644+
3645+
regular_dict = details.to_dict()
3646+
3647+
# Verify to_dict() preserves datetime objects (not converted to Unix milliseconds)
3648+
assert regular_dict["StartTimestamp"] == start_time
3649+
assert regular_dict["EndTimestamp"] == end_time
3650+
assert isinstance(regular_dict["StartTimestamp"], datetime.datetime)
3651+
assert isinstance(regular_dict["EndTimestamp"], datetime.datetime)
3652+
3653+
3654+
def test_invocation_completed_details_from_json_dict_invalid_timestamp():
3655+
"""Test InvocationCompletedDetails.from_json_dict() raises error for invalid timestamps."""
3656+
# Test with invalid timestamp that would return None
3657+
json_dict = {
3658+
"StartTimestamp": None,
3659+
"EndTimestamp": 1672531260456,
3660+
"RequestId": "req-error",
3661+
}
3662+
3663+
with pytest.raises(
3664+
InvalidParameterValueException,
3665+
match="StartTimestamp and EndTimestamp must be valid",
3666+
):
3667+
InvocationCompletedDetails.from_json_dict(json_dict)

0 commit comments

Comments
 (0)