Macula SDK — HyParView Guide

View Source

Bounded partial-view membership for realm-scoped overlays.

HyParView Active and Passive Views

Audience: anyone building a process that maintains a realm's own membership on top of the mesh — for example a per-realm dispatcher that decides who else is "in" a realm and gossips over that group. If you just want to publish/subscribe to topics or call an RPC, you don't need this — use PUBSUB_GUIDE.md or RPC_GUIDE.md instead. This guide is for the layer those are built on when the group of participants isn't "every station", but "every member of realm X".


Overview

A station relays opaque frames between whatever peers happen to be connected to it — it has no notion of realms and doesn't own membership (see Clustering for the station-level, realm-agnostic backbone). Realm membership — "who else belongs to io.example.myrealm, and how do I find them" — is a separate problem, and naively solving it by having every member connect to every other member doesn't scale: state and connection cost grow O(N) per node, and a partition or a burst of churn takes down the whole graph at once.

HyParView (Leitão, Pereira, Rodrigues, "HyParView: A Membership Protocol for Reliable Gossip-Based Broadcast", DSN 2007) solves this with bounded partial views: every member keeps a small, fixed-size active view (the peers it actually holds connections to) and a larger passive view (candidates to promote when an active peer dies). Views self-heal under churn through gossip alone — no coordinator, no global membership list anywhere. Plumtree then rides on top of the active view to broadcast messages efficiently across the whole realm.

This SDK ships HyParView as three pure modules — macula_hyparview_view (the view data structure), macula_hyparview_proto (the protocol orchestrator), and macula_hyparview_endorsement (realm-admission gating). None of them touch the network. You drive them from your own process, using macula_station_link's overlay transport to actually move frames.


Active view vs. passive view

Active viewPassive view
What it isPeers you hold a live connection toCandidate peers, no connection held
Default cap54 × active cap (20)
Who gossips over itPlumtree eager-pushes hereNobody — refreshed by periodic shuffles
On overflowA random active peer is demoted to passiveA random passive peer is dropped
On active peer failureA random passive peer is promoted to fill the gap

Both caps are configurable (macula_hyparview_view:new/2), but the paper's formula — active_cap = max(5, ceil(log2(N))), capped at 15 — holds for realms up to tens of thousands of members without needing per-realm tuning.

