Skip to content

feat(extras): suspend a session MicroVM after a job - #845

Merged
ParidelPooya merged 15 commits into
mainfrom
feat/microvm-suspend-after
Oct 26, 2026
Merged

ParidelPooya merged 15 commits into
mainfrom
feat/microvm-suspend-after

Conversation

@ParidelPooya

@ParidelPooya ParidelPooya commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Summary

This PR adds suspend_after to vm.invoke. A session job with suspend_after=True asks the worker to suspend its MicroVM when the job ends. The next job of the session resumes the MicroVM. So a session can stop paying for a running MicroVM during a long wait, without auto-suspend.

Why

  1. A session has one way to suspend its MicroVM today: auto-suspend after an idle time.
  2. The developer often knows the exact point where the MicroVM can suspend, such as the job before a long callback or wait.
  3. A suspend from the durable function would need its own durable step, and it could freeze a job that still runs.
  4. So the worker suspends the MicroVM after the job, once no other work runs. The durable function only marks the job.

The JavaScript SDK adds the same option in aws/aws-durable-execution-sdk-js#1105.

Changes

Worker (aws-durable-execution-sdk-python-microvm-worker):

  • The job request can carry "suspendAfter": true. The worker accepts only JSON true. The job document stays version 1, so older workers ignore the field.
  • The worker answers such a request with the header X-Microvm-Suspend-After: accepted. A second delivery gets the header when the first request carried the field.
  • The worker suspends only the MicroVM that the run hook named, never one from a job request.
  • When a suspend_after job ends, after its report, the worker records a request. The request fires when no job, report, run hook, or suspend call runs, and the worker is not terminating, closed, or refusing jobs.
  • The call uses the existing suspend path: the refusal of jobs, one attempt, no attempt after a resume hook, the outcome classification, and the 30-second grace.
  • A request is done when any suspend starts, or when a suspend refusal is in effect. A terminate hook and close() clear it. A resume hook never sets it.
  • Without auto-suspend, a suspend refused for now is not retried.
  • New log lines: suspending the MicroVM after a job and cannot suspend the MicroVM after a job: the run hook named no MicroVM. The failed-suspend warning adds trigger (idle or job) as its last field.
  • Decisions are made under _locked(), and the actions run after the block.

Extras (aws-durable-execution-sdk-python-extras):

  • MicrovmInvokeOptions.suspend_after: bool = False. microvm() rejects it with ValidationError, and a value that is not a bool is rejected, both before any durable operation.
  • The session sets an in-memory flag before the job's child context. From then on, each job checks the state before delivery, and resumes a suspended MicroVM, as in resume mode.
  • The flag is not rebuilt when a completed map item, parallel branch, or child context replays. So every session job also has a fallback. A delivery that gets HTTP 502 with x-aws-proxy-error: MICROVM_CONNECT_FAILED, or no answer, makes one state check, resumes a suspending or suspended MicroVM, and sends the job again. A resume that the service rejects with a conflict while the MicroVM is still suspending is sent again once the MicroVM is suspended. It runs at most one check per 2 seconds.
  • A denied GetMicrovm in the fallback logs one warning per job, and the delivery keeps retrying. A denied ResumeMicrovm while the MicroVM is suspended fails the job at once, and the message names lambda:ResumeMicrovm.
  • A 202 without the acknowledgement header logs one warning per session: the worker is too old, and the MicroVM keeps running.
  • README sections and docstrings are updated. The docs say when to use the option, and when not: on back-to-back jobs, on map items, and on the last job.

Examples:

  • New example microvm_session_suspend_after.py and cloud test test_microvm_session_suspend_after.py. The session has no auto-suspend. The first job writes a file with suspend_after=True. A callback gate holds the second job until the test sees the MicroVM SUSPENDED. The second job resumes the MicroVM and reads the file.

