Skip to Content

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 source acks 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 dest has exactly one source. A second router with a different source into the same dest is rejected 409 topic_exists_incompatible with error.detail.reason: "router_dest_fan_in". (Direct writes to a dest, or a mixed direct+derived graph under allow_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 into meta._topics_router; if recovery or catch-up sees that key already present in dest, it advances the cursor without appending another copy. The key must still be retained in dest; a delete or eviction removes the evidence.
  • Per-source FIFO. Records forwarded from one source into one dest preserve source commit order. The forwarded copy gets a fresh $seq and $ts in dest (its own independent log); the dest seq 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 by dest commit time, with only per-source FIFO guaranteed.
  • What carries through. $node (verbatim, never rewritten when preserve_node — this is what makes loop-prevention work across the route), $tag (when preserve_tag), meta, and data pass through. exactly_once routers reserve meta._topics_router for 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 a memory dest, 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 by dest.$ts (forward time), not source time.
  • Deletes and node filters are per-topic. They do not propagate through a router. A delete on source removes records from source, but a copy may already have been forwarded to dest; to remove it there too, delete on dest.
  • 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) set allow_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. An allow_cycle graph 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 source or dest — a router referencing a missing endpoint cannot exist. The delete response echoes the removed router names in routers_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

PUT/v0/routers/: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" }
FieldTypeReq?DefaultMeaning
sourcestringyesSource topic; records appended here are forwarded.
deststringyesDestination topic; must differ from source.
preserve_nodeboolnotrueKeep the original $node on forwarded records (required for loop prevention across the fan-out). false clears it.
preserve_tagboolnotrueKeep $tag (so a tag delete can be applied at the dest too).
create_destboolnotrueAuto-create dest if absent. false404 if missing.
filtertuple | nullnonullOptional forward-time filter, same tuple language as delete — e.g. ["tag","Glob","public:*"]. null = forward all.
allow_cycleboolnofalseIf 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 } }
FieldMeaning
routerThe router name (the path you addressed).
createdtrue only when this call brought the router into existence.
source / destThe configured endpoints.
preserve_node / preserve_tag / filter / allow_cycle / guaranteeThe resolved config, echoed back.

Errors

error.codeWhen
invalid_requestMissing source/dest, source == dest, or a malformed filter tuple (400).
topic_not_foundcreate_dest:false and dest is absent (404).
router_cycleThe router would close a directed cycle and allow_cycle is false; error.detail.cycle carries the path, e.g. ["A","B","A"] (409).
forbiddenThe key lacks the admin scope, or source/dest falls outside its prefix allowlist (403).

GET /v0/routers/:router — get a router

GET/v0/routers/: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 } }
FieldMeaning
forwarded_totalRecords forwarded by this router since it was created.

Errors404 router_not_found if absent; 403 forbidden (lacks read scope or outside the prefix allowlist).

GET /v0/routers — list routers

GET/v0/routers

Enumerate routers, opaque-cursor paginated and filterable by source/dest.

Queryprefix (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=.

Errors400 invalid_request (malformed/corrupt cursor).

DELETE /v0/routers/:router — delete a router

DELETE/v0/routers/: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.

Errors403 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 -X PUT $TOPICS/v0/routers/orders-to-audit \ -H 'content-type: application/json' \ -d '{ "source": "orders", "dest": "audit" }'

See also

Last updated on