How to isolate tenants and share models¶
This guide shows you how to route connections to tenant-local sessions, subscribe tenants to shared models, and choose a merge strategy for shared writes.
Create a hub¶
A Hub maps each connection handle to a tenant key. With Starlette, the connection handle is the
WebSocket.
import transports
hub = transports.Hub(key=lambda ws: ws.path_params["tenant"])
Host private tenant models¶
Each tenant has its own Session. Private model ids can overlap between tenants because each tenant
has an isolated store.
from pydantic import BaseModel
class Document(BaseModel):
title: str
body: str = ""
hub.tenant("alice").host(Document(title="Alice notes"))
hub.tenant("bob").host(Document(title="Bob notes"))
A connection for alice receives only Alice’s private snapshots. A private edit is echoed only to
other Alice connections.
Retire inactive tenants¶
Closing a connection removes its awareness and connection state. By default, the Hub retains its private Session and shared-model subscriptions for future reconnects.
For temporary sessions, set tenant_idle_timeout to a finite positive number of seconds:
hub = transports.Hub(
key=lambda ws: ws.path_params["tenant"],
tenant_idle_timeout=60,
)
The idle window starts when the tenant is created and restarts when its last connection closes.
An open connection prevents expiration; reconnecting within the window preserves the Session.
Host-side reads and writes do not extend the window. flush() and autosync(hub) sweep expired
tenants, and tenant(key) checks expiration before returning a Session. Without periodic flushing,
call hub.prune_tenants() from your own maintenance loop; it returns the retired keys.
For immediate application-controlled cleanup after disconnect, call:
removed = hub.retire_tenant("alice")
It returns False for a missing tenant and raises ValueError if any connection remains. It removes
the private Session and subscriptions, leaving shared models and other tenants intact. External
references to a Session still belong to the application; retiring it only releases the Hub’s ownership.
Model watchers do not keep an otherwise unreferenced Session alive.
Reusing a retired key creates an empty Session. Initialize its private models and grant subscriptions
again during connection setup. A key function can do that before Hub.open() sends snapshots:
def tenant_key(ws):
key = ws.path_params["tenant"]
session = hub.tenant(key)
if not session.ids():
session.host(Document(title=f"{key} notes"))
return key
hub = transports.Hub(key=tenant_key, tenant_idle_timeout=60)
Retirement discards private state and replay history. After the first retirement, private reconnects on that Hub use snapshots instead of revision-only replay: model IDs and revisions may be reused by a fresh Session. This avoids retaining a record for every retired key. Clients retaining models that are no longer hosted should start a fresh Client; retirement does not send model-deletion frames.
With RelayBroadcaster, configure the timeout on its Hub. Relay flushing performs the same sweep;
retirement is local to that worker. Shared CRDT state and replica ownership are retained, so another
tenant cannot claim a previously used replica ID. Their lifetime and compaction remain separate from
private-session retirement.
Share a model read-only¶
Register a shared model and subscribe tenants with READ access.
from transports import READ
sid = hub.share(Document(title="Roadmap"))
hub.subscribe("alice", sid, READ)
hub.subscribe("bob", sid, READ)
Write to the shared model from the host side with set_shared. Subscribers receive the patch on the
next sync or autosync tick.
hub.set_shared(sid, Document(title="Roadmap", body="Updated"))
Allow shared writes¶
Subscribe writers with WRITE access.
from transports import WRITE
hub.subscribe("alice", sid, WRITE)
hub.subscribe("bob", sid, WRITE)
A write from one subscriber is merged into the authoritative shared value and echoed to every subscriber, including the origin.
Choose a merge strategy¶
Use LastWriteWins for arrival-order writes. Use LwwMapCrdt when top-level map fields should
converge independent of arrival order.
sid = hub.share(Document(title="Shared"), merge=transports.LwwMapCrdt)
For custom reconciliation, implement merge(current, patch, origin) -> value on a MergeStrategy
subclass and pass the class to share.
class MyMerge(transports.MergeStrategy):
def merge(self, current, patch, origin):
...
sid = hub.share(Document(title="Shared"), merge=MyMerge)
Share a schema-directed CRDT¶
Use a CrdtSpec when clients need offline edits, concurrent sequence changes, keyed-set changes, or
nested merge behavior that must match in Python and JavaScript. A CRDT-backed shared model uses the
shared Rust reducer instead of a Python MergeStrategy.
spec = transports.CrdtSpec(
{
"kind": "map",
"fields": {
"title": {"kind": "register"},
"body": {"kind": "sequence", "materialization": "string"},
},
}
)
sid = hub.share(Document(title="Shared", body=""), crdt_spec=spec)
hub.subscribe("alice", sid, WRITE)
hub.subscribe("bob", sid, WRITE)
Clients receive the specification and reducer state in the initial snapshot. edit_crdt /
editCrdt applies mutations locally and returns causally identified operations. propose_crdt /
proposeCrdt sends them over a managed connection.
await client.propose_crdt(
sid,
[
{
"kind": "sequence_insert",
"path": [{"kind": "key", "key": "body"}],
"after": None,
"values": list("Hello"),
}
],
)
A disconnected proposal remains in the client’s CRDT outbox. The client reapplies it over the next
server snapshot and resends it when a managed WebSocket opens. The reducer’s causal dots make that
resend idempotent. pending_crdt_ops() / pendingCrdtOps() reports how many local operations still
await an authoritative echo. The outbox has no automatic size limit; applications that allow long
offline sessions should monitor that count and set their own editing or storage policy.
To apply a control’s final value, plan against the current replica and submit the result immediately:
mutations = client.crdt_diff(sid, "Updated title", path=["title"])
if mutations:
await client.propose_crdt(sid, mutations)
For map or keyed-set fields, pass the complete desired value at that path. Changes to retained keys and members become nested edits. Use explicit mutations when you already have precise editing intent, or when editing from a stale copy. See the diff contract.
Host code uses hub.mutate_shared_crdt(sid, mutations). With a RelayBroadcaster, use
await relay.mutate_shared_crdt(...) so the operation also reaches other workers.
To persist the model, store all arguments delivered to Hub.on_shared_write. Its merge_state
argument contains crdt_spec and crdt_state; pass it back to share(..., rev=..., merge_state=...) after a restart. Hub.snapshot_shared() returns the same nested merge_state
shape for explicit checkpoints.
Hub binds each observed client replica ID to its tenant key for that process lifetime and rejects
another tenant’s attempt to reuse it. A relay propagates observed bindings with accepted operations,
so other workers enforce them after receiving the operation. Replica IDs are still coordination
identifiers, not durable authentication credentials; simultaneous reuse on different workers is not
an authorization boundary. The process-lifetime binding table grows with distinct replica IDs, as
does uncompacted reducer history; long-lived services should monitor both with their session policy.
Enforce writer authorization with subscription modes and tenant keys.
Reducer metadata grows with edit history until causally stable dots are compacted. After every
replica has acknowledged a version-vector frontier, call hub.compact_shared_crdt(sid, frontier).
For a relayed hub, call await relay.compact_shared_crdt(sid, frontier) so every worker compacts the
same frontier. The durability callback receives the compacted state without incrementing the model
revision; persistence must store that checkpoint even when its revision matches the previous write.
Never infer a frontier from one connection.
Serve the hub over WebSocket¶
import asyncio
from starlette.applications import Starlette
from starlette.routing import WebSocketRoute
async def startup():
asyncio.create_task(transports.autosync(hub))
app = Starlette(
routes=[WebSocketRoute("/ws/{tenant}", transports.ws_endpoint(hub))],
on_startup=[startup],
)
Clients use the same Client API as single-tenant servers. The hub decides which private and shared
model snapshots each tenant receives.