Runtime model
Underneath, an Edda app is a tree of BEAM processes. saga_http's
serve runs one process accepting connections on the listening
socket and spawns a new process for every accepted connection —
that's where your app req runs, where the route runs, where any
with handlers around the route get installed. When the request
finishes (or the connection closes, or the handler panics), that
process exits. The next request gets a fresh one.
This is how saga_http works, which in turn is how the BEAM has worked since the late 1980s.
What falls out
Three consequences of one-process-per-request that matter for how you think about an Edda app:
Requests are isolated. No shared mutable state between concurrent handlers. A panic in one handler kills its own process and nothing else — the accept loop keeps running, every other in-flight request keeps running. There is no "global crash" failure mode in this architecture; the only way one bad request can take down another is if you wire them together through shared resources you control.
Concurrency is cheap. BEAM processes are not OS threads. They
start in microseconds, occupy a couple of KB of memory each, and
scale to hundreds of thousands per node on commodity hardware.
saga_http spawns one per request without ceremony because the
platform was built for that exact pattern. You don't need a worker
thread pool, an event loop, or async/await colouring on your
functions — the runtime multiplexes processes across CPU cores for
you.
Per-request state is automatic. Anything you bind inside app req — a request ID, a DB transaction handle, a logger with the
trace ID baked in — is local to that request's process. It cannot
leak into another request. This is why ambient context
handlers work the way they
do: the handler closes over the request because the handler is
running in a process dedicated to that request.
The rest of this page is patterns that build on these three.
Supervision can restart a failed handler because the failed handler
was its own process. Background spawns survive past the response
because they're independent processes from the start. Resource
cleanup with finally is bounded by the request process's lifetime.
None of it requires Edda — it falls out of having request handlers
running on the BEAM.
The actor effects, briefly
Saga exposes the BEAM's process machinery as ordinary effects
(Process, Actor, Monitor, Timer). The standard library
provides handlers (beam_actor, Std.Supervisor) that map those
effects onto real BEAM processes and supervision. Edda doesn't wrap
any of it — because it's effects and handlers, it composes with
routes the same way every other Saga pattern does.
The hello-world main already discharges the actor effects that
serve needs:
main () = {
let cfg = { default_config | port: 8080 }
case serve cfg (to_handler app) {
Err e -> dbg ("startup failed", e)
Ok h -> await_shutdown h
}
} with {beam_actor, console}beam_actor is the handler that connects the abstract effects to the
concrete runtime. The server, the accept loop, and each request
handler are all real BEAM processes underneath. The effect row
disappears at this boundary; everything inside serve runs against
real processes.
Panics in routes
A route that panics doesn't take the server with it: the per-request process dies and the client gets a dropped connection. That's sometimes what you want, but usually not — clients see a connection error instead of a status code.
For a clean 500 instead, wrap the app with with_panic_recovery. See
error handling
for the pattern. catch_panic handles both Saga panics and native
BEAM exceptions (badarith, badmatch, ...), so a single wrap covers
both.
Supervised retries inside a route
Some operations are flaky for boring reasons — a transient network
hiccup, a database deadlock, a rate-limit blip. Std.Supervisor's
supervised retries a computation a fixed number of times:
import Std.Supervisor (supervised)
fun show_item : Request -> Response needs {Fail}
show_item req = case supervised 3 (fun () -> fetch_from_upstream id) {
Ok value -> json 200 value
Err _why -> fail! (Internal "upstream unreachable")
}supervised catches fail! from the inner computation and retries
up to N times before giving up. For exponential backoff between
retries, the saga-website supervision
guide has a supervise_backoff
recipe that adds a Timer effect and a doubling delay.
The route's own Fail row stays intact — the inner supervisor only
catches the inner computation's failure, not the route's.
Background work per request
Fire-and-forget work after the response goes out — sending an audit
event, enqueueing an email, warming a cache — is a spawn! away:
fun create_user : Request -> Response needs {Process}
create_user req = {
let u = persist input
spawn! (fun () -> {
send_welcome_email u
log_audit "user.created" u.id
})
json 201 u
}The spawned process is independent of the request handler. The
response goes out as soon as json 201 u returns; the email and
audit log happen on their own time. If the child crashes, the request
is unaffected.
If you want the child to be supervised (retried on crash), spawn into
supervise:
spawn! (fun () -> supervise (fun () -> send_welcome_email u))The supervisor lives inside the child process, so a crash and retry happens locally without involving the request handler at all.
Resource cleanup across restarts
When a supervised computation acquires resources, pair supervise
with run_scoped so each retry gets a fresh resource and the old one
is released:
fun process_message : Message -> Unit needs {Fail, Scope}
process_message msg = {
let db = acquire_scoped! (fun () -> connect db_url) disconnect
do_work db msg
}
# spawned background worker
spawn! (fun () -> {
supervise (fun () -> process_message msg)
} with run_scoped)If do_work calls fail!, the supervisor catches it, the finally
block in run_scoped closes the old DB connection, and the next
attempt acquires a fresh one. The cleanup story is the same whether
the failure is a fail!, an abort from another handler, or a panic
— finally runs in all three cases.
This is the BEAM "let it crash" story translated to Saga: write the
happy path, let supervisors handle restarts, let finally handle
cleanup. Each piece is its own small handler; they compose because
they share the same mechanism.
What Edda contributes
Nothing in particular. The router is a pure function; the boundary
helper is a pure function; the request handlers are pure functions
(modulo their needs row). Everything in this page is the language
and standard library doing the work — Edda just gives the request
handler somewhere natural to live.
The framework-level question to keep an eye on as Edda evolves: what
patterns get repeated enough across apps that they're worth promoting
to library helpers? Per-request panic recovery is an obvious
candidate. Per-request scoping (auto-run_scoped around every
handler) might be another. Supervision per-route isn't, because the
right restart policy is too app-specific. The principle is that any
helper Edda ships has to read as a thin wrapper over a Saga pattern
that already works without it — not a new abstraction.