Run several replicas¶
Session routing, rolling deploys, restorable sessions and Redis for an app that runs more than one process. For platform recipes see Run on Fly and Run on Kubernetes.
Several Fly machines¶
Turn on the route hint and the shell does the pinning itself, with no gateway configuration. Set a cluster secret (any long random string, the same on every machine) and scale out:
On Fly, INDAH_ROUTE_HINT=auto turns the hint on once the secret is set. The stream's first message
then names the machine that holds the tab (route in init, see the protocol), and the
shell sends that machine id as fly-force-instance-id (and X-Indah-Route) with every event and
upload. Download links carry ?r=<machine id>: a browser navigation cannot set a header, so a machine
that receives a link for another machine's file answers fly-replay and Fly Proxy sends the request
to the right one.
What to know:
- The secret is explicit. It is never taken from
INDAH_SECRET, so one secret does not do two jobs. Without it the hint stays off (route_hint="fly"with no secret logs a warning). On Fly the hint is only a machine id, so nothing is signed with the secret yet. - If the machine is gone (a deploy replaced it, it was stopped), Fly answers
400for the header or indah answers409. The shell opens its stream again on a machine that is running, shows the one "Reconnected" toast, and the next click works. The session's state is not carried over. - Only indah's shell sends the hint. A script that talks to the wire protocol directly still works
on one machine; to use several, read
routefrominitand send the header yourself (deploy/probe/sticky_check.py --follow-routedoes this). - A Cloudflare Worker in front passes request headers through, so the hint should survive it. That was not tested.
peers(a forwarder for Kubernetes without cookie affinity) is not built;INDAH_ROUTE_HINT=peersis refused. On Kubernetes use an ingress with cookie affinity (see Run on Kubernetes).
Not yet checked on real machines: 20 of 20 events on a three-machine Fly app (the unit, integration and browser tests cover the shell, the headers and the replay; the probe run is still to do).
Rolling out without losing tabs¶
When a process is replaced (a new image on Kubernetes or Fly), its open tabs are connected to it.
indah drains first: on SIGTERM the process stops taking new tabs, tells every open one to reconnect
elsewhere, keeps answering the sessions it still holds, and exits when the last stream is gone or
after drain_seconds (default 10), whichever is first. A tab reconnects without an error. It shows
"reconnecting…", and a quiet "Still reconnecting…" note only if that takes more than a few tries. A
tab that lands on the next process starts a fresh session there, so state that is not in your own
database is rebuilt, not carried over.
GET /healthis liveness:200while the process runs.GET /readyis readiness:200, then503once the drain starts. It needs no login and touches no session.create_app(drain_seconds=10)sets the drain length;drain_on_sigterm=Falseleaves SIGTERM alone (you drain yourself).- Under
launch()anduvicorn app:appthe SIGTERM handler is installed for you. Under gunicorn workers, a parent app that mounts indah, or anything else, callawait app.state.drain.run()from your own shutdown hook. A second SIGTERM stops at once.
LaunchHandle.drain() does the same from code (wait=False returns as soon as the drain has begun);
stop() is unchanged and does not wait for tabs, so call drain() first when you want them to move.
On Kubernetes¶
Ready-made manifests, with the checks that were run on them, are in Run on Kubernetes; the Fly equivalent is Run on Fly.
spec:
terminationGracePeriodSeconds: 30 # more than preStop + drain_seconds
containers:
- name: app
readinessProbe:
httpGet: { path: /ready, port: 7860 }
periodSeconds: 5
livenessProbe:
httpGet: { path: /health, port: 7860 }
lifecycle:
preStop:
exec: { command: ["sleep", "5"] } # let the ingress stop routing here first
Kubernetes removes the pod from the service as soon as it starts terminating, and sends SIGTERM after
preStop finishes. The short sleep gives the ingress time to stop sending new tabs to the old pod
before the drain begins. Several replicas also need your ingress to keep a tab on one pod; see
Sessions and tokens.
On Fly¶
kill_signal = "SIGTERM" # Fly's default signal is SIGINT, which indah does not drain on
kill_timeout = 15 # seconds, more than drain_seconds
[[http_service.checks]]
method = "GET"
path = "/ready"
interval = "10s"
timeout = "2s"
grace_period = "10s"
These snippets follow the Kubernetes and Fly documentation. The drain itself is tested here with real processes and a real browser; a rollout on a real cluster or a real Fly deploy is not yet.
Restorable sessions¶
A tab's session lives in one process, so a restart, a rollout or a move to another replica starts it
again. ctx.persist keeps the small state worth keeping (the selected tab, filters, a half-typed
note) in a state store and puts it back when the session is rebuilt:
def build(ctx):
tab = Signal("overview")
filters = Signal({})
ctx.persist("tab", tab) # restores the saved value now, saves on every change
ctx.persist("filters", filters, version=2, ttl=3600)
...
The value is restored inside the factory, before the first paint. After that it is saved when it changes
(a burst of keystrokes is one write, about a quarter of a second later) and once more when the session
ends. A tab is found again by its session id: the shell keeps it in sessionStorage, so a reload or a
rebuild on another replica finds the same state. A new tab, another browser or another device starts
from the defaults. In a secure app the saved value also records the user, and it is ignored for anyone
else.
| Store | Use it for | Notes |
|---|---|---|
"memory" (default) |
a tab rebuilt in the same process (an idle session reaped, a dropped stream) | does not survive a restart |
"sqlite:///state.db" |
one replica that restarts | the standard library, one file; do not share it between replicas over a network file system |
"redis://host:6379/0" |
several replicas, or state that must outlive a pod | pip install "indah[redis]"; you provide the Redis (see below) |
your own StateStore |
any other shared store | five methods (get, set, delete, delete_prefix, close), each plain or async def |
Nothing runs or bundles a service for you.
Redis¶
Redis is the store to use when a rolled-out or moved replica must find the state the old one saved. Install
the extra (pip install "indah[redis]", which adds the redis package; the base install does not need it)
and point the app at your Redis:
INDAH_STATE_STORE=redis://redis:6379/0 # or: create_app(state_store="redis://redis:6379/0")
INDAH_STATE_STORE=redis INDAH_REDIS_URL=... # the URL in its own Secret, with the password in it
- TLS and users:
rediss://is TLS, andredis://app:password@host:6379/0signs in as an ACL user. That user needsGET,SET,DEL,UNLINK,SCANandPINGon the keysindah:*(yourstate_namespaceis the first part of each key). For exampleACL SETUSER app on >password ~indah:* +get +set +del +unlink +scan +ping. - Expiry is Redis's own: each write sets the value's lifetime (24 hours by default, set per value with
ttl=). - Failures: timeouts are one second. A Redis that is down or slow never holds a tab: the saved values are read in the background after the first paint, a failed read starts from the defaults, a failed write is dropped, and the log says so about once a minute. A connection that was cut is reopened on the next call.
- The doctor (
/_indah/doctor) shows the store kind and, for Redis,okorunreachablewith a warning. - Not in this store: no registry of which replica holds which tab, and no pub/sub, so a
Topicis still per replica (below).
On Kubernetes, a Redis Deployment and a Service named redis are enough for a start (one replica,
no persistence: restored state is a convenience, and losing it only sends tabs back to their defaults):
apiVersion: apps/v1
kind: Deployment
metadata: {name: redis}
spec:
replicas: 1
selector: {matchLabels: {app: redis}}
template:
metadata: {labels: {app: redis}}
spec:
containers:
- name: redis
image: redis:7-alpine
args: ["--save", "", "--maxmemory", "128mb", "--maxmemory-policy", "volatile-ttl"]
ports: [{containerPort: 6379}]
---
apiVersion: v1
kind: Service
metadata: {name: redis}
spec:
selector: {app: redis}
ports: [{port: 6379}]
Then set INDAH_STATE_STORE=redis://redis:6379/0 in the app's ConfigMap. Without a password, keep the
Service inside the cluster and add a NetworkPolicy; with one, put the URL in a Secret and use
INDAH_STATE_STORE=redis with INDAH_REDIS_URL from it.
On Fly, use Upstash Redis (fly redis create, which prints a rediss:// URL) or a Redis of your own on
a Fly app, in the same region as your machines, and set the URL as a secret:
fly secrets set INDAH_STATE_STORE=redis INDAH_REDIS_URL='rediss://default:...@fly-...upstash.io:6379'.
These snippets are not yet applied to a real cluster or a real Fly app; the store itself is tested against a real Redis in CI.
What is saved, and what never is:
- Plain JSON only:
str,int,float,bool,None,listanddictwith string keys. Anything else raisesValueErrorat thepersistcall. - Size caps: 16 KB per value and 128 KB per session. A larger value is not saved (one warning), and the
older copy is removed so it cannot come back over a newer one.
INDAH_PERSIST_MAX_BYTESorpersist_max_bytes=changes the per-value cap. - Lifetime: 24 hours after the last write (
ttl=per value,INDAH_PERSIST_TTLorpersist_ttl=for the default). - No secrets: persist UI state, not tokens. A signal shown in a
SecretReveal(an ephemeral prop) is refused with an error in the log, and anything stored for it is deleted. A store holds what your app chose to save, so treat it like your own database. - Shape changes: pass
version=and bump it when a signal's shape changes; older values are ignored.validate=can veto a restored value. - Failures: a store that is down never breaks the tab. A failed read starts from the defaults, a failed write is dropped, and the log says so about once a minute.
- With an
asyncstore, the read is awaited in the background, so the saved values arrive a moment after the first paint.
guard and your Policy are unaffected: restoring a value runs no handler. Several apps that share one
store should each set state_namespace= (or INDAH_STATE_NAMESPACE) so their keys do not meet.
The browser could also hold this state itself and send it back, which needs no store at all. That is not
built: the browser can read and change its own values, so the app would have to validate every restored
value, and it needs a shell change. The StateStore interface admits such a store when it is wanted.
ctx.persist(name, signal) saves a signal's value and puts it back when the tab's session is built again. Pick a view, type a note and reload the page: both return. The same happens after a restart with state_store="sqlite:///state.db".def persist(ctx: indah.Context) -> indah.Component:
view = indah.Signal("overview")
draft = indah.Signal("")
# Saved when they change, put back when this tab's session is built again (a reload here, a
# restart or a move to another replica in production). Plain JSON values only.
ctx.persist("view", view)
ctx.persist("draft", draft, ttl=3600)
return indah.Column(
children=[
indah.Select(view, options=["overview", "billing", "settings"], label="View"),
indah.TextInput(draft, label="Draft note", placeholder="Type, then reload this page"),
indah.Text(lambda: f"Showing {view.value}; draft is {len(draft.value)} characters."),
]
)This example needs a server: restoring needs a server that outlives the tab, and the browser runtime has none. Run make demo to try it. Open in the Studio (make demo, then c/live/persist)
Topic and Stream with several replicas¶
A Topic and a Stream live in one process: a producer feeds the tabs of its own replica only. With
more than one replica, run the producer in every replica and let each one read the source itself (a
poll, a queue consumer, a database change feed). Every replica then holds the same recent history and its
own tabs follow it. There is no bus between replicas, and a Topic you publish to in one replica is not
seen in another. If you need a shared feed that a single producer fills, tell us what you are feeding
it from; a Redis-backed Topic is a later card, not part of the current design.