Realtime delivery to offline users
Online realtime push is the easy half: emit to the room, the socket gets it. The hard half is the user whose phone was asleep when the shipment confirmed. Forze handles it with a per-recipient mailbox and a per-device cursor — store the durable signal, replay what a device hasn't seen when it reconnects, and trim once it's acked. See Realtime push for the concepts; this is the runnable shape.
The runnable version lives at examples/recipes/realtime_offline/ (mock — no
sockets or broker needed). In a live app the Socket.IO gateway
calls store for you and attach_realtime_connection does the reconnect replay
and realtime.ack; here we drive the same public components directly so the
mechanism is visible.
Set the scene¶
A durable signal is addressed to a principal — a single recipient. The mailbox
scopes to the ambient tenant (read from the bound context, never passed as a
parameter), so a worker binds it before calling — the gateway from the signal
header, the connection from the live connection. Here main() binds it once around
the flow:
TENANT = UUID("11111111-1111-1111-1111-111111111111")
BOB = "bob" # the recipient principal (Audience.principal id form)
def _signal(text: str) -> RealtimeSignal:
return RealtimeSignal.of(Audience.principal(BOB), "order.shipped", {"text": text})
Store what an offline user can't receive yet¶
When the gateway processes a durable principal signal it writes it to that
recipient's mailbox. Online, it would also sio.emit; offline — no room members —
the mailbox is the only delivery, drained later:
async def emit_while_offline(
mailbox: DocumentRealtimeMailbox,
*,
event_id: str,
hlc: HlcTimestamp,
signal: RealtimeSignal,
) -> None:
"""What the gateway does for a durable principal signal: store it for replay.
(Online it would also ``sio.emit``; offline — no room members — the mailbox is the
only delivery, drained on reconnect.) No tenant here — the store scopes ambiently.
"""
await mailbox.store(principal=BOB, event_id=event_id, hlc=hlc, signal=signal)
Replay on reconnect, then ack¶
On connect, a device is replayed everything past its own cursor — so two devices catch up independently. The client acks what it processed, which advances that device's cursor and trims whatever every known device has now seen:
async def reconnect(
mailbox: DocumentRealtimeMailbox,
cursors: DocumentMailboxCursors,
*,
device: str,
) -> list[MailboxEntry]:
"""What the connection layer replays on connect: everything past this device's cursor."""
since = await cursors.get(principal=BOB, client_key=device)
return await mailbox.read_since(principal=BOB, since=since)
async def ack(
mailbox: DocumentRealtimeMailbox,
cursors: DocumentMailboxCursors,
*,
device: str,
event_id: str,
) -> None:
"""What ``realtime.ack {up_to}`` does: advance the device cursor, trim what all acked."""
position = await mailbox.position_of(principal=BOB, event_id=event_id)
if position is not None:
await cursors.advance(principal=BOB, client_key=device, up_to=position)
floor = await cursors.min_cursor(principal=BOB)
if floor is not None:
await mailbox.trim(principal=BOB, before=floor)
Run end to end, two signals stored while the phone is offline arrive in order on reconnect; after the client acks the last one, a later reconnect of that same device replays nothing — it's caught up:
phone reconnect: ['shipped', 'delivered']
phone reconnect again: []
Notes¶
- Only durable, principal-addressed signals are mailboxed. Ephemeral signals
(
publish) are emit-only, and topic broadcasts have no per-recipient mailbox. - An event opts out with
RealtimeEvent(..., offline_delivery=False)— emit-only, best-effort. - The mailbox is bounded recent history: trimmed once all known devices ack, with a TTL/cap backstop. A device offline longer than the window loses the oldest signals — guarantee-forever delivery belongs in domain state.
- The cursor key is a
ClientIdentity— a client-supplieddevice_id(stable across logins) or the authenticated sessionsid. With neither, it falls back to the per-connection socket id. - Tenant is ambient, never a mailbox argument: the methods take only
principal, and the implementation reads the tenant from the bound context (the worker binds it). The same discipline as the publisher reading the ambient tenant for the message header.