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.
Files changed (48) hide show
  1. aws_durable_execution_sdk_python_testing/__about__.py +1 -1
  2. aws_durable_execution_sdk_python_testing/checkpoint/core.py +171 -0
  3. aws_durable_execution_sdk_python_testing/checkpoint/effects.py +61 -0
  4. aws_durable_execution_sdk_python_testing/checkpoint/processor.py +124 -45
  5. aws_durable_execution_sdk_python_testing/checkpoint/processors/base.py +40 -14
  6. aws_durable_execution_sdk_python_testing/checkpoint/processors/callback.py +5 -2
  7. aws_durable_execution_sdk_python_testing/checkpoint/processors/context.py +6 -0
  8. aws_durable_execution_sdk_python_testing/checkpoint/processors/execution.py +4 -9
  9. aws_durable_execution_sdk_python_testing/checkpoint/processors/invoke.py +139 -0
  10. aws_durable_execution_sdk_python_testing/checkpoint/processors/step.py +15 -21
  11. aws_durable_execution_sdk_python_testing/checkpoint/processors/wait.py +10 -16
  12. aws_durable_execution_sdk_python_testing/checkpoint/transformer.py +145 -48
  13. aws_durable_execution_sdk_python_testing/checkpoint/validators/checkpoint.py +65 -8
  14. aws_durable_execution_sdk_python_testing/checkpoint/validators/operations/invoke.py +79 -18
  15. aws_durable_execution_sdk_python_testing/child_dispatcher.py +828 -0
  16. aws_durable_execution_sdk_python_testing/cli.py +48 -1
  17. aws_durable_execution_sdk_python_testing/client.py +21 -10
  18. aws_durable_execution_sdk_python_testing/clock.py +69 -0
  19. aws_durable_execution_sdk_python_testing/exceptions.py +14 -0
  20. aws_durable_execution_sdk_python_testing/execution.py +583 -49
  21. aws_durable_execution_sdk_python_testing/executor.py +1813 -349
  22. aws_durable_execution_sdk_python_testing/invoker.py +356 -85
  23. aws_durable_execution_sdk_python_testing/model.py +81 -73
  24. aws_durable_execution_sdk_python_testing/observer.py +100 -88
  25. aws_durable_execution_sdk_python_testing/runner.py +407 -30
  26. aws_durable_execution_sdk_python_testing/scheduler.py +38 -13
  27. aws_durable_execution_sdk_python_testing/stores/filesystem.py +10 -2
  28. aws_durable_execution_sdk_python_testing/stores/sqlite.py +18 -6
  29. aws_durable_execution_sdk_python_testing/threads.py +157 -0
  30. aws_durable_execution_sdk_python_testing/token.py +11 -2
  31. aws_durable_execution_sdk_python_testing/web/handlers.py +33 -7
  32. aws_durable_execution_sdk_python_testing/web/serialization.py +1 -2
  33. aws_durable_execution_sdk_python_testing/web/server.py +1 -1
  34. aws_durable_execution_sdk_python_testing/worker/__init__.py +9 -0
  35. aws_durable_execution_sdk_python_testing/worker/checkpoint_tasks.py +119 -0
  36. aws_durable_execution_sdk_python_testing/worker/execution_worker.py +117 -0
  37. aws_durable_execution_sdk_python_testing/worker/lane.py +118 -0
  38. aws_durable_execution_sdk_python_testing/worker/registry.py +124 -0
  39. aws_durable_execution_sdk_python_testing/worker/status.py +27 -0
  40. aws_durable_execution_sdk_python_testing/worker/task.py +57 -0
  41. {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
  42. aws_durable_execution_sdk_python_testing-1.3.0.dist-info/RECORD +64 -0
  43. {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
  44. aws_durable_execution_sdk_python_testing/checkpoint/validators/transitions.py +0 -66
  45. aws_durable_execution_sdk_python_testing-1.2.1.dist-info/RECORD +0 -52
  46. {aws_durable_execution_sdk_python_testing-1.2.1.dist-info/licenses → aws_durable_execution_sdk_python_testing}/LICENSE +0 -0
  47. {aws_durable_execution_sdk_python_testing-1.2.1.dist-info/licenses → aws_durable_execution_sdk_python_testing}/NOTICE +0 -0
  48. {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 UTC, datetime, timedelta
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 = datetime.now(UTC) + timedelta(seconds=delay)
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
- 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
- ),
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
- # 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
- )
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 logging
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 = datetime.now(UTC) + timedelta(
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=datetime.now(UTC),
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
- # 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
- )
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
- """Operation transformer for converting OperationUpdates to Operations."""
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
- from typing import TYPE_CHECKING
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
- class OperationTransformer:
44
- """Transforms OperationUpdates to Operations while maintaining order and triggering scheduler actions."""
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
- self.processors = processors if processors else self._DEFAULT_PROCESSORS
59
-
60
- def process_updates(
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
- 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()
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
- 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
- )
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
- return result_operations, updates
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.checkpoint.validators.transitions import (
33
- ValidActionsByOperationTypeValidator,
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
- ValidActionsByOperationTypeValidator.validate(
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(current_state, update)
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