Testing

  • Worker unit tests cover each rule: the target, the fire conditions, the re-checks, the clears, the acknowledgement, the logs, and the interaction with auto-suspend and the lifecycle hooks.
  • Extras unit tests cover the validation, the field, the warning, the flag, the fallback trigger, the rate limit, and each error rule.
  • Composed tests run the session against the real worker: a suspend_after job and then a job that resumes the MicroVM; a map with one suspend_after item; a replay that lost the flag and recovers through the fallback; an old worker without the acknowledgement.
  • hatch run dev-microvm-worker:cov: 519 tests pass, worker.py at 100%. hatch run dev-extras:cov: 457 tests pass, 100%. Both typechecks, hatch fmt --check, dev-examples:test, and lintcommit.py pass.
  • All 4 MicroVM cloud example tests passed against the live service, including the new one. The MicroVM suspended after the first job, and the second job resumed it and read the file.

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.

A SuspendMicrovm retry can run after the MicroVM resumed and freeze the
next job. The default client now makes one attempt, and the before-send
handler starts no attempt after a resume hook.
A resend of a known job got 503 while the worker refused jobs. The
durable function then resumed the MicroVM only to get the duplicate
answer. The worker now answers a known callback ID with 202 first.
The 503 note for a resent job said that nothing would suspend the
MicroVM again. The idle time restarts after the refusal, so the worker
does suspend it again. The real cost is a resume of the MicroVM for
nothing.

A resume hook can stop the first SuspendMicrovm attempt. Nothing was
sent then. The classification docs now say that an earlier attempt, if
there was one, may have suspended the MicroVM.

The README now states the cost of one attempt. A first-attempt 5xx or
dropped connection counts as unknown, and the worker refuses jobs for up
to 30 seconds on a MicroVM that did not suspend. It also states why a
resent job whose callback is gone gets 202 after the terminate hook.
The JavaScript worker logs an info line when a resume hook arrives
during its SuspendMicrovm call. This worker logged nothing until the
unknown warning at the end of the call. It now logs the same line, with
the same text and the call's microvmId.

The resume hook decides under the lock and logs after it. Only the
first resume hook during a call logs the line. A resume hook after the
call returned logs nothing, because no attempt is left to stop.

New listener tests cover a resume hook before the first attempt, a
suspend hook after that resume and before the call returns, a second
resume hook during the call, and a resume hook after the call returned.

The README documents the line. It also states that the JavaScript worker
cancels the attempt in flight, while this worker can only stop the next
attempt.
A job request with "suspendAfter": true asks the worker to suspend its
MicroVM when the job ends. The worker answers such a request with the
header X-Microvm-Suspend-After: accepted. It suspends only the MicroVM
that the run hook named, once no other work runs. The call uses the
same path as the suspend when idle.
A session job with suspend_after=True asks the worker to suspend the
MicroVM after the job. The next job of the session checks the state
and resumes the MicroVM. A job whose delivery gets a 502 with
MICROVM_CONNECT_FAILED, or no answer, also checks the state. So a
replay that lost the session's flag still resumes the MicroVM.
…after

# Conflicts:
#	packages/aws-durable-execution-sdk-python-extras/tests/microvm/worker_composed_test.py
#	packages/aws-durable-execution-sdk-python-microvm-worker/README.md
#	packages/aws-durable-execution-sdk-python-microvm-worker/src/aws_durable_execution_sdk_python_microvm_worker/worker.py
#	packages/aws-durable-execution-sdk-python-microvm-worker/tests/lifecycle_hooks_test.py
A suspend refusal means that the MicroVM is suspending already. So a
suspend request that meets one is done. The suspend hook now clears a
pending request, and a suspendAfter job that ends during a refusal sets
none. Before, the request fired after the next job, even when that job
did not ask for it. Tests cover a job that ends between the suspend and
resume hooks, and a request that waits when the suspend hook arrives.
The terminate test now asserts that the request is cleared.
A replay of a completed job calls vm.invoke again, so it sets the
suspend_after record again. Only a completed map item, parallel branch,
or child context skips its function. The suspend_after job itself
checks the state, because the record is set before the job starts.

