<?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: lepp-sd</title>
    <description>The latest articles on DEV Community by lepp-sd (@leppsd).</description>
    <link>https://dev.to/leppsd</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%2F4219645%2Fc7745de3-0c99-4559-9adf-6ebdb1f34d26.png</url>
      <title>DEV Community: lepp-sd</title>
      <link>https://dev.to/leppsd</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/leppsd"/>
    <language>en</language>
    <item>
      <title>When a node loses its lease anyway: a takeover, second by second</title>
      <dc:creator>lepp-sd</dc:creator>
      <pubDate>Wed, 04 Nov 2026 21:57:45 +0000</pubDate>
      <link>https://dev.to/leppsd/when-a-node-loses-its-lease-anyway-a-takeover-second-by-second-4jp8</link>
      <guid>https://dev.to/leppsd/when-a-node-loses-its-lease-anyway-a-takeover-second-by-second-4jp8</guid>
      <description>&lt;p&gt;I am building a cloud trade copier for MetaTrader. Each follower account has one broker session on one node, and that node copies the master's trades onto it. In &lt;a href="https://snapcopier.hashnode.dev/two-nodes-one-account-leasing-with-postgres-and-a-safety-margin" rel="noopener noreferrer"&gt;an earlier post&lt;/a&gt; I described how a node leases its accounts from Postgres: a &lt;code&gt;node_id&lt;/code&gt; and a &lt;code&gt;lease_until&lt;/code&gt; on the account row, &lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt; to claim, and a node that stops sending orders 15 seconds before its lease ends.&lt;/p&gt;

&lt;p&gt;That post was about keeping ownership. This one is about losing it: what happens, second by second, when a node is cut off from the database, another node takes its accounts, and both have to end up agreeing with what is actually open at the broker.&lt;/p&gt;

&lt;h2&gt;
  
  
  The defaults
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;default&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;lease&lt;/td&gt;
&lt;td&gt;60 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;renewal cycle&lt;/td&gt;
&lt;td&gt;every 20 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;fence (stop sending before the lease ends)&lt;/td&gt;
&lt;td&gt;15 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;retry after a failed cycle&lt;/td&gt;
&lt;td&gt;2 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These are configuration values, not measurements.&lt;/p&gt;

&lt;h2&gt;
  
  
  The timeline
&lt;/h2&gt;

&lt;p&gt;Say node A last renewed its leases at t = 0. Then its connection to the database breaks. The broker sessions and the message bus are still fine, which is the uncomfortable part: A can still trade, it just can no longer prove that it should.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;t = 0.&lt;/strong&gt; The renewal query wrote &lt;code&gt;lease_until = now() + 60 s&lt;/code&gt;, so in the database the lease runs until at least t = 60. A recorded its own deadline as &lt;code&gt;started + 60 s - 15 s&lt;/code&gt;, where &lt;code&gt;started&lt;/code&gt; was read &lt;em&gt;before&lt;/em&gt; the query.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;t = 20.&lt;/strong&gt; The next cycle fails. Nothing changes yet. A logs the error and tries again every 2 seconds. It keeps its broker sessions and keeps copying, because its lease is still confirmed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;t = 45.&lt;/strong&gt; &lt;code&gt;HoldsLease&lt;/code&gt; turns false. From this moment every unit of work for every follower on A is dropped at the gate, right before it would execute:&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="n"&gt;_engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HoldsLease&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Cut off from the database long enough that another node may own this slave now:&lt;/span&gt;
    &lt;span class="c1"&gt;// sending orders could duplicate its copies. The reconcile after the next lease&lt;/span&gt;
    &lt;span class="c1"&gt;// renewal (or on the new owner) catches up.&lt;/span&gt;
    &lt;span class="n"&gt;_log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogWarning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"[slave {Id}] Lease not confirmed, {Work} not executed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_slaveId&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="nf"&gt;GetType&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&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;The same check guards the manual path: an internal "close this position" request answers &lt;code&gt;503&lt;/code&gt; while the lease is not confirmed. The node's uptime record marks those minutes as degraded, with the reason "no account lease".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;t = 60.&lt;/strong&gt; &lt;code&gt;lease_until&lt;/code&gt; passes by the database's clock. From now on, any node's claim query may take A's accounts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;t = 60 to 80.&lt;/strong&gt; Node B's next cycle claims them. Its cycle runs every 20 seconds, so the claim lands somewhere in that window. B opens a broker session for each account, and when a follower's session is connected, B queues a reconcile for each of its links.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Later, when A reaches the database again&lt;/strong&gt;, one of two things happens:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;B has taken the account.&lt;/strong&gt; A's cycle returns the accounts A still owns, and this one is not among them, so A logs "Account no longer owned by this node, disconnecting" and closes its session.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nobody has taken it&lt;/strong&gt; (the whole database was down, so nobody could claim). A's renewal goes through (&lt;code&gt;WHERE node_id = &amp;lt;A&amp;gt;&lt;/code&gt;, whatever the expiry). Because A held back orders while it was unsure, its first cycle with a confirmed lease runs a reconcile of every link, with the reason &lt;code&gt;lease_restored&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Between t = 45 and the new owner's reconcile, nobody trades that follower. That gap is the price. A duplicated order on a real account is worse than a late one, so the design pays it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dropped work is not replayed
