Skip to content

feat(extras): add microvm_session - #779

Draft
ParidelPooya wants to merge 13 commits into
mainfrom
feat/microvm-session
Draft

ParidelPooya wants to merge 13 commits into
mainfrom
feat/microvm-session

Conversation

@ParidelPooya

@ParidelPooya ParidelPooya commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Part 5 of the MicroVM port: microvm_session() runs several jobs in one MicroVM. It follows microvmSession in @aws/durable-execution-sdk-js-extras.

A session launches one MicroVM and runs a handler with a MicrovmSession handle. Each vm.invoke sends one job to the MicroVM and returns its result. The jobs share the MicroVM's files and processes, so a later job can use what an earlier job wrote. The MicroVM is terminated when the handler returns or raises.

def pipeline(vm: MicrovmSession, ctx: DurableContext) -> Any:
    build = vm.invoke(ctx, "build", job_input=event, options=MicrovmInvokeOptions(path="/build", timeout=Duration.from_minutes(15)))
    return vm.invoke(ctx, "test", job_input=build, options=MicrovmInvokeOptions(timeout=Duration.from_minutes(10)))

result = microvm_session(context, "pipeline", config=SESSION, handler=pipeline)

Durable operations

The session runs in one child context named name:

  1. <name>.session generates a random session ID and checkpoints it.
  2. <name>.launch calls RunMicrovm. The client token is the SHA-256 hex digest of the session ID. So a retried or replayed launch returns the same MicroVM.
  3. The handler runs. Each vm.invoke creates a child context named after the job, with a callback <job>.callback and a request step <job>.request. Every job goes over HTTP, and the MicroVM gets the ALL_INGRESS connector.
  4. <name>.terminate calls TerminateMicrovm.

vm.invoke takes the context that the call runs in, as microvm does. So it works in the session's context, a nested child context, and map and parallel items. It raises TypeError for the context that microvm_session was called with. A job created there lands outside the session's child context. The core SDK does not refuse that, and the next replay fails with NonDeterministicExecutionError after the job has run. JS refuses an operation in a parent context at the first call. Checking every context outside the session would need support in the core SDK.

Suspending an idle MicroVM

A session can suspend its MicroVM between jobs:

  1. auto_suspend_on_idle=True sends autoSuspendIdleSeconds in the run hook payload. The worker then suspends its own MicroVM when no job has run for that long. The default is 60 seconds, or the session timeout if that is shorter.
  2. idle_policy sets the RunMicrovm idle policy.

With either setting, each request step calls GetMicrovm first, resumes a suspended MicroVM, and sends the job to the endpoint that GetMicrovm reports. The MicroVM can suspend itself between that check and the request. So after a 502, 503, or 504, or a request without an answer, the step checks the state again before it retries. A MicroVM that is terminating, terminated, or removed fails the job at once with the new MicrovmNotRunningError, a subclass of MicrovmDeliveryError.

The Python worker reads autoSuspendIdleSeconds but does not suspend its MicroVM yet. That comes with the worker's suspend and terminate hooks, in the next PR. The JavaScript worker suspends today. The README states this.

Errors and termination

  1. A failed vm.invoke raises its MicroVM error inside the handler. The handler can catch it and send another job to the same MicroVM.
  2. A MicroVM error that the handler raises itself reaches the caller with its own class. Any other error stays the SDK's ChildContextError, as from run_in_child_context. This includes a MicroVM error that leaves a child context that the handler created, because that child context records ChildContextError as its error type. The caller then gets the MicroVM error's message, but not its class. JS behaves the same way for that nested case. JS differs for an SDK error that leaves the handler, such as the StepError of a failed step: JS passes it to the caller with its own class. This PR follows run_in_child_context, so the caller gets ChildContextError with the SDK error as its __cause__.
  3. The session terminates the MicroVM after the handler returns, and after an error that ends the session.
  4. It does not terminate for SuspendExecution, so a handler that waits keeps its MicroVM.
  5. It does not terminate for a retryable InvocationError, such as RetryableSerDesError. Lambda runs the invocation again, and the handler replays on the same MicroVM. A terminate step recorded for it would take the position of the handler's next operation, and the replay would fail with NonDeterministicExecutionError.
  6. It does not terminate for an ExecutionError, such as NonDeterministicExecutionError after a deploy that changes the handler. The core SDK lets that error leave a child context without a checkpoint, and the execution fails without a retry. A terminate step would add an operation to that history, or take the ID of an operation already in it. So the MicroVM runs until its maximum duration, as in JS.

The job wait in microvm() and in vm.invoke also passes an InvocationError or an ExecutionError through, instead of recording it as a job failure. JS does not have this gap, because its SDK ends the invocation with a promise that never settles.

