Socket.IO
forze[socketio] is an inbound transport — like FastAPI, but for
real-time events. It routes Socket.IO commands to Forze operations, validates
payloads, and resolves the execution context per connection.
Install¶
uv add 'forze[socketio]'
No service for a single worker; an optional Redis backplane enables delivery across multiple processes.
Build the server¶
from forze_socketio import build_socketio_server, build_socketio_asgi_app
sio = build_socketio_server() # pass redis_url=... for multi-process
asgi = build_socketio_asgi_app(sio) # or mount alongside a FastAPI app
An emit to a room starts each socket's send as an eager task, which costs the event loop
nothing when the send does not wait. python-socketio's own manager schedules a task per
socket, and at a few thousand sockets in a room scheduling them costs more than sending. A
send that does wait carries on beside the others, as the library's would, and the same
packets reach the same sockets. Pass client_manager=AsyncManager() to keep the library's
manager.
Route commands to operations¶
Declare commands on a namespace router — each maps an event to an operation, with typed payload and optional ack — then bind them through the adapter:
from forze_socketio import ForzeSocketIOAdapter, SocketIONamespaceRouter
chat = SocketIONamespaceRouter(namespace="/chat")
chat.command(event="message.send", operation="messages.create", payload_type=SendMessage, ack_type=ReadMessage)
adapter = ForzeSocketIOAdapter(
sio=sio,
context_factory=lambda request: runtime.get_context(), # tenant/deps wiring here
operation_resolver=registry.resolve, # the frozen registry
)
adapter.include_router(chat)
On each event the adapter builds the context, validates the payload, runs the operation, and returns the (validated) result as the Socket.IO ack.
Errors and identity¶
Every handler runs inside an error boundary: a CoreException is acked as
{"error": {"detail", "code", "kind", ...}} honoring the same egress policy as
the HTTP boundary, so internals never leak to clients. An optional identity_resolver
on the adapter authenticates connections at connect time (refusing them via
ConnectionRefusedError, whose data is that same error payload) and binds the resolved AuthnIdentity onto each event;
without one, handlers run unauthenticated. Tenant resolution stays in the
context_factory.
Push to clients (realtime egress)¶
Server push is an egress plane: a handler publishes a signal as data onto messaging and a gateway consumes it and bridges to live connections — the handler never touches a socket. The concept (audiences, ephemeral vs durable) is Realtime; this is the Socket.IO gateway that hosts it.
A handler publishes through a RealtimePublisher:
from forze_kits.integrations.realtime import build_realtime_transport, build_realtime_publisher
from forze.application.contracts.realtime import Audience, RealtimeEvent
MESSAGE_NEW = RealtimeEvent(name="message.new", payload_type=MessageView)
rt_transport = build_realtime_transport() # one source of truth for the channel specs
rt = build_realtime_publisher(
ctx, stream_spec=rt_transport.stream_spec, outbox_spec=rt_transport.outbox_spec
)
await rt.publish(Audience.topic("chat:42"), MESSAGE_NEW, view) # ephemeral, at-most-once
await rt.stage(Audience.principal(user_id), ORDER_SHIPPED, dto) # durable, at-least-once
The gateway runs as a supervised background lifecycle step, consuming the stream via
a consumer group and emitting to a tenant-scoped room (fanned out cluster-wide by the
Redis manager). Supervision is the same machinery every other background loop uses: a
crash restarts the loop after a jittered backoff (a configuration error is terminal —
wiring doesn't fix itself), and the loop registers as a drainable, so
runtime.shutdown() asks it to finish its in-flight batch before teardown instead of
cancelling it mid-emit. Durable signals also need the relay to move staged rows from
the outbox to the stream after commit, plus the gateway's dedup for exactly-once
delivery:
from datetime import timedelta
from forze_socketio import (
RealtimeGateway, StreamGroupSignalSource, GatewayDedup,
realtime_gateway_lifecycle_step, attach_realtime_connection,
)
from forze_kits.integrations.realtime import (
realtime_group_ensure_lifecycle_step, realtime_relay_lifecycle_step,
)
gateway = RealtimeGateway(
sio=sio,
source=StreamGroupSignalSource(stream_spec=rt_transport.stream_spec),
dedup=GatewayDedup(inbox_spec=rt_transport.inbox_spec, tx_route="..."), # exactly-once for durable
)
lifecycle = [
realtime_group_ensure_lifecycle_step(stream_spec=rt_transport.stream_spec), # before serving
realtime_relay_lifecycle_step(
outbox_spec=rt_transport.outbox_spec, stream_spec=rt_transport.stream_spec,
),
realtime_gateway_lifecycle_step(gateway),
]
attach_realtime_connection( # the single connect path
sio, resolve=resolve_connection, presence=presence, ctx_dep=runtime.get_context,
)
attach_realtime_connection is the single connect path — it authenticates and
auto-joins the principal room, so do not also give ForzeSocketIOAdapter an
identity_resolver on the same namespace. It refuses a delegated identity; a custom
resolver must return the identity with its actor chain intact, not normalized to its
subject. The publish-side Audience.principal(id) must
use the same id the gateway joins with (str(authn.principal_id)).
Pass ctx_dep (or the serving runtime) so the connect can ask whether the deployment is
tenanted. Once it registers a tenant resolver, an anonymous connect and a connection
without a tenant are refused (auth_required, tenant_required), at connect and at
realtime.reauth, whatever the resolver returned. Anonymous connects are accepted only
where no tenancy is wired. build_socketio_connection_resolver applies both rules itself,
so they hold without ctx_dep too; a hand-written resolver relies on it. tenant_required
asks the client to select a tenant; a token refresh does not clear it.
A client that opens several namespaces over one connection is authenticated once per
namespace. Pass share_identity=True to each of them to authenticate once per Engine.IO
connection instead. Later namespaces with the same credential and resolver reuse the
first one's identity until it expires or the connection closes, and a different
credential is checked again. It is opt-in because a namespace connecting just after the
first no longer re-checks a revoked session.
build_socketio_connection_resolver builds resolve_connection for you: a fixed
credential ladder (connect/reauth auth payload, then the cookie, then
Authorization: Bearer, then an opt-in query parameter) verified through the wired
authn plane, filling the principal, tenant, device and credential expires_at the
expiry sweep enforces. It is the same ladder
build_ws_connection_resolver uses, so both
transports authenticate by one set of rules — including its rule that cookie mode
requires a restricted cors_allowed_origins, attested with
origin_allowlist_attested=True. Socket.IO can also authenticate from the connect
auth payload alone (header_name=None), which the raw-WebSocket route cannot: its
upgrade carries no payload, and only a realtime.reauth frame does.
The delivery envelope (client contract)¶
Every frame is a uniform envelope — { "id": <id|null>, "data": <payload> }. Durable
frames carry the stable event id (dedup on it); ephemeral frames carry null. The
contract is versioned: send protocol: 1 in the connect auth (missing means 1; an
unsupported version is refused with realtime_protocol_unsupported), and ignore
unknown envelope fields — additive changes never bump the version:
const socket = io(url, { auth: { token, device_id, protocol: 1 } });
socket.on("order.shipped", ({ id, data }) => {
if (id && seen.has(id)) return;
if (id) { seen.add(id); socket.emit("realtime.ack", { up_to: id }); }
render(data);
});
The full normative contract (handshake, envelope, ack, replay, errors — for this
transport and the SSE route) is the
realtime wire protocol; its machine-readable twin
is asyncapi_document(catalog, router) — an AsyncAPI 3 document generated from the
typed event catalog and command router, ready for review or client-type generation.
Tenancy and addressing¶
Audience is principal(id) or topic(name) — no tenant. The publish layer puts
the ambient tenant in the message headers and the gateway scopes the room
(t:{tenant}:{kind}:{name}); a connection only joins its own tenant's rooms, so the
stream can stay tenant-global.
For trusted per-tenant isolation without trusting a header, put the stream on the
tenancy ladder: wire the stream route
tenant_aware and consume with TenantShardedSignalSource(shard=shard) instead of
StreamGroupSignalSource — one consume loop per assigned tenant, each bound to the
tenant of the stream it reads (no header trust). StreamGroupSignalSource and
PubSubSignalSource read with no tenant bound, so over a tenant_aware route the gateway
step refuses to start (tenant_global_required). The other way round, the sharded source
refuses a route that is not tenant_aware (tenant_aware_required), because every
tenant's loop would read the same stream. The shard is a fixed snapshot resolved
at startup, so onboarding a new tenant needs a restart; hand the same RealtimeShard to
the source, the group-ensure step (realtime_tenant_group_ensure_lifecycle_step), and —
for a partitioned outbox — realtime_tenant_relay_lifecycle_step. The read-side rules
for every messaging resource are in the
tenancy matrix.
Offline delivery, presence, and hardening¶
A durable, principal-addressed signal is also stored in a per-recipient mailbox, so
a device offline at emit time receives it on reconnect: give the gateway a
mailbox_factory, and pass attach_realtime_connection a mailbox_factory, a
cursors_factory, and the serving runtime. All three are required — the two
factories alone won't enable it (each device has its own cursor, so it never re-receives
what it acked). Replay and realtime.ack run on that runtime's live context, under the
connection's identity and tenant, so its scope must be open while the server serves, as
the app's lifespan holds it. With it closed, each connect's replay logs a configuration
error coded realtime_replay_scope_closed and delivers nothing, so that connection's acks
are ignored. For multi-node, use
RedisRealtimePresence (TTL-backed, so a crashed node's rooms lapse) with
realtime_presence_heartbeat_lifecycle_step — on a client not routed per tenant, since
rooms carry their tenant and connects, the gateway and the heartbeat use it with none
bound — and drop connections whose credential has lapsed with
realtime_identity_expiry_lifecycle_step. Both sweeps skip a socket already on its way out
(its namespace's disconnect begun, or its Engine.IO session gone) and leave it to its
disconnect handlers, without logging it. The full store-and-forward flow —
per-device cursors, trimming, and opting an event out (offline_delivery=False) — is the
offline-delivery recipe.
The gateway's store half is shared code. A deployment with no Socket.IO server runs it
alone as RealtimeMailboxWriter, filling the mailbox the SSE and raw-WebSocket routes
replay from (see offline delivery without Socket.IO).
Never let a gateway with dedup set, mailbox or not, share a dedup inbox with a writer:
whichever marks a signal first owns it, so the gateway skips its live emit or the writer
never stores the row. In one process the second to start is refused
(realtime_dedup_inbox_shared); across processes it is the deployment's to avoid.
Operational hardening around the loop:
- Token rotation without a reconnect — a client sends
realtime.reauthwith its fresh auth payload; the same connection resolver re-verifies it in place (same principal only) and swaps the stored identity +expires_at. No reconnect, no replay. - Poison ceiling — a durable signal whose bridge fails on every delivery is dropped
(acked, logged critical, counted) after
max_deliveries(default 5) instead of reclaim-looping forever. Mailboxed principal signals are unaffected: their store commits with the first successful mark, so the client recovers them on reconnect. - A single emit is bounded by default —
emit_timeoutdefaults to 5 s, so a dead backplane can't freeze the consume loop; the failure falls into the normal per-lane ack policy. - Delivery counters — hand one
RealtimeGatewayStatsto the gateway and its source, then callinstrument_realtime_gateway(stats)once at assembly; alarm onforze.realtime.gateway.poisonedand onemit_failedclimbing whileemittedis flat. Backlog is a property of the consumer group, not the process: pollAckStreamGroupAdminPort.depth(group, stream)for undelivered/pending/oldest-idle — the same surfacequiesce(ack_streams=[(stream_spec, group)])attests at standstill. - Backplane heartbeat —
realtime_backplane_heartbeat_lifecycle_step(sio, health)pushes a probe frame through the Redis manager on an interval andinstrument_realtime_backplane(health)exports its freshness; a dead manager listener otherwise stops every cross-node emit silently. - The realtime stream route must stay plaintext — the gateway has no decrypt seam,
so a stream route declaring an encryption tier is refused at run start
(
realtime_stream_encryption_unsupported) rather than emitting ciphertext to clients. - Cap the stream — the realtime stream has an unbounded producer; set
RedisStreamConfig(retention_max_entries=...)on its route (DEFAULT_REALTIME_STREAM_MAX_ENTRIESis the recommended starting point) so it cannot grow into a Redis-memory incident. Size the cap so its horizon at peak emit rate far exceeds the reclaim window; alarm ondepth()'s pending age long before the cap matters. Addrealtime_stream_trim_lifecycle_stepto keep steady-state memory near the gateway group's acknowledged horizon instead of the cap — the sweep only removes entries every group has delivered and acked, so it can never outrun a slow or crashed gateway (passtenants=lambda: shard.tenantson a tenant-sharded stream).
Deployment¶
- In-process — run the gateway lifecycle step inside the socket-holding workers (fine for single node / dev).
- Emit worker — at scale, run the gateway and relay as a dedicated
redis_write_onlyprocess holding no client sockets; the Redis manager fans emits to the socket nodes. - Consumer group —
realtime_group_ensure_lifecycle_stepcreates it idempotently before serving (the gateway reads but doesn't create it) and reclaims stranded pending entries so a durable signal whose consumer died is recovered, not lost.
Drain a replica¶
When a replica leaves — a deploy, a scale-down — its sockets would otherwise close all at once when the process stops, and every client would reconnect to the replicas that are left in the same second. Instead, the replica closes its connections gently while it still serves: spread over a window, after readiness has failed, so the clients reconnect through the balancer to replicas that are still ready, a few at a time.
import asyncio
from datetime import timedelta
import uvicorn
from forze.application.execution import build_runtime
from forze_fastapi import serve_uvicorn
from forze_socketio import (
RealtimeConnectionStats,
attach_realtime_connection,
build_socketio_asgi_app,
build_socketio_server,
connection_drain_lifecycle_step,
instrument_realtime_connections,
)
sio = build_socketio_server()
stats = RealtimeConnectionStats()
runtime = build_runtime(
lifecycle_steps=connection_drain_lifecycle_step(
sio,
window=timedelta(seconds=20), # spread the closes over this
tail=timedelta(seconds=5), # then wait this long for the slow ones
stats=stats,
),
drain_budget=timedelta(seconds=30),
)
attach_realtime_connection(
sio,
resolve=resolve_connection,
draining=lambda: runtime.draining, # turn connects away while leaving
stats=stats,
ctx_dep=runtime.get_context,
)
instrument_realtime_connections(stats)
asyncio.run(
serve_uvicorn(
uvicorn.Config(build_socketio_asgi_app(sio, other_asgi_app=api)),
runtime,
drain_budget=timedelta(seconds=30),
announce_delay=timedelta(seconds=5), # at least the balancer's notice time
)
)
Set announce_delay. Readiness fails at once, but a balancer takes a while to notice —
for Kubernetes, the readiness probe's periodSeconds × failureThreshold, plus the time to
drop the endpoint. Until it does, it keeps sending reconnects here. With no delay — the
default — the drain closes connections at once and every client is turned away again on
its first reconnect; with announce_delay at least that long, the closes start once the
balancer sends reconnects elsewhere.
The step registers a drain phase.
When the runtime drains — on SIGTERM under
serve_uvicorn, before uvicorn's own
shutdown closes every socket at once — the phase lists this node's connections and closes
them at the Engine.IO level, close i of n starting at i × window / n. Each close runs
on its own, so a slow one never holds up the next; after the last start the phase waits up
to tail, then stops waiting. A close still running carries on in the background — it
may be inside your disconnect handler — and is counted timed_out. The phase never takes
more than window + tail, but teardown waits for those closes, up to the runtime's
shutdown_step_timeout, before it closes any client: a disconnect handler writing to your
presence store finds it open. Past that the closes are no longer waited for, and process
exit can still cut one short.
Fit the window to the budget. The phase measures what is left of its allowance after
listing, gives the tail at most half of it and the window at most the rest. A budget
smaller than window + tail shrinks both; a larger one never lengthens the window, so a
generous budget does not slow the drain down. Under 100 ms the phase is skipped and the
connections close when the server stops. The budget itself has to fit the orchestrator's
grace period — see Fit the budget to the grace period.
Choose what closes. namespaces (default ("/",)) names the namespaces the drain may
close, and select(namespace, sid) -> bool can keep individual connections open. One
Engine.IO connection carries every namespace a client opened, and closing it ends them
all, so it closes only when every namespace on it is eligible — checked again right
before its close, so one that has opened a namespace the drain keeps since it was listed
stays open. close_spread(sio, targets,
window, tail=...) is the scheduler on its own, for closing connections at other times.
What clients see¶
- socket.io's JavaScript client reports a transport close (
"transport close") and reconnects with its usual backoff. A namespace-leveldisconnectwould read as"io server disconnect", after which it does not reconnect on its own — which is why the drain closes the transport instead. - The Python client (python-socketio) stops after either kind of close. Reconnect it yourself.
- A connect that arrives while the replica drains is turned away the same way: its
Engine.IO connection is closed, and the JavaScript client reconnects — through the
balancer, which by then sends it elsewhere. A connect on a connection that already
carries another accepted namespace — one the drain kept open — is admitted instead:
closing the connection would end that namespace too, and the connect goes with the
connection when the drain or the process closes it. The client's backoff starts over
whenever a connection opens, so a turned-away connect is held for a random 1–5 seconds
before its connection closes: the hold is the backoff, and spreads the retries. A
refused connect would not do:
socket.io's JavaScript client treats a
connect_errorfrom the server as final and stops. Over long-polling the client sees its next poll fail rather than a close, and reconnects the same way. The check runs before authentication, again once admission and authentication have run, and as the connect's last step. After the hold the connection is looked at again: one the client already closed is left alone, and one that meanwhile carries another accepted namespace admits the connect after all. The close runs a late connect's own disconnect handlers, which undo its presence and room. Only if the close fails is the connect undone directly and does the client see a refusal:Server is draining, with{"code": "realtime_draining"}as its data.
draining= takes the serving runtime's state, lambda: runtime.draining, and nothing
defaults it, not even the runtime offline replay is wired with.
Spreading the closes spreads the reconnects, but a balancer that sends them all to one replica still overloads it. Route WebSocket traffic least-connections, not round-robin or by hash, so the reconnects fill the replicas with room.
Admit connects¶
attach_realtime_connection(..., admission=...) takes an async callable that receives the
SocketIOConnect (sid, namespace, environ, auth) before the protocol is negotiated or the
connection authenticated. Return to admit it; awaiting delays the connect, so an app can
pace admissions by its own rules — priority lanes, a per-tenant budget. Raise a
CoreException to refuse it: the refusal renders as an error ack does, its message the
detail and its data {"detail", "code", "kind", ...}; a server-side kind shows the generic
detail and is logged.
Count connections¶
instrument_realtime_connections(stats) exports forze.realtime.connections (open
connections per namespace), forze.realtime.connect_refused (by reason: protocol,
auth, draining, admission, internal) and forze.realtime.drain_closed (closes the
drain started, by outcome). See the metric catalog.
Rebalance a fleet¶
A least-connections balancer places every new connection on the emptiest replica, but a socket that stays open never moves. After a deploy the replica that restarted last holds few connections and the others hold the rest, and when clients stay connected for hours, so does the imbalance. If your clients reconnect often anyway — page loads, short sessions — the balancer evens the fleet out on its own, and you do not need this.
The rebalance step lets a replica well above the fleet's mean close a few of its connections at a time, only those you mark movable. Their clients reconnect through the balancer to replicas with room. Each replica learns the others' counts from fleet membership, so every replica runs both steps:
from datetime import timedelta
from forze.application.contracts.fleet import FleetSpec
from forze.application.execution import build_runtime
from forze_kits.integrations.fleet import fleet_membership_lifecycle_step
from forze_socketio import (
build_socketio_server,
connection_rebalance_lifecycle_step,
connection_report,
)
GATEWAYS = FleetSpec(name="gateways", ttl=timedelta(seconds=10))
NAMESPACES = ("/lobby", "/game")
def movable(namespace: str, sid: str) -> bool:
return namespace == "/lobby" # a game connection never moves
sio = build_socketio_server()
runtime = build_runtime(
deps_module, # wires the fleet, e.g. RedisDepsModule(fleets=...)
lifecycle_steps=[
fleet_membership_lifecycle_step(
GATEWAYS,
every=timedelta(seconds=3),
report=connection_report(sio, namespaces=NAMESPACES, movable=movable),
on_stop="linger", # hold the settle gate shut until a replacement joins
linger_for=timedelta(seconds=90), # longer than a replacement takes to boot
depends_on=("redis_lifecycle",),
),
connection_rebalance_lifecycle_step(
sio,
fleet=GATEWAYS,
every=timedelta(seconds=10),
namespaces=NAMESPACES,
movable=movable,
per_minute=60,
),
],
)
Every every, the step reads the fleet's view and puts its own counts in its own entry. Its
load there is the lower of its live count and its last report, so connections that arrived
since that report are not counted; its movable count is live. It
then asks FleetView.excess how far
above the mean it is, and closes that many movable connections, no more than its rate allows,
the way the drain does: at the Engine.IO level, spread over every, each given up to another
every to finish. Each is checked again right before its close starts, so a client that
joined a game since the tick picked it stays.
Route least-connections. It is a prerequisite, not a tuning choice: a balancer that
round-robins, hashes, or pins a client to its replica with a sticky cookie can send the shed
clients straight back, and the step then moves load in circles. Long-polling across several
replicas needs every request of one Engine.IO session on one replica: pin by the session
(its sid query parameter, which a reconnect replaces), not by a cookie that outlives it,
or let clients use the WebSocket transport only.
Shed only clients that reconnect. socket.io's JavaScript client reconnects after the
close, as it does after the drain's (see What clients see). The Python
client stops: give it reconnect logic of its own, or leave its connections out of movable.
The report counts transports. One Engine.IO connection carries every namespace its
client opened, and closing it ends them all, so every count is of Engine.IO connections, not
of namespace connections. A connection is movable when every namespace on it is in
namespaces and passes movable(namespace, sid); one game namespace keeps the whole
connection. The step reads its peers' loads from their membership reports, under load
("connections"); only its own movable count limits its shed, so a peer's movable_report
("movable") is there for you to watch. connection_report returns the report= callable
that fills both with the counting the step uses for its own: give the two the same
namespaces and movable. A replica that reports no load is left out of the mean,
and sheds nothing itself. List every namespace your clients open in namespaces, so the
counts match what the balancer counts, and let movable decide what may move: a namespace
left out is not counted, and the step and the balancer then disagree about which replica is
full.
When it sheds. Only while all of these hold:
- Every replica in the view has been serving for
settle(120 s by default), so nobody sheds while replicas start. A replica that leaves with the membership step's defaulton_stop="leave"drops out of the view at once, though, so midway through a rolling deploy the replicas left can count as settled and shed. Give the membership stepon_stop="linger"with alinger_forlonger than a replacement takes to boot, as above: the leaving replica'sdrainingentry then holds the gate shut until its replacement beats asstarting. Shedding does not change a replica's state, so it does not restart this clock. - This replica has an entry in the view, is serving, and is not draining. A draining replica closes everything through its drain instead. A shed already running when the drain begins goes on; one still running at shutdown starts no more closes, and teardown waits for the closes it started, as it does for the drain's.
- Its count is more than
tolerance(15%) above the mean. Peers' reports can be a beat old; the tolerance absorbs that. Replicas beat at different moments, so in a connect storm their reports differ by up to one beat's growth: keep the membership step'severyshort enough that this stays within the tolerance. - No earlier shed is still closing, and
cooldown(default:settle) has passed since the last one ended, long enough for its clients to land elsewhere and be reported.
The rate. per_minute is a long-run rate. Each tick adds per_minute for each minute
that passed to an allowance, keeping the fraction, and a shed spends whole connections, so the
rate holds however short every is: a low rate sheds one connection every few ticks, not one
every tick. The allowance banks what refills over one shed cycle — the longer of a minute and
cooldown plus three every — so the cooldown does not lower the rate, and one shed can
spend all of it at once, spread over every. With the default 120 s cooldown and
every=10s, a shed every two minutes or so closes about two minutes' worth. The step's
member_id must be the membership step's; both default to instance_id(). It needs a
long-running process, so a SERVERLESS runtime refuses it.
Settings¶
SocketIOSettings carries the Redis backplane: the URL (as a SecretStr — it holds the
password), the channel, and whether this process publishes without subscribing. Build the
URL from a RedisSettings.dsn; this package cannot import forze_redis itself. See
connection settings.
What it provides¶
| Surface | What it does |
|---|---|
SocketIONamespaceRouter.command(...) |
inbound: event → operation, with typed payload/ack |
RealtimePublisher.publish / .stage |
egress: publish a signal to messaging (ephemeral / durable) |
RealtimeGateway + realtime_gateway_lifecycle_step |
egress: consume the stream, bridge to rooms — supervised (restart + backoff), drainable, bounded emit_timeout, poison ceiling |
TenantShardedSignalSource + realtime_tenant_group_ensure_lifecycle_step |
egress: tenant-aware per-tenant streams; binds tenant from the stream (trusted), no header trust; per-tenant fault isolation |
PubSubSignalSource + realtime_pubsub_spec + RealtimePubSubPublisher |
egress: broadcast pubsub live lane (at-most-once, every node sees every signal); durables keep riding outbox → stream → mailbox |
realtime_tenant_relay_lifecycle_step |
egress: per-tenant durable relay for a partitioned (tenant-aware) outbox |
attach_realtime_connection |
auto-join principal rooms + presence on connect; offline replay + ack |
build_socketio_connection_resolver |
the shipped connection resolver: connect/reauth payload, then cookie, header and opt-in query — the WebSocket route's ladder |
DocumentRealtimeMailbox + DocumentMailboxCursors |
offline store-and-forward: per-principal mailbox + per-device cursor |
RedisRealtimePresence + realtime_presence_heartbeat_lifecycle_step |
crash-safe multi-node presence (TTL + heartbeat) |
realtime_identity_expiry_lifecycle_step |
drop connections whose credential (expires_at) has lapsed |
connection_drain_lifecycle_step + close_spread |
close a leaving replica's connections spread over a window, before the server stops |
connection_rebalance_lifecycle_step + connection_report |
shed an overloaded replica's movable connections toward the fleet's mean, at a bounded rate |
RealtimeConnectionStats + instrument_realtime_connections |
open and refused connects, and the drain's closes |
realtime.reauth (built-in event) |
refresh a rotating token in place — same principal, no reconnect |
RealtimeGatewayStats + instrument_realtime_gateway |
delivery counters for the live path (emitted / failed / skipped / poisoned) |
BackplaneHealth + realtime_backplane_heartbeat_lifecycle_step + instrument_realtime_backplane |
probe the Redis fan-out path; alarm on staleness |
Notes¶
- The registry must be frozen (
OperationRegistry(...).freeze()), same as any transport. operation_resolveris the registry's ownresolve— its signature is(operation_key, context).- Payloads validate through a per-route Pydantic
TypeAdapter; authenticate connections withidentity_resolver, bind tenant in thecontext_factory. - Multi-process delivery needs the Redis backplane (
redis_url=); without it the server is single-worker.