&lt;/h2&gt;

&lt;p&gt;During the gap the master keeps trading. A receives those events and drops them. B was not listening yet. Neither of them keeps a queue of "things to do once I am sure". Replaying would mean trusting a history that is, by definition, incomplete.&lt;/p&gt;

&lt;p&gt;Instead, the reconcile compares two &lt;strong&gt;states&lt;/strong&gt;: what is open on the master now, and what is open on the follower now. The event history does not matter. It is a pure function, which made it the easiest part of the system to test:&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;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;IReadOnlyList&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;CopyAction&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;Plan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ReconcileInput&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ISlaveContext&lt;/span&gt; &lt;span class="n"&gt;slave&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The input holds the master's positions and pending orders, the follower's positions and pending orders, the link's settings, and the pair rows the database knows about (master ticket ↔ follower ticket). The output is a list of actions. Nothing runs inside it.&lt;/p&gt;

&lt;p&gt;For each pair it knows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The follower's copy is gone&lt;/strong&gt; (closed by its own stop, by hand, by a stop-out): mark the pair closed, do not open a new one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The master's position is gone:&lt;/strong&gt; close the copy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The master was partially closed&lt;/strong&gt; while nobody listened: close the same share of the copy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stop loss or take profit differ:&lt;/strong&gt; modify the copy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For each master position without a pair:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Look for our own copy at the broker first&lt;/strong&gt; (see below). If there is one, adopt it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Otherwise open it late, but only if the price is still near&lt;/strong&gt; the master's open price. "Near" is the link's own slippage limit. If the price has moved further, it records a skip (&lt;code&gt;late_open_price_moved&lt;/code&gt;), and the trade is not copied. A copy at a much worse price is not the trade the master took.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A master ticket that already completed on this link is never opened again.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The broker decides what was already done
&lt;/h2&gt;

&lt;p&gt;The hard case is a trade that was copied &lt;em&gt;just before&lt;/em&gt; the gap.&lt;/p&gt;

&lt;p&gt;On the copy path, the order goes to the broker first. The database write follows in a batch, about every 100 ms. If A opened a copy at t = 44 and its database connection was already gone, the pair row never arrived. B's reconcile then sees an open master position with no pair. Without anything else, B would open it a second time.&lt;/p&gt;

&lt;p&gt;What prevents that is a tag on the order itself. Every copy carries a per-link magic number and a comment with the master's ticket, &lt;code&gt;mc&amp;lt;masterTicket&amp;gt;&lt;/code&gt;. Before planning a late open, the reconcile looks for it among the follower's positions:&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;private&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;PositionLink&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;FindTagged&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CopyLink&lt;/span&gt; &lt;span class="n"&gt;link&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;masterTicket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;IEnumerable&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Position&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;positions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;IEnumerable&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;PendingOrder&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;pendings&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;HashSet&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;long&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;claimed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;tag&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;CopyLink&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SlaveComment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;masterTicket&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;positions&lt;/span&gt;&lt;span class="p"&gt;)&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;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Magic&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;link&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SlaveMagic&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;claimed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Ticket&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;TagMatches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Comment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;PositionLink&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="n"&gt;SlaveTicket&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Ticket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;MapStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Open&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// the same for pending orders&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A tagged position becomes the pair (&lt;code&gt;AdoptAction&lt;/code&gt;) instead of a reason to open. The same lookup resolves a pair row that was written as "sending" but never learned its follower ticket: found by tag, it is adopted, and if it is not found, the send never reached the broker.&lt;/p&gt;

