March Docs

GlobalRegistry

GlobalRegistry: the cluster-wide name registry as a composite CRDT (P1 L5).

Maps a name -> the (node, pid) that holds it, gossip-replicated. Each binding carries a VectorClock that causally orders updates to that name, plus a presence bit so an unregister converges like an LWW-element-set removal (tombstones merge by the same rule rather than vanishing). Per-name conflict resolution:

  • causally newer (VectorClock After) -> the newer binding wins;
  • causally older (Before) / Equal -> keep the existing (idempotent);
  • Concurrent (a genuine conflict) -> a deterministic tiebreak on
(node_id, pid) so every replica converges to the same winner.

That makes merge associative/commutative/idempotent — the CRDT join the generic gossip/anti-entropy loop uses (Merkle anti-entropy at this layer, since the registry can hold many names; see the design spec).

Types

typeEntryEntry = { node_id : String, pid : Int, clock : VectorClock, present : Bool, creation : Int,#

A name binding. present = false is a tombstone left by unregister. creation is the holder node's creation when it registered: a node that restarts reuses local pids, so a binding from an earlier creation names whatever process now has that pid -- the node service hides it and its owner tombstones it (specs/progress/2026-09-18-cluster-node-service.md). 0 for every caller that predates the service. registrant is the identity (a node certificate's URI) of the node that registered the binding, as the REPLICA holding the entry recorded it (distributed-deploys step 11b); "" when unknown (shared-secret mode, or a relayed binding whose holder this replica has not verified). It is local bookkeeping: never on the wire, not in the Merkle hash, and no part of the merge order, so replicas that recorded it differently still agree. retired_at is when a tombstone was first stamped (Unix ms), 0 for a present binding or a tombstone not yet stamped. collect_tombstones stamps and later drops it. It travels with the entry (see NetKernel.encode_registry_sync_resp) so every replica expires a tombstone at about the same moment, but it is not in the Merkle hash and no part of the merge order.

typeNamesNames = Names(Map(String, Entry))#

name -> Entry.

Functions

fnall_entriesall_entries(reg : Names) : List((String, Entry))#

Collect all (name, entry) pairs, including tombstones, for wire transfer.

fncollect_tombstonescollect_tombstones(reg : Names, now : Int, grace_ms : Int) : Names#

Expire tombstones: stamp every unstamped tombstone with [now] (Unix ms), and drop every tombstone stamped at least [grace_ms] ago. Present bindings are untouched.

A tombstone exists so that a delayed, causally older binding cannot bring an unregistered name back. Once every replica has seen it, which takes far less than [grace_ms] on a healthy cluster, it guards nothing. Dropping it bounds the registry (and each anti-entropy round) by the live names rather than by every name ever unregistered. The cost: a partition longer than [grace_ms] can bring back a binding whose holder is gone, which the node service then hides and its owner tombstones again.

fncontainscontains(reg : Names, name : String) : Bool#

Whether [name] has a present (non-tombstoned) binding.

fndiff_entriesdiff_entries(local : Names, remote_root : String,#

CRDT-merge [remote_leaves] into [local], but only when hashes differ. remote_leaves is the list of (name, entry) pairs from the remote node. Returns the updated registry.

fnemptyempty() : Names do Names(Map.empty()) end#

An empty registry: no bindings and no tombstones.

fnentryentry(reg : Names, name : String) : Option(Entry)#

The stored entry for [name], tombstone or not; None if the name was never seen or its tombstone has been collected.

fnlookuplookup(reg : Names, name : String) : Option((String, Int))#

The (node_id, pid) currently bound to [name], or None when the name is unknown or tombstoned.

fnmergemerge(a : Names, b : Names) : Names#

CRDT join: observe every binding of [b], tombstones included, into [a].

fnnamesnames(reg : Names) : List(String)#

The names with a present (non-tombstoned) binding, in unspecified order.

fnnext_clocknext_clock(reg : Names, name : String, node_id : String) : VectorClock#

The clock a new update to [name] by [node_id] should carry: the name's current clock (tombstones included) with [node_id]'s slot bumped, or a fresh clock {node_id: 1} for an unknown name. An update made after seeing a binding is then causally After it and wins without the tiebreak.

fnobserveobserve(reg : Names, name : String, entry : Entry) : Names#

Fold one binding (present or tombstone) for [name] into the registry, keeping the winner: the causally newer clock wins, an older one is ignored, an Equal clock replaces the stored entry, and Concurrent clocks fall to a deterministic tiebreak (higher node_id, then higher creation, then higher pid). Presence plays no part in the order. Two copies of the same tombstone (Equal clocks) keep the earlier retired_at stamp, so a re-sent copy does not restart its expiry. An unknown name is simply added.

fnregisterregister(reg : Names, name : String, node_id : String, pid : Int, clock : VectorClock) : Names#

Bind [name] to (node_id, pid) at [clock] for a holder at creation 0, with no recorded registrant. [clock] should be the result of next_clock; the binding takes effect only if it wins under the observe order, so a causally older clock is silently ignored.

Examples

let clk = GlobalRegistry.next_clock(reg, "db", "n1")
let reg = GlobalRegistry.register(reg, "db", "n1", 42, clk)
GlobalRegistry.lookup(reg, "db")    -- Some(("n1", 42))
fnregister_asregister_as(reg : Names, name : String, node_id : String, creation : Int, pid : Int, clock : VectorClock,#

register_at, also recording [registrant], the registering node's certificate identity (local bookkeeping only).

fnregister_atregister_at(reg : Names, name : String, node_id : String, creation : Int, pid : Int, clock : VectorClock) : Names#

register for a holder at node creation [creation].

fnroot_hashroot_hash(reg : Names) : String#

Build a Merkle tree over sorted (name, entry) pairs and return its root hash. Comparing root hashes across nodes detects divergence in O(1). Returns the empty-string sentinel when the registry is empty.

fnsizesize(reg : Names) : Int do List.length(names(reg)) end#

The number of present bindings (tombstones excluded). O(n).

fntombstone_counttombstone_count(reg : Names) : Int#

The number of tombstones held. O(n).

fnunregisterunregister(reg : Names, name : String, clock : VectorClock) : Names#

Unregister [name] at [clock]: observe a tombstone that carries the prior binding's holder (so concurrent register/unregister tiebreak deterministically). The tombstone only takes effect if [clock] wins under the observe order. Returns the registry unchanged when the name is unknown.

fnunregister_ownunregister_own(reg : Names, name : String, node_id : String, creation : Int, pid : Int,#

Compare-and-delete: tombstone [name] at [clock] only if its current entry is the present binding (node_id, creation, pid). Returns None, leaving the registry alone, when the name is unknown, already tombstoned, or held by anyone else; use this rather than unregister, which tombstones whatever is present.