Behavior change in microvm()

A non-retryable InvocationError during the job wait used to become MicrovmJobFailedError. It now becomes a plain MicrovmError, with the original message and error type. The job did not fail, so the new class is more accurate. A caller that catches only MicrovmJobFailedError no longer catches this case. A caller that catches MicrovmError still does. An ExecutionError during the job wait used to become MicrovmJobFailedError, and the operation terminated the MicroVM. It now passes through unchanged, and the execution fails with it.

Shared code

microvm() and the session share the launch, request, and terminate steps in _MicrovmScope. MicrovmBaseConfig holds the settings that MicrovmConfig and MicrovmSessionConfig share. MicrovmInvokeOptions extends MicrovmRequestConfig with the job's timeout and heartbeat_timeout. microvm() keeps its behavior, and its tests pass unchanged except two that patch the renamed launch method.

send_job gains two options for the session: window_started_at, so the state check and the request share one retry window, and recheck, for the state check after a gateway error. require_time_for_call runs before the state check, so a session job with less than 11 seconds left gets the message that names the function timeout.

Differences from JS

Not in this PR

Testing

  • Package: 257 tests pass, with 100% line and branch coverage. mypy and ruff are clean.
  • Repository: 4,960 tests pass. One worker test, test_answers_400_to_a_chunked_body_over_the_limit_that_the_client_sends, failed once in the full run with a 401, and passed 5 of 5 runs alone. This PR does not change the worker package.
  • session_test.py runs sessions on the local test runner against a local HTTP endpoint: two jobs on one MicroVM, durable waits between jobs, map and parallel jobs, a caught and an uncaught job failure, the handler's own error, invalid jobs that the handler catches, config validation, the idle time sent to the worker, a resume and its new endpoint, the recheck after 502 and 503, a removed MicroVM, MicrovmNotRunningError on replay, terminate only after the handler ends, the retryable and non-retryable InvocationError paths, the outer context refused by vm.invoke, and a job error that leaves a child context of the handler.
  • lifecycle_test.py covers ensure_running: each state, one resume with ConflictException ignored, a removed MicroVM at GetMicrovm and at ResumeMicrovm, the wait limit, and the invocation reserve.
  • worker_composed_test.py runs two session jobs through the real Python worker. The second job reads the file that the first job wrote.
  • A test checks the RunMicrovm request with an idle policy, GetMicrovm, and ResumeMicrovm against the botocore service model.
  • Mutation checks: each of these changes makes a test fail: no recheck, no MicrovmNotRunningError mapping, a terminate for SuspendExecution, a terminate for a retryable InvocationError, every handler error turned into a MicroVM error, no not-found mapping at ResumeMicrovm, no outer-context check, and a terminate or a job failure for an ExecutionError in the handler or in either job wait.
  • A sub-agent review found the retryable InvocationError defect before this PR opened. Its probe now ends SUCCEEDED, with one RunMicrovm and one TerminateMicrovm.
  • Not run against the live service.

The branch merges main after #776, so the commit list shows the #776 commits. The diff against main has only this change.

A job whose run hook payload exceeds 4,096 code points, or any job when
the config sets a request path, now goes over HTTP instead of failing
with MicrovmDeliveryError. This matches the JavaScript operation.

- The launch sends a run hook payload without the job, gives the MicroVM
  the ALL_INGRESS connector, and checkpoints the endpoint.
- A new <name>.request step creates an auth token and POSTs the job to
  the endpoint. Inside one step attempt it retries connection errors,
  timeouts, 429, and 5xx from 250 ms to 4 s, and one 401 or 403 with a
  new token. It stops at the retry window, 60 seconds by default, or
  10 seconds before the invocation times out.
- boto3 cannot cancel a call. So a CreateMicrovmAuthToken call with a
  deadline runs in a daemon thread, and the step stops waiting at the
  reserve.
- The default retry strategy retries the end of the first tier, and does
  not retry a route that rejects the job with another 4xx.
- MicrovmRequestConfig sets the path, the port, and the retry window.
- Replay follows the delivery that the launch checkpointed.
The heartbeat timeout's first period now covers the request step too,
in the config docs and the README. A new test checks that a replay sends
no second POST. It fails when the POST runs outside a step.
- Run each POST in a daemon thread, and wait at most 10 seconds or
  until the invocation reserve. A socket timeout applies to each socket
  operation, and each byte received starts it again. So a slow endpoint
  kept the request step running past the end of the invocation.
- Do not follow redirects. A 301, 302, or 303 turned the POST into a GET,
  and its 2xx counted as accepted although the job never started. A
  redirect to another host received the auth token. A 3xx now raises
  MicrovmRequestRejectedError with the Location in the message.