&lt;p&gt;So the database is not the record of what the copier did. It is a fast cache of it that may lag behind. The record is the order at the broker, and it carries enough to be matched back. The tests that pin this down are small and named after the case:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Tagged_slave_order_without_a_row_is_adopted_not_reopened
Sending_row_without_ticket_is_resolved_by_tag
Untracked_tagged_orphan_is_closed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  An order already on its way
&lt;/h2&gt;

&lt;p&gt;The gate runs before a unit of work starts, not while it waits for the broker. An order that passed the gate at t = 44.9 is still sent, and its answer comes back after t = 45. This is what the 15 seconds are for: the order has to be out before the lease can move at t = 60. An ordinary pause fits into that. A process frozen for longer does not, and the earlier post says so.&lt;/p&gt;

&lt;h2&gt;
  
  
  A bug I found while writing this
&lt;/h2&gt;

&lt;p&gt;Going through the timeline for this post, I followed A's buffered writes to the end. When A reaches the database again, it flushes everything it kept, including the pair row of the copy it opened at t = 44. By then B has adopted that copy and written its own live pair for the same master ticket. A partial unique index allows only one live pair per link and master ticket, so A's row is refused. The rule is right, but the writer that hit it was not ready for it. A refused copy event was written row by row and only the bad row is dropped (I learned that lesson once already). A refused pair row was retried with the whole batch, forever, and it held back the node's other writes with it.&lt;/p&gt;

&lt;p&gt;It needs a rare combination: at least two nodes per platform, and one of them cut off from the database for a minute while it keeps copying. A clean shutdown writes everything before it lets go of the accounts. A crash loses the buffered rows, and the new owner adopts the copy from its tag. The bug never happened in production. It was still a stalled writer waiting to happen, and I had already learned that this kind costs a whole node its writes.&lt;/p&gt;

&lt;p&gt;The fix has two parts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Live pair rows are fenced by the owner too&lt;/strong&gt;, like the account writes. A row that says a copy is open only goes in while this node owns the follower. A row that ends a pair always goes in, because a close is true whoever writes it.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Sending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Open'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
   &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;copy_links&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;trading_accounts&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;slave_account_id&lt;/span&gt;
              &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;link_id&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;node_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A refused pair batch is written row by row&lt;/strong&gt;, the same way as the copy events. Only the refused row is dropped, with what it was in the log.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I tested it against a throwaway Postgres with the real writer: B writes its adopted pair, A's stale live row is left out, A's own rows go in, a forced refusal is dropped, and both writers are empty and healthy afterwards.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not cover
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The gap is real.&lt;/strong&gt; For the seconds between the fence and the new owner's reconcile, nothing is copied to that follower. A trade the master opens in that window is copied late at the current price, or skipped if the price has moved past the link's limit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A reconcile repairs state, not timing.&lt;/strong&gt; It gets the follower back in line with the master. It cannot make the copy as early as it would have been.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;There is still no test with two real nodes racing for the same rows.&lt;/strong&gt; The reconcile is covered by unit tests because it is pure. The handover itself has run in production, not in a test.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The tag lives in a broker-side comment.&lt;/strong&gt; If a broker rewrote or truncated order comments, the lookup would not find the copy. The reconcile also closes an order with our magic number that no pair and no tag explains, so that case leans towards fewer positions, not more.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Plan the failover the same way as the normal path. Write it down second by second, with the defaults filled in.&lt;/li&gt;
&lt;li&gt;A node that is not sure it owns something should stop acting and keep nothing to replay. Repair from state, not from a history it might have missed.&lt;/li&gt;
&lt;li&gt;Make the reconcile a pure function: state in, actions out. It is the part that runs in every bad case, so it should be the easiest part to test.&lt;/li&gt;
&lt;li&gt;If the write comes after the action, the action has to carry its own identity. Then the external system, not your database, tells you what you already did.&lt;/li&gt;
&lt;li&gt;Follow every buffered write of a node that lost ownership to the end. Mine found a bug, and the fix was the same fence again.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Everything here comes from SnapCopier, the cloud trade copier for MT4 and MT5 that I am building. From the trader's side, all of the above shrinks to two promises: an account is traded by one node at a time, and a follower that missed something is compared with its master and repaired. I wrote that side up in &lt;a href="https://snapcopier.com/guides/trade-copier-without-vps" rel="noopener noreferrer"&gt;how a cloud trade copier works without a VPS&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>distributedsystems</category>
      <category>dotnet</category>
      <category>backend</category>
    </item>
    <item>
      <title>810 blocking calls at once: what the .NET thread pool did to the first burst</title>
      <dc:creator>lepp-sd</dc:creator>
      <pubDate>Sat, 31 Oct 2026 20:48:04 +0000</pubDate>
      <link>https://dev.to/leppsd/810-blocking-calls-at-once-what-the-net-thread-pool-did-to-the-first-burst-551o</link>
      <guid>https://dev.to/leppsd/810-blocking-calls-at-once-what-the-net-thread-pool-did-to-the-first-burst-551o</guid>
      <description>&lt;p&gt;I am building a cloud trade copier for MetaTrader. When a trader closes a position on the master account, the same close has to go out on every follower account, as fast as possible. For MT4 the vendor library is a .NET DLL with a &lt;strong&gt;synchronous&lt;/strong&gt; API: &lt;code&gt;OrderClose&lt;/code&gt; blocks its thread until the broker answers. In my setup that answer takes about 130 ms.&lt;/p&gt;

