Skip to content

Commit fa6190d

Browse files
committed
Merge remote-tracking branch 'origin/main' into refactor/per-invocation-plugin-instances
# Conflicts: # packages/aws-durable-execution-sdk-python-conformance-tests-otel/pyproject.toml # packages/aws-durable-execution-sdk-python-conformance-tests/pyproject.toml # packages/aws-durable-execution-sdk-python/src/aws_durable_execution_sdk_python/__about__.py
2 parents 9b88343 + 2313fcb commit fa6190d

44 files changed

Lines changed: 6750 additions & 334 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/cloud-tests.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ permissions:
2424
jobs:
2525
example-tests:
2626
name: example-tests (${{ matrix.python-version }})
27+
# 1. Run for non-PR events, such as scheduled runs and manual invocations
28+
# 2. For PRs, run only same-repo branches from non-Dependabot actors; forked PRs or Dependabot should not access secrets.
29+
if: github.event_name != 'pull_request' || (github.actor != 'dependabot[bot]' && github.event.pull_request.head.repo.full_name == github.repository)
2730
runs-on: ubuntu-latest
2831
permissions:
2932
contents: read

.github/workflows/conformance-tests.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ permissions:
3939
jobs:
4040
discover_suites:
4141
name: discover conformance suites
42+
# 1. Run for non-PR events, such as scheduled runs and manual invocations
43+
# 2. For PRs, run only same-repo branches from non-Dependabot actors; forked PRs or Dependabot should not access secrets.
44+
if: github.event_name != 'pull_request' || (github.actor != 'dependabot[bot]' && github.event.pull_request.head.repo.full_name == github.repository)
4245
runs-on: ubuntu-latest
4346
outputs:
4447
suites: ${{ steps.discover.outputs.suites }}

.github/workflows/opentelemetry-conformance-tests.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ permissions: {}
5252

5353
jobs:
5454
opentelemetry:
55+
# 1. Run for non-PR events, such as scheduled runs and manual invocations
56+
# 2. For PRs, run only same-repo branches from non-Dependabot actors; forked PRs or Dependabot should not access secrets.
57+
if: github.event_name != 'pull_request' || (github.actor != 'dependabot[bot]' && github.event.pull_request.head.repo.full_name == github.repository)
5558
permissions:
5659
actions: write
5760
contents: read

