<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
    <title>Neelima</title>
    <subtitle>Debugging adventures, design notes, and other technical writing.</subtitle>
    <link rel="self" type="application/atom+xml" href="https://neelima.io/atom.xml"/>
    <link rel="alternate" type="text/html" href="https://neelima.io"/>
    <generator uri="https://www.getzola.org/">Zola</generator>
    <updated>2026-08-30T00:00:00+00:00</updated>
    <id>https://neelima.io/atom.xml</id>
    <entry xml:lang="en">
        <title>The Erlang global Livelock: A 34-Million-Iteration Bug</title>
        <published>2026-08-30T00:00:00+00:00</published>
        <updated>2026-08-30T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://neelima.io/technical/erlang-global-livelock/"/>
        <id>https://neelima.io/technical/erlang-global-livelock/</id>
        
        <content type="html" xml:base="https://neelima.io/technical/erlang-global-livelock/">&lt;p&gt;We ran into an issue where a 45-node Couchbase Server cluster ran out of memory on multiple nodes at a customer site. The nodes were running OTP 25.3. The nodes that ran out of memory were OOM-killed, and the disk filled while writing 60-100 GiB crash dumps, so when they restarted, the process state was lost. The root cause was a 34-million iteration livelock in &lt;code&gt;global&lt;&#x2F;code&gt;. This is how I traced it without a reproduction, using the other nodes’ logs.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;day-1&quot;&gt;Day 1&lt;&#x2F;h2&gt;