&lt;p&gt;That is fine for one close. It stops being fine when 27 positions close in the same second and each of them has 30 followers.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I measured
&lt;/h2&gt;

&lt;p&gt;I measure "own time": the time from the master's callback to the moment the follower's order call is made. It excludes the broker's answer, so it is the part my code is responsible for. In a quiet minute it is 1-3 ms.&lt;/p&gt;

&lt;p&gt;During a burst of 27 simultaneous closes (810 copies, MT4 follower accounts, a real broker with demo accounts) it looked like this, grouped by how many threads the pool had when each copy started:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;thread pool threads at start&lt;/th&gt;
&lt;th&gt;own time, median&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;33-36&lt;/td&gt;
&lt;td&gt;5.5-14 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;40-69&lt;/td&gt;
&lt;td&gt;19 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;73-102&lt;/td&gt;
&lt;td&gt;45 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;134-163&lt;/td&gt;
&lt;td&gt;71 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;344-486&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;125-190 ms&lt;/strong&gt; (max 244 ms)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The pool had grown from 33 to 486 threads during those twelve seconds, and the delay grew with it. A staircase like that has a cause, and it is not one of the usual suspects.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it was not
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GC.&lt;/strong&gt; The longest pause was 6-34 ms, none above 200 ms, and the staircase was too regular to be GC.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Channel wake-ups and fan-out.&lt;/strong&gt; A microbenchmark writing one event to 30 channels showed the last reader waking about 20 µs later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Eager listing of open positions&lt;/strong&gt; before the send. It looked suspicious, but warm it costs 2 µs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One note on method, because I got it wrong three times: drop the first five minutes after a restart (JIT, cold caches, a small pool), and never write log lines on the path you are measuring. A log line before the send added 2-3 ms. My measuring code only stored timestamps and a background thread printed them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it was
&lt;/h2&gt;

&lt;p&gt;Every in-flight order call holds a pool thread for the broker's whole answer. During a burst, all of them are blocked at once, and the next piece of work (the next follower's dispatcher continuation) needs a &lt;em&gt;new&lt;/em&gt; thread. The pool creates threads gradually, and creating a thread costs about 0.2-0.5 ms. With hundreds of blocked threads and hundreds of new ones to create, those fractions of a millisecond add up to the staircase.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ThreadPool.SetMinThreads&lt;/code&gt; did not help the way I expected, for a dull reason: it silently rejects the &lt;strong&gt;whole call&lt;/strong&gt; if either value is above the maximum. The I/O thread maximum was 1000 and I had asked for 1024, so the minimum had never taken effect and stayed at the core count (4 on my server). A warning on rejection and a log line with the effective minimum at startup fixed that. It removes the "two new threads per second" throttle, but not the cost of creating the thread.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: threads that never retire
&lt;/h2&gt;

