Shutdown & fleets
Processes get told to stop — deploys, autoscaling, a node going away. And in production there is rarely one process: the same app runs as N replicas behind a load balancer. The runtime treats both as first-class: shutdown drains instead of dropping work, and a declared fleet posture catches the mistakes that only N replicas can make.
Graceful drain¶
ExecutionRuntime.shutdown() — and therefore scope() exit and
runtime_lifespan — does not tear infrastructure down under in-flight work.
It drains first:
- The scope stops admitting new top-level invocations. They fail with a
retryable
throttled(code="draining") — a 429 at the FastAPI edge, a requeue-worthy nack for a queue consumer. - In-flight operations get a bounded window —
drain_timeout, default 10 seconds — to finish before lifecycle teardown closes the clients they depend on. - Lifecycle shutdown runs as usual, in reverse wave order.
runtime = build_runtime(..., drain_timeout=timedelta(seconds=20))
An operation that is already running keeps all of its machinery: nested dispatch deliberately rides the outer invocation's slot, so draining never starves an admitted operation of its own dispatch chains. Zero in-flight work exits immediately; a window that expires logs the leftover count and proceeds — shutdown is never blocked indefinitely.
Readiness¶
The load balancer should stop routing before the drain window starts. The
runtime exposes its state — runtime.ready and runtime.draining — and the
FastAPI integration turns it into a probe:
from forze_fastapi.routes import attach_readiness_route
attach_readiness_route(router, runtime) # GET /readyz
200 while a scope is active and not draining; 503 draining once shutdown
flips the gate, 503 unavailable before the scope exists. Point your
orchestrator's readiness check here and the rollout sequence takes care of
itself: routing stops, in-flight work drains, teardown runs.
Point liveness somewhere else — at attach_liveness_route(router), which answers
200 unconditionally. A draining pod is alive but not ready; one endpoint answering
both questions gets the pod killed mid-drain instead of letting it finish.
Processes that are not HTTP servers — the outbox relay, inbox and commit-stream consumers, the socket.io gateway, durable runners — get both probes from one lifecycle step:
from forze.application.execution.background import probe_listener_step
lifecycle_steps.append(probe_listener_step(runtime, port=8079))
Stdlib asyncio, no HTTP framework, same runtime state, same drain semantics: it
reports draining for the whole window and closes only after. See
The Grafana stack for the Kubernetes mapping.
Are the dependencies reachable?¶
The drain gate answers whether the process is willing to take traffic. It says
nothing about whether it can serve one: an un-drained process whose database is gone
answers ready, the load balancer keeps routing, and every request 500s.
Name the clients you cannot serve without and each is resolved from the deps
container and asked health() — the signature every forze client port already
declares:
from datetime import timedelta
from forze_postgres import PostgresClientDepKey
from forze_redis import RedisClientDepKey
attach_readiness_route(
router,
runtime,
probes={"postgres": PostgresClientDepKey, "redis": RedisClientDepKey},
probe_timeout=timedelta(seconds=2),
)
The response then carries a per-dependency breakdown, and any failed check makes the
whole probe a 503:
{"status": "degraded",
"checks": {"postgres": {"ok": true, "detail": "ok"},
"redis": {"ok": false, "detail": "timed out after 2.0s"}}}
probe_timeout is the budget for each probe, not for the sweep — deliberately.
The two look equivalent, since the probes run concurrently and the wall clock is the
same either way. They are not: when a dependency is unreachable its driver retries
past the deadline, and a sweep-wide timeout then cancels every probe and answers
503 with checks: {} — no breakdown, in exactly the situation the breakdown exists
for. Per probe, a hanging dependency is one failed check with a name on it.
A probe reports failures and never becomes one. A client whose health() raises is
reported as {"ok": false, "detail": "<ExceptionType>"} — the type and not the
message, because a driver's connection error routinely carries the DSN and this body
is served to anything that can reach the probe. Nothing is auto-discovered: the
integrations satisfy health() structurally, with no base port to enumerate, and a
sweep of every registered dependency would probe things readiness does not depend on.
A probe_timeout that is not positive is refused at attach time.
Keep the endpoint internal, as you would /metrics. detail is the client's own
summary, and a failing driver's is its exception text — which routinely names the
host, the port and the user it failed to authenticate as. That is exactly what the
breakdown is for and exactly what should not reach the public internet. Every request
also runs every probe, so the probe period is a real query rate against each
dependency.
No probes (the default) leaves the drain-gate-only behaviour untouched.
Declare the fleet posture¶
Some startup work is safe in one process and a stampede in twenty — N replicas
all running CREATE INDEX or a data migration at the same moment. Declaring
the posture makes that a composition-time error instead of a 3 a.m. incident:
from forze.application.execution import DeploymentProfile
runtime = build_runtime(..., deployment=DeploymentProfile.FLEET)
Under FLEET, building the runtime fails for any lifecycle step marked
mutates_shared_state=True that is not also singleton_guarded. The markers
are declared by the step author — mutation can't be detected structurally, so
the validation is honest-by-declaration: mark the steps that touch shared
backends, and the profile enforces that each one is guarded.
Singleton lifecycle steps¶
The guard itself ships in forze_kits: wrap a step in a distributed lock so
one replica runs it and the rest skip —
from forze.application.contracts.dlock import DistributedLockSpec
from forze_kits.lifecycle import singleton_lifecycle_step
step = singleton_lifecycle_step(
ensure_indexes_step,
spec=DistributedLockSpec(name="ensure-indexes"), # resolved from the scope
owner=instance_id,
)
You pass the lock spec, not a live port: the guard resolves the command port
from the execution context (ctx.dlock.command(spec)) at startup, so it slots
into a lifecycle plan that's assembled before any scope exists.
The first replica to acquire the lock runs the startup and releases it; replicas that find the lock held skip — the holder is doing the work. Shutdown later runs only on the replica whose startup actually executed. The step must be idempotent ("ensure"-style): a replica that starts after the holder released will acquire and run it again. Size the lock's TTL to comfortably exceed the step's duration — no heartbeat extends it here.
Migrations are deploy steps
singleton_lifecycle_step is for ensure-style work: indexes, queue
declarations, seed data. One-shot work like a schema migration wants
run-exactly-once semantics, which a skip-if-held lock does not give —
run it as a deploy step in your pipeline, not as a runtime step.
What's shared, what's per-process¶
Most coordination state already lives in your backends (idempotency records, distributed locks, the outbox). The resilience layer's state is process-local by default, and each piece has a deliberate fleet answer:
| State | Default | In a fleet |
|---|---|---|
| Circuit breaker | per-process | share via redis_circuit_breaker_store — one replica's open circuit protects them all |
| Rate limits | per-process (fleet rate = permits × replicas) |
share via redis_rate_limit_store — the declared rate becomes the fleet's rate |
| Bulkheads | per-process | stays local by design — fleet capacity is max_concurrency × replicas, and adaptive bulkheads converge across uncoordinated replicas |
Wiring for the shared stores is on the resilience page. The framework's periodic loops — the outbox relay tick, consumer crash-restart backoff — are jittered out of the box, so N replicas don't synchronize into a thundering herd against the same claim query.
Knowing a fleet drains cleanly — or spots one thundering against a claim query — is something you confirm by watching it; that's Observability.