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.
Files changed (55) hide show
  1. LICENSE +175 -0
  2. NOTICE +8 -0
  3. VERSION.py +5 -0
  4. async_durable_execution_runner/__about__.py +33 -0
  5. async_durable_execution_runner/__init__.py +23 -0
  6. async_durable_execution_runner/checkpoint/__init__.py +1 -0
  7. async_durable_execution_runner/checkpoint/processor.py +101 -0
  8. async_durable_execution_runner/checkpoint/processors/__init__.py +1 -0
  9. async_durable_execution_runner/checkpoint/processors/base.py +199 -0
  10. async_durable_execution_runner/checkpoint/processors/callback.py +89 -0
  11. async_durable_execution_runner/checkpoint/processors/context.py +59 -0
  12. async_durable_execution_runner/checkpoint/processors/execution.py +52 -0
  13. async_durable_execution_runner/checkpoint/processors/step.py +124 -0
  14. async_durable_execution_runner/checkpoint/processors/wait.py +95 -0
  15. async_durable_execution_runner/checkpoint/transformer.py +104 -0
  16. async_durable_execution_runner/checkpoint/validators/__init__.py +1 -0
  17. async_durable_execution_runner/checkpoint/validators/checkpoint.py +242 -0
  18. async_durable_execution_runner/checkpoint/validators/operations/__init__.py +1 -0
  19. async_durable_execution_runner/checkpoint/validators/operations/callback.py +45 -0
  20. async_durable_execution_runner/checkpoint/validators/operations/context.py +73 -0
  21. async_durable_execution_runner/checkpoint/validators/operations/execution.py +47 -0
  22. async_durable_execution_runner/checkpoint/validators/operations/invoke.py +56 -0
  23. async_durable_execution_runner/checkpoint/validators/operations/step.py +106 -0
  24. async_durable_execution_runner/checkpoint/validators/operations/wait.py +54 -0
  25. async_durable_execution_runner/checkpoint/validators/transitions.py +66 -0
  26. async_durable_execution_runner/cli.py +498 -0
  27. async_durable_execution_runner/client.py +50 -0
  28. async_durable_execution_runner/exceptions.py +288 -0
  29. async_durable_execution_runner/execution.py +444 -0
  30. async_durable_execution_runner/executor.py +1234 -0
  31. async_durable_execution_runner/invoker.py +340 -0
  32. async_durable_execution_runner/model.py +3296 -0
  33. async_durable_execution_runner/observer.py +144 -0
  34. async_durable_execution_runner/py.typed +1 -0
  35. async_durable_execution_runner/runner.py +1167 -0
  36. async_durable_execution_runner/scheduler.py +246 -0
  37. async_durable_execution_runner/stores/__init__.py +1 -0
  38. async_durable_execution_runner/stores/base.py +147 -0
  39. async_durable_execution_runner/stores/filesystem.py +79 -0
  40. async_durable_execution_runner/stores/memory.py +38 -0
  41. async_durable_execution_runner/stores/sqlite.py +273 -0
  42. async_durable_execution_runner/token.py +49 -0
  43. async_durable_execution_runner/web/__init__.py +1 -0
  44. async_durable_execution_runner/web/errors.py +8 -0
  45. async_durable_execution_runner/web/handlers.py +813 -0
  46. async_durable_execution_runner/web/models.py +266 -0
  47. async_durable_execution_runner/web/routes.py +692 -0
  48. async_durable_execution_runner/web/serialization.py +235 -0
  49. async_durable_execution_runner/web/server.py +243 -0
  50. async_durable_execution_runner-2.0.0a1.dist-info/METADATA +238 -0
  51. async_durable_execution_runner-2.0.0a1.dist-info/RECORD +55 -0
  52. async_durable_execution_runner-2.0.0a1.dist-info/WHEEL +4 -0
  53. async_durable_execution_runner-2.0.0a1.dist-info/entry_points.txt +2 -0
  54. async_durable_execution_runner-2.0.0a1.dist-info/licenses/LICENSE +175 -0
  55. 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."""