Self = macula_identity:public(MyIdentity),
View0 = macula_hyparview_view:new(Self),                    %% defaults
View1 = macula_hyparview_view:new(Self, #{active_cap => 8}), %% custom

macula_hyparview_view:active(View0).           %% => []
macula_hyparview_view:counts(View0).           %% => #{active => 0, passive => 0}

macula_hyparview_view is pure — every mutator (add_active/2, add_passive/2, promote/2, demote/2, remove_active/2, remove_passive/2, merge_shuffle/2) takes a view and returns a new one. Nothing here sends a frame; macula_hyparview_proto is what turns a mutation into the disconnect/forward/ack messages the protocol requires.


The protocol messages

macula_hyparview_proto:process/4 takes the current view, the sender's NodeId, an inbound frame, and a ctx() map, and returns {NewView, Actions} — a list of {send, TargetPeer, Frame} tuples for your process to transmit. It never blocks and never touches the network itself.

FrameSent whenEffect
hyparview_joinA new member contacts one known realm peerReceiver adds the joiner to active, forwards FORWARD_JOIN to its other active peers, evicts if over cap
hyparview_forward_joinRelaying a JOIN through the meshForwarded ARWL hops (default 6); added to passive at PRWL hops (default 3) remaining; accepted into active at ttl 0 or if the active view is too small to forward
hyparview_neighborAck to a JOIN, or unsolicited after a shuffle-driven promotionpriority: high always admits (evicting if needed); priority: low only admits if there's room, else adds to passive
hyparview_disconnectGraceful active-peer teardownSender is demoted to the receiver's passive view
hyparview_shuffleEvery ~30s, to a random active peerForwarded while ttl > 0; at ttl 0, replies with a random sample of the receiver's own view and merges the incoming sample into its passive view
hyparview_shuffle_replyAnswering a SHUFFLESender's sample is merged into the passive view

A minimal dispatcher loop looks like:

handle_info({macula_overlay_frame, _Ref, Frame, #{sender := From}}, State) ->
    {View1, Actions} = macula_hyparview_proto:process(State#state.view, From, Frame, State#state.ctx),
    lists:foreach(fun({send, Target, F}) -> send_to(Target, F, State) end, Actions),
    {noreply, State#state{view = View1}};

send_to/3 is your own responsibility — resolve Target (a NodeId) to a connection and hand F to macula_station_link:send_overlay_frame/2, dialing first if you're not already connected to it.


Realm-gated admission

Without gating, any node that can reach a realm member can JOIN its overlay. ctx()'s optional realm_admin_pubkey field turns that on: every JOIN, FORWARD_JOIN, and NEIGHBOR then requires a realm_member_endorsement record (macula_record:realm_member_endorsement/2,3) — an admin-signed statement of the form {realm, member_node, roles, valid_from, valid_until} — signed by that exact key, naming that exact (realm, member) pair, currently inside its validity window. A missing or invalid endorsement is dropped silently: no ack, no forward, the view is unchanged. Trust is never assumed transitively — a FORWARD_JOIN carries the original JOIN's endorsement all the way through the relay chain, and every hop re-verifies it independently rather than trusting the peer that forwarded it.

Minting an endorsement (done by whoever administers the realm — see GuideRealmLifecycle.AdmitRealmMember in macula-realm for a real example):

Realm  = macula_identity:public(AdminIdentity),   %% the admin's own pubkey doubles as the realm id
Member = macula_identity:public(CandidateIdentity),
Unsigned = macula_record:realm_member_endorsement(
             Realm, #{realm => Realm, member_node => Member, roles => [<<"station">>]}),
Endorsement = macula_record:sign(Unsigned, AdminIdentity).

Joining with it:

Ctx = #{self_id => Member, realm => Realm, identity => CandidateIdentity},
JoinFrame0 = macula_hyparview_proto:build_join(Ctx),
JoinFrame  = JoinFrame0#{record => Endorsement}.

And gating admission on the receiving side:

GatedCtx = Ctx#{realm_admin_pubkey => Realm,
                %% Required so THIS peer's own NEIGHBOR acks carry proof of
                %% its own membership — a gated receiver drops a NEIGHBOR
                %% with no endorsement attached, same as it would a JOIN.
                self_endorsement => MyOwnEndorsement}.

Without realm_admin_pubkey in ctx(), admission is unconditional — the opt-in default, useful for a dev-only or single-operator realm where minting endorsements isn't worth the overhead yet.


Wire transport: sending and receiving frames

macula_hyparview_proto never touches a socket. The SDK's client-facing overlay transport in maculastation_link (overlay_subscribe/3, overlay_unsubscribe/2, send_overlay_frame/2) is what actually moves `hyparview*` frames over an existing connection:

{ok, SubRef} = macula_station_link:overlay_subscribe(Link, Realm, self()),
%% ... your process now receives:
%%   {macula_overlay_frame, SubRef, Frame, #{sender := FromNodeId}}
%%   {macula_overlay_gone, SubRef, Reason}   -- on disconnect

ok = macula_station_link:send_overlay_frame(Link, macula_hyparview_proto:build_join(Ctx)).

send_overlay_frame/2 is a raw primitive — you build and sign the frame yourself (via macula_hyparview_proto's builders), it just puts it on the wire. There's no wire-level SUBSCRIBE/UNSUBSCRIBE round trip: overlay frames already arrive addressed at a specific connection, they aren't fanned out by topic the way PUBLISH/EVENT is.

send_overlay_frame/2 only reaches whoever is on the other end of Link — correct once you're already connected to your intended contact (e.g. the seed peer a JOIN goes to), but most {send, TargetPeer, Frame} actions macula_hyparview_proto:process/4 returns name a peer you aren't directly connected to at all. For that, resolve the target's own current station first (its published node_record's station_id field, keyed by the target's own pubkey — macula:find_record(Pool, TargetPeer)), dial that station directly the same way direct-dial does, then use send_overlay_frame/3:

ok = macula_station_link:send_overlay_frame(Link, TargetPeer,
                                            macula_hyparview_proto:build_join(Ctx)).

The station relays it to whichever of its other connections authenticates as TargetPeer, and stamps the delivered copy's Meta.sender with your own authenticated identity — not something you can spoof by naming a different TargetPeer in the frame content, since there isn't one; the routing lives entirely in the envelope, verified against the connection that sent it. macula-realm's Overlay.SelfPublisher (publish your own presence) and Overlay.PeerResolver.resolve_and_dial/2 (the resolve-then-dial sequence above, as reusable code) are a concrete example of both halves.

The frame itself carries its own signature, separate from the envelope's, and it must be made with the identity your link connects with: the receiving link delivers the frame only when that signature verifies against the sender the station names. macula_hyparview_proto's builders sign with Ctx's identity, so give Ctx the same key pair as the link.


See also

  • Plumtree Guide — broadcast trees riding on the active view this module maintains.
  • Records Guide — signed, TTL'd records in general; realm_member_endorsement is one instance of the pattern.
  • Authorization — the broader DID/UCAN trust model this endorsement mechanism complements.
  • plans/PLAN_MACULA_V2_PART3_DISCOVERY.md §7.1 — the original design doc.
  • Leitão, Pereira, Rodrigues, "HyParView: A Membership Protocol for Reliable Gossip-Based Broadcast", DSN 2007.