aws-durable-execution-sdk-python-testing 1.2.1__py3-none-any.whl → 1.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aws_durable_execution_sdk_python_testing/__about__.py +1 -1
- aws_durable_execution_sdk_python_testing/checkpoint/core.py +171 -0
- aws_durable_execution_sdk_python_testing/checkpoint/effects.py +61 -0
- aws_durable_execution_sdk_python_testing/checkpoint/processor.py +124 -45
- aws_durable_execution_sdk_python_testing/checkpoint/processors/base.py +40 -14
- aws_durable_execution_sdk_python_testing/checkpoint/processors/callback.py +5 -2
- aws_durable_execution_sdk_python_testing/checkpoint/processors/context.py +6 -0
- aws_durable_execution_sdk_python_testing/checkpoint/processors/execution.py +4 -9
- aws_durable_execution_sdk_python_testing/checkpoint/processors/invoke.py +139 -0
- aws_durable_execution_sdk_python_testing/checkpoint/processors/step.py +15 -21
- aws_durable_execution_sdk_python_testing/checkpoint/processors/wait.py +10 -16
- aws_durable_execution_sdk_python_testing/checkpoint/transformer.py +145 -48
- aws_durable_execution_sdk_python_testing/checkpoint/validators/checkpoint.py +65 -8
- aws_durable_execution_sdk_python_testing/checkpoint/validators/operations/invoke.py +79 -18
- aws_durable_execution_sdk_python_testing/child_dispatcher.py +828 -0
- aws_durable_execution_sdk_python_testing/cli.py +48 -1
- aws_durable_execution_sdk_python_testing/client.py +21 -10
- aws_durable_execution_sdk_python_testing/clock.py +69 -0
- aws_durable_execution_sdk_python_testing/exceptions.py +14 -0
- aws_durable_execution_sdk_python_testing/execution.py +583 -49
- aws_durable_execution_sdk_python_testing/executor.py +1813 -349
- aws_durable_execution_sdk_python_testing/invoker.py +356 -85
- aws_durable_execution_sdk_python_testing/model.py +81 -73
- aws_durable_execution_sdk_python_testing/observer.py +100 -88
- aws_durable_execution_sdk_python_testing/runner.py +407 -30
- aws_durable_execution_sdk_python_testing/scheduler.py +38 -13
- aws_durable_execution_sdk_python_testing/stores/filesystem.py +10 -2
- aws_durable_execution_sdk_python_testing/stores/sqlite.py +18 -6
- aws_durable_execution_sdk_python_testing/threads.py +157 -0
- aws_durable_execution_sdk_python_testing/token.py +11 -2
- aws_durable_execution_sdk_python_testing/web/handlers.py +33 -7
- aws_durable_execution_sdk_python_testing/web/serialization.py +1 -2
- aws_durable_execution_sdk_python_testing/web/server.py +1 -1
- aws_durable_execution_sdk_python_testing/worker/__init__.py +9 -0
- aws_durable_execution_sdk_python_testing/worker/checkpoint_tasks.py +119 -0
- aws_durable_execution_sdk_python_testing/worker/execution_worker.py +117 -0
- aws_durable_execution_sdk_python_testing/worker/lane.py +118 -0
- aws_durable_execution_sdk_python_testing/worker/registry.py +124 -0
- aws_durable_execution_sdk_python_testing/worker/status.py +27 -0
- aws_durable_execution_sdk_python_testing/worker/task.py +57 -0
- {aws_durable_execution_sdk_python_testing-1.2.1.dist-info → aws_durable_execution_sdk_python_testing-1.3.0.dist-info}/METADATA +50 -58
- aws_durable_execution_sdk_python_testing-1.3.0.dist-info/RECORD +64 -0
- {aws_durable_execution_sdk_python_testing-1.2.1.dist-info → aws_durable_execution_sdk_python_testing-1.3.0.dist-info}/WHEEL +1 -1
- aws_durable_execution_sdk_python_testing/checkpoint/validators/transitions.py +0 -66
- aws_durable_execution_sdk_python_testing-1.2.1.dist-info/RECORD +0 -52
- {aws_durable_execution_sdk_python_testing-1.2.1.dist-info/licenses → aws_durable_execution_sdk_python_testing}/LICENSE +0 -0
- {aws_durable_execution_sdk_python_testing-1.2.1.dist-info/licenses → aws_durable_execution_sdk_python_testing}/NOTICE +0 -0
- {aws_durable_execution_sdk_python_testing-1.2.1.dist-info → aws_durable_execution_sdk_python_testing-1.3.0.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Chained invoke operation processor for handling CHAINED_INVOKE operation updates."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime
|
|
6
|
+
from dataclasses import replace
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from aws_durable_execution_sdk_python.lambda_service import (
|
|
10
|
+
ChainedInvokeDetails,
|
|
11
|
+
ErrorObject,
|
|
12
|
+
Operation,
|
|
13
|
+
OperationAction,
|
|
14
|
+
OperationStatus,
|
|
15
|
+
OperationUpdate,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
from aws_durable_execution_sdk_python_testing.checkpoint.processors.base import (
|
|
19
|
+
OperationProcessor,
|
|
20
|
+
)
|
|
21
|
+
from aws_durable_execution_sdk_python_testing.exceptions import (
|
|
22
|
+
InvalidParameterValueException,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from collections.abc import Callable
|
|
27
|
+
|
|
28
|
+
from aws_durable_execution_sdk_python_testing.observer import ExecutionNotifier
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ChainedInvokeProcessor(OperationProcessor):
|
|
32
|
+
"""Processes CHAINED_INVOKE operation updates.
|
|
33
|
+
|
|
34
|
+
The service resolves the target before it schedules anything. Its
|
|
35
|
+
checkpoint response then carries the operation STARTED, because the
|
|
36
|
+
scheduling event is written as part of completing the checkpoint,
|
|
37
|
+
or FAILED when the target could not be resolved, so the handler
|
|
38
|
+
sees such a failure in the response to its own checkpoint and
|
|
39
|
+
raises without suspending. This processor does the same with
|
|
40
|
+
``preflight``: a START update whose target fails the preflight
|
|
41
|
+
comes back FAILED with that error; otherwise it comes back STARTED
|
|
42
|
+
and raises a :class:`ChainedInvokeStarted` effect for the caller to
|
|
43
|
+
dispatch the target once the checkpoint write completes. The
|
|
44
|
+
operation holds STARTED until its terminal transition, so a handler
|
|
45
|
+
observing it sees exactly one status change; the target's terminal
|
|
46
|
+
state completes it. Completion is never a handler checkpoint, so
|
|
47
|
+
START is the only accepted action.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, preflight: Callable[[str], ErrorObject | None] | None = None):
|
|
51
|
+
"""``preflight`` resolves a target at checkpoint time; without it
|
|
52
|
+
every target is dispatched and any failure arrives later."""
|
|
53
|
+
self._preflight = preflight
|
|
54
|
+
|
|
55
|
+
def process(
|
|
56
|
+
self,
|
|
57
|
+
update: OperationUpdate,
|
|
58
|
+
current_op: Operation | None,
|
|
59
|
+
notifier: ExecutionNotifier,
|
|
60
|
+
execution_arn: str,
|
|
61
|
+
now: datetime.datetime,
|
|
62
|
+
) -> Operation:
|
|
63
|
+
"""Process CHAINED_INVOKE operation update."""
|
|
64
|
+
match update.action:
|
|
65
|
+
case OperationAction.START:
|
|
66
|
+
options = update.chained_invoke_options
|
|
67
|
+
if options is None:
|
|
68
|
+
msg_options_required: str = (
|
|
69
|
+
"Update for CHAINED_INVOKE operation requires "
|
|
70
|
+
"ChainedInvokeOptions."
|
|
71
|
+
)
|
|
72
|
+
raise InvalidParameterValueException(msg_options_required)
|
|
73
|
+
|
|
74
|
+
start_timestamp: datetime.datetime | None = self._get_start_time(
|
|
75
|
+
current_op, now
|
|
76
|
+
)
|
|
77
|
+
error: ErrorObject | None = (
|
|
78
|
+
self._preflight(options.function_name)
|
|
79
|
+
if self._preflight is not None
|
|
80
|
+
else None
|
|
81
|
+
)
|
|
82
|
+
if error is not None:
|
|
83
|
+
return Operation(
|
|
84
|
+
operation_id=update.operation_id,
|
|
85
|
+
parent_id=update.parent_id,
|
|
86
|
+
name=update.name,
|
|
87
|
+
start_timestamp=start_timestamp,
|
|
88
|
+
end_timestamp=now,
|
|
89
|
+
operation_type=update.operation_type,
|
|
90
|
+
status=OperationStatus.FAILED,
|
|
91
|
+
sub_type=update.sub_type,
|
|
92
|
+
chained_invoke_details=ChainedInvokeDetails(
|
|
93
|
+
result=None, error=error
|
|
94
|
+
),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
operation: Operation = Operation(
|
|
98
|
+
operation_id=update.operation_id,
|
|
99
|
+
parent_id=update.parent_id,
|
|
100
|
+
name=update.name,
|
|
101
|
+
start_timestamp=start_timestamp,
|
|
102
|
+
end_timestamp=None,
|
|
103
|
+
operation_type=update.operation_type,
|
|
104
|
+
status=OperationStatus.STARTED,
|
|
105
|
+
sub_type=update.sub_type,
|
|
106
|
+
chained_invoke_details=ChainedInvokeDetails(
|
|
107
|
+
result=None, error=None
|
|
108
|
+
),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
notifier.notify_chained_invoke_started(
|
|
112
|
+
execution_arn=execution_arn,
|
|
113
|
+
operation_id=update.operation_id,
|
|
114
|
+
function_name=options.function_name,
|
|
115
|
+
tenant_id=options.tenant_id,
|
|
116
|
+
payload=update.payload,
|
|
117
|
+
)
|
|
118
|
+
return operation
|
|
119
|
+
case _:
|
|
120
|
+
msg_invalid_action: str = "Invalid action for CHAINED_INVOKE operation."
|
|
121
|
+
raise InvalidParameterValueException(msg_invalid_action)
|
|
122
|
+
|
|
123
|
+
def stored_update(
|
|
124
|
+
self,
|
|
125
|
+
update: OperationUpdate,
|
|
126
|
+
current_op: Operation | None,
|
|
127
|
+
updated_op: Operation,
|
|
128
|
+
) -> OperationUpdate:
|
|
129
|
+
"""Keep no input for a chained invoke that failed before starting.
|
|
130
|
+
|
|
131
|
+
The service stores no input payload when the target cannot be
|
|
132
|
+
resolved: it records a payload size of zero and an empty payload
|
|
133
|
+
location, and its history shows the function name and the error
|
|
134
|
+
without the input. So the runner stores the update without its
|
|
135
|
+
payload, which also keeps it out of the operation's size.
|
|
136
|
+
"""
|
|
137
|
+
if current_op is None and updated_op.status is OperationStatus.FAILED:
|
|
138
|
+
return replace(update, payload=None)
|
|
139
|
+
return update
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from datetime import
|
|
5
|
+
from datetime import datetime, timedelta
|
|
6
6
|
from typing import TYPE_CHECKING
|
|
7
7
|
|
|
8
8
|
from aws_durable_execution_sdk_python.lambda_service import (
|
|
@@ -34,6 +34,7 @@ class StepProcessor(OperationProcessor):
|
|
|
34
34
|
current_op: Operation | None,
|
|
35
35
|
notifier: ExecutionNotifier,
|
|
36
36
|
execution_arn: str,
|
|
37
|
+
now: datetime,
|
|
37
38
|
) -> Operation:
|
|
38
39
|
"""Process STEP operation update with scheduler integration for retries."""
|
|
39
40
|
match update.action:
|
|
@@ -42,6 +43,7 @@ class StepProcessor(OperationProcessor):
|
|
|
42
43
|
update=update,
|
|
43
44
|
current_operation=current_op,
|
|
44
45
|
status=OperationStatus.STARTED,
|
|
46
|
+
now=now,
|
|
45
47
|
)
|
|
46
48
|
case OperationAction.RETRY:
|
|
47
49
|
# set Status=PENDING, next attempt time, attempt count + 1
|
|
@@ -50,7 +52,7 @@ class StepProcessor(OperationProcessor):
|
|
|
50
52
|
if update.step_options
|
|
51
53
|
else 0
|
|
52
54
|
)
|
|
53
|
-
next_attempt_time =
|
|
55
|
+
next_attempt_time = now + timedelta(seconds=delay)
|
|
54
56
|
|
|
55
57
|
# Build new step_details with incremented attempt
|
|
56
58
|
current_attempt = (
|
|
@@ -61,16 +63,8 @@ class StepProcessor(OperationProcessor):
|
|
|
61
63
|
new_step_details = StepDetails(
|
|
62
64
|
attempt=current_attempt + 1,
|
|
63
65
|
next_attempt_timestamp=next_attempt_time,
|
|
64
|
-
result=
|
|
65
|
-
|
|
66
|
-
if current_op and current_op.step_details
|
|
67
|
-
else None
|
|
68
|
-
),
|
|
69
|
-
error=(
|
|
70
|
-
current_op.step_details.error
|
|
71
|
-
if current_op and current_op.step_details
|
|
72
|
-
else None
|
|
73
|
-
),
|
|
66
|
+
result=update.payload,
|
|
67
|
+
error=update.error,
|
|
74
68
|
)
|
|
75
69
|
|
|
76
70
|
# Create new operation with updated step_details
|
|
@@ -80,9 +74,7 @@ class StepProcessor(OperationProcessor):
|
|
|
80
74
|
status=OperationStatus.PENDING,
|
|
81
75
|
parent_id=update.parent_id,
|
|
82
76
|
name=update.name,
|
|
83
|
-
start_timestamp=(
|
|
84
|
-
current_op.start_timestamp if current_op else datetime.now(UTC)
|
|
85
|
-
),
|
|
77
|
+
start_timestamp=(current_op.start_timestamp if current_op else now),
|
|
86
78
|
end_timestamp=None,
|
|
87
79
|
sub_type=update.sub_type,
|
|
88
80
|
execution_details=current_op.execution_details
|
|
@@ -99,24 +91,26 @@ class StepProcessor(OperationProcessor):
|
|
|
99
91
|
else None,
|
|
100
92
|
)
|
|
101
93
|
|
|
102
|
-
#
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
94
|
+
# Timer scheduling is handled centrally by
|
|
95
|
+
# Executor._schedule_earliest_pending. The
|
|
96
|
+
# processor only needs to set
|
|
97
|
+
# step_details.next_attempt_timestamp; the executor
|
|
98
|
+
# finds the earliest pending wake across all ops and
|
|
99
|
+
# arms a single timer.
|
|
108
100
|
return retry_operation
|
|
109
101
|
case OperationAction.SUCCEED:
|
|
110
102
|
return self._translate_update_to_operation(
|
|
111
103
|
update=update,
|
|
112
104
|
current_operation=current_op,
|
|
113
105
|
status=OperationStatus.SUCCEEDED,
|
|
106
|
+
now=now,
|
|
114
107
|
)
|
|
115
108
|
case OperationAction.FAIL:
|
|
116
109
|
return self._translate_update_to_operation(
|
|
117
110
|
update=update,
|
|
118
111
|
current_operation=current_op,
|
|
119
112
|
status=OperationStatus.FAILED,
|
|
113
|
+
now=now,
|
|
120
114
|
)
|
|
121
115
|
case _:
|
|
122
116
|
msg: str = "Invalid action for STEP operation."
|
|
@@ -2,9 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import
|
|
6
|
-
import os
|
|
7
|
-
from datetime import UTC, datetime, timedelta
|
|
5
|
+
from datetime import datetime, timedelta
|
|
8
6
|
from typing import TYPE_CHECKING
|
|
9
7
|
|
|
10
8
|
from aws_durable_execution_sdk_python.lambda_service import (
|
|
@@ -36,6 +34,7 @@ class WaitProcessor(OperationProcessor):
|
|
|
36
34
|
current_op: Operation | None,
|
|
37
35
|
notifier: ExecutionNotifier,
|
|
38
36
|
execution_arn: str,
|
|
37
|
+
now: datetime,
|
|
39
38
|
) -> Operation:
|
|
40
39
|
"""Process WAIT operation update with scheduler integration for timers."""
|
|
41
40
|
match update.action:
|
|
@@ -43,13 +42,8 @@ class WaitProcessor(OperationProcessor):
|
|
|
43
42
|
wait_seconds = (
|
|
44
43
|
update.wait_options.wait_seconds if update.wait_options else 0
|
|
45
44
|
)
|
|
46
|
-
time_scale = float(os.getenv("DURABLE_EXECUTION_TIME_SCALE", "1.0"))
|
|
47
|
-
logging.info("Using DURABLE_EXECUTION_TIME_SCALE: %f", time_scale)
|
|
48
|
-
scaled_wait_seconds = wait_seconds * time_scale
|
|
49
45
|
|
|
50
|
-
scheduled_end_timestamp =
|
|
51
|
-
seconds=scaled_wait_seconds
|
|
52
|
-
)
|
|
46
|
+
scheduled_end_timestamp = now + timedelta(seconds=wait_seconds)
|
|
53
47
|
|
|
54
48
|
# Create WaitDetails with scheduled timestamp
|
|
55
49
|
wait_details = WaitDetails(
|
|
@@ -63,7 +57,7 @@ class WaitProcessor(OperationProcessor):
|
|
|
63
57
|
status=OperationStatus.STARTED,
|
|
64
58
|
parent_id=update.parent_id,
|
|
65
59
|
name=update.name,
|
|
66
|
-
start_timestamp=
|
|
60
|
+
start_timestamp=now,
|
|
67
61
|
end_timestamp=None,
|
|
68
62
|
sub_type=update.sub_type,
|
|
69
63
|
execution_details=None,
|
|
@@ -74,12 +68,11 @@ class WaitProcessor(OperationProcessor):
|
|
|
74
68
|
chained_invoke_details=None,
|
|
75
69
|
)
|
|
76
70
|
|
|
77
|
-
#
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
)
|
|
71
|
+
# Timer scheduling is handled centrally by
|
|
72
|
+
# Executor._schedule_earliest_pending. The processor
|
|
73
|
+
# only needs to set wait_details.scheduled_end_timestamp;
|
|
74
|
+
# the executor finds the earliest pending wake across
|
|
75
|
+
# all ops and arms a single timer.
|
|
83
76
|
return wait_operation
|
|
84
77
|
case OperationAction.CANCEL:
|
|
85
78
|
# TODO: need to cancel the WAIT in the executor
|
|
@@ -88,6 +81,7 @@ class WaitProcessor(OperationProcessor):
|
|
|
88
81
|
update=update,
|
|
89
82
|
current_operation=current_op,
|
|
90
83
|
status=OperationStatus.CANCELLED,
|
|
84
|
+
now=now,
|
|
91
85
|
)
|
|
92
86
|
case _:
|
|
93
87
|
msg: str = "Invalid action for WAIT operation."
|
|
@@ -1,13 +1,17 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Checkpoint request dispatcher.
|
|
2
|
+
|
|
3
|
+
Routes each ``OperationUpdate`` in a checkpoint to its type-specific
|
|
4
|
+
processor (step, wait, callback, context, execution) and applies the
|
|
5
|
+
result to the execution.
|
|
6
|
+
"""
|
|
2
7
|
|
|
3
8
|
from __future__ import annotations
|
|
4
9
|
|
|
5
|
-
|
|
10
|
+
import json
|
|
11
|
+
from typing import TYPE_CHECKING, ClassVar
|
|
6
12
|
|
|
7
13
|
from aws_durable_execution_sdk_python.lambda_service import (
|
|
8
|
-
Operation,
|
|
9
14
|
OperationType,
|
|
10
|
-
OperationUpdate,
|
|
11
15
|
)
|
|
12
16
|
|
|
13
17
|
from aws_durable_execution_sdk_python_testing.checkpoint.processors.callback import (
|
|
@@ -19,6 +23,9 @@ from aws_durable_execution_sdk_python_testing.checkpoint.processors.context impo
|
|
|
19
23
|
from aws_durable_execution_sdk_python_testing.checkpoint.processors.execution import (
|
|
20
24
|
ExecutionProcessor,
|
|
21
25
|
)
|
|
26
|
+
from aws_durable_execution_sdk_python_testing.checkpoint.processors.invoke import (
|
|
27
|
+
ChainedInvokeProcessor,
|
|
28
|
+
)
|
|
22
29
|
from aws_durable_execution_sdk_python_testing.checkpoint.processors.step import (
|
|
23
30
|
StepProcessor,
|
|
24
31
|
)
|
|
@@ -28,20 +35,37 @@ from aws_durable_execution_sdk_python_testing.checkpoint.processors.wait import
|
|
|
28
35
|
from aws_durable_execution_sdk_python_testing.exceptions import (
|
|
29
36
|
InvalidParameterValueException,
|
|
30
37
|
)
|
|
38
|
+
from aws_durable_execution_sdk_python_testing.observer import ExecutionNotifier
|
|
31
39
|
|
|
32
40
|
|
|
33
41
|
if TYPE_CHECKING:
|
|
34
|
-
from collections.abc import MutableMapping
|
|
42
|
+
from collections.abc import Callable, MutableMapping
|
|
43
|
+
from datetime import datetime
|
|
35
44
|
|
|
45
|
+
from aws_durable_execution_sdk_python.lambda_service import (
|
|
46
|
+
ErrorObject,
|
|
47
|
+
OperationUpdate,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
from aws_durable_execution_sdk_python_testing.checkpoint.effects import (
|
|
51
|
+
CheckpointEffect,
|
|
52
|
+
)
|
|
36
53
|
from aws_durable_execution_sdk_python_testing.checkpoint.processors.base import (
|
|
37
54
|
OperationProcessor,
|
|
38
55
|
)
|
|
56
|
+
from aws_durable_execution_sdk_python_testing.execution import Execution
|
|
39
57
|
|
|
40
|
-
from typing import ClassVar
|
|
41
58
|
|
|
59
|
+
class CheckpointRequestDispatcher:
|
|
60
|
+
"""Apply a batch of ``OperationUpdate``\\ s to an :class:`Execution`.
|
|
42
61
|
|
|
43
|
-
|
|
44
|
-
|
|
62
|
+
Dispatches each update to the per-type processor (step, wait,
|
|
63
|
+
callback, context, execution), upserts the resulting operation
|
|
64
|
+
into ``execution.operations``, records per-op payload size in the
|
|
65
|
+
``execution.operation_size_bytes`` sidecar dict (``Operation`` is
|
|
66
|
+
immutable, so size is tracked out-of-band), and calls the supplied
|
|
67
|
+
``touch`` callback once per accepted update.
|
|
68
|
+
"""
|
|
45
69
|
|
|
46
70
|
_DEFAULT_PROCESSORS: ClassVar[dict[OperationType, OperationProcessor]] = {
|
|
47
71
|
OperationType.STEP: StepProcessor(),
|
|
@@ -49,56 +73,129 @@ class OperationTransformer:
|
|
|
49
73
|
OperationType.CONTEXT: ContextProcessor(),
|
|
50
74
|
OperationType.CALLBACK: CallbackProcessor(),
|
|
51
75
|
OperationType.EXECUTION: ExecutionProcessor(),
|
|
76
|
+
OperationType.CHAINED_INVOKE: ChainedInvokeProcessor(),
|
|
52
77
|
}
|
|
53
78
|
|
|
54
79
|
def __init__(
|
|
55
80
|
self,
|
|
56
81
|
processors: MutableMapping[OperationType, OperationProcessor] | None = None,
|
|
82
|
+
*,
|
|
83
|
+
chained_invoke_preflight: Callable[[str], ErrorObject | None] | None = None,
|
|
57
84
|
):
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
85
|
+
"""``chained_invoke_preflight`` resolves a chained-invoke target at
|
|
86
|
+
checkpoint time (see :class:`ChainedInvokeProcessor`); it replaces
|
|
87
|
+
the default CHAINED_INVOKE processor and is ignored when explicit
|
|
88
|
+
``processors`` are given."""
|
|
89
|
+
if processors:
|
|
90
|
+
self.processors = processors
|
|
91
|
+
elif chained_invoke_preflight is not None:
|
|
92
|
+
self.processors = dict(self._DEFAULT_PROCESSORS)
|
|
93
|
+
self.processors[OperationType.CHAINED_INVOKE] = ChainedInvokeProcessor(
|
|
94
|
+
chained_invoke_preflight
|
|
95
|
+
)
|
|
96
|
+
else:
|
|
97
|
+
self.processors = self._DEFAULT_PROCESSORS
|
|
98
|
+
|
|
99
|
+
def apply_updates(
|
|
61
100
|
self,
|
|
101
|
+
execution: Execution,
|
|
62
102
|
updates: list[OperationUpdate],
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
) ->
|
|
67
|
-
"""
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
103
|
+
client_token: str | None, # noqa: ARG002 — reserved for future idempotency diagnostics
|
|
104
|
+
touch: Callable[[str], None],
|
|
105
|
+
now: datetime,
|
|
106
|
+
) -> list[CheckpointEffect]:
|
|
107
|
+
"""Apply ``updates`` to ``execution`` in place.
|
|
108
|
+
|
|
109
|
+
Callers are responsible for running
|
|
110
|
+
:class:`CheckpointValidator.validate_input` before calling this
|
|
111
|
+
method. The dispatcher does not re-validate — it assumes each
|
|
112
|
+
update is well-formed so that per-type processors can focus on
|
|
113
|
+
state transitions.
|
|
114
|
+
|
|
115
|
+
Each accepted update:
|
|
116
|
+
|
|
117
|
+
* is dispatched to the per-type processor via
|
|
118
|
+
``execution.operations`` upsert semantics (existing op with
|
|
119
|
+
matching ``operation_id`` is replaced in place; new op is
|
|
120
|
+
appended).
|
|
121
|
+
* records a payload size estimate in
|
|
122
|
+
``execution.operation_size_bytes[op_id]`` for later paging.
|
|
123
|
+
* triggers ``touch(op_id)`` exactly once, which (in the
|
|
124
|
+
production caller) bumps :attr:`Execution.seq_counter` and
|
|
125
|
+
sets ``operation_last_touched_seq[op_id]``.
|
|
126
|
+
|
|
127
|
+
Returns the lifecycle effects raised by the updates (completion,
|
|
128
|
+
failure, callback creation) for the caller to apply once the
|
|
129
|
+
write is done. Response construction is the responsibility of the
|
|
130
|
+
checkpoint orchestrator.
|
|
131
|
+
"""
|
|
132
|
+
collector = ExecutionNotifier()
|
|
133
|
+
op_map = {op.operation_id: op for op in execution.operations}
|
|
134
|
+
stored_updates: list[OperationUpdate] = []
|
|
72
135
|
|
|
73
136
|
for update in updates:
|
|
74
137
|
processor = self.processors.get(update.operation_type)
|
|
75
|
-
if processor:
|
|
76
|
-
|
|
77
|
-
updated_op = processor.process(
|
|
78
|
-
update=update,
|
|
79
|
-
current_op=current_op,
|
|
80
|
-
notifier=notifier,
|
|
81
|
-
execution_arn=execution_arn,
|
|
82
|
-
)
|
|
83
|
-
|
|
84
|
-
if updated_op is not None:
|
|
85
|
-
if update.operation_id in op_map:
|
|
86
|
-
# Update existing operation in-place
|
|
87
|
-
for i, op in enumerate(result_operations): # pragma: no branch
|
|
88
|
-
# no branch coverage because result_operation empty not reachable here
|
|
89
|
-
if op.operation_id == update.operation_id:
|
|
90
|
-
result_operations[i] = updated_op
|
|
91
|
-
break
|
|
92
|
-
else:
|
|
93
|
-
# Append new operation to end
|
|
94
|
-
result_operations.append(updated_op)
|
|
95
|
-
|
|
96
|
-
# Update map for future lookups
|
|
97
|
-
op_map[update.operation_id] = updated_op
|
|
98
|
-
else:
|
|
99
|
-
msg: str = (
|
|
100
|
-
f"Checkpoint for {update.operation_type} is not implemented yet."
|
|
101
|
-
)
|
|
138
|
+
if processor is None:
|
|
139
|
+
msg = f"Checkpoint for {update.operation_type} is not implemented yet."
|
|
102
140
|
raise InvalidParameterValueException(msg)
|
|
103
141
|
|
|
104
|
-
|
|
142
|
+
current_op = op_map.get(update.operation_id)
|
|
143
|
+
updated_op = processor.process(
|
|
144
|
+
update=update,
|
|
145
|
+
current_op=current_op,
|
|
146
|
+
notifier=collector,
|
|
147
|
+
execution_arn=execution.durable_execution_arn,
|
|
148
|
+
now=now,
|
|
149
|
+
)
|
|
150
|
+
if updated_op is None:
|
|
151
|
+
stored_updates.append(update)
|
|
152
|
+
continue
|
|
153
|
+
|
|
154
|
+
stored = processor.stored_update(update, current_op, updated_op)
|
|
155
|
+
stored_updates.append(stored)
|
|
156
|
+
if update.operation_id in op_map:
|
|
157
|
+
for i, op in enumerate(execution.operations): # pragma: no branch
|
|
158
|
+
if op.operation_id == update.operation_id:
|
|
159
|
+
execution.operations[i] = updated_op
|
|
160
|
+
break
|
|
161
|
+
else:
|
|
162
|
+
execution.operations.append(updated_op)
|
|
163
|
+
|
|
164
|
+
op_map[update.operation_id] = updated_op
|
|
165
|
+
execution.operation_size_bytes[update.operation_id] = (
|
|
166
|
+
_estimate_payload_size(stored)
|
|
167
|
+
)
|
|
168
|
+
touch(update.operation_id)
|
|
169
|
+
|
|
170
|
+
execution.updates.extend(stored_updates)
|
|
171
|
+
execution.update_timestamps.extend(now for _ in stored_updates)
|
|
172
|
+
|
|
173
|
+
return collector.effects
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _estimate_payload_size(update: OperationUpdate) -> int:
|
|
177
|
+
"""Estimate the on-wire size of an ``OperationUpdate``'s payload.
|
|
178
|
+
|
|
179
|
+
Approximate — paging decisions need this to be consistent and
|
|
180
|
+
reproducible, not exact. Sums JSON-ish lengths of ``payload`` and
|
|
181
|
+
``error`` fields when present.
|
|
182
|
+
"""
|
|
183
|
+
size = 0
|
|
184
|
+
payload = update.payload
|
|
185
|
+
if payload is not None:
|
|
186
|
+
size += _byte_length(payload)
|
|
187
|
+
error = update.error
|
|
188
|
+
if error is not None:
|
|
189
|
+
try:
|
|
190
|
+
size += len(json.dumps(error.to_dict()))
|
|
191
|
+
except (AttributeError, TypeError): # pragma: no cover — defensive
|
|
192
|
+
size += len(str(error))
|
|
193
|
+
return size
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _byte_length(payload: object) -> int:
|
|
197
|
+
if isinstance(payload, bytes):
|
|
198
|
+
return len(payload)
|
|
199
|
+
if isinstance(payload, str):
|
|
200
|
+
return len(payload.encode())
|
|
201
|
+
return len(str(payload))
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
|
-
from typing import TYPE_CHECKING
|
|
6
|
+
from typing import TYPE_CHECKING, ClassVar
|
|
7
7
|
|
|
8
8
|
from aws_durable_execution_sdk_python.lambda_service import (
|
|
9
9
|
OperationAction,
|
|
@@ -13,24 +13,33 @@ from aws_durable_execution_sdk_python.lambda_service import (
|
|
|
13
13
|
|
|
14
14
|
from aws_durable_execution_sdk_python_testing.checkpoint.validators.operations.callback import (
|
|
15
15
|
CallbackOperationValidator,
|
|
16
|
+
VALID_ACTIONS_FOR_CALLBACK,
|
|
16
17
|
)
|
|
17
18
|
from aws_durable_execution_sdk_python_testing.checkpoint.validators.operations.context import (
|
|
18
19
|
ContextOperationValidator,
|
|
20
|
+
VALID_ACTIONS_FOR_CONTEXT,
|
|
19
21
|
)
|
|
20
22
|
from aws_durable_execution_sdk_python_testing.checkpoint.validators.operations.execution import (
|
|
21
23
|
ExecutionOperationValidator,
|
|
24
|
+
VALID_ACTIONS_FOR_EXECUTION,
|
|
22
25
|
)
|
|
23
26
|
from aws_durable_execution_sdk_python_testing.checkpoint.validators.operations.invoke import (
|
|
24
27
|
ChainedInvokeOperationValidator,
|
|
28
|
+
VALID_ACTIONS_FOR_INVOKE,
|
|
25
29
|
)
|
|
26
30
|
from aws_durable_execution_sdk_python_testing.checkpoint.validators.operations.step import (
|
|
27
31
|
StepOperationValidator,
|
|
32
|
+
VALID_ACTIONS_FOR_STEP,
|
|
28
33
|
)
|
|
29
34
|
from aws_durable_execution_sdk_python_testing.checkpoint.validators.operations.wait import (
|
|
30
35
|
WaitOperationValidator,
|
|
36
|
+
VALID_ACTIONS_FOR_WAIT,
|
|
31
37
|
)
|
|
32
|
-
from aws_durable_execution_sdk_python_testing.
|
|
33
|
-
|
|
38
|
+
from aws_durable_execution_sdk_python_testing.child_dispatcher import (
|
|
39
|
+
CHAINED_INVOKE_INPUT_TOO_LARGE_MESSAGE,
|
|
40
|
+
CHILD_EXECUTION_OUTPUT_TOO_LARGE_MESSAGE,
|
|
41
|
+
MAX_CHAINED_INVOKE_PAYLOAD_BYTES,
|
|
42
|
+
payload_size_bytes,
|
|
34
43
|
)
|
|
35
44
|
from aws_durable_execution_sdk_python_testing.exceptions import (
|
|
36
45
|
InvalidParameterValueException,
|
|
@@ -48,6 +57,17 @@ MAX_ERROR_PAYLOAD_SIZE_BYTES = 32768
|
|
|
48
57
|
class CheckpointValidator:
|
|
49
58
|
"""Validates checkpoint input based on current state."""
|
|
50
59
|
|
|
60
|
+
_VALID_ACTIONS_BY_OPERATION_TYPE: ClassVar[
|
|
61
|
+
dict[OperationType, frozenset[OperationAction]]
|
|
62
|
+
] = {
|
|
63
|
+
OperationType.STEP: VALID_ACTIONS_FOR_STEP,
|
|
64
|
+
OperationType.CONTEXT: VALID_ACTIONS_FOR_CONTEXT,
|
|
65
|
+
OperationType.WAIT: VALID_ACTIONS_FOR_WAIT,
|
|
66
|
+
OperationType.CALLBACK: VALID_ACTIONS_FOR_CALLBACK,
|
|
67
|
+
OperationType.CHAINED_INVOKE: VALID_ACTIONS_FOR_INVOKE,
|
|
68
|
+
OperationType.EXECUTION: VALID_ACTIONS_FOR_EXECUTION,
|
|
69
|
+
}
|
|
70
|
+
|
|
51
71
|
@staticmethod
|
|
52
72
|
def validate_input(updates: list[OperationUpdate], execution: Execution) -> None:
|
|
53
73
|
"""Perform validation on the given input based on the current state."""
|
|
@@ -85,20 +105,55 @@ class CheckpointValidator:
|
|
|
85
105
|
) -> None:
|
|
86
106
|
"""Validate a single operation update."""
|
|
87
107
|
CheckpointValidator._validate_inconsistent_operation_metadata(update, execution)
|
|
88
|
-
CheckpointValidator._validate_payload_sizes(update)
|
|
89
|
-
|
|
108
|
+
CheckpointValidator._validate_payload_sizes(update, execution)
|
|
109
|
+
CheckpointValidator._validate_valid_action_for_type(
|
|
90
110
|
update.operation_type, update.action
|
|
91
111
|
)
|
|
92
112
|
CheckpointValidator._validate_operation_status_transition(update, execution)
|
|
93
113
|
|
|
94
114
|
@staticmethod
|
|
95
|
-
def _validate_payload_sizes(update: OperationUpdate) -> None:
|
|
96
|
-
"""Validate that operation payload sizes are not too large.
|
|
115
|
+
def _validate_payload_sizes(update: OperationUpdate, execution: Execution) -> None:
|
|
116
|
+
"""Validate that operation payload sizes are not too large.
|
|
117
|
+
|
|
118
|
+
Besides the error-object bound, two chained-invoke bounds apply:
|
|
119
|
+
the input a parent sends to a target, and the result a child
|
|
120
|
+
execution returns to its parent, are each capped at 1 MiB.
|
|
121
|
+
"""
|
|
97
122
|
if update.error is not None:
|
|
98
123
|
payload = json.dumps(update.error.to_dict())
|
|
99
124
|
if len(payload) > MAX_ERROR_PAYLOAD_SIZE_BYTES:
|
|
100
125
|
msg: str = f"Error object size must be less than {MAX_ERROR_PAYLOAD_SIZE_BYTES} bytes."
|
|
101
126
|
raise InvalidParameterValueException(msg)
|
|
127
|
+
if update.payload is None:
|
|
128
|
+
return
|
|
129
|
+
if (
|
|
130
|
+
update.operation_type == OperationType.CHAINED_INVOKE
|
|
131
|
+
and payload_size_bytes(update.payload) > MAX_CHAINED_INVOKE_PAYLOAD_BYTES
|
|
132
|
+
):
|
|
133
|
+
raise InvalidParameterValueException(CHAINED_INVOKE_INPUT_TOO_LARGE_MESSAGE)
|
|
134
|
+
if (
|
|
135
|
+
update.operation_type == OperationType.EXECUTION
|
|
136
|
+
and execution.parent_execution_arn is not None
|
|
137
|
+
and payload_size_bytes(update.payload) > MAX_CHAINED_INVOKE_PAYLOAD_BYTES
|
|
138
|
+
):
|
|
139
|
+
raise InvalidParameterValueException(
|
|
140
|
+
CHILD_EXECUTION_OUTPUT_TOO_LARGE_MESSAGE
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
@staticmethod
|
|
144
|
+
def _validate_valid_action_for_type(
|
|
145
|
+
operation_type: OperationType, action: OperationAction
|
|
146
|
+
) -> None:
|
|
147
|
+
"""Reject the checkpoint if the action is not valid for the type."""
|
|
148
|
+
valid_actions: frozenset[OperationAction] | None = (
|
|
149
|
+
CheckpointValidator._VALID_ACTIONS_BY_OPERATION_TYPE.get(operation_type)
|
|
150
|
+
)
|
|
151
|
+
if valid_actions is None:
|
|
152
|
+
msg_unknown_op: str = "Unknown operation type."
|
|
153
|
+
raise InvalidParameterValueException(msg_unknown_op)
|
|
154
|
+
if action not in valid_actions:
|
|
155
|
+
msg_invalid_action: str = "Invalid action for the given operation type."
|
|
156
|
+
raise InvalidParameterValueException(msg_invalid_action)
|
|
102
157
|
|
|
103
158
|
@staticmethod
|
|
104
159
|
def _validate_operation_status_transition(
|
|
@@ -121,7 +176,9 @@ class CheckpointValidator:
|
|
|
121
176
|
case OperationType.CALLBACK:
|
|
122
177
|
CallbackOperationValidator.validate(current_state, update)
|
|
123
178
|
case OperationType.CHAINED_INVOKE:
|
|
124
|
-
ChainedInvokeOperationValidator.validate(
|
|
179
|
+
ChainedInvokeOperationValidator.validate(
|
|
180
|
+
current_state, update, execution
|
|
181
|
+
)
|
|
125
182
|
case OperationType.EXECUTION:
|
|
126
183
|
ExecutionOperationValidator.validate(update)
|
|
127
184
|
case _: # pragma: no cover
|