A worker that takes more than 10 seconds to start listening gives the
first delivery no answer. The operation then makes one GetMicrovm call,
so a session that never suspends can make one extra call. The warning
about a worker that ignores the field is logged once per session in
each execution environment. The README snippet sets heartbeat_timeout
on both jobs, as the example does.
The map test with one suspend_after item relies on items that run at
once. MapConfig(max_concurrency=3) states that, instead of relying on
the default.
ResumeMicrovm answers ConflictException while a transition is in
progress. The state check marked the resume as sent after that answer.
So a MicroVM that was still suspending became SUSPENDED and was never
resumed. The wait then ended with MicrovmEndpointUnavailableError and
"Last state: SUSPENDED".

The check after a sign of a suspended MicroVM had this flaw for a
conflict during SUSPENDING. The check before delivery and the check
after a gateway error had it for a conflict during SUSPENDED.

A conflict now leaves the resume unaccepted. So the next SUSPENDED
answer sends ResumeMicrovm again. A PENDING answer after a conflict
still waits for RUNNING. The poll interval and the deadline bound the
calls.
The simulated endpoint copied the X-Microvm-Suspend-After value from
the worker's answer into its own answer. A value from an answer could
then reach send_header.

The endpoint now sends the header with the fixed value "accepted", and
only when the worker answered that value. The session accepts only
that value. So the tests still prove that an old worker gets a warning
and a current worker does not.
The simulated endpoint sends X-Microvm-Suspend-After only when the
worker accepted suspendAfter. No test checked that a current worker's
acknowledgement reaches the session. So a lost header would have
passed.

The suspend-after test now checks that the session logs no warning
about an ignored suspendAfter. Without the header, the test fails.

@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:

@ParidelPooya
ParidelPooya marked this pull request as ready for review October 26, 2026 20:06
@ParidelPooya
ParidelPooya deployed to ai-pr-review-runtime October 26, 2026 20:06 — with GitHub Actions Active
@ParidelPooya
ParidelPooya merged commit 9d704ff into main Oct 26, 2026
40 checks passed
@ParidelPooya
ParidelPooya deleted the feat/microvm-suspend-after branch October 26, 2026 20:06
ParidelPooya added a commit to aws/aws-durable-execution-sdk-js that referenced this pull request Oct 26, 2026
## Summary

This PR adds `suspendAfter` to `vm.invoke`. A session job with
`suspendAfter: true` asks the worker to suspend its MicroVM when the job
ends. The next job of the session resumes the MicroVM. So a session can
stop paying for a running MicroVM during a long wait, without
auto-suspend.

## Why

1. A session has one way to suspend its MicroVM today: auto-suspend
after an idle time.
2. The developer often knows the exact point where the MicroVM can
suspend, such as the job before a long callback or wait.
3. A suspend from the durable function would need its own durable step,
and it could freeze a job that still runs.
4. So the worker suspends the MicroVM after the job, once no other work
runs. The durable function only marks the job.

The Python SDK adds the same option in
aws/aws-durable-execution-sdk-python#845.

## Changes

Worker (`@aws/durable-execution-sdk-js-microvm-worker`):

- The job request can carry `"suspendAfter": true`. The worker accepts
only JSON `true`. The job document stays version 1, so older workers
ignore the field.
- The worker answers such a request with the header
`X-Microvm-Suspend-After: accepted`. A second delivery gets the header
when the first request carried the field.
- The worker suspends only the MicroVM that the run hook named, never
one from a job request.
- When a `suspendAfter` job ends, after its report, the worker records a
request. The request fires when no job, report, run hook, or suspend
call runs, and the worker is not terminating, closed, or refusing jobs.
- The call uses the existing suspend path: the refusal of jobs, one
attempt, the abort on a `resume` hook, the outcome classification, and
the 30-second grace.
- A request is done when any suspend starts, or when a suspend refusal
is in effect. A `terminate` hook and `close()` clear it. A `resume` hook
never sets it.
- Without auto-suspend, a suspend refused for now is not retried.
- New log lines: `suspending the MicroVM after a job` and `cannot
suspend the MicroVM after a job: the run hook named no MicroVM`. The
failed-suspend warning adds `trigger` (`idle` or `job`) as its last
field.

Extras (`@aws/durable-execution-sdk-js-extras`):

