Skip to Content
Core GuaranteesNode Loop-Prevention

Node Loop-Prevention

A record may carry an origin node — the $node string the writer stamped on it. A reader that presents the same node id is filtered: it never receives back the events it produced. Combined with routers that preserve $node verbatim across every forward, this makes N-way multi-master fan-out safe by construction — no echo, no infinite loop. This page covers the byte-exact match, why the filter is silent, how it interacts with the cursor, the multi-master guarantee, and the dedupe_node opt-out.

The origin node

Every record may carry an origin node string, set by the writing client (per-record, or a batch-level default). The server records it as $node: immutable, never rewritten, and echoed on reads when present.

curl -X POST $TOPICS/v0/topics/chat:general \ -H 'content-type: application/json' \ -d '{ "node": "api-fra-1", "records": [ { "data": { "from": "user-1042", "text": "hello" } } ] }'

The $node is a loop-prevention key, not data — it is bounded to 128 bytes and treated as an opaque identity token, never parsed.

Byte-exact filtering

A read (diff or watch) may carry a reader node id — the filter. When present, the read drops every record whose $node equals the filter, byte-exact, before delivery:

# api-fra-1 reads the topic it also writes to; its own records are filtered out. curl -X POST $TOPICS/v0/topics/chat:general/diff \ -H 'content-type: application/json' \ -d '{ "from_seq": 0, "node": "api-fra-1" }'

The matching rules are strict:

  • Exact equality only. No prefix, no glob, no normalization — byte-for-byte string equality. A node id is an opaque identity token.
  • Records with no $node, or a different $node, pass through unaffected.
  • A reader may pass multiple node ids ("node": ["a","b"]) to filter several of its own identities at once. The semantics are “drop if $node ∈ set.”

The filter is applied after retention and TTL filtering, on the record’s content.

Silent skip + cursor advance

Node-filtering is voluntary — the reader asked for it by supplying its own node id — so it is silent: it never emits a tombstone. Dropped seqs are simply absent from records; nothing signals their removal beyond their absence.

Crucially, the cursor still advances past filtered records. next_from_seq moves past every examined seq, including the ones that were node-filtered, so they are never re-scanned. Without this, a node reading a topic full of its own events would loop forever, unable to advance past records it can never see.

Because filtered records are dropped after the bounded batch window is selected, a batch can return fewer than limit records — even zero — while still advancing the cursor. Rely on caught_up / next_from_seq / head_seq, never on batch fullness, to know whether you are caught up. See Ordering & Cursors. The filter never scans ahead to “fill” a batch.

This is the same uniform contract for both diff and SSE watch: node on the watch session is applied to every topic it watches, and the resume cursor advances past filtered records identically.

Multi-master safety

Loop-prevention exists for one job: make router fan-out across N symmetric nodes safe (multi-master replication). The mechanism is that $node is preserved verbatim through every router forward — a router never rewrites it (with the default preserve_node: true).

Consider three symmetric nodes A, B, C, each writing to and watching a shared topology:

  1. Node A writes a record stamped $node: "A".
  2. Routers fan that record out to topics that B and C watch. The forwarded copies still carry $node: "A" — the router did not touch it.
  3. B and C read with their own node filters ("B", "C"). They receive A’s record (it is not theirs) and act on it, but never re-emit it as their own.
  4. If a forwarded copy loops back to a topic that A reads, A’s own filter drops it.

Because no router ever changes $node, a record that loops around a cyclic router graph is always filtered at the read by its origin node. That makes N-way multi-master safe even with cyclic router graphs — provided every node sets and filters its own node id. That is the documented contract for the multi-master use case.

Node-filtering prevents delivery of your own events (correctness). It does not stop a record from being forwarded around a cycle forever (resource waste). Those are two different problems: the router graph is DAG-by-default (a cycle is rejected 409 router_cycle), with an explicit hop-capped allow_cycle escape hatch for intentional A↔B mirrors. See the Routers API and the Multi-Master guide.

The dedupe_node opt-out

Node loop-prevention is enabled per topic via the dedupe_node config field, which defaults to true. Set dedupe_node: false if you genuinely want echoes — a topic where a node does receive back its own records.

curl -X PUT $TOPICS/v0/topics/echo \ -H 'content-type: application/json' \ -d '{ "dedupe_node": false }'

With dedupe_node: false, the reader’s node filter is ignored on that topic and every record is delivered regardless of $node. This is rarely what you want; it exists so a topic can deliberately opt out of filtering entirely.

See also

Last updated on