&lt;p&gt;The first step was figuring out which process(es) were consuming memory. The memory consumption was in &lt;code&gt;ns_server&lt;&#x2F;code&gt;, but nothing noteworthy was happening at the time that memory consumption spiked. Prometheus statistics revealed nothing. I came across &lt;code&gt;memsup&lt;&#x2F;code&gt; data in a text log. &lt;code&gt;memsup&lt;&#x2F;code&gt; records memory usage for top Erlang consumers every minute. I parsed and plotted &lt;code&gt;memsup&lt;&#x2F;code&gt; data from the one node Support had mentioned, and sure enough, there was a single offender, a process &lt;code&gt;&amp;lt;0.56.0&amp;gt;&lt;&#x2F;code&gt;. The memory consumed by the &lt;code&gt;&amp;lt;0.56.0&amp;gt;&lt;&#x2F;code&gt; process increased linearly.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;day-2&quot;&gt;Day 2&lt;&#x2F;h2&gt;
&lt;p&gt;I plotted the worst memory offender from &lt;code&gt;memsup&lt;&#x2F;code&gt; data on all nodes in the cluster. &lt;code&gt;&amp;lt;0.56.0&amp;gt;&lt;&#x2F;code&gt; was the culprit on all affected nodes.&lt;&#x2F;p&gt;
&lt;p&gt;It wasn’t immediately obvious what this process &lt;code&gt;&amp;lt;0.56.0&amp;gt;&lt;&#x2F;code&gt; referred to, so I spun up a node running the identical Couchbase Server version. &lt;code&gt;&amp;lt;0.56.0&amp;gt;&lt;&#x2F;code&gt; was a process registered by &lt;code&gt;global&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;global&lt;&#x2F;code&gt; is a module that is responsible for registering names on the network of nodes running the Erlang distribution protocol. It’s part of the Erlang kernel and runs whether you use it or not. From the Erlang documentation:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;The global name server also performs the critical task of continuously monitoring changes in node configuration. If a node that runs a globally registered process goes down, the name is globally unregistered. To this end, the global name server subscribes to &lt;code&gt;nodeup&lt;&#x2F;code&gt; and &lt;code&gt;nodedown&lt;&#x2F;code&gt; messages sent from module &lt;code&gt;net_kernel&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;This is an Erlang kernel process that we hadn’t tweaked. We don’t use it for process registration either.&lt;&#x2F;p&gt;
&lt;p&gt;I filtered the logs for &lt;code&gt;global&lt;&#x2F;code&gt; function hits. One stack on node-0082 caught my attention: it contained a few messages – &lt;code&gt;cancel&lt;&#x2F;code&gt;, &lt;code&gt;remove_from_known&lt;&#x2F;code&gt;, &lt;code&gt;his_the_locker&lt;&#x2F;code&gt; – in its mailbox. I suspected that the message queues for the process &lt;code&gt;&amp;lt;0.56.0&amp;gt;&lt;&#x2F;code&gt; spawned by &lt;code&gt;global&lt;&#x2F;code&gt; grew indefinitely on the nodes that ran out of memory – perhaps, there was a livelock.&lt;&#x2F;p&gt;
&lt;p&gt;The trigger was unclear. Since &lt;code&gt;global&lt;&#x2F;code&gt; responds to &lt;code&gt;nodeup&lt;&#x2F;code&gt; and &lt;code&gt;nodedown&lt;&#x2F;code&gt; messages, I suspected there were network issues causing repeated node flaps. I had noticed that the nodes occasionally restarted hundreds of times. Perhaps, the connection churn exposed a race condition in &lt;code&gt;global&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;Support told me there were no network issues but that a Kubernetes upgrade had been initiated on some nodes. However, the OOM wasn’t limited to nodes that were upgraded. Nor was it the case that every node that was upgraded ran into this issue. It was unclear whether the upgrade had anything to do with it.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;day-3&quot;&gt;Day 3&lt;&#x2F;h2&gt;
&lt;p&gt;We record diagnostic info in &lt;code&gt;cbcollect&lt;&#x2F;code&gt; dumps, which captures Erlang process info for all running processes, including their mailbox contents. I had stopped looking at process dumps after the first handful – their process state was clean as the nodes had restarted. A colleague flagged a node that still had its process dump intact. Its memory usage had increased but hadn’t run out, unlike the other nodes. They’d opened the dump and said it appeared to confirm my hypothesis – the mailbox contained 69M messages:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #CDD6F4; background-color: #1E1E2E;&quot;&gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;{message_queue_len,69628685},&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The message queue contained alternating &lt;code&gt;cancel&lt;&#x2F;code&gt; and &lt;code&gt;his_the_locker&lt;&#x2F;code&gt; messages with monotonically increasing tags, stepping by 1 each pair:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #CDD6F4; background-color: #1E1E2E;&quot;&gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;{messages,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;[&amp;lt;&amp;lt;&amp;quot;{cancel,&amp;#39;ns_1@node-85&amp;#39;,-576460752302537370,no_fun}&amp;quot;&amp;gt;&amp;gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;&amp;lt;&amp;lt;&amp;quot;{his_the_locker,&amp;lt;18602.56.0&amp;gt;,{8,[]},-576460752303387318,-576460752302537369}&amp;quot;&amp;gt;&amp;gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;&amp;lt;&amp;lt;&amp;quot;{cancel,&amp;#39;ns_1@node-85&amp;#39;,-576460752302537369,no_fun}&amp;quot;&amp;gt;&amp;gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;&amp;lt;&amp;lt;&amp;quot;{his_the_locker,&amp;lt;18602.56.0&amp;gt;,{8,[]},-576460752303387317,-576460752302537368}&amp;quot;&amp;gt;&amp;gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;&amp;lt;&amp;lt;&amp;quot;{cancel,&amp;#39;ns_1@node-85&amp;#39;,-576460752302537368,no_fun}&amp;quot;&amp;gt;&amp;gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;&amp;lt;&amp;lt;&amp;quot;{his_the_locker,&amp;lt;18602.56.0&amp;gt;,{8,[]},-576460752303387316,-576460752302537367}&amp;quot;&amp;gt;&amp;gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;&amp;lt;&amp;lt;&amp;quot;{cancel,&amp;#39;ns_1@node-85&amp;#39;,-576460752302537367,no_fun}&amp;quot;&amp;gt;&amp;gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;&amp;lt;&amp;lt;&amp;quot;{his_the_locker,&amp;lt;18602.56.0&amp;gt;,{8,[]},-576460752303387315,-576460752302537366}&amp;quot;&amp;gt;&amp;gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;&amp;lt;&amp;lt;&amp;quot;{cancel,&amp;#39;ns_1@node-85&amp;#39;,-576460752302537366,no_fun}&amp;quot;&amp;gt;&amp;gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;&amp;lt;&amp;lt;&amp;quot;{his_the_locker,&amp;lt;18602.56.0&amp;gt;,{8,[]},-576460752303387314,-576460752302537365}&amp;quot;&amp;gt;&amp;gt;]}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This sequence of messages suggested that two nodes were caught in a loop where they repeatedly attempted to establish new sessions – each new session minting a new tag, one larger than the one previously used.&lt;&#x2F;p&gt;
&lt;p&gt;From &lt;code&gt;&amp;lt;0.55.0&amp;gt;&lt;&#x2F;code&gt;’s process dictionary on node 74:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #CDD6F4; background-color: #1E1E2E;&quot;&gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;{{sync_tag_my, &amp;#39;ns_1@node-85&amp;#39;}, -576460752267720345},&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;{{sync_tag_his, &amp;#39;ns_1@node-85&amp;#39;}, -576460752268570427},&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The difference between the current tag in the dictionary and the tags in the message queue gave the iteration count: &lt;code&gt;576460752303387318 - 576460752268570427 = ~34M&lt;&#x2F;code&gt; tags, with two messages per iteration (&lt;code&gt;his_the_locker&lt;&#x2F;code&gt; + &lt;code&gt;cancel&lt;&#x2F;code&gt;) producing the 69M message total.&lt;&#x2F;p&gt;
&lt;p&gt;34M synchronization sessions had begun between this pair of nodes. I started looking at the &lt;code&gt;global&lt;&#x2F;code&gt; source code to understand the synchronization protocol.&lt;&#x2F;p&gt;
&lt;p&gt;I noticed the assumptions mentioned in &lt;code&gt;global.erl&lt;&#x2F;code&gt; lines 793-799:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #CDD6F4; background-color: #1E1E2E;&quot;&gt;&lt;code data-lang=&quot;erlang&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9399B2;font-style: italic;&quot;&gt;%% In case a connection goes down and then up again, the&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9399B2;font-style: italic;&quot;&gt;%% &amp;#39;nodedown&amp;#39; for the old connection is nowadays guaranteed to&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9399B2;font-style: italic;&quot;&gt;%% be delivered before the &amp;#39;nodeup&amp;#39; for the new connection.&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9399B2;font-style: italic;&quot;&gt;%%&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9399B2;font-style: italic;&quot;&gt;%% By keeping track of connection_id for all connections we&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9399B2;font-style: italic;&quot;&gt;%% can differentiate between different instances of connections&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9399B2;font-style: italic;&quot;&gt;%% to the same node.&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;I wondered if the ordering of &lt;code&gt;nodeup&lt;&#x2F;code&gt; and &lt;code&gt;nodedown&lt;&#x2F;code&gt; was violated somehow.&lt;&#x2F;p&gt;
&lt;p&gt;I filed an initial bug report containing the mailbox trace with Erlang&#x2F;OTP.&lt;&#x2F;p&gt;
&lt;p&gt;Separately, QA tried to reproduce the issue with Kubernetes upgrades and repeated node restarts in a large cluster, but their attempts were unsuccessful.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;day-7&quot;&gt;Day 7&lt;&#x2F;h2&gt;
&lt;p&gt;Reading further into the source, I found a comment about vsn 8:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #CDD6F4; background-color: #1E1E2E;&quot;&gt;&lt;code data-lang=&quot;erlang&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9399B2;font-style: italic;&quot;&gt;%% Vsn 8 - &amp;quot;verify connection&amp;quot; part of the protocol preventing&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9399B2;font-style: italic;&quot;&gt;%%         deadlocks in connection setup due to locker processes&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9399B2;font-style: italic;&quot;&gt;%%         being out of sync&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;code&gt;global&lt;&#x2F;code&gt; vsn 8 of the protocol introduced reconnect attempts when the locker processes on the nodes were out of sync. I didn’t see any rate control limits in the connection attempts. I suspected that the locker processes were out of sync and flooded each other with repeated handshake attempts.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;how-could-this-be-related-to-upgrades&quot;&gt;How could this be related to upgrades?&lt;&#x2F;h3&gt;
&lt;p&gt;When a Kubernetes node was upgraded, the Couchbase pods running on it were evicted and flagged for auto-failover since they became unreachable. Couchbase Autonomous Operator repeatedly recreated these failed pods but detected their state as unhealthy (since they had been failed over without its knowledge) until the pod was successfully added back to the Couchbase cluster. Each pod restart corresponded to an Erlang node restart with the same Couchbase node name, though the pod’s network identity likely changed – triggering &lt;code&gt;net_kernel&lt;&#x2F;code&gt;’s &lt;code&gt;nodeup&lt;&#x2F;code&gt; and &lt;code&gt;nodedown&lt;&#x2F;code&gt; messages. &lt;code&gt;global&lt;&#x2F;code&gt; triggered new synchronization session attempts in response to these messages. As soon as the pod stabilized and established connections successfully, the memory on a pair of Couchbase nodes increased linearly. My theory was that the handshake after upgrade caused some upgraded nodes to get stuck in a bad state, leading to indefinite retries and eventual OOM.&lt;&#x2F;p&gt;
&lt;p&gt;The vsn 8 retry logic meant that sync sessions initiated at the time of upgrade might be interrupted by repeated restarts and retried indefinitely – with no rate limit.&lt;&#x2F;p&gt;
&lt;p&gt;This was confirmed by the memory plot: pairs of nodes consumed memory in lockstep and coincided with the time of upgrade.&lt;&#x2F;p&gt;
&lt;figure class=&quot;figure&quot;&gt;
  &lt;a class=&quot;figure-link&quot; href=&quot;https:&#x2F;&#x2F;neelima.io&#x2F;images&#x2F;global-livelock-memory.png&quot; title=&quot;Open full size&quot;&gt;
    &lt;img src=&quot;https:&#x2F;&#x2F;neelima.io&#x2F;images&#x2F;global-livelock-memory.png&quot; alt=&quot;Grafana panel titled Memory Consumption (log scale), y-axis 256 MiB to 128 GiB, x-axis 07:30 to 15:30. Seven node series rise in pairs at four distinct times marked by dashed vertical lines.&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;
  &lt;&#x2F;a&gt;
  &lt;figcaption&gt;Memory consumption plot showing pairs of nodes climbing in lockstep, log scale, 512 MiB to 128 GiB over the course of the day.&lt;&#x2F;figcaption&gt;