- `MicrovmInvokeOptions.suspendAfter?: boolean`. `microvm()` rejects it,
and a value that is not a boolean is rejected, both before any durable
operation.
- The session sets an in-memory flag before the job's child context.
From then on, each job checks the state before delivery, and resumes a
suspended MicroVM, as in resume mode.
- The flag is not rebuilt when a completed `map` item, `parallel`
branch, or child context replays. So every session job also has a
fallback. A delivery that gets HTTP 502 with `x-aws-proxy-error:
MICROVM_CONNECT_FAILED`, or no answer, makes one state check, resumes a
suspending or suspended MicroVM, and sends the job again. A resume that
the service rejects with a conflict while the MicroVM is still
suspending is sent again once the MicroVM is suspended. It runs at most
one check per 2 seconds.
- A denied `GetMicrovm` in the fallback logs one warning per job, and
the delivery keeps retrying. A denied `ResumeMicrovm` while the MicroVM
is suspended fails the job at once, and the message names
`lambda:ResumeMicrovm`.
- A 202 without the acknowledgement header logs one warning per session:
the worker is too old, and the MicroVM keeps running.
- README sections, TSDoc, and CHANGELOG are updated. The docs say when
to use the option, and when not: on back-to-back jobs, on `map` items,
and on the last job.

## Design choices

Alternatives considered:

1. `vm.suspend(ctx)` from the durable function. It needs its own durable
step, and it can freeze a job that still runs. Rejected.
2. A suspend inside `vm.invoke`, before the job's child context
checkpoints. A result over 256 KB makes the child context run again on
replay, a crash before the checkpoint repeats the suspend, and parallel
jobs can still run. Rejected.
3. A session-level option. It applies to every job, but the developer
knows which job comes before a long wait. Rejected.
4. A state check before every session job, without the fallback. It
costs one `GetMicrovm` call per job, also in sessions that never
suspend. Rejected in favor of the fallback, which makes no extra call
unless a delivery fails with `MICROVM_CONNECT_FAILED` or gets no answer.

What the design keeps out: the job document stays version 1, so older
workers keep working. The worker suspends only the MicroVM that the run
hook named.

The fallback calls `ResumeMicrovm` early when the state is `SUSPENDING`.
A live test showed the service accepts that call in every state. A
conflict on `SUSPENDING` is handled: the resume is sent again once the
MicroVM is `SUSPENDED`.

The missing-acknowledgement warning is kept in a per-process set of at
most 1,000 session IDs. A session evicted from the set can warn again.
The set bounds memory in a long-lived execution environment.

Existing test expectations that change:

- The failed-suspend warning tests, the JSON-line log test, and the
shared warning assertions add `trigger` as the last field. The seven
earlier fields and the three messages are unchanged.
- The test of a second delivery's acknowledgement now takes the flag
from the first request, as the contract states, instead of from the
resend.

Follow-up work: a JavaScript example with a cloud test, a fix for a
`suspendAfter` job that ends before the first run hook arrives, and
removal of two fire checks that can no longer matter.

## Testing

- Worker unit tests cover each rule: the target, the fire conditions,
the re-checks, the clears, the acknowledgement, the logs, and the
interaction with auto-suspend and the lifecycle hooks.
- Extras unit tests cover the validation, the field, the warning, the
flag, the fallback trigger, the rate limit, and each error rule.
- Composed tests run the session against the real worker listener: a
`suspendAfter` job and then a job that resumes the MicroVM; a `map` with
one `suspendAfter` item; a replay that lost the flag and recovers
through the fallback; an old worker without the acknowledgement.
- Worker `npm test`: 341 tests pass. Extras `npm test`: 440 tests pass.
`npm run lint` and `npm run format:check` pass.
- The Python SDK's cloud test of the same contract passed against the
live service: the MicroVM suspended after the first job, and the next
job resumed it and read the file the first job wrote.

By submitting this pull request, I confirm that my contribution is made
under the terms of the Apache 2.0 license.

This branch was successfully deployed

1 active deployment
ai-pr-review-runtime — 57c2b3b7 Deployed Oct 26, 2026 by ParidelPooya via notify / Generate notification summary #308
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.

3 participants