packages/aws-durable-execution-sdk-python-testing/README.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
- [Installation](#installation)
1414
- [Quick Start](#quick-start)
15+
- [Testing functions that invoke other functions](#testing-functions-that-invoke-other-functions)
1516
- [Architecture](#architecture)
1617
- [Documentation](#documentation)
1718
- [Developer Guide](#developers)
@@ -111,6 +112,55 @@ def test_my_durable_functions():
111112
three_result: StepOperation = result.get_step("three")
112113
assert three_result.result == '"5 6"'
113114
```
115+
## Testing functions that invoke other functions
116+
117+
A durable function can call another function with `context.invoke`. The
118+
runner needs to know each target's kind: a durable target runs as a
119+
child durable execution with its own history, while a plain (non-durable)
120+
target is one invocation whose return value is the result. An unknown
121+
target fails the operation with `ResourceNotFoundException`.
122+
123+
### In-process runner
124+
125+
Register each target with the runner. `register_durable_function` marks a
126+
durable target, and takes its execution timeout and retention;
127+
`register_function` marks a plain one. The runner has one handler per
128+
registered name, not versions: an invoke of `child:prod` runs the handler
129+
registered as `child:prod` if there is one, otherwise the one registered
130+
as `child`.
131+
132+
```python
133+
from aws_durable_execution_sdk_python.context import DurableContext
134+
from aws_durable_execution_sdk_python.execution import durable_execution
135+
from aws_durable_execution_sdk_python_testing.runner import DurableFunctionTestRunner
136+
137+
@durable_execution
138+
def process_payment(event: dict, context: DurableContext) -> dict:
139+
return {"charged": event["amount"]}
140+
141+
def lookup_price(event: dict, context) -> dict:
142+
return {"price": 25}
143+
144+
@durable_execution
145+
def place_order(event: dict, context: DurableContext) -> dict:
146+
price = context.invoke("lookup-price", {"sku": event["sku"]}, name="price")
147+
payment = context.invoke(
148+
"process-payment", {"amount": price["price"]}, name="payment"
149+
)
150+
return {"sku": event["sku"], "charged": payment["charged"]}
151+
152+
def test_place_order_invokes_both_functions():
153+
with DurableFunctionTestRunner(handler=place_order) as runner:
154+
runner.register_durable_function(
155+
"process-payment", process_payment, execution_timeout=60
156+
)
157+
runner.register_function("lookup-price", lookup_price)
158+
result = runner.run(input='{"sku": "book-1"}')
159+
160+
assert result.result == '{"sku": "book-1", "charged": 25}'
161+
assert result.get_invoke("payment").status.value == "SUCCEEDED"
162+
```
163+
114164
## Architecture
115165

116166
See [docs/architecture.md](docs/architecture.md) for framework

packages/aws-durable-execution-sdk-python-testing/src/aws_durable_execution_sdk_python_testing/checkpoint/effects.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,15 @@ class CallbackCreated:
4747
callback_token: CallbackToken
4848

4949

50-
CheckpointEffect = Completed | Failed | CallbackCreated
50+
@dataclass(frozen=True)
51+
class ChainedInvokeStarted:
52+
"""A chained invoke was accepted and its target must be dispatched."""
53+
54+
execution_arn: str
55+
operation_id: str
56+
function_name: str
57+
tenant_id: str | None
58+
payload: str | None
59+
60+
61+
CheckpointEffect = Completed | Failed | CallbackCreated | ChainedInvokeStarted

packages/aws-durable-execution-sdk-python-testing/src/aws_durable_execution_sdk_python_testing/checkpoint/processor.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,12 @@
2929

3030

3131
if TYPE_CHECKING:
32-
from aws_durable_execution_sdk_python.lambda_service import OperationUpdate
32+
from collections.abc import Callable
33+
34+
from aws_durable_execution_sdk_python.lambda_service import (
35+
ErrorObject,
36+
OperationUpdate,
37+
)
3338

3439
from aws_durable_execution_sdk_python_testing.clock import Clock
3540
from aws_durable_execution_sdk_python_testing.execution import Execution
@@ -68,6 +73,18 @@ def add_execution_observer(self, observer: ExecutionObserver) -> None:
6873
"""Add observer for execution events."""
6974
self._observers.append(observer)
7075

76+
def set_chained_invoke_preflight(
77+
self, preflight: Callable[[str], ErrorObject | None]
78+
) -> None:
79+
"""Resolve chained-invoke targets at checkpoint time with ``preflight``.
80+
81+
A target it fails comes back FAILED in the checkpoint response
82+
instead of being dispatched (see ``ChainedInvokeProcessor``).
83+
"""
84+
self._dispatcher = CheckpointRequestDispatcher(
85+
chained_invoke_preflight=preflight
86+
)
87+
7188
def process_checkpoint(
7289
self,
7390
checkpoint_token: str,

packages/aws-durable-execution-sdk-python-testing/src/aws_durable_execution_sdk_python_testing/checkpoint/processors/base.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,19 @@ def process(
4444
"""
4545
raise NotImplementedError
4646

47+
def stored_update(
48+
self,
49+
update: OperationUpdate,
50+
current_op: Operation | None, # noqa: ARG002
51+
updated_op: Operation, # noqa: ARG002
52+
) -> OperationUpdate:
53+
"""The update to keep in the execution's record for history.
54+
55+
Most processors keep the update as sent. A processor overrides
56+
this when the service keeps less than it was sent.
57+
"""
58+
return update
59+
4760
def _get_start_time(
4861
self, current_operation: Operation | None, now: datetime.datetime
4962
) -> datetime.datetime | None:
Original file line numberDiff line numberDiff line change
@@ -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

packages/aws-durable-execution-sdk-python-testing/src/aws_durable_execution_sdk_python_testing/checkpoint/transformer.py

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@
2323
from aws_durable_execution_sdk_python_testing.checkpoint.processors.execution import (
2424
ExecutionProcessor,
2525
)
26+
from aws_durable_execution_sdk_python_testing.checkpoint.processors.invoke import (
27+
ChainedInvokeProcessor,
28+
)
2629
from aws_durable_execution_sdk_python_testing.checkpoint.processors.step import (
2730
StepProcessor,
2831
)
@@ -40,6 +43,7 @@
4043
from datetime import datetime
4144

4245
from aws_durable_execution_sdk_python.lambda_service import (
46+
ErrorObject,
4347
OperationUpdate,
4448
)
4549

@@ -69,13 +73,28 @@ class CheckpointRequestDispatcher:
6973
OperationType.CONTEXT: ContextProcessor(),
7074
OperationType.CALLBACK: CallbackProcessor(),
7175
OperationType.EXECUTION: ExecutionProcessor(),
76+
OperationType.CHAINED_INVOKE: ChainedInvokeProcessor(),
7277
}
7378

7479
def __init__(
7580
self,
7681
processors: MutableMapping[OperationType, OperationProcessor] | None = None,
82+
*,
83+
chained_invoke_preflight: Callable[[str], ErrorObject | None] | None = None,
7784
):
78-
self.processors = processors if processors else self._DEFAULT_PROCESSORS
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
7998

8099
def apply_updates(
81100
self,
@@ -112,6 +131,7 @@ def apply_updates(
112131
"""
113132
collector = ExecutionNotifier()
114133
op_map = {op.operation_id: op for op in execution.operations}
134+
stored_updates: list[OperationUpdate] = []
115135

116136
for update in updates:
117137
processor = self.processors.get(update.operation_type)
@@ -128,8 +148,11 @@ def apply_updates(
128148
now=now,
129149
)
130150
if updated_op is None:
151+
stored_updates.append(update)
131152
continue
132153

154+
stored = processor.stored_update(update, current_op, updated_op)
155+
stored_updates.append(stored)
133156
if update.operation_id in op_map:
134157
for i, op in enumerate(execution.operations): # pragma: no branch
135158
if op.operation_id == update.operation_id:
@@ -140,12 +163,12 @@ def apply_updates(
140163

141164
op_map[update.operation_id] = updated_op
142165
execution.operation_size_bytes[update.operation_id] = (
143-
_estimate_payload_size(update)
166+
_estimate_payload_size(stored)
144167
)
145168
touch(update.operation_id)
146169

147-
execution.updates.extend(updates)
148-
execution.update_timestamps.extend(now for _ in updates)
170+
execution.updates.extend(stored_updates)
171+
execution.update_timestamps.extend(now for _ in stored_updates)
149172

150173
return collector.effects
151174

0 commit comments

Comments
 (0)