From 91c3bf60e839ed862cf97739160ae4c88a503e64 Mon Sep 17 00:00:00 2001 From: yaythomas Date: Fri, 6 Feb 2026 03:40:23 -0800 Subject: [PATCH] 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 --- .../execution.py | 4 +- .../executor.py | 1 + .../invoker.py | 7 +- .../model.py | 35 +++++- .../web/handlers.py | 6 + tests/model_test.py | 103 +++++++++++++++++- 6 files changed, 146 insertions(+), 10 deletions(-) diff --git a/src/aws_durable_execution_sdk_python_testing/execution.py b/src/aws_durable_execution_sdk_python_testing/execution.py index b1fafb9..1d22c23 100644 --- a/src/aws_durable_execution_sdk_python_testing/execution.py +++ b/src/aws_durable_execution_sdk_python_testing/execution.py @@ -102,7 +102,7 @@ def to_json_dict(self) -> dict[str, Any]: "Operations": [op.to_json_dict() for op in self.operations], "Updates": [update.to_dict() for update in self.updates], "InvocationCompletions": [ - completion.to_dict() for completion in self.invocation_completions + completion.to_json_dict() for completion in self.invocation_completions ], "UsedTokens": list(self.used_tokens), "TokenSequence": self._token_sequence, @@ -135,7 +135,7 @@ def from_json_dict(cls, data: dict[str, Any]) -> Execution: OperationUpdate.from_dict(update_data) for update_data in data["Updates"] ] execution.invocation_completions = [ - InvocationCompletedDetails.from_dict(item) + InvocationCompletedDetails.from_json_dict(item) for item in data.get("InvocationCompletions", []) ] execution.used_tokens = set(data["UsedTokens"]) diff --git a/src/aws_durable_execution_sdk_python_testing/executor.py b/src/aws_durable_execution_sdk_python_testing/executor.py index aa90c32..2b2fbe7 100644 --- a/src/aws_durable_execution_sdk_python_testing/executor.py +++ b/src/aws_durable_execution_sdk_python_testing/executor.py @@ -115,6 +115,7 @@ def start_execution( execution = Execution.new(input=input) execution.start() self._store.save(execution) + logger.debug("Created execution with ARN: %s", execution.durable_execution_arn) completion_event = self._scheduler.create_event() self._completion_events[execution.durable_execution_arn] = completion_event diff --git a/src/aws_durable_execution_sdk_python_testing/invoker.py b/src/aws_durable_execution_sdk_python_testing/invoker.py index 7b2f5e8..42c283d 100644 --- a/src/aws_durable_execution_sdk_python_testing/invoker.py +++ b/src/aws_durable_execution_sdk_python_testing/invoker.py @@ -16,9 +16,12 @@ from aws_durable_execution_sdk_python_testing.exceptions import ( DurableFunctionsTestError, + InvalidParameterValueException, + ResourceNotFoundException, ) from aws_durable_execution_sdk_python_testing.model import LambdaContext + if TYPE_CHECKING: from collections.abc import Callable @@ -217,10 +220,6 @@ def invoke( InvalidParameterValueException: If parameters are invalid DurableFunctionsTestError: For other invocation failures """ - from aws_durable_execution_sdk_python_testing.exceptions import ( - ResourceNotFoundException, - InvalidParameterValueException, - ) # Parameter validation if not function_name or not function_name.strip(): diff --git a/src/aws_durable_execution_sdk_python_testing/model.py b/src/aws_durable_execution_sdk_python_testing/model.py index 0353870..12e69a8 100644 --- a/src/aws_durable_execution_sdk_python_testing/model.py +++ b/src/aws_durable_execution_sdk_python_testing/model.py @@ -3,12 +3,10 @@ from __future__ import annotations import datetime +import json from dataclasses import dataclass, replace from enum import Enum from typing import Any -import json - -from dateutil.tz import UTC from aws_durable_execution_sdk_python.execution import DurableExecutionInvocationOutput @@ -30,12 +28,14 @@ OperationUpdate, StepDetails, StepOptions, + TimestampConverter, WaitDetails, WaitOptions, ) from aws_durable_execution_sdk_python.types import ( LambdaContext as LambdaContextProtocol, ) +from dateutil.tz import UTC from aws_durable_execution_sdk_python_testing.exceptions import ( InvalidParameterValueException, @@ -1239,6 +1239,27 @@ def from_dict(cls, data: dict) -> InvocationCompletedDetails: request_id=data["RequestId"], ) + @classmethod + def from_json_dict(cls, data: dict) -> InvocationCompletedDetails: + """Deserialize from JSON dict with Unix millisecond timestamps.""" + start_ts: datetime.datetime | None = TimestampConverter.from_unix_millis( + data["StartTimestamp"] + ) # type: ignore[arg-type] + end_ts: datetime.datetime | None = TimestampConverter.from_unix_millis( + data["EndTimestamp"] + ) # type: ignore[arg-type] + + if start_ts is None or end_ts is None: + raise InvalidParameterValueException( + "StartTimestamp and EndTimestamp cannot be null" + ) + + return cls( + start_timestamp=start_ts, + end_timestamp=end_ts, + request_id=data["RequestId"], + ) + def to_dict(self) -> dict[str, Any]: return { "StartTimestamp": self.start_timestamp, @@ -1246,6 +1267,14 @@ def to_dict(self) -> dict[str, Any]: "RequestId": self.request_id, } + def to_json_dict(self) -> dict[str, Any]: + """Convert to JSON-serializable dict with Unix millisecond timestamps.""" + return { + "StartTimestamp": TimestampConverter.to_unix_millis(self.start_timestamp), + "EndTimestamp": TimestampConverter.to_unix_millis(self.end_timestamp), + "RequestId": self.request_id, + } + # endregion event_structures diff --git a/src/aws_durable_execution_sdk_python_testing/web/handlers.py b/src/aws_durable_execution_sdk_python_testing/web/handlers.py index a3b2f0e..e8cb841 100644 --- a/src/aws_durable_execution_sdk_python_testing/web/handlers.py +++ b/src/aws_durable_execution_sdk_python_testing/web/handlers.py @@ -291,16 +291,22 @@ def handle(self, parsed_route: Route, request: HTTPRequest) -> HTTPResponse: # Returns: HTTPResponse: The HTTP response to send to the client """ + logger.debug("🌟 HANDLER: Received POST /start-durable-execution request") try: body_data: dict[str, Any] = self._parse_json_body(request) + logger.debug("🌟 HANDLER: Parsed request body successfully") start_input: StartDurableExecutionInput = ( StartDurableExecutionInput.from_dict(body_data) ) + logger.debug( + "🌟 HANDLER: Created StartDurableExecutionInput, calling executor.start_execution()" + ) start_output: StartDurableExecutionOutput = self.executor.start_execution( start_input ) + logger.debug("🌟 HANDLER: executor.start_execution() returned successfully") response_data: dict[str, Any] = start_output.to_dict() diff --git a/tests/model_test.py b/tests/model_test.py index 447c340..10076c0 100644 --- a/tests/model_test.py +++ b/tests/model_test.py @@ -3,13 +3,14 @@ from __future__ import annotations import datetime +import json import pytest - from aws_durable_execution_sdk_python.lambda_service import ( OperationStatus, OperationType, ) + from aws_durable_execution_sdk_python_testing.exceptions import ( InvalidParameterValueException, ) @@ -46,6 +47,7 @@ GetDurableExecutionResponse, GetDurableExecutionStateRequest, GetDurableExecutionStateResponse, + InvocationCompletedDetails, ListDurableExecutionsByFunctionRequest, ListDurableExecutionsByFunctionResponse, ListDurableExecutionsRequest, @@ -3564,3 +3566,102 @@ def test_events_to_operations_invalid_sub_type(): match=f"'{invalid_sub_type}' is not a valid OperationSubType", ): events_to_operations([event]) + + +def test_invocation_completed_details_to_json_dict(): + """Test InvocationCompletedDetails.to_json_dict() converts datetime to Unix milliseconds.""" + start_time = datetime.datetime(2023, 1, 1, 0, 0, 0, 123456, tzinfo=datetime.UTC) + end_time = datetime.datetime(2023, 1, 1, 0, 1, 0, 456789, tzinfo=datetime.UTC) + + details = InvocationCompletedDetails( + start_timestamp=start_time, end_timestamp=end_time, request_id="req-123" + ) + + json_dict = details.to_json_dict() + + # Verify timestamps are converted to Unix milliseconds (integers) + assert json_dict["StartTimestamp"] == 1672531200123 + assert json_dict["EndTimestamp"] == 1672531260456 + assert json_dict["RequestId"] == "req-123" + + # Verify all values are JSON-serializable + json_str = json.dumps(json_dict) + assert json_str is not None + + +def test_invocation_completed_details_from_json_dict(): + """Test InvocationCompletedDetails.from_json_dict() converts Unix milliseconds to datetime.""" + json_dict = { + "StartTimestamp": 1672531200123, + "EndTimestamp": 1672531260456, + "RequestId": "req-456", + } + + details = InvocationCompletedDetails.from_json_dict(json_dict) + + # Verify timestamps are converted to datetime objects + assert details.start_timestamp == datetime.datetime( + 2023, 1, 1, 0, 0, 0, 123000, tzinfo=datetime.UTC + ) + assert details.end_timestamp == datetime.datetime( + 2023, 1, 1, 0, 1, 0, 456000, tzinfo=datetime.UTC + ) + assert details.request_id == "req-456" + + +def test_invocation_completed_details_json_round_trip(): + """Test InvocationCompletedDetails to_json_dict/from_json_dict round-trip.""" + original = InvocationCompletedDetails( + start_timestamp=datetime.datetime( + 2023, 6, 15, 12, 30, 45, 678000, tzinfo=datetime.UTC + ), + end_timestamp=datetime.datetime( + 2023, 6, 15, 12, 31, 50, 123000, tzinfo=datetime.UTC + ), + request_id="round-trip-test", + ) + + # Serialize to JSON dict + json_dict = original.to_json_dict() + + # Deserialize back + restored = InvocationCompletedDetails.from_json_dict(json_dict) + + # Verify round-trip preserves data + assert restored.start_timestamp == original.start_timestamp + assert restored.end_timestamp == original.end_timestamp + assert restored.request_id == original.request_id + + +def test_invocation_completed_details_to_dict_preserves_datetime(): + """Test InvocationCompletedDetails.to_dict() preserves datetime objects (not converted).""" + start_time = datetime.datetime(2023, 1, 1, 0, 0, 0, tzinfo=datetime.UTC) + end_time = datetime.datetime(2023, 1, 1, 0, 1, 0, tzinfo=datetime.UTC) + + details = InvocationCompletedDetails( + start_timestamp=start_time, end_timestamp=end_time, request_id="req-789" + ) + + regular_dict = details.to_dict() + + # Verify to_dict() preserves datetime objects (not converted to Unix milliseconds) + assert regular_dict["StartTimestamp"] == start_time + assert regular_dict["EndTimestamp"] == end_time + assert isinstance(regular_dict["StartTimestamp"], datetime.datetime) + assert isinstance(regular_dict["EndTimestamp"], datetime.datetime) + + +def test_invocation_completed_details_from_json_dict_invalid_timestamp(): + """Test InvocationCompletedDetails.from_json_dict() raises error for invalid timestamps.""" + # Test with invalid timestamp that would return None + json_dict = { + "StartTimestamp": None, + "EndTimestamp": 1672531260456, + "RequestId": "req-error", + } + + with pytest.raises( + InvalidParameterValueException, + match="StartTimestamp and EndTimestamp cannot be null", + ): + InvocationCompletedDetails.from_json_dict(json_dict)