<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Franco Pachue</title>
    <description>The latest articles on DEV Community by Franco Pachue (@franco_pachue_802c9b9d306).</description>
    <link>https://dev.to/franco_pachue_802c9b9d306</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4110216%2F27a09359-813b-4915-91b6-3fc908c90b93.png</url>
      <title>DEV Community: Franco Pachue</title>
      <link>https://dev.to/franco_pachue_802c9b9d306</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/franco_pachue_802c9b9d306"/>
    <language>en</language>
    <item>
      <title>The bug my 46 passing tests couldn't see</title>
      <dc:creator>Franco Pachue</dc:creator>
      <pubDate>Fri, 04 Sep 2026 18:04:22 +0000</pubDate>
      <link>https://dev.to/franco_pachue_802c9b9d306/the-bug-my-46-passing-tests-couldnt-see-3kko</link>
      <guid>https://dev.to/franco_pachue_802c9b9d306/the-bug-my-46-passing-tests-couldnt-see-3kko</guid>
      <description>&lt;p&gt;I maintain a small leader election library for .NET that runs on Consul. Last week I&lt;br&gt;
went back to it after a long time away and found that a node which lost its Consul&lt;br&gt;
session would keep believing it was the leader. Not for a while. Forever.&lt;/p&gt;

&lt;p&gt;All 46 unit tests were green.&lt;/p&gt;
&lt;h2&gt;
  
  
  What the code did
&lt;/h2&gt;

&lt;p&gt;The election loop acquires a KV key with a session attached, over and over:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;acquired&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_consul&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;KV&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Acquire&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pair&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;acquired&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Response&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="n"&gt;_isLeader&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;_isLeader&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;RaiseLeadershipAcquiredEvent&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;acquired&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Response&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;_isLeader&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;_isLeader&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;RaiseLeadershipLostEvent&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read it and it seems right. Stop holding the lock, &lt;code&gt;Acquire&lt;/code&gt; comes back false, flag goes&lt;br&gt;
down, event fires.&lt;/p&gt;

&lt;p&gt;That is not what Consul does. If you try to acquire a key with a session that no longer&lt;br&gt;
exists, Consul does not tell you "no". It returns &lt;strong&gt;HTTP 500&lt;/strong&gt; with the body&lt;br&gt;
&lt;code&gt;invalid session "..."&lt;/code&gt;, and Consul.NET turns that into a thrown&lt;br&gt;
&lt;code&gt;ConsulRequestException&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So execution never reaches the &lt;code&gt;else if&lt;/code&gt;. It lands here:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Exception&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;_logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Error in leader election loop"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Delay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromSeconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Log, wait a second, retry with the same dead session. Round and round.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;_isLeader&lt;/code&gt; stays true. &lt;code&gt;OnLeadershipLost&lt;/code&gt; never fires. Meanwhile another node acquires&lt;br&gt;
the lock perfectly correctly and starts doing the work. Two nodes now think they lead,&lt;br&gt;
and the first one has no path back, because nothing in that loop ever creates a new&lt;br&gt;
session.&lt;/p&gt;

&lt;p&gt;I want to be clear that Consul behaved correctly at every step here. So did the other&lt;br&gt;
node. The only thing wrong was my code's model of what a failed acquisition looks like.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why the tests said nothing
&lt;/h2&gt;

&lt;p&gt;Every test in the suite mocked &lt;code&gt;IConsulClient&lt;/code&gt;. The one covering this exact path looked&lt;br&gt;
like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;_kvEndpointMock&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Setup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Acquire&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;It&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsAny&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;KVPair&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(),&lt;/span&gt; &lt;span class="n"&gt;It&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsAny&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;CancellationToken&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ThrowsAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"First attempt failed"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A bare &lt;code&gt;Exception&lt;/code&gt;. Then it asserted that something got logged, which it did.&lt;/p&gt;