&lt;&#x2F;figure&gt;
&lt;p&gt;I had a separate concern: when the session was stuck, could it lead to locks not being released?&lt;&#x2F;p&gt;
&lt;p&gt;Any locks held are logged in &lt;code&gt;global&lt;&#x2F;code&gt;‘s ETS tables. I parsed the &lt;code&gt;global&lt;&#x2F;code&gt;-related ETS dumps in the &lt;code&gt;cbcollect&lt;&#x2F;code&gt; dumps and concluded that a lock held by one of the first two nodes in the message storm (at 7:30 am in the diagram above) hadn’t been released – and that this unreleased lock potentially stalled other nodes’ synchronization attempts.&lt;&#x2F;p&gt;
&lt;p&gt;I posted an update on the OTP ticket with these findings.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;day-8&quot;&gt;Day 8&lt;&#x2F;h2&gt;
&lt;p&gt;Since I hadn’t received any updates on the ticket, I looked further at the vsn 8 protocol, specifically the sequence of &lt;code&gt;init_connect&lt;&#x2F;code&gt; and &lt;code&gt;cancel_connect&lt;&#x2F;code&gt; messages during retries.&lt;&#x2F;p&gt;
&lt;p&gt;I noticed this in the retry path:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #CDD6F4; background-color: #1E1E2E;&quot;&gt;&lt;code data-lang=&quot;erlang&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9399B2;font-style: italic;&quot;&gt;%% This should not be possible unless global group has&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9399B2;font-style: italic;&quot;&gt;%% been configured. We got an already ongoing connection&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9399B2;font-style: italic;&quot;&gt;%% setup with Node and get yet another connection attempt&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9399B2;font-style: italic;&quot;&gt;%% from Node.&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;I was curious what would happen if it did hit this condition.&lt;&#x2F;p&gt;
&lt;p&gt;When Node B sends an &lt;code&gt;init_connect&lt;&#x2F;code&gt; to Node A, it includes a session tag &lt;code&gt;HisTag&lt;&#x2F;code&gt;, which identifies this sync attempt. If Node A already has Node B in its pending list (an ongoing sync session that’s in progress), Node A should cancel the old session, restart cleanly, and throw – discarding the old session with &lt;code&gt;HisTag&lt;&#x2F;code&gt; entirely.&lt;&#x2F;p&gt;
&lt;p&gt;Without the throw, execution falls through. Node A has just called &lt;code&gt;restart_connect&lt;&#x2F;code&gt;, which wiped Node B’s state and started a fresh sync session with a new tag &lt;code&gt;MyTag + 1&lt;&#x2F;code&gt;. But then the fall-through code saves Node B’s canceled &lt;code&gt;HisTag&lt;&#x2F;code&gt; as if it were the current session (identified by the pair &lt;code&gt;HisTag, MyTag + 1&lt;&#x2F;code&gt;).&lt;&#x2F;p&gt;
&lt;p&gt;From Node B’s perspective: it received a &lt;code&gt;cancel_connect&lt;&#x2F;code&gt; for the &lt;code&gt;HisTag&lt;&#x2F;code&gt; session, so it discards the old session, mints a new tag and sends a new &lt;code&gt;init_connect&lt;&#x2F;code&gt; with &lt;code&gt;HisTag + 1&lt;&#x2F;code&gt;. Node A now finds Node B in its pending list again – because the fall-through set it incorrectly – and the cycle repeats.&lt;&#x2F;p&gt;
&lt;p&gt;One missing throw. 34 million iterations.&lt;&#x2F;p&gt;
&lt;p&gt;When Node B is upgraded and rejoins the cluster, it sends &lt;code&gt;init_connect&lt;&#x2F;code&gt; to establish a new sync session with Node A. If Node A already has Node B in its pending list from a prior attempt, this is what happens:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #CDD6F4; background-color: #1E1E2E;&quot;&gt;&lt;code data-lang=&quot;mermaid&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;sequenceDiagram&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    participant B as Node B&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    participant A as Node A (global_name_server)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    participant L as Node A (locker)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    B-&amp;gt;&amp;gt;A: init_connect(HisTag)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    Note over A: {pending, B} already set&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    A-&amp;gt;&amp;gt;B: cancel_connect(HisTag)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    Note over A: restart_connect(MyTag)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    Note over A: {pending, B} cleared&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    A-&amp;gt;&amp;gt;L: {cancel, B, MyTag, no_fun}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    Note over A: handle_nodeup → MyTag+1&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    A-&amp;gt;&amp;gt;B: init_connect(MyTag+1)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    Note over A: missing throw -- falls through&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    A-&amp;gt;&amp;gt;L: {his_the_locker, B_locker, HisTag, MyTag+1}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    A-&amp;gt;&amp;gt;B: init_connect_ack(HisTag, MyTag+1)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    Note over A: {pending, B} set again&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    Note over B: cancel_connect → restart → HisTag+1&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    B-&amp;gt;&amp;gt;A: init_connect(HisTag+1)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    Note over A: {pending, B} -- repeat ↑&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Each loop iteration deposits this pair into A’s locker mailbox:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #CDD6F4; background-color: #1E1E2E;&quot;&gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Abstract                                    node0074 production values&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;--------------------------------------------------------------------------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;{cancel, B,       MyTag,   no_fun}     -&amp;gt;  {cancel,&amp;#39;ns_1@node-85&amp;#39;, -576460752302537370, no_fun}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;{his_the_locker,                       -&amp;gt;  {his_the_locker,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  B_locker,                                 &amp;lt;18602.56.0&amp;gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  {8,[]},                                   {8,[]},&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  HisTag,                                   -576460752303387318,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  MyTag+1}                                  -576460752302537369}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Next iteration: &lt;code&gt;MyTag+1&lt;&#x2F;code&gt; becomes &lt;code&gt;MyTag&lt;&#x2F;code&gt;, &lt;code&gt;HisTag+1&lt;&#x2F;code&gt; arrives from B.&lt;&#x2F;p&gt;
&lt;p&gt;I posted an update on the ticket: Should there be a &lt;code&gt;throw&lt;&#x2F;code&gt; after the &lt;code&gt;restart_connect&lt;&#x2F;code&gt;?&lt;&#x2F;p&gt;
&lt;h2 id=&quot;day-14&quot;&gt;Day 14&lt;&#x2F;h2&gt;
&lt;p&gt;Rickard, an OTP maintainer, confirmed, “Great find!” He asked if we had &lt;code&gt;global_groups&lt;&#x2F;code&gt; configured and why the connections kept going up and down.&lt;&#x2F;p&gt;
&lt;p&gt;Neither of us had the full picture yet.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;day-21&quot;&gt;Day 21&lt;&#x2F;h2&gt;
&lt;p&gt;Since we weren’t able to reproduce the issue, there was some reluctance in pushing a speculative fix. Unfortunately, the customer ran into the issue again.&lt;&#x2F;p&gt;
&lt;p&gt;The open question: How could an &lt;code&gt;init_connect&lt;&#x2F;code&gt; arrive while a connection was marked pending?&lt;&#x2F;p&gt;
&lt;p&gt;Was it possible that a &lt;code&gt;global&lt;&#x2F;code&gt; &lt;code&gt;init_connect&lt;&#x2F;code&gt; message arrived before the &lt;code&gt;nodeup&lt;&#x2F;code&gt; from &lt;code&gt;net_kernel&lt;&#x2F;code&gt;? This seemed highly unlikely based on the comments here:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #CDD6F4; background-color: #1E1E2E;&quot;&gt;&lt;code data-lang=&quot;erlang&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9399B2;font-style: italic;&quot;&gt;%% Monitor all &amp;#39;nodeup&amp;#39;&#x2F;&amp;#39;nodedown&amp;#39; messages of visible nodes.&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9399B2;font-style: italic;&quot;&gt;%% In case&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9399B2;font-style: italic;&quot;&gt;%%&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9399B2;font-style: italic;&quot;&gt;%% * no global group is configured, we use these as is. This&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9399B2;font-style: italic;&quot;&gt;%% way we know that &amp;#39;nodeup&amp;#39; comes before any traffic from&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9399B2;font-style: italic;&quot;&gt;%% the node on the newly established connection and &amp;#39;nodedown&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9399B2;font-style: italic;&quot;&gt;%% comes after any traffic on this connection from the node.&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;and the &lt;code&gt;net_kernel&lt;&#x2F;code&gt; delivery guarantees:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;Delivery guarantees of nodeup&#x2F;nodedown messages:
&lt;code&gt;nodeup&lt;&#x2F;code&gt; messages are delivered before delivery of any signals from the remote node through the newly established connection.
&lt;code&gt;nodedown&lt;&#x2F;code&gt; messages are delivered after all the signals from the remote node over the connection have been delivered.
&lt;code&gt;nodeup&lt;&#x2F;code&gt; messages are delivered after the corresponding node appears in results from &lt;code&gt;erlang:nodes()&lt;&#x2F;code&gt;.
&lt;code&gt;nodedown&lt;&#x2F;code&gt; messages are delivered after the corresponding node has disappeared in results from &lt;code&gt;erlang:nodes()&lt;&#x2F;code&gt;.
As of OTP 23.0, a &lt;code&gt;nodedown&lt;&#x2F;code&gt; message for a connection being taken down will be delivered before a &lt;code&gt;nodeup&lt;&#x2F;code&gt; message due to a new connection to the same node. Prior to OTP 23.0, this was not guaranteed to be the case.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;The &lt;code&gt;nodeup&lt;&#x2F;code&gt;&#x2F;&lt;code&gt;nodedown&lt;&#x2F;code&gt; sequence in the logs was consistent – I didn’t have visibility into &lt;code&gt;init_connect&lt;&#x2F;code&gt;, but nothing in the &lt;code&gt;net_kernel&lt;&#x2F;code&gt; events suggested ordering anomalies.&lt;&#x2F;p&gt;
&lt;p&gt;I pivoted to looking at how network connections were created and destroyed during a Kubernetes upgrade. The presence of &lt;code&gt;wait_pending&lt;&#x2F;code&gt; in the &lt;code&gt;net_kernel&lt;&#x2F;code&gt; logs seemed odd. I looked it up. &lt;code&gt;wait_pending&lt;&#x2F;code&gt; revealed that the old connection was still alive when the new one completed its handshake – &lt;code&gt;net_kernel&lt;&#x2F;code&gt; only kills the old controller at that point. This meant messages from the old session could be delivered on the new connection, unlike a clean FIN&#x2F;RST disconnect where the old connection is gone before the new one is established. This might have exposed a corner condition in &lt;code&gt;global&lt;&#x2F;code&gt;. During &lt;code&gt;handle_nodedown&lt;&#x2F;code&gt;, a &lt;code&gt;send_cancel_connect_message&lt;&#x2F;code&gt; uses &lt;code&gt;erlang:send({global_name_server, Node}, Msg, [noconnect])&lt;&#x2F;code&gt; – it silently drops if there’s no connection. With &lt;code&gt;wait_pending&lt;&#x2F;code&gt;, the new connection is already established when &lt;code&gt;nodedown&lt;&#x2F;code&gt; fires – that’s what triggers the teardown – so the cancel goes through instead of being silently dropped.&lt;&#x2F;p&gt;
&lt;p&gt;Building on this, I wondered if tags were recycled during pod restarts, and whether they could collide and confuse &lt;code&gt;global&lt;&#x2F;code&gt;. I spun up Erlang VMs repeatedly and noticed that &lt;code&gt;erlang:unique_integer([monotonic])&lt;&#x2F;code&gt; always starts from the same number (&lt;code&gt;?MIN_64BIT_SMALL_INT&lt;&#x2F;code&gt;). This implied that repeated restarts of Node B after an upgrade could reuse tags used in previous incarnations. Might this expose a corner case in conjunction with &lt;code&gt;wait_pending&lt;&#x2F;code&gt;?&lt;&#x2F;p&gt;
&lt;p&gt;I established a packet sequence that could lead to the condition where Node A determined that it already had a connection when the &lt;code&gt;init_connect&lt;&#x2F;code&gt; was processed.&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;A had an in-flight sync session with B’s previous life using tag T&lt;&#x2F;li&gt;
&lt;li&gt;B restarts and begins a new connection attempt, minting the same tag T from scratch&lt;&#x2F;li&gt;
&lt;li&gt;Because connection termination is delayed until the new connection is accepted, a TCP connection already exists when the &lt;code&gt;nodedown&lt;&#x2F;code&gt; is delivered to A. In response, A sends &lt;code&gt;cancel_connect(T)&lt;&#x2F;code&gt; – and because B is reachable, it goes through. In the common case, the old connection is already gone before the new one is established, so the cancel is silently dropped and B’s new life never sees it.&lt;&#x2F;li&gt;
&lt;li&gt;B recognizes &lt;code&gt;cancel_connect(T)&lt;&#x2F;code&gt; as valid – the tag matches its own &lt;code&gt;sync_tag_my&lt;&#x2F;code&gt; – and restarts with &lt;code&gt;T+1&lt;&#x2F;code&gt;. (If it hadn’t matched, it would be silently discarded.)&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;Consequently, A gets two &lt;code&gt;init_connect&lt;&#x2F;code&gt; messages from B, the second when it already has B in pending state.&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #CDD6F4; background-color: #1E1E2E;&quot;&gt;&lt;code data-lang=&quot;mermaid&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;sequenceDiagram&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    participant A as A&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    participant B as B (new life, tag=T)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    Note over A: mailbox: [nodedown(B), nodeup(B)]&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    Note over B: New connection fully established&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    Note over B: nodeup(A) fires&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    B-&amp;gt;&amp;gt;A: init_connect(T)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    Note over A: processing nodedown(B) -- old session tag = T (B&amp;#39;s previous life)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    A-&amp;gt;&amp;gt;B: cancel_connect(T) -- B is reachable, send goes through&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    Note over B: sync_tag_my = T -- T == T? Yes, restart fires&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    B-&amp;gt;&amp;gt;A: init_connect(T+1)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    Note over A: processing nodeup(B)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    Note over A: processing init_connect(T) -&amp;gt; {pending, B}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    Note over A: processing init_connect(T+1) -- {pending,B} set -&amp;gt; livelock&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;I was not able to reproduce this specific condition, but pushed the speculative fix (the missing throw) to the customer. The issue hasn’t recurred. Rickard fixed it upstream in OTP shortly after.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;conclusion&quot;&gt;Conclusion&lt;&#x2F;h2&gt;
&lt;p&gt;The missing throw is the confirmed bug: once two nodes ended up in the state where &lt;code&gt;{pending, B}&lt;&#x2F;code&gt; was already set when a new &lt;code&gt;init_connect&lt;&#x2F;code&gt; arrived, the livelock was inevitable – unbounded retries, no error logged, memory growing silently. Each iteration deposited exactly the alternating &lt;code&gt;cancel&lt;&#x2F;code&gt; and &lt;code&gt;his_the_locker&lt;&#x2F;code&gt; pairs found in the production mailbox – 34M iterations, two messages each, 69M total.&lt;&#x2F;p&gt;
&lt;p&gt;What’s less certain is how that precondition arose. The most plausible path involves two things coinciding: &lt;code&gt;wait_pending&lt;&#x2F;code&gt; delaying teardown of the old connection long enough for messages from the old &lt;code&gt;global&lt;&#x2F;code&gt; synchronization session to be delivered on the new one, combined with tag reuse – every Erlang VM starts minting tags from &lt;code&gt;?MIN_64BIT_SMALL_INT&lt;&#x2F;code&gt; on boot, so a restarted pod can issue a tag that collides with one the peer still has in flight from the pod’s previous incarnation. I wasn’t able to reproduce this specific sequence, but the fix held.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;his_the_locker&lt;&#x2F;code&gt;, &lt;code&gt;HisTag&lt;&#x2F;code&gt;, and related names appear verbatim from the Erlang source.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;references&quot;&gt;References&lt;&#x2F;h3&gt;
&lt;ul&gt;
&lt;li&gt;OTP issue: &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;erlang&#x2F;otp&#x2F;issues&#x2F;9117&quot;&gt;erlang&#x2F;otp#9117&lt;&#x2F;a&gt; – filed Nov 26, 2024, assigned to Rickard Green (OTP maintainer)&lt;&#x2F;li&gt;
&lt;li&gt;Affected version: OTP 25.3&lt;&#x2F;li&gt;
&lt;li&gt;Fixed in: OTP 25.3.2.17&lt;&#x2F;li&gt;
&lt;li&gt;Relevant source: &lt;code&gt;lib&#x2F;kernel&#x2F;src&#x2F;global.erl&lt;&#x2F;code&gt;&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
</content>
        
    </entry>
</feed>
