async-durable-execution-runner 2.0.0a1__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.
- LICENSE +175 -0
- NOTICE +8 -0
- VERSION.py +5 -0
- async_durable_execution_runner/__about__.py +33 -0
- async_durable_execution_runner/__init__.py +23 -0
- async_durable_execution_runner/checkpoint/__init__.py +1 -0
- async_durable_execution_runner/checkpoint/processor.py +101 -0
- async_durable_execution_runner/checkpoint/processors/__init__.py +1 -0
- async_durable_execution_runner/checkpoint/processors/base.py +199 -0
- async_durable_execution_runner/checkpoint/processors/callback.py +89 -0
- async_durable_execution_runner/checkpoint/processors/context.py +59 -0
- async_durable_execution_runner/checkpoint/processors/execution.py +52 -0
- async_durable_execution_runner/checkpoint/processors/step.py +124 -0
- async_durable_execution_runner/checkpoint/processors/wait.py +95 -0
- async_durable_execution_runner/checkpoint/transformer.py +104 -0
- async_durable_execution_runner/checkpoint/validators/__init__.py +1 -0
- async_durable_execution_runner/checkpoint/validators/checkpoint.py +242 -0
- async_durable_execution_runner/checkpoint/validators/operations/__init__.py +1 -0
- async_durable_execution_runner/checkpoint/validators/operations/callback.py +45 -0
- async_durable_execution_runner/checkpoint/validators/operations/context.py +73 -0
- async_durable_execution_runner/checkpoint/validators/operations/execution.py +47 -0
- async_durable_execution_runner/checkpoint/validators/operations/invoke.py +56 -0
- async_durable_execution_runner/checkpoint/validators/operations/step.py +106 -0
- async_durable_execution_runner/checkpoint/validators/operations/wait.py +54 -0
- async_durable_execution_runner/checkpoint/validators/transitions.py +66 -0
- async_durable_execution_runner/cli.py +498 -0
- async_durable_execution_runner/client.py +50 -0
- async_durable_execution_runner/exceptions.py +288 -0
- async_durable_execution_runner/execution.py +444 -0
- async_durable_execution_runner/executor.py +1234 -0
- async_durable_execution_runner/invoker.py +340 -0
- async_durable_execution_runner/model.py +3296 -0
- async_durable_execution_runner/observer.py +144 -0
- async_durable_execution_runner/py.typed +1 -0
- async_durable_execution_runner/runner.py +1167 -0
- async_durable_execution_runner/scheduler.py +246 -0
- async_durable_execution_runner/stores/__init__.py +1 -0
- async_durable_execution_runner/stores/base.py +147 -0
- async_durable_execution_runner/stores/filesystem.py +79 -0
- async_durable_execution_runner/stores/memory.py +38 -0
- async_durable_execution_runner/stores/sqlite.py +273 -0
- async_durable_execution_runner/token.py +49 -0
- async_durable_execution_runner/web/__init__.py +1 -0
- async_durable_execution_runner/web/errors.py +8 -0
- async_durable_execution_runner/web/handlers.py +813 -0
- async_durable_execution_runner/web/models.py +266 -0
- async_durable_execution_runner/web/routes.py +692 -0
- async_durable_execution_runner/web/serialization.py +235 -0
- async_durable_execution_runner/web/server.py +243 -0
- async_durable_execution_runner-2.0.0a1.dist-info/METADATA +238 -0
- async_durable_execution_runner-2.0.0a1.dist-info/RECORD +55 -0
- async_durable_execution_runner-2.0.0a1.dist-info/WHEEL +4 -0
- async_durable_execution_runner-2.0.0a1.dist-info/entry_points.txt +2 -0
- async_durable_execution_runner-2.0.0a1.dist-info/licenses/LICENSE +175 -0
- async_durable_execution_runner-2.0.0a1.dist-info/licenses/NOTICE +1 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Context operation processor for handling CONTEXT operation updates."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from async_durable_execution.lambda_service import (
|
|
8
|
+
Operation,
|
|
9
|
+
OperationAction,
|
|
10
|
+
OperationStatus,
|
|
11
|
+
OperationUpdate,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
from async_durable_execution_runner.checkpoint.processors.base import (
|
|
15
|
+
OperationProcessor,
|
|
16
|
+
)
|
|
17
|
+
from async_durable_execution_runner.exceptions import (
|
|
18
|
+
InvalidParameterValueException,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from async_durable_execution_runner.observer import ExecutionNotifier
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ContextProcessor(OperationProcessor):
|
|
27
|
+
"""Processes CONTEXT operation updates for execution context management."""
|
|
28
|
+
|
|
29
|
+
def process(
|
|
30
|
+
self,
|
|
31
|
+
update: OperationUpdate,
|
|
32
|
+
current_op: Operation | None,
|
|
33
|
+
notifier: ExecutionNotifier, # noqa: ARG002
|
|
34
|
+
execution_arn: str, # noqa: ARG002
|
|
35
|
+
) -> Operation:
|
|
36
|
+
"""Process CONTEXT operation update for context state transitions."""
|
|
37
|
+
match update.action:
|
|
38
|
+
case OperationAction.START:
|
|
39
|
+
# TODO: check for "Cannot start a CONTEXT operation that already exists."
|
|
40
|
+
return self._translate_update_to_operation(
|
|
41
|
+
update=update,
|
|
42
|
+
current_operation=current_op,
|
|
43
|
+
status=OperationStatus.STARTED,
|
|
44
|
+
)
|
|
45
|
+
case OperationAction.SUCCEED:
|
|
46
|
+
return self._translate_update_to_operation(
|
|
47
|
+
update=update,
|
|
48
|
+
current_operation=current_op,
|
|
49
|
+
status=OperationStatus.SUCCEEDED,
|
|
50
|
+
)
|
|
51
|
+
case OperationAction.FAIL:
|
|
52
|
+
return self._translate_update_to_operation(
|
|
53
|
+
update=update,
|
|
54
|
+
current_operation=current_op,
|
|
55
|
+
status=OperationStatus.FAILED,
|
|
56
|
+
)
|
|
57
|
+
case _:
|
|
58
|
+
msg: str = "Invalid action for CONTEXT operation."
|
|
59
|
+
raise InvalidParameterValueException(msg)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Execution operation processor for handling EXECUTION operation updates."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from async_durable_execution.lambda_service import (
|
|
8
|
+
ErrorObject,
|
|
9
|
+
Operation,
|
|
10
|
+
OperationAction,
|
|
11
|
+
OperationUpdate,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
from async_durable_execution_runner.checkpoint.processors.base import (
|
|
15
|
+
OperationProcessor,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from async_durable_execution_runner.observer import ExecutionNotifier
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ExecutionProcessor(OperationProcessor):
|
|
24
|
+
"""Processes EXECUTION operation updates for workflow completion."""
|
|
25
|
+
|
|
26
|
+
def process(
|
|
27
|
+
self,
|
|
28
|
+
update: OperationUpdate,
|
|
29
|
+
current_op: Operation | None, # noqa: ARG002
|
|
30
|
+
notifier: ExecutionNotifier,
|
|
31
|
+
execution_arn: str,
|
|
32
|
+
) -> Operation | None:
|
|
33
|
+
"""Process EXECUTION operation update for workflow completion/failure."""
|
|
34
|
+
match update.action:
|
|
35
|
+
case OperationAction.SUCCEED:
|
|
36
|
+
notifier.notify_completed(
|
|
37
|
+
execution_arn=execution_arn, result=update.payload
|
|
38
|
+
)
|
|
39
|
+
case _:
|
|
40
|
+
# intentional. actual service will fail any EXECUTION update that is not SUCCEED.
|
|
41
|
+
error = (
|
|
42
|
+
update.error
|
|
43
|
+
if update.error
|
|
44
|
+
else ErrorObject.from_message(
|
|
45
|
+
"There is no error details but EXECUTION checkpoint action is not SUCCEED."
|
|
46
|
+
)
|
|
47
|
+
)
|
|
48
|
+
# All EXECUTION failures go through normal fail path
|
|
49
|
+
# Timeout/Stop status is set by executor based on the operation that caused it
|
|
50
|
+
notifier.notify_failed(execution_arn=execution_arn, error=error)
|
|
51
|
+
# TODO: Svc doesn't actually create checkpoint for EXECUTION. might have to for localrunner though.
|
|
52
|
+
return None
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Step operation processor for handling STEP operation updates."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import UTC, datetime, timedelta
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from async_durable_execution.lambda_service import (
|
|
9
|
+
Operation,
|
|
10
|
+
OperationAction,
|
|
11
|
+
OperationStatus,
|
|
12
|
+
OperationUpdate,
|
|
13
|
+
StepDetails,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
from async_durable_execution_runner.checkpoint.processors.base import (
|
|
17
|
+
OperationProcessor,
|
|
18
|
+
)
|
|
19
|
+
from async_durable_execution_runner.exceptions import (
|
|
20
|
+
InvalidParameterValueException,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from async_durable_execution_runner.observer import ExecutionNotifier
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class StepProcessor(OperationProcessor):
|
|
29
|
+
"""Processes STEP operation updates with retry scheduling."""
|
|
30
|
+
|
|
31
|
+
def process(
|
|
32
|
+
self,
|
|
33
|
+
update: OperationUpdate,
|
|
34
|
+
current_op: Operation | None,
|
|
35
|
+
notifier: ExecutionNotifier,
|
|
36
|
+
execution_arn: str,
|
|
37
|
+
) -> Operation:
|
|
38
|
+
"""Process STEP operation update with scheduler integration for retries."""
|
|
39
|
+
match update.action:
|
|
40
|
+
case OperationAction.START:
|
|
41
|
+
return self._translate_update_to_operation(
|
|
42
|
+
update=update,
|
|
43
|
+
current_operation=current_op,
|
|
44
|
+
status=OperationStatus.STARTED,
|
|
45
|
+
)
|
|
46
|
+
case OperationAction.RETRY:
|
|
47
|
+
# set Status=PENDING, next attempt time, attempt count + 1
|
|
48
|
+
delay = (
|
|
49
|
+
update.step_options.next_attempt_delay_seconds
|
|
50
|
+
if update.step_options
|
|
51
|
+
else 0
|
|
52
|
+
)
|
|
53
|
+
next_attempt_time = datetime.now(UTC) + timedelta(seconds=delay)
|
|
54
|
+
|
|
55
|
+
# Build new step_details with incremented attempt
|
|
56
|
+
current_attempt = (
|
|
57
|
+
current_op.step_details.attempt
|
|
58
|
+
if current_op and current_op.step_details
|
|
59
|
+
else 0
|
|
60
|
+
)
|
|
61
|
+
new_step_details = StepDetails(
|
|
62
|
+
attempt=current_attempt + 1,
|
|
63
|
+
next_attempt_timestamp=next_attempt_time,
|
|
64
|
+
result=(
|
|
65
|
+
current_op.step_details.result
|
|
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
|
+
),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Create new operation with updated step_details
|
|
77
|
+
retry_operation = Operation(
|
|
78
|
+
operation_id=update.operation_id,
|
|
79
|
+
operation_type=update.operation_type,
|
|
80
|
+
status=OperationStatus.PENDING,
|
|
81
|
+
parent_id=update.parent_id,
|
|
82
|
+
name=update.name,
|
|
83
|
+
start_timestamp=(
|
|
84
|
+
current_op.start_timestamp if current_op else datetime.now(UTC)
|
|
85
|
+
),
|
|
86
|
+
end_timestamp=None,
|
|
87
|
+
sub_type=update.sub_type,
|
|
88
|
+
execution_details=current_op.execution_details
|
|
89
|
+
if current_op
|
|
90
|
+
else None,
|
|
91
|
+
context_details=current_op.context_details if current_op else None,
|
|
92
|
+
step_details=new_step_details,
|
|
93
|
+
wait_details=current_op.wait_details if current_op else None,
|
|
94
|
+
callback_details=current_op.callback_details
|
|
95
|
+
if current_op
|
|
96
|
+
else None,
|
|
97
|
+
chained_invoke_details=current_op.chained_invoke_details
|
|
98
|
+
if current_op
|
|
99
|
+
else None,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# Schedule step retry timer to fire after delay
|
|
103
|
+
notifier.notify_step_retry_scheduled(
|
|
104
|
+
execution_arn=execution_arn,
|
|
105
|
+
operation_id=update.operation_id,
|
|
106
|
+
delay=delay,
|
|
107
|
+
)
|
|
108
|
+
return retry_operation
|
|
109
|
+
case OperationAction.SUCCEED:
|
|
110
|
+
return self._translate_update_to_operation(
|
|
111
|
+
update=update,
|
|
112
|
+
current_operation=current_op,
|
|
113
|
+
status=OperationStatus.SUCCEEDED,
|
|
114
|
+
)
|
|
115
|
+
case OperationAction.FAIL:
|
|
116
|
+
return self._translate_update_to_operation(
|
|
117
|
+
update=update,
|
|
118
|
+
current_operation=current_op,
|
|
119
|
+
status=OperationStatus.FAILED,
|
|
120
|
+
)
|
|
121
|
+
case _:
|
|
122
|
+
msg: str = "Invalid action for STEP operation."
|
|
123
|
+
|
|
124
|
+
raise InvalidParameterValueException(msg)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Wait operation processor for handling WAIT operation updates."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
from datetime import UTC, datetime, timedelta
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from async_durable_execution.lambda_service import (
|
|
11
|
+
Operation,
|
|
12
|
+
OperationAction,
|
|
13
|
+
OperationStatus,
|
|
14
|
+
OperationUpdate,
|
|
15
|
+
WaitDetails,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
from async_durable_execution_runner.checkpoint.processors.base import (
|
|
19
|
+
OperationProcessor,
|
|
20
|
+
)
|
|
21
|
+
from async_durable_execution_runner.exceptions import (
|
|
22
|
+
InvalidParameterValueException,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from async_durable_execution_runner.observer import ExecutionNotifier
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class WaitProcessor(OperationProcessor):
|
|
31
|
+
"""Processes WAIT operation updates with timer scheduling."""
|
|
32
|
+
|
|
33
|
+
def process(
|
|
34
|
+
self,
|
|
35
|
+
update: OperationUpdate,
|
|
36
|
+
current_op: Operation | None,
|
|
37
|
+
notifier: ExecutionNotifier,
|
|
38
|
+
execution_arn: str,
|
|
39
|
+
) -> Operation:
|
|
40
|
+
"""Process WAIT operation update with scheduler integration for timers."""
|
|
41
|
+
match update.action:
|
|
42
|
+
case OperationAction.START:
|
|
43
|
+
wait_seconds = (
|
|
44
|
+
update.wait_options.wait_seconds if update.wait_options else 0
|
|
45
|
+
)
|
|
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
|
+
|
|
50
|
+
scheduled_end_timestamp = datetime.now(UTC) + timedelta(
|
|
51
|
+
seconds=scaled_wait_seconds
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Create WaitDetails with scheduled timestamp
|
|
55
|
+
wait_details = WaitDetails(
|
|
56
|
+
scheduled_end_timestamp=scheduled_end_timestamp
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# Create new operation with wait details
|
|
60
|
+
wait_operation = Operation(
|
|
61
|
+
operation_id=update.operation_id,
|
|
62
|
+
operation_type=update.operation_type,
|
|
63
|
+
status=OperationStatus.STARTED,
|
|
64
|
+
parent_id=update.parent_id,
|
|
65
|
+
name=update.name,
|
|
66
|
+
start_timestamp=datetime.now(UTC),
|
|
67
|
+
end_timestamp=None,
|
|
68
|
+
sub_type=update.sub_type,
|
|
69
|
+
execution_details=None,
|
|
70
|
+
context_details=None,
|
|
71
|
+
step_details=None,
|
|
72
|
+
wait_details=wait_details,
|
|
73
|
+
callback_details=None,
|
|
74
|
+
chained_invoke_details=None,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# Schedule wait timer to complete after delay
|
|
78
|
+
notifier.notify_wait_timer_scheduled(
|
|
79
|
+
execution_arn=execution_arn,
|
|
80
|
+
operation_id=update.operation_id,
|
|
81
|
+
delay=scaled_wait_seconds,
|
|
82
|
+
)
|
|
83
|
+
return wait_operation
|
|
84
|
+
case OperationAction.CANCEL:
|
|
85
|
+
# TODO: need to cancel the WAIT in the executor
|
|
86
|
+
# TODO: increase sequence id
|
|
87
|
+
return self._translate_update_to_operation(
|
|
88
|
+
update=update,
|
|
89
|
+
current_operation=current_op,
|
|
90
|
+
status=OperationStatus.CANCELLED,
|
|
91
|
+
)
|
|
92
|
+
case _:
|
|
93
|
+
msg: str = "Invalid action for WAIT operation."
|
|
94
|
+
|
|
95
|
+
raise InvalidParameterValueException(msg)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Operation transformer for converting OperationUpdates to Operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from async_durable_execution.lambda_service import (
|
|
8
|
+
Operation,
|
|
9
|
+
OperationType,
|
|
10
|
+
OperationUpdate,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
from async_durable_execution_runner.checkpoint.processors.callback import (
|
|
14
|
+
CallbackProcessor,
|
|
15
|
+
)
|
|
16
|
+
from async_durable_execution_runner.checkpoint.processors.context import (
|
|
17
|
+
ContextProcessor,
|
|
18
|
+
)
|
|
19
|
+
from async_durable_execution_runner.checkpoint.processors.execution import (
|
|
20
|
+
ExecutionProcessor,
|
|
21
|
+
)
|
|
22
|
+
from async_durable_execution_runner.checkpoint.processors.step import (
|
|
23
|
+
StepProcessor,
|
|
24
|
+
)
|
|
25
|
+
from async_durable_execution_runner.checkpoint.processors.wait import (
|
|
26
|
+
WaitProcessor,
|
|
27
|
+
)
|
|
28
|
+
from async_durable_execution_runner.exceptions import (
|
|
29
|
+
InvalidParameterValueException,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from collections.abc import MutableMapping
|
|
35
|
+
|
|
36
|
+
from async_durable_execution_runner.checkpoint.processors.base import (
|
|
37
|
+
OperationProcessor,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
from typing import ClassVar
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class OperationTransformer:
|
|
44
|
+
"""Transforms OperationUpdates to Operations while maintaining order and triggering scheduler actions."""
|
|
45
|
+
|
|
46
|
+
_DEFAULT_PROCESSORS: ClassVar[dict[OperationType, OperationProcessor]] = {
|
|
47
|
+
OperationType.STEP: StepProcessor(),
|
|
48
|
+
OperationType.WAIT: WaitProcessor(),
|
|
49
|
+
OperationType.CONTEXT: ContextProcessor(),
|
|
50
|
+
OperationType.CALLBACK: CallbackProcessor(),
|
|
51
|
+
OperationType.EXECUTION: ExecutionProcessor(),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
processors: MutableMapping[OperationType, OperationProcessor] | None = None,
|
|
57
|
+
):
|
|
58
|
+
self.processors = processors if processors else self._DEFAULT_PROCESSORS
|
|
59
|
+
|
|
60
|
+
def process_updates(
|
|
61
|
+
self,
|
|
62
|
+
updates: list[OperationUpdate],
|
|
63
|
+
current_operations: list[Operation],
|
|
64
|
+
notifier,
|
|
65
|
+
execution_arn: str,
|
|
66
|
+
) -> tuple[list[Operation], list[OperationUpdate]]:
|
|
67
|
+
"""Transform updates maintaining operation order and return (operations, updates)."""
|
|
68
|
+
op_map = {op.operation_id: op for op in current_operations}
|
|
69
|
+
|
|
70
|
+
# Start with copy of current operations list
|
|
71
|
+
result_operations = current_operations.copy()
|
|
72
|
+
|
|
73
|
+
for update in updates:
|
|
74
|
+
processor = self.processors.get(update.operation_type)
|
|
75
|
+
if processor:
|
|
76
|
+
current_op = op_map.get(update.operation_id)
|
|
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
|
+
)
|
|
102
|
+
raise InvalidParameterValueException(msg)
|
|
103
|
+
|
|
104
|
+
return result_operations, updates
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Checkpoint validation module."""
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Main checkpoint input validator."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from async_durable_execution.lambda_service import (
|
|
9
|
+
OperationAction,
|
|
10
|
+
OperationType,
|
|
11
|
+
OperationUpdate,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
from async_durable_execution_runner.checkpoint.validators.operations.callback import (
|
|
15
|
+
CallbackOperationValidator,
|
|
16
|
+
)
|
|
17
|
+
from async_durable_execution_runner.checkpoint.validators.operations.context import (
|
|
18
|
+
ContextOperationValidator,
|
|
19
|
+
)
|
|
20
|
+
from async_durable_execution_runner.checkpoint.validators.operations.execution import (
|
|
21
|
+
ExecutionOperationValidator,
|
|
22
|
+
)
|
|
23
|
+
from async_durable_execution_runner.checkpoint.validators.operations.invoke import (
|
|
24
|
+
ChainedInvokeOperationValidator,
|
|
25
|
+
)
|
|
26
|
+
from async_durable_execution_runner.checkpoint.validators.operations.step import (
|
|
27
|
+
StepOperationValidator,
|
|
28
|
+
)
|
|
29
|
+
from async_durable_execution_runner.checkpoint.validators.operations.wait import (
|
|
30
|
+
WaitOperationValidator,
|
|
31
|
+
)
|
|
32
|
+
from async_durable_execution_runner.checkpoint.validators.transitions import (
|
|
33
|
+
ValidActionsByOperationTypeValidator,
|
|
34
|
+
)
|
|
35
|
+
from async_durable_execution_runner.exceptions import (
|
|
36
|
+
InvalidParameterValueException,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from collections.abc import MutableMapping
|
|
42
|
+
|
|
43
|
+
from async_durable_execution_runner.execution import Execution
|
|
44
|
+
|
|
45
|
+
MAX_ERROR_PAYLOAD_SIZE_BYTES = 32768
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class CheckpointValidator:
|
|
49
|
+
"""Validates checkpoint input based on current state."""
|
|
50
|
+
|
|
51
|
+
@staticmethod
|
|
52
|
+
def validate_input(updates: list[OperationUpdate], execution: Execution) -> None:
|
|
53
|
+
"""Perform validation on the given input based on the current state."""
|
|
54
|
+
if not updates:
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
CheckpointValidator._validate_conflicting_execution_update(updates)
|
|
58
|
+
CheckpointValidator._validate_parent_id_and_duplicate_id(updates, execution)
|
|
59
|
+
|
|
60
|
+
for update in updates:
|
|
61
|
+
CheckpointValidator._validate_operation_update(update, execution)
|
|
62
|
+
|
|
63
|
+
@staticmethod
|
|
64
|
+
def _validate_conflicting_execution_update(updates: list[OperationUpdate]) -> None:
|
|
65
|
+
"""Validate that there are no conflicting execution updates."""
|
|
66
|
+
execution_updates = [
|
|
67
|
+
update
|
|
68
|
+
for update in updates
|
|
69
|
+
if update.operation_type == OperationType.EXECUTION
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
if len(execution_updates) > 1:
|
|
73
|
+
msg_multiple_exec: str = "Cannot checkpoint multiple EXECUTION updates."
|
|
74
|
+
|
|
75
|
+
raise InvalidParameterValueException(msg_multiple_exec)
|
|
76
|
+
|
|
77
|
+
if execution_updates and updates[-1].operation_type != OperationType.EXECUTION:
|
|
78
|
+
msg_exec_last: str = "EXECUTION checkpoint must be the last update."
|
|
79
|
+
|
|
80
|
+
raise InvalidParameterValueException(msg_exec_last)
|
|
81
|
+
|
|
82
|
+
@staticmethod
|
|
83
|
+
def _validate_operation_update(
|
|
84
|
+
update: OperationUpdate, execution: Execution
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Validate a single operation update."""
|
|
87
|
+
CheckpointValidator._validate_inconsistent_operation_metadata(update, execution)
|
|
88
|
+
CheckpointValidator._validate_payload_sizes(update)
|
|
89
|
+
ValidActionsByOperationTypeValidator.validate(
|
|
90
|
+
update.operation_type, update.action
|
|
91
|
+
)
|
|
92
|
+
CheckpointValidator._validate_operation_status_transition(update, execution)
|
|
93
|
+
|
|
94
|
+
@staticmethod
|
|
95
|
+
def _validate_payload_sizes(update: OperationUpdate) -> None:
|
|
96
|
+
"""Validate that operation payload sizes are not too large."""
|
|
97
|
+
if update.error is not None:
|
|
98
|
+
payload = json.dumps(update.error.to_dict())
|
|
99
|
+
if len(payload) > MAX_ERROR_PAYLOAD_SIZE_BYTES:
|
|
100
|
+
msg: str = f"Error object size must be less than {MAX_ERROR_PAYLOAD_SIZE_BYTES} bytes."
|
|
101
|
+
raise InvalidParameterValueException(msg)
|
|
102
|
+
|
|
103
|
+
@staticmethod
|
|
104
|
+
def _validate_operation_status_transition(
|
|
105
|
+
update: OperationUpdate, execution: Execution
|
|
106
|
+
) -> None:
|
|
107
|
+
"""Validate that the operation status transition is valid."""
|
|
108
|
+
current_state = None
|
|
109
|
+
for operation in execution.operations:
|
|
110
|
+
if operation.operation_id == update.operation_id:
|
|
111
|
+
current_state = operation
|
|
112
|
+
break
|
|
113
|
+
|
|
114
|
+
match update.operation_type:
|
|
115
|
+
case OperationType.STEP:
|
|
116
|
+
StepOperationValidator.validate(current_state, update)
|
|
117
|
+
case OperationType.CONTEXT:
|
|
118
|
+
ContextOperationValidator.validate(current_state, update)
|
|
119
|
+
case OperationType.WAIT:
|
|
120
|
+
WaitOperationValidator.validate(current_state, update)
|
|
121
|
+
case OperationType.CALLBACK:
|
|
122
|
+
CallbackOperationValidator.validate(current_state, update)
|
|
123
|
+
case OperationType.CHAINED_INVOKE:
|
|
124
|
+
ChainedInvokeOperationValidator.validate(current_state, update)
|
|
125
|
+
case OperationType.EXECUTION:
|
|
126
|
+
ExecutionOperationValidator.validate(update)
|
|
127
|
+
case _: # pragma: no cover
|
|
128
|
+
msg: str = "Invalid operation type."
|
|
129
|
+
|
|
130
|
+
raise InvalidParameterValueException(msg)
|
|
131
|
+
|
|
132
|
+
@staticmethod
|
|
133
|
+
def _validate_inconsistent_operation_metadata(
|
|
134
|
+
update: OperationUpdate, execution: Execution
|
|
135
|
+
) -> None:
|
|
136
|
+
"""Validate that operation metadata is consistent with existing operation."""
|
|
137
|
+
current_state = None
|
|
138
|
+
for operation in execution.operations:
|
|
139
|
+
if operation.operation_id == update.operation_id:
|
|
140
|
+
current_state = operation
|
|
141
|
+
break
|
|
142
|
+
|
|
143
|
+
if current_state is not None:
|
|
144
|
+
if (
|
|
145
|
+
update.operation_type is not None
|
|
146
|
+
and update.operation_type != current_state.operation_type
|
|
147
|
+
):
|
|
148
|
+
msg: str = "Inconsistent operation type."
|
|
149
|
+
raise InvalidParameterValueException(msg)
|
|
150
|
+
|
|
151
|
+
if (
|
|
152
|
+
update.sub_type is not None
|
|
153
|
+
and update.sub_type != current_state.sub_type
|
|
154
|
+
):
|
|
155
|
+
msg_subtype: str = "Inconsistent operation subtype."
|
|
156
|
+
raise InvalidParameterValueException(msg_subtype)
|
|
157
|
+
|
|
158
|
+
if update.name is not None and update.name != current_state.name:
|
|
159
|
+
msg_name: str = "Inconsistent operation name."
|
|
160
|
+
raise InvalidParameterValueException(msg_name)
|
|
161
|
+
|
|
162
|
+
if (
|
|
163
|
+
update.parent_id is not None
|
|
164
|
+
and update.parent_id != current_state.parent_id
|
|
165
|
+
):
|
|
166
|
+
msg_parent: str = "Inconsistent parent operation id."
|
|
167
|
+
raise InvalidParameterValueException(msg_parent)
|
|
168
|
+
|
|
169
|
+
@staticmethod
|
|
170
|
+
def _validate_parent_id_and_duplicate_id(
|
|
171
|
+
updates: list[OperationUpdate], execution: Execution
|
|
172
|
+
) -> None:
|
|
173
|
+
"""Validate parent IDs and check for duplicate operation IDs.
|
|
174
|
+
|
|
175
|
+
Validate that any provided parentId is valid, and also validate no duplicate operation is being
|
|
176
|
+
updated at the same time (unless it is a STEP/CONTEXT starting + performing one more non-START action).
|
|
177
|
+
"""
|
|
178
|
+
operations_started: MutableMapping[str, OperationUpdate] = {}
|
|
179
|
+
last_updates_seen: MutableMapping[str, OperationUpdate] = {}
|
|
180
|
+
|
|
181
|
+
for update in updates:
|
|
182
|
+
if CheckpointValidator._is_invalid_duplicate_update(
|
|
183
|
+
update, last_updates_seen
|
|
184
|
+
):
|
|
185
|
+
msg_duplicate: str = (
|
|
186
|
+
"Cannot checkpoint multiple operations with the same ID."
|
|
187
|
+
)
|
|
188
|
+
raise InvalidParameterValueException(msg_duplicate)
|
|
189
|
+
|
|
190
|
+
if not CheckpointValidator._is_valid_parent_for_update(
|
|
191
|
+
execution, update, operations_started
|
|
192
|
+
):
|
|
193
|
+
msg_parent: str = "Invalid parent operation id."
|
|
194
|
+
raise InvalidParameterValueException(msg_parent)
|
|
195
|
+
|
|
196
|
+
if update.action == OperationAction.START:
|
|
197
|
+
operations_started[update.operation_id] = update
|
|
198
|
+
|
|
199
|
+
last_updates_seen[update.operation_id] = update
|
|
200
|
+
|
|
201
|
+
@staticmethod
|
|
202
|
+
def _is_invalid_duplicate_update(
|
|
203
|
+
update: OperationUpdate, last_updates_seen: MutableMapping[str, OperationUpdate]
|
|
204
|
+
) -> bool:
|
|
205
|
+
"""Check if this is an invalid duplicate update."""
|
|
206
|
+
last_update = last_updates_seen.get(update.operation_id)
|
|
207
|
+
if last_update is None:
|
|
208
|
+
return False
|
|
209
|
+
|
|
210
|
+
if last_update.operation_type in (OperationType.STEP, OperationType.CONTEXT):
|
|
211
|
+
# Allow duplicate for STEP/CONTEXT if last was START and current is not START
|
|
212
|
+
allow_duplicate = (
|
|
213
|
+
last_update.action == OperationAction.START
|
|
214
|
+
and update.action != OperationAction.START
|
|
215
|
+
)
|
|
216
|
+
return not allow_duplicate
|
|
217
|
+
|
|
218
|
+
return True
|
|
219
|
+
|
|
220
|
+
@staticmethod
|
|
221
|
+
def _is_valid_parent_for_update(
|
|
222
|
+
execution: Execution,
|
|
223
|
+
update: OperationUpdate,
|
|
224
|
+
operations_started: MutableMapping[str, OperationUpdate],
|
|
225
|
+
) -> bool:
|
|
226
|
+
"""Check if the parent ID is valid for the update."""
|
|
227
|
+
parent_id = update.parent_id
|
|
228
|
+
|
|
229
|
+
if parent_id is None:
|
|
230
|
+
return True
|
|
231
|
+
|
|
232
|
+
# Check if parent is in operations started in this batch
|
|
233
|
+
if parent_id in operations_started:
|
|
234
|
+
parent_update = operations_started[parent_id]
|
|
235
|
+
return parent_update.operation_type == OperationType.CONTEXT
|
|
236
|
+
|
|
237
|
+
# Check if parent exists in current execution state
|
|
238
|
+
for operation in execution.operations:
|
|
239
|
+
if operation.operation_id == parent_id:
|
|
240
|
+
return operation.operation_type == OperationType.CONTEXT
|
|
241
|
+
|
|
242
|
+
return False
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Operation-specific validators."""
|