Skip to content

The outbox dispatcher

Subspace has exactly one change-propagation mechanism. Any committed transaction that changes durable state appends a row to ops.outbox, and a single dispatcher fans those rows out to two audiences: WebSocket topics (so open pages update live) and in-process consumers (the derived-state engines). Everything reactive in the product, live editing, search indexing, the OKF dump, table recompute, event triage, mail and calendar sync, the agent engine, notifications, rides this one feed.

LISTEN/NOTIFY with one dedicated connection

Section titled “LISTEN/NOTIFY with one dedicated connection”

The write path commits an outbox row inside the same transaction as the change it describes, then a trigger fires NOTIFY subspace, <seq>. A single dispatcher task holds one reserved Postgres connection just for LISTEN. The postgres.js pool sets aside this connection so a Gmail backfill or a burst of keystrokes can never starve the propagation path.

The notification payload is only the sequence number, never the change itself. That keeps the notification tiny and, more importantly, means a dropped or coalesced notification cannot lose data.

Reading past the cursor (missed-notify recovery)

Section titled “Reading past the cursor (missed-notify recovery)”

On each wake-up (a NOTIFY, or a periodic safety poll), the dispatcher reads ops.outbox rows past its cursor, not just the row named in the payload. So the mechanism is:

  1. Commit appends a row

    The write transaction appends ops.outbox(seq, ts, topic, kind, ref) and commits.

  2. Notify wakes the dispatcher

    A trigger fires NOTIFY with the new seq. If that notification is ever missed, the next one (or the safety poll) still triggers a read.

  3. Read everything new

    The dispatcher selects all outbox rows with seq greater than its cursor and processes them in order. A missed notify is recovered by the cursor; nothing is lost across a restart.

This is why a server can crash mid-fan-out and lose nothing: the cursor in ops.cursors only advances after a row is durably handled, so on reboot the dispatcher simply reads from where it left off.

For each outbox row, the dispatcher does two things:

WebSocket topic bus

The row’s topic (for example page:<id>, run:<id>, or an invalidation channel) is published to every WebSocket client subscribed to it. Open pages fold the echoed command; list views receive an invalidation and re-fetch.

In-process consumers

Each registered consumer sees the row and advances its own ops.cursors row independently. A slow consumer never holds up a fast one, and each consumer is either idempotent or fully rebuildable from base tables.

The ops.cursors table has one row per consumer (consumer PK, seq). Because every consumer tracks its own progress, adding a new consumer is just registering a function and giving it a cursor; it back-fills from wherever you point its cursor and then keeps pace.

The dispatcher drives a roster of consumers, each turning “something committed” into derived state or deferred work:

Consumer What it does
Search indexer Chunks changed nodes, files, mail, and memory, embeds them, and upserts the pgvector index (see search)
OKF exporter Rewrites changed pages to their Markdown files on disk (see the OKF bridge)
Recompute Walks the deps closure of a change and recomputes table formulas and custom functions in topological order (see tables and formulas)
Triage Classifies new events by severity and routes them (see events and triage)
Mail / calendar sync Reacts to sync events and folds provider changes into the graph (see mail, calendar)
Agent engine Resumes runs on approval and question notices, and sweeps for orphaned runs (see the agent engine)
Notifications Delivers severity-routed alerts to desktop and mobile (see notifications)
Blob replicator Uploads each new content-addressed blob to off-machine storage within seconds
Retention Prunes outbox, events, command log, and run history past their windows

The scheduler, reminders, and inbox all build on this same feed.

A consumer that can call an external API or spawn work needs guardrails so one bad row cannot wedge the whole feed:

  • Timeout. Each consumer step runs under a timeout so a hung network call cannot stall the cursor indefinitely.
  • Backoff. Transient failures retry with exponential backoff rather than spinning.
  • Poison-row parking. A row that keeps failing past its retry budget is parked (marked and skipped) so the cursor can advance, and it is surfaced for inspection rather than blocking every later row behind it.

Because derived consumers (indexer, exporter, recompute) are rebuildable, a consumer that falls too far behind can be reset and replayed from base tables. subspace reindex does exactly this for the search arms.

The outbox is not only a live feed; it is how the system schedules durable work that must survive a restart. An agent that pauses for approval, a follow-up nudge scheduled for three days out, or a blob waiting to replicate all live as rows plus cursors, never as in-memory timers. When the process restarts, the deferred work is still there and the relevant consumer picks it back up.