&lt;p&gt;The calls that block for the broker's answer now run on their own threads, created once, kept forever, and reused:&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;public&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;Func&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;&amp;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;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;Job&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;&amp;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;ct&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Interlocked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Decrement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ref&lt;/span&gt; &lt;span class="n"&gt;_inFlight&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;wanted&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Interlocked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Increment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ref&lt;/span&gt; &lt;span class="n"&gt;_inFlight&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;_queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// One thread per outstanding call, up to the cap: never fewer threads than calls to run at once.&lt;/span&gt;
    &lt;span class="nf"&gt;EnsureThreads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;wanted&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Task&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;Each worker is a plain loop over a &lt;code&gt;BlockingCollection&lt;/code&gt;. A few details turned out to matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The task's continuation must &lt;strong&gt;not&lt;/strong&gt; run on these threads (&lt;code&gt;TaskCreationOptions.RunContinuationsAsynchronously&lt;/code&gt;), or an awaiting caller can hold one.&lt;/li&gt;
&lt;li&gt;The in-flight counter is decremented &lt;em&gt;before&lt;/em&gt; the task completes. Whoever continues from the task and starts the next burst then sees the true number of running calls, and no thread is created for a call that has already finished.&lt;/li&gt;
&lt;li&gt;A call still waiting for a thread is cancelled at once, like &lt;code&gt;Task.Run&lt;/code&gt;, not when a thread finally gets to it.&lt;/li&gt;
&lt;li&gt;Past the cap, calls wait in a FIFO queue.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  My first fix was not enough
&lt;/h2&gt;

&lt;p&gt;I started the executor with 64 ready threads and let it grow on demand. A burst of 12 closes (360 MT4 copies) then made it create about 296 threads right in the copy path, and the own time barely improved:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;closes&lt;/th&gt;
&lt;th&gt;own time p50 / p95 / max&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;before the fix, warm pool&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;2.8 / 20.7 / 25.2 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;executor, 64 ready threads&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;25.9 / 55.3 / 61.1 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;executor, 512 ready threads&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.2 / 5.0 / 7.9 ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The cost was never the pool's. It was thread creation, and it is paid wherever it happens in the burst. The fix was to create the threads &lt;strong&gt;before&lt;/strong&gt; the burst: the worst case is &lt;code&gt;followers × per-follower parallelism&lt;/code&gt; (30 × 16 = 480 here), so that is how many threads the executor starts with. With 512 ready threads the burst created none, and the own time stayed at about 1 ms regardless of the burst size.&lt;/p&gt;

&lt;p&gt;The three waves are different bursts at different times, not a controlled A/B, but they have the same size (12 closes), and the thread counts show the mechanism: 296 created in the second, 0 in the third.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a ready thread costs
&lt;/h2&gt;

&lt;p&gt;Sleeping threads are cheap. On Linux with .NET 10, 1024 idle threads cost about 35 MiB of resident memory (35 KiB each) and 186 ms to create at startup. The whole node stayed at 318-380 MiB resident with 452 threads, the same as before the change.&lt;/p&gt;

&lt;h2&gt;
  
  
  A side effect I did not prove
&lt;/h2&gt;

&lt;p&gt;During the 64-thread run, my &lt;em&gt;MT5&lt;/em&gt; node, which has no blocking calls and was unchanged, also slowed down: p95 went from about 2 ms to 28.6 ms. The two nodes share a machine, and the MT4 node was creating 300-450 threads. With 512 ready threads the MT5 numbers went back to normal. The correlation is measured; the cause is not proven.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;A synchronous call on the thread pool costs a thread for as long as it blocks. In a burst, that is a lot of threads, created exactly when you can least afford it.&lt;/li&gt;
&lt;li&gt;Warming up the pool is not enough, and neither is &lt;code&gt;SetMinThreads&lt;/code&gt;: creating a thread costs time wherever it happens. Create the threads before the burst.&lt;/li&gt;
&lt;li&gt;Size them from the worst case you can compute, not from the average. Mine is one multiplication.&lt;/li&gt;
&lt;li&gt;Check that &lt;code&gt;SetMinThreads&lt;/code&gt; actually returned &lt;code&gt;true&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you are curious about the project: I build &lt;a href="https://snapcopier.com" rel="noopener noreferrer"&gt;SnapCopier&lt;/a&gt;, a cloud trade copier for MT4 and MT5. The numbers above are from its own node logs.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>performance</category>
      <category>backenddevelopment</category>
    </item>
  </channel>
</rss>