&lt;p&gt;The mock was never going to catch this, because writing a mock that returns&lt;br&gt;
&lt;code&gt;ConsulRequestException(500, "invalid session")&lt;/code&gt; requires already knowing that is what&lt;br&gt;
Consul returns. If I had known, I would have written the branch correctly in the first&lt;br&gt;
place. The mock recorded my belief about the dependency and then confirmed it back to&lt;br&gt;
me.&lt;/p&gt;

&lt;p&gt;I found the bug by writing a test that starts a real Consul in a container, takes&lt;br&gt;
leadership, destroys the session from the outside, and asserts the node notices. It went&lt;br&gt;
red on the first run.&lt;/p&gt;

&lt;p&gt;That test now has friends. There are four more that stand up three Consul servers with&lt;br&gt;
real Raft and kill the leader, and those found things too.&lt;/p&gt;
&lt;h2&gt;
  
  
  The part that isn't a bug
&lt;/h2&gt;

&lt;p&gt;Fixing the exception handling took about ten lines. It also fixed nothing important,&lt;br&gt;
because the API was still this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;IsLeaderAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everyone uses it the same way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_leaderElection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsLeaderAsync&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;DoTheWork&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That boolean describes a moment that has already passed. Between the check and the work,&lt;br&gt;
this instance's session can expire. A GC pause longer than the session TTL will do it.&lt;br&gt;
Another node gets leadership. Both nodes run &lt;code&gt;DoTheWork()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Checking a second time inside the loop does not help. A process that is paused cannot&lt;br&gt;
check anything, which is the whole problem.&lt;/p&gt;

&lt;p&gt;There is no implementation of that signature that avoids this. The race is in the shape&lt;br&gt;
of the method.&lt;/p&gt;
&lt;h2&gt;
  
  
  Kleppmann was right and it applies to you too
&lt;/h2&gt;

&lt;p&gt;Martin Kleppmann wrote&lt;br&gt;
&lt;a href="https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html" rel="noopener noreferrer"&gt;How to do distributed locking&lt;/a&gt;&lt;br&gt;
in 2016. It was aimed at Redlock, but the argument has nothing to do with Redis.&lt;/p&gt;

&lt;p&gt;Summarised in my words, not his: a client holding a lock gets paused for longer than the&lt;br&gt;
lease. GC, page fault, scheduler, hypervisor, does not matter. The lock expires. Someone&lt;br&gt;
else takes it. The paused client wakes up and writes, still believing it holds a lock it&lt;br&gt;
lost thirty seconds ago.&lt;/p&gt;

&lt;p&gt;Read the original. The diagrams do more than any paragraph will.&lt;/p&gt;

&lt;p&gt;The uncomfortable conclusion is that the lock service is not the thing that can fix&lt;br&gt;
this. Consul did everything right in my bug. So did etcd and ZooKeeper in the equivalent&lt;br&gt;
version of it. The client is the problem, and the client is asleep.&lt;/p&gt;

&lt;p&gt;What actually works is making the &lt;strong&gt;resource&lt;/strong&gt; reject stale writers. For that it needs a&lt;br&gt;
number from the lock that only ever goes up. A fencing token.&lt;/p&gt;
&lt;h2&gt;
  
  
  What that looks like
&lt;/h2&gt;

&lt;p&gt;Consul hands you one without asking. The &lt;code&gt;ModifyIndex&lt;/code&gt; of the lock key comes from the&lt;br&gt;
Raft log index, and Raft indices only grow, so every new leader sees a strictly higher&lt;br&gt;
number than every leader before it.&lt;/p&gt;

&lt;p&gt;So the API stops being a boolean and becomes a lease you hold:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;lease&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_leases&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AcquireLeadershipAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stoppingToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;work&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;CancellationTokenSource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateLinkedTokenSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;lease&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LostToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stoppingToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;work&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsCancellationRequested&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CloseBatchAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lease&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FencingToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;work&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Delay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromSeconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;5&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;work&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And then the half that gets skipped, which is where the safety actually comes from:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;invoice_batches&lt;/span&gt;
   &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'closed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fencing_token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;
 &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
   &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;fencing_token&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ExecuteAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Sql&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fencingToken&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;FencedOutException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fencingToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;WHERE fencing_token &amp;lt;= @token&lt;/code&gt; is the entire idea. Without it you have an advisory lock&lt;br&gt;
