Routers
A router is a server-side forwarding rule: every record appended to a source topic is
also appended to a dest topic. Routers make pub/sub and multi-master fan-out safe because
the forwarded copy keeps its origin $node, and node loop-prevention
stops a node from receiving back what it produced. Routers live in their own namespace at
/v0/routers and are managed with the same idempotent PUT/GET/DELETE shape as topics.
Forwarding semantics
Before the endpoints, the rules that govern what a router does — these are the contract you design against.
- Async, off the write/ack path. A write to
sourceacks immediately; a background per-router worker forwards copies afterward, driven by a durable per-router cursor. Forwarding never blocks or delays the source write. - Derived — no WAL amplification. A forwarded copy is derived: it is not separately WAL-logged. One source append produces exactly one WAL write regardless of fan-out (a source feeding N dests still costs one WAL write); the derived copies are reconstructed on recovery by replaying from the durable router cursor, not from per-copy WAL frames.
- Single-source per derived dest. A derived
desthas exactly onesource. A second router with a differentsourceinto the samedestis rejected409 topic_exists_incompatiblewitherror.detail.reason: "router_dest_fan_in". (Direct writes to a dest, or a mixed direct+derived graph underallow_cycle, remain best-effort interleavings.) - Delivery guarantee. The default is
guarantee:"at_least_once": the router persists its source cursor and replays from it on restart, so a crash can re-forward and consumers must be idempotent.guarantee:"exactly_once"keeps the derived/no-WAL design but stamps a stable router idempotency key intometa._topics_router; if recovery or catch-up sees that key already present indest, it advances the cursor without appending another copy. The key must still be retained indest; a delete or eviction removes the evidence. - Per-source FIFO. Records forwarded from one
sourceinto onedestpreservesourcecommit order. The forwarded copy gets a fresh$seqand$tsindest(its own independent log); thedestseq is unrelated to the source seq. Because each derived dest is single-source, its forwarded stream is a single ordered sequence; direct writes plus that one router can still interleave bydestcommit time, with only per-source FIFO guaranteed. - What carries through.
$node(verbatim, never rewritten whenpreserve_node— this is what makes loop-prevention work across the route),$tag(whenpreserve_tag),meta, anddatapass through.exactly_oncerouters reservemeta._topics_routerfor source identity: router name, source topic, source topic id, source epoch, source seq, and the stable idempotency key. - The destination’s config governs how the copy is retained. Because forwarding is async
and the copies are derived (not WAL-logged), the source ack never waits on the destination.
The
dest’s durability class governs how/whether the re-derived copy is retained and recovered — including amemorydest, which retains a best-effort copy (it may survive or be lost, exactly like a direct write to that topic).dest’s caps and TTL apply too — TTL is measured bydest.$ts(forward time), not source time. - Deletes and node filters are per-topic. They do not propagate through a router. A
delete on
sourceremoves records fromsource, but a copy may already have been forwarded todest; to remove it there too, delete ondest. - DAG-by-default, with a hop-capped escape hatch. Creating a router that would
introduce a directed cycle is rejected at creation with
409 router_cycle. For an intentional cyclic topology (an A↔B mirror) setallow_cycle: true; the route then uses runtime hop-cap loop-breaking — each record carries a bounded internal, in-memory hop count (not exposed on the wire — there is no$ttl_hops,$route_path, or persisted hop set), and once the cap is reached the record is not forwarded again, so forwarding always terminates. Anallow_cyclegraph mixing direct and derived routers is best-effort. Node-filtering prevents delivery of your own events (correctness); the DAG/hop-cap prevents unbounded forwarding (resource safety). - Cascade on topic delete. Deleting a topic removes every router that names
it as
sourceordest— a router referencing a missing endpoint cannot exist. The delete response echoes the removed router names inrouters_removed.
Backpressure. A forward into a discard:"reject" dest that is full (or otherwise
erroring) is held as backpressure: the router does not advance its cursor and the
record stays available in source, so a dest that stays full or broken lags behind its
source until it recovers. A durable fan-out should size dest at least as large as
source, or use discard:"old" on dest. The delivery guarantee itself is unaffected:
forwarding remains at-least-once via the source cursor.
PUT /v0/routers/:router — create or configure a router
Create a router, or update an existing one. Idempotent and upsertable, exactly like
topic create: an identical PUT is a no-op 200, a changed PUT applies the
diff, a new one returns 201.
Path — :router, same charset as topic names (^[A-Za-z0-9][A-Za-z0-9._:-]{0,254}$, plus
> is allowed for the "<source>-><dest>" convention). Case-sensitive, byte-exact.
Request body
{ "source": "orders", "dest": "fulfillment",
"preserve_node": true, "preserve_tag": true,
"create_dest": true, "filter": null, "allow_cycle": false,
"guarantee": "at_least_once" }| Field | Type | Req? | Default | Meaning |
|---|---|---|---|---|
source | string | yes | — | Source topic; records appended here are forwarded. |
dest | string | yes | — | Destination topic; must differ from source. |
preserve_node | bool | no | true | Keep the original $node on forwarded records (required for loop prevention across the fan-out). false clears it. |
preserve_tag | bool | no | true | Keep $tag (so a tag delete can be applied at the dest too). |
create_dest | bool | no | true | Auto-create dest if absent. false ⇒ 404 if missing. |
filter | tuple | null | no | null | Optional forward-time filter, same tuple language as delete — e.g. ["tag","Glob","public:*"]. null = forward all. |
allow_cycle | bool | no | false | If false, a router that would introduce a directed cycle is rejected 409 router_cycle (DAG-by-default). If true, the route is permitted and runtime hop-cap loop-breaking applies instead (for intentional A↔B multi-master). |
guarantee | "at_least_once" | "exactly_once" | no | "at_least_once" | Delivery mode. exactly_once uses a stable derived-record idempotency key in meta._topics_router to suppress duplicate destination appends when the key is still present in dest. |
Response (201 / 200)
{ "router": "orders->fulfillment", "created": true,
"source": "orders", "dest": "fulfillment",
"preserve_node": true, "preserve_tag": true, "filter": null, "allow_cycle": false,
"guarantee": "at_least_once",
"performance": { "server_total_ms": 0.20 } }| Field | Meaning |
|---|---|
router | The router name (the path you addressed). |
created | true only when this call brought the router into existence. |
source / dest | The configured endpoints. |
preserve_node / preserve_tag / filter / allow_cycle / guarantee | The resolved config, echoed back. |
Errors
error.code | When |
|---|---|
invalid_request | Missing source/dest, source == dest, or a malformed filter tuple (400). |
topic_not_found | create_dest:false and dest is absent (404). |
router_cycle | The router would close a directed cycle and allow_cycle is false; error.detail.cycle carries the path, e.g. ["A","B","A"] (409). |
forbidden | The key lacks the admin scope, or source/dest falls outside its prefix allowlist (403). |
GET /v0/routers/:router — get a router
Read a single router’s config plus a lifetime forward counter.
Response (200)
{ "router": "orders->fulfillment", "source": "orders", "dest": "fulfillment",
"preserve_node": true, "preserve_tag": true, "filter": null, "allow_cycle": false,
"guarantee": "at_least_once",
"forwarded_total": 480231,
"performance": { "server_total_ms": 0.04 } }| Field | Meaning |
|---|---|
forwarded_total | Records forwarded by this router since it was created. |
Errors — 404 router_not_found if absent; 403 forbidden (lacks read scope or outside the prefix allowlist).
GET /v0/routers — list routers
Enumerate routers, opaque-cursor paginated and filterable by source/dest.
Query — prefix (string), source (string), dest (string), page_size (default
100, max 1000), cursor (opaque). The result set is filtered to the key’s prefix
allowlist, so a prefix-limited key cannot enumerate cross-tenant routers.
Response (200)
{ "routers": [
{ "router": "orders->fulfillment", "source": "orders", "dest": "fulfillment", "guarantee": "at_least_once", "forwarded_total": 480231 },
{ "router": "orders->analytics", "source": "orders", "dest": "analytics", "guarantee": "exactly_once", "forwarded_total": 480231 }
],
"next_cursor": "eyJhZnRlciI6Im9yZGVycy0+ZnVsZmlsbG1lbnQifQ==",
"performance": { "server_total_ms": 0.09 } }next_cursor is present only when more pages exist — its absence means the last page.
Pass it back as ?cursor=.
Errors — 400 invalid_request (malformed/corrupt cursor).
DELETE /v0/routers/:router — delete a router
Stop forwarding immediately and remove the router. Idempotent. Already-forwarded records in
dest are untouched — forwarding is a copy, not a link.
Response (200)
{ "router": "orders->fulfillment", "deleted": true, "performance": { "server_total_ms": 0.06 } }Deleting an absent router returns 200 with "deleted": false.
You rarely delete routers by hand to tear down a topology. Deleting a topic
cascades to every router that references it as source or dest, and the topic-delete
response lists the removed routers in routers_removed.
Errors — 403 forbidden (lacks delete scope or outside the prefix allowlist).
Example: mirror a stream into an audit log
A router copies every record appended to orders into audit, at-least-once and per-source
FIFO. The origin $node rides through untouched.
curl
curl -X PUT $TOPICS/v0/routers/orders-to-audit \
-H 'content-type: application/json' \
-d '{ "source": "orders", "dest": "audit" }'See also
- Pub/Sub guide — fan one feed out to many consumers with routers.
- Multi-master guide — symmetric N-node topologies that don’t echo.
- Node loop-prevention — why preserving
$nodeis what makes fan-out safe. - Topics API — the
destconfig that governs a forward.