Skip to content

Add isolated sessions: a fresh Python process per session, from a prepared template - #87

Open
benoitc wants to merge 12 commits into
mainfrom
feature/isolated-sessions
Open

benoitc wants to merge 12 commits into
mainfrom
feature/isolated-sessions

Conversation

@benoitc

@benoitc benoitc commented Sep 26, 2026 •

Copy link
Copy Markdown
Owner

Some callers need each run of a Python function to start from the same state and leave nothing behind for the next one. A durable-execution engine replaying workflow steps is the case that prompted this. An isolated context keeps its interpreter between calls, and a new one costs an interpreter start plus every import. Temporal's Python SDK isolates a workflow run by importing its module again in a fresh sys.modules, inside a shared process. Restate's SDK does not isolate invocations at all.

py_session:template/1 prepares an interpreter once: paths, imports, preload, environment, hash seed and limits. py_session:new/1 then gives a new child process per session that starts from it. By default the child is forked from a zygote that already ran the imports and preload, so a session is ready in a few milliseconds instead of the 40 to 60 ms of a new interpreter. start => spawn starts a new interpreter per session, optionally from a warm pool, for code that cannot be forked. A session is an isolated context, so calls, callbacks back into the same session, interrupts, loops and pass_fd work unchanged. close/1 kills it. A session whose process dies answers with the reason until it is closed, and is never restarted.

Measured on the same machine and workflow module: a forked session costs about 3.5 ms to start, use and close, the same order as a Temporal workflow sandbox (0.5 ms to 4.6 ms depending on what the workflow imports), and gives a separate process instead of a separate module dictionary. Restate's SDK costs almost nothing per invocation because it isolates nothing. One zygote serves about 650 sessions a second on macOS and 1,000 on Linux; zygotes => N adds more. A call into a session crosses a local socket (30 to 70 µs), which the in-process SDKs avoid.

When fresh module state per run is enough, start => reimport runs each call on a worker or owngil context instead: the function's module is imported again in a new module dictionary, swapped per thread, the way Temporal's Python SDK isolates a workflow run. The standard library and the modules the template lists are shared, and so are the process, its environment and C-extension state. A run costs about 0.25 ms, and calls inside it stay in-process.

It also fixes a hang on owngil contexts: a callback calling back into the same owngil context waited until the request timeout, because the context thread waited for erlang.call on its pipe while the nested call sat in its queue. It now waits inline and serves the nested call, as worker contexts do.

Isolated contexts also gain clear_env and hash_seed, which sessions use so that each one sees only the template's environment and orders sets the same way.

A session is a process boundary, not a security boundary. Confining what a session can reach (Landlock, seccomp, Seatbelt) and freezing time and randomness for deterministic replay are the next steps.

The socket listen/accept sequence, frames, executable lookup and port
options move to py_child, so another process can drive a Python child
the same way. No change in behaviour.
clear_env => true gives the child only the variables named in env,
and hash_seed pins PYTHONHASHSEED, so two children built the same way
hash strings and order sets the same way. Bad values are refused when
the context starts.
A context started with session => true runs in its own scratch
directory, is killed rather than asked to stop, and is never restarted:
once its child is gone it answers every request with the exit reason
until it is closed. Its child can be forked by a session template
instead of spawned; the template reports the exit by message. The child
runtime can be built before it is connected, and the serve loop is
shared by both kinds of child.
py_session:template/1 prepares an interpreter once: imports, preload,
environment and hash seed. py_session:new/1 then gives a fresh child
process per session, forked from the template's zygote or spawned, and
close/1 kills it. A session is an isolated context, so calls, callbacks
back into the same session, interrupts and loops work unchanged.

A forked session detaches from the zygote's stdio and logs its output
through its own context, so a zygote that dies is seen and rebuilt while
its sessions keep running.
The stress suite logs what a fresh session costs next to a plain
isolated context, where a fork's time goes, sessions per second and call
overhead. The soak suite churns sessions from fork and spawn templates
with crashes, kills and timeouts, then checks that processes, ports,
fds, children, zombies and scratch directories are back to baseline.
The examples compare the isolation step with Temporal's and Restate's
Python SDKs on the same workflow.
A task-oriented guide (template, new, run, fork or spawn, what a session
sees, limits, costs), the decision record for forking sessions from a
zygote, the exited state of a session context, and the changelog.
Every file:make_dir, del_dir_r, delete and change_mode call goes through
the node's single file server, so sessions opened in parallel queued
behind each other: on macOS eight callers got fewer sessions a second
than one. The socket directory is now created once and cached, and a
session's socket file and scratch directory are handled with prim_file
from its own process. close/1 replies before the directory is removed.
An owngil context thread waited for erlang.call on its callback pipe,
so a callback calling back into the same context queued behind the
request that was waiting for it, until the timeout. Requests with a
caller now wait inline and serve the nested call, as on a worker
context.
The NIF read sys.modules to find the erlang module when it extends it.
When sys.modules is replaced by a mapping (a re-import template does
that), PyDict_GetItemString on it returns nothing and the extension code
ran without erlang. PyImport_GetModuleDict() is the table the import
system itself uses.
A template with start => reimport keeps worker or owngil contexts, and
py_session:run/5 imports the function's module again in a new module
dictionary on one of them, the way Temporal's Python SDK isolates a
workflow run. The standard library, imports and passthrough modules are
shared; the swap is per thread, so worker contexts run at once without
seeing each other's modules. It isolates module state only, and the
guide says what stays shared.
The zygote reaps a child and its template reports how it died; on a
busy machine the pid disappears before that report arrives, and the
session concluded it was killed. It now waits for the report while the
template is alive, and falls back to the pid only when it is gone.
Figures from examples/bench_sessions.erl on an unloaded machine, with
throughput per start mode and the isolation step of Temporal's and
Restate's SDKs on the same workflow.
@benoitc
benoitc force-pushed the feature/isolated-sessions branch from 15521fe to 5810f47 Compare September 26, 2026 19:33
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.

1 participant