Multi-Master Fan-out
Multi-master means several symmetric nodes that each write events and each consume the union of everyone’s events — without a node ever receiving back what it produced, and without a record looping forever around the topology. topics makes this safe by construction with two complementary layers: node loop-prevention (content-level — a node never delivers its own records to itself) and router cycle control (topology-level — a hop-cap stops unbounded forwarding). The contract is simple: every node sets its own node id on writes and presents it as a filter on reads, and routers preserve $node verbatim through every forward.
Get those two halves right and an A↔B↔C mirror is echo-free and loop-free with no distributed consensus, no clock sync, no leader — just append-only logs and forwarding rules on each topic.
The whole pattern rests on one rule: a node must set node: "X" on its writes AND pass
node: "X" as the loop-prevention filter on its reads/watches. A record carries its
origin $node unchanged through every router hop, so when it arrives back at a topic that X
reads, X filters it out — silently (no tombstone, since it’s a voluntary, content-based
drop). Forget the filter on reads and a node will see its own echoes. See
Node loop-prevention.
How the two layers compose
- Node loop-prevention (correctness). Every record may carry an origin
$node, stamped by the writer, immutable, and preserved verbatim through every router forward. A read that presents a matchingnodedrops every record whose$nodeequals it, byte-exact, before delivery. So node A writes$node=A; routers fan it to B’s and C’s topics; B and C read with their own node filters and receive A’s record but never re-emit it as their own. A record that loops back to a topic A reads is filtered at A’s read. This makes delivery echo-free. - Router cycle control (resource safety). Node filtering stops a node from consuming its own events but does not stop a record from being forwarded around a cycle forever (wasteful, amplifying). So the server rejects any router that would create a directed cycle with
409 router_cycleby default — and for an intentional cycle (A↔B), you setallow_cycle: true, which switches that route to runtime hop-cap loop-breaking: a forwarded record carries a bounded hop counter incremented on every forward, and once it reaches the cap (MAX_ROUTER_HOPS, default 8) the record is not forwarded again. Forwarding always terminates.
Complementary: node-filtering prevents delivery of your own events; the hop-cap prevents unbounded forwarding.
Recipe — a two-node api-fra-1 ↔ api-iad-1 mirror
The simplest multi-master: two logical nodes (origin labels), each with its own topic in the same server, mirrored both ways with local routers so each node sees the other’s writes. (Nodes are content labels, not machines — topics is single-server.)
node api-fra-1 (label) node api-iad-1 (label)
write $node=api-fra-1 ─► ┌──────────────┐ router fra→iad ┌──────────────────────┐ ◄─ write $node=api-iad-1
│ chat:general │ ───────────────► │ chat:general:mirror │
read node=api-fra-1 ◄─ │ (local) │ ◄─────────────── │ (local) │ ─► read node=api-iad-1
└──────────────┘ router iad→fra └──────────────────────┘Each node writes with its own node id
A node stamps every write with its identity. The batch-level node is the common one-writer case.
Examples use $TOPICS as the base URL — see the Quickstart.
# On node api-fra-1:
curl -X POST $TOPICS/v0/topics/chat:general \
-H 'content-type: application/json' \
-d '{ "node": "api-fra-1",
"records": [ { "data": { "from": "user-1042", "text": "hey team" } } ] }'
# On node api-iad-1:
curl -X POST $TOPICS/v0/topics/chat:general \
-H 'content-type: application/json' \
-d '{ "node": "api-iad-1",
"records": [ { "data": { "from": "user-2087", "text": "on my way" } } ] }'The origin $node (api-fra-1 or api-iad-1) is now immutable and rides through every forward.
Create symmetric routers between the nodes
One router each direction. Because fra→iad and iad→fra together form a directed cycle, set allow_cycle: true on both so creation isn’t rejected with 409 router_cycle; the route then uses hop-cap loop-breaking. preserve_node: true (the default) is what carries $node across the forward — do not set it false, or loop-prevention breaks.
# Router forwarding api-fra-1's topic into api-iad-1's topic:
curl -X PUT $TOPICS/v0/routers/fra-to-iad \
-H 'content-type: application/json' \
-d '{ "source": "chat:general", "dest": "chat:general:mirror", "allow_cycle": true }'
# Router forwarding api-iad-1's topic back into api-fra-1's topic (symmetric):
curl -X PUT $TOPICS/v0/routers/iad-to-fra \
-H 'content-type: application/json' \
-d '{ "source": "chat:general:mirror", "dest": "chat:general", "allow_cycle": true }'# →
{ "router": "fra-to-iad", "created": true,
"source": "chat:general", "dest": "chat:general:mirror",
"preserve_node": true, "preserve_tag": true, "filter": null, "allow_cycle": true,
"performance": { "server_total_ms": 0.20 } }This is a single-server, local pattern. A “node” here is a logical origin label
($node), not a separate machine, and a router’s source/dest are both local topic
names in the same process — there is no remote / cross-machine / HTTP-endpoint router, and
topics does not do multi-server replication or HA. The mirror is built from local topics
plus local routers; $node preservation and the hop-cap give you echo-free, loop-free
fan-out across the logical nodes within one server.
Each node reads the union, filtering its own events
Each node reads the merged stream and passes its own node id as the loop-prevention filter, so it sees the others’ events but never its own echoes.
# On node api-fra-1 — read everything, drop records api-fra-1 produced:
curl -X POST $TOPICS/v0/topics/chat:general/diff \
-H 'content-type: application/json' \
-d '{ "from_seq": 0, "node": "api-fra-1" }'# →
{ "topic": "chat:general",
"records": [
{ "$seq": 12, "$ts": 1748470000500, "$node": "api-iad-1",
"data": { "from": "user-2087", "text": "on my way" } }
],
"next_from_seq": 12, "head_seq": 12, "earliest_seq": 1,
"caught_up": true, "tombstone": null, "lag": 0,
"performance": { "server_total_ms": 0.31 } }api-fra-1’s own records ($node: "api-fra-1") were filtered out — but the cursor still advanced past them. So caught_up, not records.length, is the “no more right now” signal.
Node-filtered records are dropped after the batch window is selected, so a batch can
return fewer than limit records — even zero — while next_from_seq advances. A node
reading a topic full of its own events would otherwise loop forever. Always rely on
next_from_seq / head_seq / caught_up, never on batch fullness. See
Ordering & Cursors.
Scaling to N nodes
The pattern generalizes: each of N nodes sets a distinct node id, and you connect them with routers so every node’s writes reach every other node. You can use a full mesh (a router from each node to each other), a ring, or a hub — node loop-prevention keeps delivery echo-free in any topology, because the origin $node is preserved no matter how many hops a record takes. Reads on each node pass that node’s own id (or several ids, "node": ["api-fra-1","api-fra-1-replica"], to filter multiple of its own identities).
Watch the merged stream live instead of polling — the loop-prevention filter applies to SSE identically:
curl -X POST $TOPICS/v0/watch \
-H 'content-type: application/json' \
-d '{ "node": "api-fra-1", "topics": { "chat:general": { "from_seq": 0 } } }'
# → returns a wid; GET it to stream. Records with $node "api-fra-1" are never delivered to api-fra-1.Loop-breaking: why it never runs forever
Two independent guarantees stop runaway forwarding:
- The DAG default. Without
allow_cycle: true, the server rejects any router that would close a directed cycle at creation time (409 router_cycle, witherror.detail.cyclenaming the loop). A non-cyclic fan-out can never loop by construction. - The runtime hop-cap. On an
allow_cycle: trueroute, each forwarded record carries a bounded hop counter, incremented on every forward; once it reaches the cap (MAX_ROUTER_HOPS, default 8) the record is not forwarded again. So even a fully symmetric A↔B↔C mesh terminates every record after a bounded number of hops.
Node-filtering ensures correctness (you never consume your own event); the hop-cap ensures resource safety (a record is never forwarded without bound). You need both — node-filtering alone would let a record circulate forever even though no one delivers it.
Gotchas
- You must set
nodeon reads too. Setting it only on writes stamps the records but does nothing to filter them — a node will see its own echoes. The filter is opt-in per read/watch. - Don’t clear
$nodeon forwards.preserve_nodedefaultstrue; setting itfalsestrips the origin id and breaks loop-prevention across that route. - Deletes and filters are per-topic. A delete on one node’s topic does not propagate through routers — a copy may already have been forwarded. To remove a record everywhere, delete it on each topic (or design idempotent consumers). See Routers.
- Forwarding is at-least-once. A crash between “appended to dest” and “advanced router cursor” can re-forward, so a record may arrive twice. Combined with the hop-cap and node-filtering, duplicates are the only residue — consumers must be idempotent (dedupe on
$node+ a key indata/meta). - Per-topic durability still applies independently. Each topic in the mirror chooses its own durability class; a forward’s re-derived copy is governed by the destination topic’s class (a
memorydest keeps a best-effort copy — it may survive or be lost on restart).
See also
- Node loop-prevention — the byte-exact
$nodefilter and why it’s silent. - Routers — forward mechanics,
preserve_node,allow_cycle, cycle rejection, the hop-cap. - Ordering & Cursors — why filtered records advance the cursor.
- Pub/Sub — single-publisher fan-out, the simpler cousin of this recipe.