Skip to content

Commit 43780e6

Browse files
authored
feat(extras): add the microvm operation (#769)
* feat(extras): add the microvm operation Add the aws-durable-execution-sdk-python-extras package with microvm(context, name, job_input, config). It runs one job in a new AWS Lambda MicroVM, in one child context: 1. A durable callback whose ID identifies the job. 2. A launch step that calls RunMicrovm with the job in the run hook payload. The client token is the SHA-256 of the callback ID, so a retried or replayed launch returns the same MicroVM. 3. A wait on the callback, which the worker in the MicroVM completes. 4. A terminate step after success, failure, and timeout. A terminate failure is logged, because the outcome is already recorded. Failures raise MicrovmLaunchError, MicrovmDeliveryError, MicrovmJobFailedError, or MicrovmTimeoutError. The operation rebuilds the class from the child context's checkpointed error type, so the first run and every replay raise the same class. A job too large for the run hook fails with MicrovmDeliveryError. HTTP delivery for larger jobs, and the session operation, follow in later changes. The operations record the default subtypes until the core SDK accepts custom ones. * fix(extras): address microvm review findings - Register the MicroVM error classes with the SDK's error registry. A caller's own child context, or a map item, now rebuilds the MicroVM error as the __cause__ of ChildContextError. - Take job_input and config by keyword only. A job and a name can both be strings, so swapped positional arguments ran the wrong job. - Terminate the MicroVM and fail when the launch checkpoint names a delivery that this release does not know. - Keep the stack trace of the launch step and of the MicroVM's report. - Escape only lone surrogates in the run hook payload, as JSON.stringify does. One surrogate no longer escapes the whole input. - Depend on the core SDK >=2.0.0,<3. 2.0.0 already has the APIs, and the bound guards one read of internal state. - README: publish only with MicroVM subtypes, because a later subtype change fails executions in flight. Document the cases in which no terminate runs, and when MicrovmLaunchError can leave a MicroVM. - Fix the client token and terminate comments. * fix(extras): bound the default MicroVM client - The default lambda-microvms client has a 5-second connect timeout, a 30-second read timeout, and 3 attempts in standard retry mode. botocore's defaults are 60 seconds each, with legacy retries, so one step attempt could outlive the invocation and record no outcome. - State that the 5-minute launch margin shrinks for a timeout near the 8-hour cap. - State that the job input is sent as JSON, so a tuple arrives as a list and a non-string dict key as a string. * fix(extras): count all attempts of the default client botocore's max_attempts counts only the retries, so the default client made 4 attempts where the docs state 3. total_max_attempts counts the first attempt too. * fix(extras): address the microvm live-run review - Checkpoint the launch step as plain JSON, as the JavaScript operation does, so the execution history shows the MicroVM ID. - Do not step-retry InsufficientCapacityException, which the service answers with HTTP 500. - Step-retry botocore's connection and HTTP client errors, and the RequestTimeout, RequestTimeoutException, and PriorRequestNotComplete codes. - Keep an explicit empty connector tuple empty. - Fail a job too large for the run hook before the callback exists, when it does not fit even without the callback ID. - Raise MicrovmJobFailedError for a result that is not valid JSON. - Guard the read of the reported error type from internal state. - Terminate for a launch checkpoint without a delivery. - Name the error type when a launch error has no message. - Document the qualified error type, the heartbeat timeout that must cover the launch, and the 8-hour cap; fix the README error example. * fix(extras): address the second microvm review - Return a copy of the MicroVM error from from_child_context_error. The operation raises it from the ChildContextError, whose __cause__ was the same object. So each error was the other's __cause__, and a loop that follows __cause__ never ended. - Raise the core's ValidationError for an invalid name, config, or Region, the class that Duration raises for a negative value. - Say that the JavaScript operation fails the invocation with SERDES_FAILED for a result that is not valid JSON. - Remove a fallback that never ran. - Add tests: the cause chain ends, terminate runs only after the wait ends, and every invalid timeout raises ValidationError. - Move the tests under tests/microvm/ to mirror the source tree, with microvm_test.py renamed to operation_test.py, and move the function-level imports to the top of each module.
1 parent 9a87021 commit 43780e6

21 files changed

Lines changed: 2696 additions & 2 deletions

File tree

‎.github/scripts/type-checks.sh‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ mypy --install-types --non-interactive \
2121
packages/aws-durable-execution-sdk-python-microvm-worker/src/aws_durable_execution_sdk_python_microvm_worker \
2222
packages/aws-durable-execution-sdk-python-microvm-worker/tests
2323

24+
mypy --install-types --non-interactive \
25+
packages/aws-durable-execution-sdk-python-extras/src/aws_durable_execution_sdk_python_extras \
26+
packages/aws-durable-execution-sdk-python-extras/tests
27+
2428
# comment out this for now as there are many type check errors in this package
2529
#mypy --install-types --non-interactive \
2630
# packages/aws-durable-execution-sdk-python-testing/src/aws_durable_execution_sdk_python_testing \

‎AGENTS.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,9 @@ Do NOT add or modify **conformance tests** without coordinating with the team. C
8484
(`aws-durable-execution-sdk-python-testing`), the OpenTelemetry plugin
8585
(`aws-durable-execution-sdk-python-otel`), the Workflow Insight plugin
8686
(`aws-durable-execution-sdk-python-insight`), the Lambda MicroVM worker
87-
(`aws-durable-execution-sdk-python-microvm-worker`), and examples
87+
(`aws-durable-execution-sdk-python-microvm-worker`), the extras package with
88+
custom operations such as `microvm`
89+
(`aws-durable-execution-sdk-python-extras`), and examples
8890
(`aws-durable-execution-sdk-python-examples`).
8991
- Read [CONTRIBUTING.md](CONTRIBUTING.md) before making changes. Use
9092
`hatch` for all tests, type checks, and formatting (for example
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
# AWS Durable Execution SDK for Python: extras
2+
3+
> This package is experimental. Its API can change in any release.
4+
5+
Custom operations for the [AWS Durable Execution SDK for Python](../aws-durable-execution-sdk-python). Each operation composes the SDK's built-in operations and calls AWS APIs. The operations live in this package, so the core SDK does not depend on those APIs' clients.
6+
7+
The package is the Python counterpart of [`@aws/durable-execution-sdk-js-extras`](https://github.com/aws/aws-durable-execution-sdk-js/tree/main/packages/aws-durable-execution-sdk-js-extras).
8+
9+
## `microvm`: run a job in an AWS Lambda MicroVM
10+
11+
`microvm` launches a new [Lambda MicroVM](https://docs.aws.amazon.com/lambda/latest/dg/lambda-microvms-guide.html), delivers the job to it, waits for the job's result, and terminates the MicroVM. The function invocation ends while the MicroVM works, so no Lambda compute is billed during the wait.
12+
13+
```python
14+
import os
15+
16+
from aws_durable_execution_sdk_python.config import Duration
17+
from aws_durable_execution_sdk_python.context import DurableContext
18+
from aws_durable_execution_sdk_python.execution import durable_execution
19+
from aws_durable_execution_sdk_python_extras.microvm import MicrovmConfig, microvm
20+
21+
CONFIG = MicrovmConfig(
22+
image_identifier=os.environ["MICROVM_IMAGE_ARN"],
23+
execution_role_arn=os.environ["MICROVM_ROLE_ARN"],
24+
timeout=Duration.from_hours(1),
25+
heartbeat_timeout=Duration.from_minutes(5),
26+
)
27+
28+
29+
@durable_execution
30+
def handler(event: dict, context: DurableContext) -> dict:
31+
return microvm(context, "build", job_input={"repo": event["repo"]}, config=CONFIG)
32+
```
33+
34+
The first argument is the context that the call runs in. Inside a child context or a `map` item, pass that context, so the operation's steps belong to it. `job_input` and `config` are keyword-only. A job can be a string, and so is the name. So positional arguments in the wrong order would pass validation and run the wrong job.
35+
36+
The code in the MicroVM receives the job and reports its result. The worker package [`aws-durable-execution-sdk-python-microvm-worker`](../aws-durable-execution-sdk-python-microvm-worker) does this, and so does the JavaScript worker. The job document is the same in every SDK language.
37+
38+
### What it does
39+
40+
The operation runs its durable operations in one child context named after the second argument:
41+
42+
1. `<name>.callback` creates a durable callback. The service generates the callback ID, and the SDK checkpoints it. So the ID is unique to this call and stays the same on replay.
43+
2. `<name>.launch` calls `RunMicrovm`. The job goes in the `runHookPayload`, and Lambda passes it to the worker's `run` lifecycle hook. The client token is the SHA-256 hex digest of the callback ID. So a retried or replayed launch returns the same MicroVM instead of starting a second one. The step checkpoints plain JSON, `{"microvmId": "...", "delivery": "run-hook"}`, as the JavaScript operation does. So a reader of the execution history finds the MicroVM ID there.
44+
3. The child context waits for the MicroVM to complete the callback.
45+
4. `<name>.terminate` calls `TerminateMicrovm`. It runs after success, failure, and timeout alike.
46+
47+
A terminate failure does not fail the operation. The job's outcome is already recorded at that point. So the operation logs an error instead of raising. The MicroVM then keeps running, and is billed, until the platform ends it at its maximum duration: the operation timeout plus 5 minutes, at most 8 hours.
48+
49+
The terminate step runs only when the operation sees the job's outcome. In these cases it never does, and the MicroVM also runs until its maximum duration:
50+
51+
- A `map` or `parallel` completes early, for example with `min_successful`, while an item still waits for its MicroVM. The SDK never runs that item again.
52+
- The execution fails, times out, or is stopped while the operation waits.
53+
- Every launch attempt timed out or lost its connection, but the service created the MicroVM anyway. The operation never learned the MicroVM's ID.
54+
55+
The `run` hook payload holds at most 4,096 Unicode code points. It includes the callback ID and the job's fields, so an input of about 3,600 code points fits. The operation fails a larger job with `MicrovmDeliveryError` before the launch. When the job does not fit even without the callback ID, it fails before the callback exists. Delivery over HTTP, for larger jobs, comes in a later release.
56+
57+
### Errors
58+
59+
Every MicroVM failure is a `MicrovmError`. Catch the base class for all of them, or a subclass for one kind:
60+
61+
| Error | When |
62+
|---|---|
63+
| `MicrovmLaunchError` | `RunMicrovm` failed after all retries. When the last attempt timed out or lost its connection, the service may have created the MicroVM anyway, and it then runs the job. So running the job again after this error can run it twice. |
64+
| `MicrovmDeliveryError` | The job is too large for the `run` hook. |
65+
| `MicrovmJobFailedError` | The job handler in the MicroVM failed, or the MicroVM sent a result that is not valid JSON. The message includes the error type that the MicroVM reported. |
66+
| `MicrovmTimeoutError` | No result arrived within `timeout`, or no heartbeat within `heartbeat_timeout`. A MicroVM that crashed ends this way. |
67+
68+
```python
69+
from aws_durable_execution_sdk_python_extras.microvm import (
70+
MicrovmError,
71+
MicrovmTimeoutError,
72+
)
73+
74+
try:
75+
result = microvm(context, "build", job_input=job_input, config=CONFIG)
76+
except MicrovmTimeoutError:
77+
... # The MicroVM crashed, or took too long.
78+
except MicrovmError:
79+
... # Any other MicroVM failure.
80+
```
81+
82+
The class is the same on the first run and on every replay, so code that branches on it stays deterministic. A `microvm` call inside your own child context, or inside a `map` or `parallel` item, reaches the code around that context as the SDK's `ChildContextError`. Its `__cause__` is the MicroVM error, so check `isinstance(error.__cause__, MicrovmTimeoutError)` there. An invalid name or config raises the SDK's `ValidationError`, and an input that is not JSON-serializable raises `TypeError`, before any durable operation starts. `Duration` raises the same `ValidationError` for a negative value.
83+
84+
When the error escapes the handler, the execution's error type is the class's fully-qualified name, such as `aws_durable_execution_sdk_python_extras.microvm.errors.MicrovmJobFailedError`. The SDK uses that name to rebuild the class on replay. The JavaScript operation reports the short name, `MicrovmJobFailedError`. So a rule that matches the error type of both SDKs, such as a Step Functions `Catch` or a metric filter, must match both forms. For example, match on the suffix.
85+
86+
### Retries
87+
88+
The launch and terminate steps retry with `default_microvm_retry_strategy`, unless the config sets `retry_strategy`. It retries throttling, server errors, connection failures, and timeouts, for at most 5 attempts, with a delay of 2, 4, 8, and 16 seconds. Every other error, such as `ValidationException`, `AccessDeniedException`, or `InsufficientCapacityException`, fails the step at once. The boto3 client also retries throttling and server errors inside each attempt.
89+
90+
### Defaults and limits
91+
92+
- `timeout` must be between 1 second and 8 hours, because a MicroVM lives at most 8 hours. The MicroVM's `maximumDurationInSeconds` is `timeout` plus 5 minutes, at most 8 hours. So a timeout above 7 hours 55 minutes leaves the MicroVM less than 5 minutes beyond the callback's timeout.
93+
- `job_input` is sent as JSON. So the MicroVM receives what `json.dumps` writes: a tuple arrives as a list, and a dict key that is not a string arrives as a string.
94+
- Without connectors in the config, the MicroVM gets the `NO_INGRESS` connector, because the job arrives in the `run` hook, and the `INTERNET_EGRESS` connector, because it needs egress to reach the Lambda callback APIs. An empty tuple sends no connector.
95+
- `heartbeat_timeout` starts when the callback is created, before the launch. So it must cover the launch, with its retries, and the boot. A throttled launch can take several minutes. With a shorter heartbeat timeout, the operation fails with `MicrovmTimeoutError` before the MicroVM sends its first heartbeat.
96+
- The Region comes from the durable execution ARN, or from `AWS_REGION`.
97+
- Without a `client`, the operation creates one `lambda-microvms` client per Region, on first use. It has a 5-second connect timeout, a 30-second read timeout, and 3 attempts in botocore's standard retry mode. So one launch or terminate attempt takes at most about 2 minutes. A call that runs past the function's timeout records no outcome for its step attempt, and the step runs again. The launch is idempotent through its client token, so it is repeated, not doubled.
98+
99+
### Permissions
100+
101+
The function's execution role needs:
102+
103+
- `lambda:RunMicrovm` and `lambda:TerminateMicrovm` on the MicroVM image. Without `lambda:TerminateMicrovm`, every operation still returns its result, but every MicroVM runs until its timeout plus 5 minutes, at most 8 hours. The only sign is an error in the function's log.
104+
- `lambda:PassNetworkConnector` on the connectors that the MicroVM uses. With the defaults, that is `arn:aws:lambda:<region>:aws:network-connector:aws-network-connector:*`.
105+
- `iam:PassRole` on the MicroVM execution role.
106+
107+
The MicroVM execution role (`execution_role_arn`) needs `lambda:SendDurableExecutionCallbackSuccess`, `lambda:SendDurableExecutionCallbackFailure`, and `lambda:SendDurableExecutionCallbackHeartbeat` on the function ARN (`arn:aws:lambda:<region>:<account>:function:<name>:*`).
108+
109+
### Subtypes
110+
111+
The JavaScript operation records MicroVM subtypes, such as `Microvm` on the child context and `MicrovmLaunch` on the launch step. This package records the SDK's default subtypes for now, because the Python SDK does not accept custom subtypes yet.
112+
113+
On replay, the SDK compares the subtype of each operation with its checkpoint. A mismatch fails the execution with `NonDeterministicExecutionError`. So a release that changed the subtypes would fail every execution that waits on a MicroVM during the deploy. So this package is published only after the SDK accepts custom subtypes, and it records the MicroVM subtypes from its first release.
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
[build-system]
2+
requires = ["hatchling!=1.32.1,<1.33"]
3+
build-backend = "hatchling.build"
4+
5+
[project]
6+
name = "aws-durable-execution-sdk-python-extras"
7+
dynamic = ["version"]
8+
description = 'Custom durable operations for the AWS Durable Execution SDK for Python, such as running a job in an AWS Lambda MicroVM'
9+
readme = "README.md"
10+
requires-python = ">=3.11"
11+
license = "Apache-2.0"
12+
keywords = ["lambda", "microvm", "durable-execution"]
13+
authors = [{ name = "AWS durable-execution-dev", email = "durable-execution-dev@amazon.com" }]
14+
classifiers = [
15+
"Development Status :: 3 - Alpha",
16+
"Programming Language :: Python",
17+
"Programming Language :: Python :: 3.11",
18+
"Programming Language :: Python :: 3.12",
19+
"Programming Language :: Python :: 3.13",
20+
"Programming Language :: Python :: 3.14",
21+
"Programming Language :: Python :: Implementation :: CPython",
22+
]
23+
dependencies = [
24+
# 2.0.0 is the first release with the graded callback errors
25+
# (CallbackTimeoutError and CallbackExternalError) and register_operation_error.
26+
# The upper bound guards one read of the SDK's internal ExecutionState, for
27+
# the error type that a MicroVM reports. See _reported_error_type.
28+
"aws-durable-execution-sdk-python>=2.0.0,<3",
29+
# botocore 1.43.35 is the first release with the lambda-microvms client.
30+
# The core SDK requires only boto3 1.42.1.
31+
"boto3>=1.43.35",
32+
]
33+
34+
[project.urls]
35+
Documentation = "https://github.com/aws/aws-durable-execution-sdk-python#readme"
36+
Issues = "https://github.com/aws/aws-durable-execution-sdk-python/issues"
37+
Source = "https://github.com/aws/aws-durable-execution-sdk-python"
38+
39+
[tool.hatch.build.targets.sdist.force-include]
40+
"../../LICENSE" = "LICENSE"
41+
"../../NOTICE" = "NOTICE"
42+
43+
[tool.hatch.build.targets.wheel]
44+
packages = ["src/aws_durable_execution_sdk_python_extras"]
45+
46+
[tool.hatch.build.targets.wheel.force-include]
47+
"../../LICENSE" = "aws_durable_execution_sdk_python_extras/LICENSE"
48+
"../../NOTICE" = "aws_durable_execution_sdk_python_extras/NOTICE"
49+
50+
[tool.hatch.version]
51+
path = "src/aws_durable_execution_sdk_python_extras/__about__.py"
52+
53+
[tool.hatch.publish.index]
54+
disable = true
55+
56+
[tool.coverage.run]
57+
source_pkgs = ["aws_durable_execution_sdk_python_extras"]
58+
branch = true
59+
parallel = true
60+
omit = ["src/aws_durable_execution_sdk_python_extras/__about__.py"]
61+
62+
[tool.coverage.report]
63+
exclude_lines = ["no cov", "if __name__ == .__main__.:", "if TYPE_CHECKING:"]
64+
65+
[tool.ruff]
66+
line-length = 88
67+
target-version = "py311"
68+
69+
[tool.ruff.lint]
70+
preview = true
71+
select = ["E4", "E7", "E9", "F", "TID252"]
72+
73+
[tool.ruff.lint.isort]
74+
known-first-party = ["aws_durable_execution_sdk_python_extras"]
75+
force-single-line = false
76+
lines-after-imports = 2
77+
78+
[tool.ruff.lint.per-file-ignores]
79+
"tests/**" = ["ARG001", "ARG002", "ARG005", "S101", "PLR2004", "PLR6301", "SIM117", "TRY301"]
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# SPDX-FileCopyrightText: 2026-present Amazon.com, Inc. or its affiliates.
2+
#
3+
# SPDX-License-Identifier: Apache-2.0
4+
__version__ = "0.0.1"
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# SPDX-FileCopyrightText: 2026-present Amazon.com, Inc. or its affiliates.
2+
#
3+
# SPDX-License-Identifier: Apache-2.0
4+
"""Custom durable operations for the AWS Durable Execution SDK for Python.
5+
6+
This package is experimental. Its API can change in any release.
7+
8+
Each operation composes the SDK's built-in operations and calls AWS APIs. The
9+
operations live in this package, so the core SDK does not depend on those
10+
APIs' clients. Import each operation from its module, for example
11+
``aws_durable_execution_sdk_python_extras.microvm``.
12+
"""
13+
14+
from aws_durable_execution_sdk_python_extras.__about__ import __version__
15+
16+
17+
__all__ = ["__version__"]
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# SPDX-FileCopyrightText: 2026-present Amazon.com, Inc. or its affiliates.
2+
#
3+
# SPDX-License-Identifier: Apache-2.0
4+
"""Run durable jobs in AWS Lambda MicroVMs.
5+
6+
This module is experimental. Its API can change in any release.
7+
8+
The exports match the parts of ``@aws/durable-execution-sdk-js-extras/microvm``
9+
that this package has so far. The session operation and delivery over HTTP
10+
come in later changes.
11+
"""
12+
13+
from aws_durable_execution_sdk_python_extras.microvm.config import (
14+
MAX_MICROVM_DURATION_SECONDS,
15+
MicrovmConfig,
16+
)
17+
from aws_durable_execution_sdk_python_extras.microvm.errors import (
18+
MicrovmDeliveryError,
19+
MicrovmError,
20+
MicrovmJobFailedError,
21+
MicrovmLaunchError,
22+
MicrovmTimeoutError,
23+
)
24+
from aws_durable_execution_sdk_python_extras.microvm.job import (
25+
MAX_RUN_HOOK_PAYLOAD_LENGTH,
26+
)
27+
from aws_durable_execution_sdk_python_extras.microvm.operation import microvm
28+
from aws_durable_execution_sdk_python_extras.microvm.retry import (
29+
default_microvm_retry_strategy,
30+
)
31+
32+
33+
__all__ = [
34+
"MAX_MICROVM_DURATION_SECONDS",
35+
"MAX_RUN_HOOK_PAYLOAD_LENGTH",
36+
"MicrovmConfig",
37+
"MicrovmDeliveryError",
38+
"MicrovmError",
39+
"MicrovmJobFailedError",
40+
"MicrovmLaunchError",
41+
"MicrovmTimeoutError",
42+
"default_microvm_retry_strategy",
43+
"microvm",
44+
]

0 commit comments

Comments
 (0)