and a race. With it, the sleepy node's write bounces off the database, because the&lt;br&gt;
database has already seen a bigger number from someone else.&lt;/p&gt;

&lt;p&gt;Notice what it does not do. It does not stop the stale node from trying. It makes the&lt;br&gt;
attempt land nowhere, which turns out to be enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two states
&lt;/h2&gt;

&lt;p&gt;The loop this leads to has two states and no third. Either you are holding a lease and&lt;br&gt;
working under a token that dies when the lease does, or you are not holding one and you&lt;br&gt;
wait.&lt;/p&gt;

&lt;p&gt;"Am I the leader?" is not a question you get to ask, because any answer is out of date&lt;br&gt;
by the time you act on it.&lt;/p&gt;

&lt;p&gt;That is a smaller library than the one I started with. &lt;code&gt;IsLeaderAsync&lt;/code&gt; is gone. So is the&lt;br&gt;
campaign loop, and so are the &lt;code&gt;OnLeadershipAcquired&lt;/code&gt; / &lt;code&gt;OnLeadershipLost&lt;/code&gt; events, which&lt;br&gt;
had the same problem wearing a nicer outfit: a handler that gets told it is now the&lt;br&gt;
leader has nothing to hand downstream.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I put in the README instead
&lt;/h2&gt;

&lt;p&gt;There is now a section in it called &lt;em&gt;What this does not guarantee&lt;/em&gt;, and it opens with&lt;br&gt;
this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two instances never run leader work concurrently. They can.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;During the pause window the old leader's cancellation token has not fired and its code&lt;br&gt;
is running. That is true of this library and every other one. What I can promise is&lt;br&gt;
narrower: the old leader's writes carry a lower fencing token than the new leader's, so&lt;br&gt;
a resource that checks will refuse them.&lt;/p&gt;

&lt;p&gt;Writing that down was the most useful thing in the whole release. A library that claims&lt;br&gt;
mutual exclusion is lying, and the lie is dangerous exactly because it is comforting. If&lt;br&gt;
I tell you where my guarantee stops, you get to decide what to put on the other side of&lt;br&gt;
the line.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the resource can't take a token
&lt;/h2&gt;

&lt;p&gt;Sometimes it can't. A third-party API with no conditional write. A filesystem. A shell&lt;br&gt;
command.&lt;/p&gt;

&lt;p&gt;Best option is to put something in front of it that can fence. A database row, a&lt;br&gt;
compare-and-set in your coordination store, a conditional write in object storage.&lt;/p&gt;

&lt;p&gt;Failing that, make the operation idempotent so running it twice is boring.&lt;/p&gt;

&lt;p&gt;Failing that, accept the risk on purpose, work out how long your pause window actually&lt;br&gt;
is, and write down somewhere that you accepted it. That last one is not a cop-out. It is&lt;br&gt;
the difference between a known risk and a surprise.&lt;/p&gt;

&lt;p&gt;There is no fourth option where the lock by itself makes it safe.&lt;/p&gt;




&lt;p&gt;The library is &lt;a href="https://www.nuget.org/packages/DLeader.Consul" rel="noopener noreferrer"&gt;DLeader.Consul&lt;/a&gt;. MIT,&lt;br&gt;
net8/9/10. It is small and I am the only person working on it, so apply the usual&lt;br&gt;
caution you would to any young dependency.&lt;/p&gt;

&lt;p&gt;Honestly though, the library is the least interesting part. The two things worth taking&lt;br&gt;
away work anywhere: mocks of an external system will agree with whatever you already&lt;br&gt;
believe about it, and a lock without a fencing token is a suggestion.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>distributedsystems</category>
    </item>
  </channel>
</rss>