- Percent-encode the path as fetch does. A space or a non-ASCII
  character failed every attempt with a retryable error.
- Re-raise a BaseException from the deadline thread with its own type.
- Add tests: replay keeps the launch's delivery in both directions, the
  whole POST is bounded, redirects are rejected, the path is encoded,
  and proxy settings are ignored. Each fails under the mutation it
  guards against.
- Fail the request step at once, with a message that names the function
  timeout, when less than 11 seconds remain in the invocation. The error
  stays retryable. Before, the message said that the MicroVM did not
  accept the job after 0 attempts.
- Send the request path as written, and reject a path that is not an
  RFC 3986 path with ValidationError. The workers match the raw request
  path, so an encoded path missed a route registered with the same
  string.
- Add an optional opener to the config, for a network that reaches the
  endpoint only through a proxy. Reject a redirect that such an opener
  followed. Build the default opener on first use.
- Read the remaining time from the Lambda context directly. A failure of
  the call now raises, instead of turning off the reserve.
- Document that a route can receive the same job again, and must ignore
  a repeated callback ID.
microvm_session launches one MicroVM and runs a handler that sends
several jobs to it with vm.invoke. The jobs share the MicroVM's files
and processes. The MicroVM is terminated when the handler returns or
raises. It ports microvmSession from the JavaScript SDK.

- Each job is a child context with a callback and a request step, and
  always goes over HTTP.
- With auto_suspend_on_idle or an idle_policy, each request step calls
  GetMicrovm first and resumes a suspended MicroVM. After a 502, 503,
  504, or no answer, it checks the state again. A MicroVM that ended
  fails the job at once with MicrovmNotRunningError.
- An uncaught MicroVM error reaches the caller with its own class.
  Any other handler error stays the SDK's ChildContextError.
- microvm and the session share the launch, request, and terminate
  steps. MicrovmBaseConfig holds the settings that both configs share.

The Python worker does not suspend its MicroVM yet. The README says so.
A retryable InvocationError, such as RetryableSerDesError, makes Lambda
run the invocation again. The session terminated the MicroVM for it.
The terminate step then took the position of the handler's next
operation, and the replay failed with NonDeterministicExecutionError.
The session now terminates only for an error that ends it.

- The job wait in microvm and in vm.invoke passes an InvocationError
  through, instead of recording it as a job failure. microvm keeps the
  MicroVM for a retryable one.
- A ResumeMicrovm answer of ResourceNotFoundException now fails the job
  with MicrovmNotRunningError, as a GetMicrovm answer does.
- The README session example has its imports.
- New tests: parallel jobs, two items that resume one MicroVM, and the
  invocation error paths.
A caller's opener that follows redirects sent the auth token to the
redirect target, also on another host. The POST now adds its headers as
unredirected headers, which urllib does not copy to a followed request.

Two fixes from the review of the JavaScript port apply here too:

- The request path must not have a "." or ".." segment, also with %2e.
  The JavaScript SDK's fetch resolves such a segment, so both SDKs now
  accept the same paths.
- The short-timeout message rounds the time down. So it never reads
  11.0 seconds next to "at least 11 seconds".

The README states that the 11 seconds must be left when the request
step starts, and which jobs go over HTTP.
# Conflicts:
#	packages/aws-durable-execution-sdk-python-extras/src/aws_durable_execution_sdk_python_extras/microvm/request.py
#	packages/aws-durable-execution-sdk-python-extras/tests/microvm/request_test.py
A job created in the context that microvm_session was called with lands
outside the session's child context. The core SDK does not refuse that.
So the job ran, and the next replay failed the execution with
NonDeterministicExecutionError. vm.invoke now raises TypeError for that
context, before the job's durable operations start.

The docstring and the README said that an uncaught MicroVM error keeps
its class. That holds only for an error that the handler raises itself.
A child context that the handler created records ChildContextError as
its error type, so the caller gets the message but not the class.
Comment thread packages/aws-durable-execution-sdk-python-extras/README.md Outdated
NonDeterministicExecutionError is an ExecutionError, and the session's
except Exception ran the terminate step for it. The core SDK lets an
ExecutionError leave a child context without a checkpoint. So the
terminate step took the ID of an operation already in the history, and
the execution failed with a second error, or it added an operation to
the history of a failed execution. The session and both job waits now
pass an ExecutionError through. The MicroVM then runs until its maximum
duration, as in JS.

- The README states that an SDK error that leaves the session handler
  reaches the caller as ChildContextError, and that JS differs here.
- The path and port docstrings limit the run hook sentences to
  microvm(), because a session job always goes over HTTP.

@yaythomas yaythomas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

:shipit:

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants