Skip to content

Registry name lost on a third node when the process registered during a split dies before the split heals #96

Open

Description

@vheathen

syn master (5191a98) and 3.4.2 (34749b6), OTP 27.3.4.11.

After a partial net split heals, one node can be left without the entry of a live registered process while every other node has it, and syn does not bring the entry back on its own.

Take three nodes in one Scope: NodeA, NodeB and NodeCommon. A process on NodeA, PidOnNodeA, is registered under some name and all three nodes hold the entry. The connection between NodeA and NodeB drops while both stay connected to NodeCommon. NodeB purges NodeA's entries, no longer sees the name, starts its own PidOnNodeB and registers it under the same name. The sync_register goes to NodeB's nodes_map, which is NodeCommon alone; NodeCommon owns neither process, so handle_registry_sync replaces PidOnNodeA with PidOnNodeB, the newer registration. NodeA hears nothing. Then PidOnNodeB dies before the connection comes back: NodeB's DOWN handler removes it and broadcasts sync_unregister, again to NodeCommon alone, and the sync_unregister handler there finds the same pid and removes the entry. NodeCommon now has nothing for the name.

NodeA and NodeB reconnect and exchange snapshots. NodeB's has nothing for the name, NodeA's gives NodeB PidOnNodeA back as a plain new entry. Neither owner sees a conflict, so resolve_conflict never runs, and the keep-local branch, the one place in the protocol that re-broadcasts a kept registration with a fresh time, sends nothing. NodeCommon never lost its connection to NodeA, so no snapshot from NodeA reaches it either.

End state: all three nodes connected and in the scope's subcluster, syn:lookup returns {PidOnNodeA, Meta} on NodeA and NodeB and undefined on NodeCommon. It lasts until NodeCommon resyncs with NodeA (after a disconnect, or a restart of either scope process) or NodeA broadcasts the entry again (a registration that changes its metadata, or a conflict NodeA wins). No resolver is called on this route, so the default resolution and a custom one end the same. If PidOnNodeB is still alive when the owners reconnect, it heals: both owners see the conflict in the other's snapshot, and the one that keeps its process re-broadcasts it to NodeCommon too.

Reproduction

The TLA+ model in test/tla finds it as soon as it gets a third node. With one change to test/tla/syn.cfg,

Nodes = {n1, n2, n3}

the command from test/tla/README.md (TLC 2.18, the jar in test/tla) reports Invariant AllRegistered is violated at state 12 after about a second. n1 is NodeA, n2 is NodeB and n3 is NodeCommon; the only difference from the story above is that n1 registers after the cut, so n2 never holds n1's entry.

The trace, condensed to the steps that matter
  1. n1 queues a registration of a.
  2. The n1-n2 link is cut and a DOWN is queued on both sides; n3 keeps both links.
  3. n1 registers a at time 0 and sends sync_register to n3 alone, since n2 is not visible.
  4. n2 queues a registration of a; n1 and n2 handle their DOWN and forget each other.
  5. n2 registers a at time 1 and sends sync_register to n3 alone.
  6. n2's process dies: n2 removes its entry and sends sync_unregister to n3.
  7. n3 handles n1's sync_register and holds a under n1, then n2's with the newer time and replaces it, then n2's sync_unregister and drops it.
  8. Every queue is empty, n3 sees both other nodes, registered[a] is 1 because n1's process lives, and n3 holds nothing. AllRegistered fails for n3.

TLC stops there because AllRegistered is checked per node, and n3 sees both other nodes while n1 and n2 are still apart. To check the state after the split heals, we added an invariant that applies only once every link is back and every queue is empty, in a module next to syn.tla:

---- MODULE syn_connected ----
EXTENDS syn

Quiescent == \A n \in Nodes: Len(inbox[n]) = 0

FullyConnected == \A n \in Nodes: visible_nodes[n] = AllOtherNodes(n)

LiveNames == {r \in DOMAIN registered: registered[r] > 0}

AllRegisteredWhenConnected ==
    (Quiescent /\ FullyConnected) =>
        \A n \in Nodes: AllRegisteredForNode(locally_registered[n]) = LiveNames
====

syn_connected.cfg is syn.cfg with Nodes = {n1, n2, n3} and AllRegisteredWhenConnected in place of AllRegistered. Run on these two files, the README command reports Invariant AllRegisteredWhenConnected is violated at state 17 after about ten seconds. In that state every link is back, every queue is empty, n1 and n2 hold a under n1, and n3 holds nothing. The two-node configuration cannot reach either state: with two nodes no node owns neither process.

We also have a deterministic three-node test with peer (the cut made with erlang:disconnect_node/1, PidOnNodeB killed with exit/2, bounded waits only). It fails on master 5191a98 in 10 of 10 runs and on 3.4.2 in every run, with the default resolution and with a custom resolver that keeps the older start time from Meta; the variant that leaves PidOnNodeB alive until the reconnect passes every time. The peers run with prevent_overlapping_partitions set to false, only so that NodeCommon keeps both connections; with the OTP default, global on NodeCommon drops both. We can turn it into a case for syn_registry_SUITE.

Where we hit it

