Macula SDK — Pub/Sub Guide
View SourceTopic-based messaging over the relay mesh.
Audience: Erlang/OTP applications publishing or subscribing to events. Building something the wrappers below don't fit? See PUBSUB_PROTOCOL.md for the raw
macula:subscribe/4,5/macula:publish/4,5primitives underneath.
Overview
A subscriber subscribes to a topic and receives every matching event; a
publisher publishes an event to a topic — macula_subscriber and
macula_publisher, an addressable pid you can monitor and cancel.
Subscribing and publishing are independent capabilities, unlike RPC's
request/response or a single content transfer — an application might do
only one, the other, or both. Every operation on either side names an
explicit realm and topic; see Three core ideas, below,
before writing your first macula_topic:app_fact/6 call.
See Supervised wrappers, right below.
Supervised wrappers: macula_subscriber / macula_publisher
macula:subscribe/4,5 delivers events as raw messages to whatever pid you
pass it, and macula:publish/4,5 is a plain blocking call — neither has an
addressable pid you can supervise, monitor, or cancel from outside.
macula_subscriber and macula_publisher wrap the same two primitives as
proper OTP behaviours. Only the publish side announces itself on the mesh —
macula_publisher publishes pubsub.publish_started_v1 /
pubsub.publish_completed_v1 around each transfer; macula_subscriber has
no mesh-fact equivalent, since a subscription has no single "done" moment to
announce.
Subscriber side — start_link/5,6 opens the subscription in its own
init/1 and threads macula_event / macula_event_gone dispatch into
Module:handle_event/4 for you — no hand-rolled handle_info clauses
needed (see PUBSUB_PROTOCOL.md
for the manual pattern this replaces, if you're building something the
wrapper doesn't fit). handle_event/4 receives Topic, Payload, and
Meta — a map carrying delivery context:
| Key | Type | Meaning |
|---|---|---|
realm | <<_:256>> | Realm tag (matches the subscribe call) |
publisher | <<_:256>> | The publisher's node_id (the original publisher, not the relay) |
seq | non_neg_integer() | Per-publisher monotonic sequence |
published_at | non_neg_integer() | When the publisher published it, in milliseconds since the epoch |
delivered_via | plumtree | direct | How this copy reached the station link |
publication_hash | <<_:384>> | SHA-384 of the publication's signed tbs; the pool delivers each hash once |
expires_at | non_neg_integer() | The last moment, in milliseconds, a verifier accepts the publication |
-module(my_orders_listener).
-behaviour(macula_subscriber).
-export([init/1, handle_event/4]).
init(_Args) -> {ok, #{}}.
handle_event(_Topic, Payload, _Meta, State) ->
on_order_placed(Payload),
{noreply, State}.Topic = macula_topic:app_fact(Realm, my_org, my_app,
<<"orders">>, <<"placed">>, 1),
{ok, Pid} = macula_subscriber:start_link(my_orders_listener, Pool, Realm,
Topic, []).Opts (the arity-6 start_link/6) passes straight through to
macula:subscribe/5 — the delivery option covered in
Delivery ordering, below, works exactly the same way
whether you call it raw or through the wrapper.
A macula_event_gone for this subscription stops the sink with that reason
(see When the subscription ends, below). To
stop receiving events deliberately, stop the sink itself
(gen_server:stop/1, or let its supervisor terminate it) — the pool
monitors the subscriber pid directly and drops the wire-level subscription
automatically once it's gone, the same cleanup an explicit unsubscribe
gives a raw caller.
Publisher side — start_link/5,6 returns immediately with a pid; the
publish runs in a linked worker and the outcome reaches
Module:handle_published/2:
-module(status_publisher).
-behaviour(macula_publisher).
-export([init/1, handle_published/2]).
init(Parent) -> {ok, Parent}.
handle_published(Result, Parent) ->
Parent ! {published, Result},
{stop, normal, Parent}.{ok, Pid} = macula_publisher:start_link(status_publisher, Pool, Realm,
Topic, Payload, self()).Result | Meaning |
|---|---|
ok | At least one link accepted the PUBLISH frame |
{error, {transient, no_healthy_station}} | The pool has zero spawned links — caller may retry |
{error, _} | Other failures (validation, etc.) |
Partial success counts as success. With
replication_factor > 1, the publish resolves as soon as the first selected link accepts the frame. Subsequent links are best-effort.
macula_publisher:cancel/1 stops it before the publish resolves,
delivering outcome => cancelled in the pubsub.publish_completed_v1
mesh fact — same shape as macula_feeder's sharing.put_started_v1 /
sharing.put_completed_v1.
start_link/5,6 returns as soon as the publisher is running. The start
announcement and the publish follow, so the caller never waits on the pool.
With the defaults, each publish costs three frames: the payload and the two
announcements. A publisher that sends many facts, such as telemetry, passes
announce => false to start_link/7 and sends only the payload.
A subscriber ends when its subscription ends. That is macula_event_gone
when the pool closes cleanly, or {pool_down, Reason} when the pool process
dies without saying so, because it was killed or taken down by a link. Both
are abnormal exits, so a supervisor restarts the subscriber against the pool
that is current. A process that calls macula:subscribe/4,5 itself should
monitor the pool the same way.
Unlike macula_subscriber, macula_publisher:start_link/6's last argument
is Args (for Module:init/1) — there's no way to pass macula:publish/5's
own Opts (e.g. timeout_ms) through the wrapper. See
PUBSUB_PROTOCOL.md if you
need that.
Three core ideas
1. Realm-per-call
Every publish and every subscribe carries an explicit 32-byte realm tag. There is no connect-time default realm. Realms are how the mesh isolates traffic — a subscriber on Realm A never receives an event published to Realm B, even if the topic strings match exactly.
%% Same topic, different realms — totally separate streams.
ok = macula:publish(Pool, RealmA, Topic, PayloadA),
ok = macula:publish(Pool, RealmB, Topic, PayloadB).Realms are 32-byte binaries. Use macula_realm:id/1 (SHA-256 of the
human-readable realm name) or your own realm registry to derive them.
2. Topics describe event types, not entities
Non-negotiable for scalable pub/sub.
Topic = WHAT happened (event type, immutable)
Payload = WHO/WHERE/WHEN it happened (entity details)| Approach | 1M sensors | Topics | Result |
|---|---|---|---|
| ID in topic | 1M sensors | 1M topics | DHT explosion, memory exhaustion |
| ID in payload | 1M sensors | 1 topic | Scalable, efficient routing |
Wrong (topic explosion):
%% DO NOT DO THIS — entity ID baked into topic
Topic = macula_topic:app_fact(Realm, Org, App,
<<"weather">>,
<<"manchester_main_street_wind_measured">>, 1),
macula:publish(Pool, Realm, Topic, #{speed => 42.5}).Right (IDs in payload):
Topic = macula_topic:app_fact(Realm, Org, App,
<<"weather">>, <<"wind_measured">>, 1),
macula:publish(Pool, Realm, Topic, #{
station_id => <<"manchester-main-street">>,
speed => 42.5,
unit => <<"km/h">>,
timestamp => erlang:system_time(millisecond)
}).3. Topics are built — never hand-typed
%% Always
Topic = macula_topic:app_fact(Realm, Org, App, Domain, Name, Version),
%% Never
Topic = <<"my.realm/my.org/my.app/orders/placed_v1">>.macula_topic returns a canonical binary that the SDK and stations
agree on. Nothing on the send path rejects a hand-rolled string —
macula:publish/4,5 and subscribe/4,5 take Topic as an opaque
binary and send whatever you pass, so a typo just never matches
instead of erroring. Building via macula_topic turns that class of
bug into a wrong value your own tests can catch. See
Validation in the Topic
Naming Guide for exactly what is and isn't enforced.
See TOPIC_NAMING_GUIDE.md for the canonical
specification of the five-segment shape and the realm_fact / org_fact / app_fact tier choice.
When the subscription ends
The only way a live subscription produces a terminal message today is
the pool closing — delivered to handle_event/4's raw equivalent as
{macula_event_gone, SubRef, pool_closed}, and to a macula_subscriber
sink as its own termination with that reason.
A link dying does not end the subscription — the pool logs
_macula.client.link_down, schedules a respawn, and silently
re-issues the subscription against the new link once it's up (see
Connecting Guide). Neither layer
sees a gap-signaling message for that case, only a possible gap in
delivery itself, which ordered mode's order_timeout_ms skip
handles the same way it handles any other loss.
After the subscription ends, no further events come for it.
Delivery ordering
The delivery option chooses how a single publisher's stream is
ordered on the way to your subscriber — pass it in Opts to
macula_subscriber:start_link/6 the same way you would to raw
macula:subscribe/5. This matters because the mesh sends copies of a
fact down several links at once, and the pool dedups to the first
arrival — so without ordering, a single publisher's seq 1, 2, 3 can
reach you as 1, 3, 2. Each publisher's seq is pool-monotonic and
contiguous, which is exactly what makes ordered delivery possible.
| Mode | Behaviour | Use when |
|---|---|---|
ordered (default) | Per-publisher FIFO by seq. Out-of-order arrivals are buffered and released in order; a genuinely missing seq is skipped after order_timeout_ms. | Event / delta streams where order matters. |
latest_only | Deliver only seqs newer than the highest seen for that publisher (drop stale). No buffering, no head-of-line delay. | State snapshots — you want the freshest value, not every value. |
as_arrives | Raw arrival order. Zero added latency; you order it yourself. | You have your own versioning, or you truly do not care. |
Ordering state is kept per publisher, and separately for EVENTs whose publisher signature verified and for all others: unsigned EVENTs are ordered among themselves, per publisher, and never affect the order of a verified publisher's EVENTs.
%% default — per-publisher FIFO
{ok, Pid1} = macula_subscriber:start_link(my_orders_listener, Pool, Realm, Topic, []),
%% newest-wins, drop stale
{ok, Pid2} = macula_subscriber:start_link(my_orders_listener, Pool, Realm, Topic, [],
#{delivery => latest_only}),
%% raw arrival order (the pre-8.8 behaviour)
{ok, Pid3} = macula_subscriber:start_link(my_orders_listener, Pool, Realm, Topic, [],
#{delivery => as_arrives}).Ordered mode and loss. ordered trades a bounded delay for order:
if seq 2 never arrives, the buffer holds 3, 4, … only until
order_timeout_ms elapses, then skips the gap and releases them. That
skip is the accepted "order-not-guaranteed delivery" trade for a lost
fact — a reorder buffer cannot invent a message the mesh dropped. Design
mesh facts to be idempotent and version-stamped so an occasional
skip washes out.
A publisher's first facts. When an ordered subscription first hears
from a publisher, or hears from it again after a restart, it does not yet
know where that publisher's order starts: copies arrive over several links
in any order. It holds that publisher's first facts for up to
order_timeout_ms (or until order_max_buffer facts are held), then starts
the order at the lowest seq it has seen, so a lower seq that arrives after a
higher one is still delivered, in order. A publisher's first facts therefore
reach the subscriber up to one order timeout later than its later facts.
Publisher restarts. A publisher's seq is seeded from wall-clock
microseconds at start (macula_client for an SDK pool; hecate_pubsub_server
for a station's own facts, since 10.17.0), so a restart shows up as a jump
far wider than any reorder window. Both ordered and latest_only read a
jump of more than 10 000 in either direction as a new epoch: the old
epoch's buffered tail is released, then delivery continues from the new
counter. The backward case matters for any publisher whose counter restarts
at 0: without it, every fact after such a restart is dropped as "already
delivered" until the counter climbs back over the old watermark, with
nothing in macula:status/1 showing it (a live read model went blind for
10+ hours this way in September 2026). A backstep smaller than that is still
a late duplicate and is still dropped.
Total order is not offered, by design. ordered restores a single
publisher's order (cheap, over ordered transport). It does not impose a
total order across different publishers — that would need a single
sequencer (a consensus log) that the mesh deliberately does not have.
Cross-publisher order is not something a decentralised broadcast can give
you; carry a version or timestamp in the fact if a consumer needs to
relate two publishers' events.
Pool-level tuning (connect/2 options)
| Option | Default | Meaning |
|---|---|---|
order_timeout_ms | 250 | How long an ordered sub waits for a missing seq before skipping the gap, and the longest it holds a new publisher's first facts. Bounds head-of-line delay. |
order_max_buffer | 1024 | Per-publisher reorder-buffer count cap. Over it, the head gap is skipped early (memory guard for a high-rate publisher gapping). |
Telemetry — is loss real?
macula:status/1 reports pubsub_gap_skips: the number of per-publisher
gaps given up on after the timeout, i.e. the genuine loss rate an
ordered subscriber could not fill. A near-zero value means the mesh is
delivering and ordered costs you almost nothing; a rising value is the
signal to look at delivery, not ordering.
{ok, #{pubsub_gap_skips := Skips}} = macula:status(Pool).Dedup and delivery guarantees
Every EVENT's publication is verified before delivery, and one that does
not verify is dropped. The dedup key is the publication's hash, the SHA-384
of its signed tbs: the pool guarantees you see each publication at most
once, even when it arrives via multiple links (e.g. with
replication_factor > 1). Two publications that share a realm, publisher
and seq are still two publications, with two hashes. In
ordered and latest_only modes the delivery layer additionally uses the
seq to order or drop; in as_arrives the dedup layer is the only filter.
- At-most-once — fire and forget. No publisher-visible ack from subscribers.
- Per-publisher delivery order —
orderedby default at the subscriber (see Delivery ordering above): out-of-order arrivals are buffered and released inseqorder, with a genuinely missingseqskipped afterorder_timeout_ms. The mesh itself does not guarantee arrival order — a relay spreads one publisher's burst across concurrent verify workers, and a receiver may admit an event by more than one path — the subscriber-sideorderedbuffer is what turns that into in-order delivery. Opt intoas_arrivesif you'd rather see raw arrival order and reorder yourself. - Cross-publisher ordering — none, by design. Two publishers' events arrive in arbitrary interleaving; see "Total order is not offered, by design" above.
- Cross-link dedup: the pool dedupes by publication hash and keeps
each hash until the publication expires,
published_atplus itsttl_ms(at most 1 hour), or 10 minutes without one, plus 5 minutes, after which every verifier refuses it. The pool judges expiry when an event arrives, and records a hash only while a subscription matches it. - Cross-station gossip — default since 4.5.0. A daemon connected to station A and a daemon connected to station B see each other's publishes once subscription interest and the fact itself have gossiped between the stations; every hop verifies a publication once, keyed by its hash, before it delivers or forwards it, which is what makes this safe past one hop.
Topic naming reference
Quick reference. Full specification: TOPIC_NAMING_GUIDE.md.
Every topic is exactly five slash-separated segments:
{realm}/{publisher_org}/{publisher_app}/{domain}/{name}_v{N}Pick a tier based on who owns the topic:
| Tier | Builder | Use when |
|---|---|---|
| Realm-level | macula_topic:realm_fact/4, macula_topic:realm_hope/4 | Topic owned by the realm itself |
| Org-level | macula_topic:org_fact/5, macula_topic:org_hope/5 | Topic owned by an organization within a realm |
| App-level | macula_topic:app_fact/6, macula_topic:app_hope/6 | Topic owned by an application within an organization |
Past tense for facts (order_placed, wind_measured, user_registered).
Present tense for hopes (order_place, payment_authorize).
System topics (_mesh.*, _macula.*) are infrastructure-owned and
dot-separated. Do not publish to them from app code.
Patterns
Re-subscribe after pool restart
If your supervisor restarts the pool, your subscribers are not automatically re-attached. Either:
- Restart your subscriber processes (or
macula_subscribersinks) alongside the pool (one supervisor withrest_for_onestrategy), or - Watch for the subscription ending with
pool_closedand re-subscribe to the new pool.
Multiple subscribers on one pool
A single pool can have arbitrarily many local subscribers — whether raw
processes or macula_subscriber sinks. The pool issues exactly one
wire-level SUBSCRIBE per (Realm, Topic), multiplexes inbound events to
every local subscriber for that pair, and dedupes across links.
Backpressure
Events are delivered as Erlang messages. If a subscriber is slow, its mailbox grows. The pool itself never blocks. Apply your usual mailbox-flow-control patterns (process throttling, batching, etc.).
Idempotent handlers
The pool delivers each publication at most once, and verifiers refuse a copy that arrives after the publication expired. A publisher that sends the same fact twice still makes two publications, so make handlers idempotent — match on a payload-level key (order id, sensor id + timestamp, etc.) when correctness matters.
Best practices
- IDs in payloads, not topics. Always.
- Past-tense facts, present-tense hopes. No CRUD verbs
(
created,updated,deleted). - Build topics via
macula_topic— never inline strings. - Include a timestamp —
erlang:system_time(millisecond)in every payload. - Keep handlers fast — spawn workers for heavy processing.
- Make handlers idempotent: the pool delivers each publication once, but a publisher can send one fact as two publications; don't rely on exactly-once.
Diagnostics
| Event topic | When | Meta |
|---|---|---|
_macula.client.link_down | A pool link's worker died | seed, pid, reason |
_macula.peering.handshake_timeout | A station handshake hung past state_timeout | role, buf_size, has_stream, timeout_ms |
These come through macula_diagnostics:event/2; wire them into your
observability layer.
See also
- PUBSUB_PROTOCOL.md — the raw primitives underneath, plus how relay-side routing works.
- Connecting Guide — pool model, lifecycle, options
- Topic Naming Guide — canonical topic shape
- Authorization Guide — UCAN/DID identity
macula_pubsub— slice modulemacula_client— pool implementationmacula_subscriber— supervised subscriber behaviour