Tenant-sharded realtime
The default realtime stream is tenant-global: one stream carries every tenant's
signals and the tenant rides an (untrusted) header. For trusted per-tenant isolation,
put the stream on the tenancy tier ladder —
wire the stream tenant_aware so each tenant gets its own key, and consume one loop per
tenant so a signal's tenant is the stream it came from, never a forgeable header. The
Socket.IO gateway automates the
consume side with TenantShardedSignalSource; this recipe shows what makes it work end to
end for durable signals.
Wire the stream tenant-aware, keep the outbox global¶
Wire only the realtime stream route tenant_aware (each tenant gets its own key). The
outbox stays tenant-global — a shared table whose rows are tagged with their tenant —
so the tenant-less relay can drain every tenant from one place:
ACME = UUID("11111111-1111-1111-1111-111111111111") # tenant A
GLOBEX = UUID("22222222-2222-2222-2222-222222222222") # tenant B
STREAM = realtime_stream_spec() # the per-tenant stream (wired tenant_aware below)
# The outbox keeps its own name so it can stay tenant-global while the stream is tenant-aware
# (the mock keys routes by spec name; in production they are separate backends).
OUTBOX = realtime_outbox_spec(name="realtime-outbox", stream=str(STREAM.name))
class _OrderShipped(BaseModel):
order: str
SHIPPED = RealtimeEvent(name="order.shipped", payload_type=_OrderShipped)
def _context() -> ExecutionContext:
routes = {
str(STREAM.name): MockRouteConfig(tenant_aware=True), # per-tenant stream key
# "realtime-outbox" is intentionally absent → tenant-global (tagged) outbox
}
return ExecutionContext(
deps=DepsRegistry.from_modules(MockDepsModule(routes=routes)).freeze().resolve()
)
Stage a durable signal under its tenant¶
A handler stages the signal with no realtime or tenant plumbing — the staging tenant is ambient, and the outbox row is tagged with it:
async def publish_durable(ctx: ExecutionContext, *, tenant: UUID, order: str) -> None:
"""A handler stages a durable signal under its tenant — no realtime/tenant plumbing.
The signal is addressed to a principal; the staging tenant is ambient. The outbox row
is tagged with that tenant, which the relay later uses to route it. (A real handler
flushes in its own transaction; here both tenants stage into one buffer, flushed once
by :func:`relay` so the in-process demo shares a single mock store.)
"""
with ctx.inv_ctx.bind_identity(tenant=TenantIdentity(tenant_id=tenant)):
publisher = build_realtime_publisher(
ctx, stream_spec=STREAM, outbox_spec=OUTBOX
)
await publisher.stage(
Audience.principal("alice"), SHIPPED, _OrderShipped(order=order)
)
The relay routes each row to its tenant's stream¶
The relay runs with no tenant bound, yet routes correctly: it binds each row's staged tenant before appending, so a durable signal lands on that tenant's stream key.
async def relay(ctx: ExecutionContext) -> None:
"""The background relay drains the (tenant-global) outbox and forwards each row.
It runs with **no** tenant bound, yet routes correctly: it binds each row's staged
tenant before appending, so the durable signal lands on that tenant's stream key.
"""
await ctx.outbox.command(
OUTBOX
).flush() # write staged rows to the tenant-global outbox
await OutboxRelay(outbox_spec=OUTBOX).to_stream(ctx, STREAM)
Consume one tenant's stream¶
Binding a tenant resolves the stream adapter to that tenant's key, so a per-tenant
consumer only ever sees its own signals — the isolation is the stream's, not a header
check. This is what TenantShardedSignalSource runs once per assigned tenant:
async def read_tenant_stream(
ctx: ExecutionContext, tenant: UUID
) -> list[RealtimeSignal]:
"""What ``TenantShardedSignalSource`` does per assigned tenant: bind it, read its stream.
Binding the tenant resolves the stream adapter to *that tenant's* key, so this only ever
sees the bound tenant's signals — the isolation is the stream's, not a header check.
"""
with ctx.inv_ctx.bind_identity(tenant=TenantIdentity(tenant_id=tenant)):
stream = ctx.deps.resolve_configurable(
ctx, StreamQueryDepKey, STREAM, route=STREAM.name
)
messages = await stream.read({str(STREAM.name): "0"})
return [m.payload for m in messages]
Assign tenants at startup¶
The shard is a fixed snapshot, so something has to pick each replica's tenants before its
gateway starts. Fleet membership can: each
replica beats as starting through one ttl, so replicas starting together see each
other, then takes the tenants it ranks first for among the serving and starting members:
GATEWAYS = FleetSpec(name="realtime-gateways", ttl=timedelta(seconds=10))
async def assign_shard(
fleet: FleetPort,
me: str,
tenants: Sequence[UUID],
*,
spec: FleetSpec = GATEWAYS,
) -> RealtimeShard:
"""Take the tenants this replica ranks first for, among replicas serving or starting."""
# Beat through one ttl, at a third of it, so replicas starting meanwhile see this one
# and it sees them; rank over the last beat's view.
view = await fleet.beat(me, "starting", {}, ttl=spec.ttl)
for _ in range(3):
await asyncio.sleep(spec.ttl.total_seconds() / 3)
view = await fleet.beat(me, "starting", {}, ttl=spec.ttl)
mine = [
t for t in tenants if view.rank(str(t), states=("serving", "starting"))[0] == me
]
return RealtimeShard(stream_spec=STREAM, tenants=mine)
The shard is consumed when the steps are built, before the runtime exists, so build the
port directly — a RedisFleetAdapter over a RedisClient you initialize for it. Once
assigned, keep the replica in the fleet with the
membership step on the same
FleetSpec and member id, so replicas started later rank it in; it beats serving once
startup finishes. The first replica of a fresh fleet finds only itself and takes every
tenant.
The assignment is a snapshot, sound only when every replica starts within one ttl of
the others — an all-at-once rollout — and only while the fleet keeps that shape:
- a rolling deploy or restart leaves tenants with no owner: each new replica leaves to an old one the tenants it ranks first for, and the old one is then stopped;
- a replica that dies leaves its tenants unowned until the fleet restarts;
- a replica that starts later, or whose beats land after another's read, can take tenants another replica also holds, or leave some that no replica holds.
Moving tenants between running replicas is not covered; to reassign, restart every replica at once.
Notes¶
- The outbox stays tenant-global by design. Only the stream is
tenant_aware; the shared outbox is drained by the standard relay with per-row tenant routing. To partition the outbox table itself, wire its routetenant_awaretoo and use the sharded relay (realtime_tenant_relay_lifecycle_step). - In production,
TenantShardedSignalSource(shard=…)replaces the hand-written per-tenant read: one consume loop per assigned tenant, bound to it. Hand the sameRealtimeShardto the source, the group-ensure step, and (for a partitioned outbox) the relay so they can't drift. - Assignment, not discovery — the shard is a fixed snapshot resolved at startup (by rank, as above, or by your own assignment), so onboarding a new tenant (or rebalancing) needs a restart. Broker-level enforcement (so a rogue producer can't write another tenant's key) is the operator's job (Redis ACLs).