A 15-node cluster with prevent_overlapping_partitions at its default and a resolver that keeps the process with the older start time. global's partition prevention produced waves of disconnects, and one node was cut from NodeA five times in five seconds. Three nodes ended up with no entry for a live process while every node was connected to every other and in the scope's subcluster, and still had none the next day; two more lost it the same way and got it back only when a later wave cut them from NodeA and the reconnect snapshot restored it. The cut node's logs have a gap in the second that matters, so the route on the cluster is our reconstruction: PidOnNodeB seems to have lost a conflict to NodeA's snapshot on NodeB (our resolver keeps the older process; the default one would have kept PidOnNodeB), NodeB then told the third nodes to drop it, and NodeA never saw PidOnNodeB. The end state matches the test.

Not the late unregister from test/tla/README.md

In the case listed under "Calls Made From Another Node", a node writes an entry for a process on another node, and a sync_unregister that node sent earlier removes it. Here every node registers only its own processes and every message arrives in order; the entry is lost because the one message that would restore it on the third node, the winner's re-broadcast after a conflict, is never sent, since the winner's node never sees the challenger.

Once the direction is agreed I can send a PR, or the test case for the suite on its own.

Activity

  1. ostinelli commented on Oct 19, 2026

    @ostinelli
    Owner

    This should already be fixed on master in 37710b9. NodeCommon used to forget NodeA's registration as soon as NodeB's newer one arrived, so it had nothing to fall back on when NodeB's process died. Now it keeps NodeA's registration aside and brings it back in that case. I'm also considering updating the TLA+ model to match, since it doesn't keep anything aside yet and your three-node run still fails there.

    I'm also thinking of covering the case where NodeB's process loses a conflict instead of dying, which takes a different path. Could you run your peer test against master to confirm?

  2. vheathen commented on Oct 19, 2026

    @vheathen
    ContributorAuthor

    Thanks, that was quick. Confirmed: on master (a950280) our peer test passes in 10 of 10 runs, with the default resolution and with a resolver that keeps the older start time, and it still fails on 5191a98. NodeCommon has PidOnNodeA back as soon as PidOnNodeB dies, while NodeA and NodeB are still apart. I had to relax one step of the test: it expected NodeCommon to be empty right after the kill, which is exactly what changed.

    The conflict case is the one we hit on the cluster, so we'd be glad to see it covered as well, and I'm happy to run our setup against it when it lands.

  3. vheathen commented on Oct 19, 2026

    @vheathen
    ContributorAuthor

    I also wrote a peer test for the conflict case, since that is the route we hit on the cluster. It still fails on master (a950280), the same way as on 5191a98. NodeB merges NodeA's snapshot after it has registered PidOnNodeB, the resolver keeps the older PidOnNodeA, NodeB stops PidOnNodeB and sends sync_unregister with reason syn_conflict_resolution, and NodeA never sees PidOnNodeB. NodeCommon has PidOnNodeA in its shadow table but does not bring it back: handle_registry_unregister_sync skips show_shadowed for that reason and leaves it to the node that kept its process, and NodeA never had a conflict, so it sends nothing. 10 of 10 runs fail on both commits. With the default resolver PidOnNodeB wins and the same sequence converges.

    To get that order every time, the test suspends the scope processes with sys:suspend and cuts the link a second time, with dist_auto_connect set to once and connect_all set to false so the cut stays a cut. Happy to share it or turn it into a case for the suite if that helps.

  4. vheathen commented on Oct 29, 2026

    @vheathen
    ContributorAuthor

    One more data point, this time from a 7-node cluster rather than the peer test. The nodes run master 5191a98 with #93 on top (no changes to the registry or scope code), prevent_overlapping_partitions at its default, and a resolver that keeps the older start time from Meta.

    To emulate a split, we froze one node with SIGSTOP until the others had taken over its names, then let it run again. When it resumed, global's overlapping-partition prevention cut the connections between several pairs of nodes within about 400 ms. In that window one node, NodeCommon below, was left with no entry for a live registered process. It stayed that way for about 14 minutes, while all 7 nodes were connected and the other six held the process, and healed only when a new process on NodeCommon registered the name, lost the conflict, and NodeA re-broadcast its entry.

    syn logs nothing on a third node when it replaces or purges an entry, and three nodes have gaps in their logs in that second, so the sequence below is our reconstruction. NodeCommon had just lost its own conflict for the name and held NodeA's process. NodeB, cut from NodeA at the time, registered its own process, and NodeCommon replaced NodeA's entry with it as the newer registration. NodeB's process then lost a conflict on NodeB (against NodeCommon's earlier process, which NodeCommon had already stopped) and was stopped, and 8 ms later global disconnected NodeB from NodeCommon. NodeCommon purged NodeB's entries and had nothing left for the name. NodeB and NodeCommon reconnected and resynced about 130 ms later, but NodeB's snapshot had nothing for the name either, and from then on the cluster stayed fully connected. Nothing else brought the entry back: NodeCommon's link to NodeA did not drop again after their resync earlier in that second, and NodeA never saw NodeB's process, so it never re-broadcast.

    So this is the same shape with a third way for the challenger to leave NodeCommon: the purge on scope down. Reading 37710b9, master restores the shadowed entry on that path. Had NodeB's sync_unregister reached NodeCommon before the disconnect, it would be the conflict case from my previous comment, which master does not restore yet.

    For scale, in the same test runs each rolling restart of the 7 nodes produced 390 to 469 overlapping-partition disconnects and 77 to 431 name conflicts for one group of singleton names, so the conditions for this come up on every rollout.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions