<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[SnapCopier Engineering]]></title><description><![CDATA[Engineering notes on building a cloud trade copier for MetaTrader 4 and 5: measurements, design decisions and how MT4 and MT5 really differ.]]></description><link>https://snapcopier.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6ae65cf851f46ecf02fc0051/00d73ec1-f307-4751-84f5-3925a5d485a2.png</url><title>SnapCopier Engineering</title><link>https://snapcopier.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Thu, 05 Nov 2026 19:23:21 GMT</lastBuildDate><atom:link href="https://snapcopier.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Two nodes, one account: leasing with Postgres and a safety margin]]></title><description><![CDATA[How a trade copier makes sure only one node ever trades an account: Postgres SKIP LOCKED leases and a 15-second safety margin.]]></description><link>https://snapcopier.hashnode.dev/two-nodes-one-account-leasing-with-postgres-and-a-safety-margin</link><guid isPermaLink="true">https://snapcopier.hashnode.dev/two-nodes-one-account-leasing-with-postgres-and-a-safety-margin</guid><category><![CDATA[postgres]]></category><category><![CDATA[distributed systems]]></category><category><![CDATA[dotnet]]></category><category><![CDATA[backend]]></category><dc:creator><![CDATA[lepp-sd]]></dc:creator><pubDate>Sat, 31 Oct 2026 21:52:04 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae65cf851f46ecf02fc0051/b543aa72-d0b0-46e7-bf86-1fff9639485e.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I am building a cloud trade copier for MetaTrader. Every follower account has a live broker session on some node, and that node copies the master's trades onto it. The rule that must never break: <strong>an account is traded by one node at a time.</strong> If two nodes both think they own the same follower, each opens its own copy of every trade. That is real money, doubled.</p>
<p>I also wanted three things: nodes that scale out without per-node configuration, a crashed node's accounts picked up by the others, and no extra service to run just to decide who owns what. The answer turned out to be one table, one SQL statement and a margin of fifteen seconds.</p>
<h2>Ownership is two columns</h2>
<p>The account row is the thing being owned, so ownership lives on the row:</p>
<pre><code class="language-sql">trading_accounts (
    id          bigint primary key,
    node_id     text,          -- who owns it now
    lease_until timestamptz,   -- until when, by the database's clock
    ...
)
</code></pre>
<p>A node owns an account while <code>node_id</code> is its own id <strong>and</strong> <code>lease_until</code> is in the future. Nobody has to keep a heartbeat table, and an owner that disappears simply stops renewing.</p>
<h2>One lease cycle is one transaction</h2>
<p>Every 20 seconds each node runs one cycle. In one transaction it releases accounts that were switched off, renews its own leases, claims free ones while it is below capacity, and reads back everything it now owns:</p>
<pre><code class="language-sql">-- renew what I hold
UPDATE trading_accounts SET lease_until = now() + make_interval(secs =&gt; $2)
WHERE node_id = $1 AND platform = $3 AND enabled;

-- claim free or expired accounts, up to my free capacity
UPDATE trading_accounts SET node_id = $1, lease_until = now() + make_interval(secs =&gt; $2)
WHERE id IN (
    SELECT id FROM trading_accounts
    WHERE platform = $3 AND enabled
      AND (node_id IS NULL OR lease_until IS NULL OR lease_until &lt; now())
    ORDER BY id
    LIMIT $4
    FOR UPDATE SKIP LOCKED);
</code></pre>
<p><code>FOR UPDATE SKIP LOCKED</code> is what lets many nodes claim at once. Two nodes that run the claim in the same moment do not wait for each other and do not block; each locks its own rows and skips the ones the other has locked. No account can be handed to two nodes, and no node needs to know the others exist. A new node just starts, claims up to its capacity and is part of the cluster.</p>
<p>The node id defaults to <code>&lt;platform&gt;-&lt;hostname&gt;</code>, so replicas need no instance configuration at all.</p>
<h2>The lease alone is not enough</h2>
<p>The tempting reading is: "I renewed, so I own it until <code>lease_until</code>." That is not safe. The node can be cut off from the database, for example by a network problem, and then it does not know whether another node has already taken its accounts. If it keeps sending orders until the lease runs out by its own count, it can overlap with the new owner.</p>
<p>So the node stops <em>earlier</em> than the lease ends. The numbers are configuration, not something I measured:</p>
<table>
<thead>
<tr>
<th></th>
<th>default</th>
</tr>
</thead>
<tbody><tr>
<td>lease</td>
<td>60 s</td>
</tr>
<tr>
<td>renew every</td>
<td>20 s</td>
</tr>
<tr>
<td>fence</td>
<td>15 s</td>
</tr>
</tbody></table>
<pre><code class="language-csharp">// AccountManager.SyncAsync
var started = DateTime.UtcNow;
var result = await _db.ClaimAccountsAsync(...);
_leaseValidUntil = started + _options.Lease - _options.LeaseFence;

// AccountManager
public bool HoldsLease =&gt; DateTime.UtcNow &lt; _leaseValidUntil;
</code></pre>
<p>The node trusts its ownership for 45 seconds after the last successful renewal, not 60. Two details make that work:</p>
<ul>
<li><strong><code>started</code> is read before the query.</strong> The database writes <code>lease_until = now() + 60 s</code> at some moment after that, so the lease in the database ends at least 60 s after <code>started</code>. The node's own deadline is <code>started + 45 s</code>. The gap is at least 15 s however the query was delayed.</li>
<li><strong>Only elapsed time on the node's own clock matters.</strong> The node never compares its clock with the database's or another node's. It measures "how long since I last got a confirmed renewal". Clock offsets between machines do not enter into it.</li>
</ul>
<p>Another node can claim the account only after <code>lease_until &lt; now()</code>, which is at least 60 s after the last renewal. The old owner has stopped sending 15 s before that.</p>
<h2>The gate on every order</h2>
<p>Every unit of work for a follower checks the lease right before it executes:</p>
<pre><code class="language-csharp">if (!_engine.HoldsLease)
{
    // Cut off from the database long enough that another node may own this slave now:
    // sending orders could duplicate its copies. The reconcile after the next lease
    // renewal (or on the new owner) catches up.
    _log.LogWarning("[slave {Id}] Lease not confirmed, {Work} not executed", ...);
    return;
}
</code></pre>
<p>Dropping the work looks dangerous, but the copier is designed so that a missed event is repaired rather than ignored. Each event carries an epoch and sequence number, and a gap or an unknown epoch triggers a reconcile that compares the master's positions with the follower's. A node that lost its lease and got it back runs one. A node that took over a follower runs one too.</p>
<h2>Writes are fenced by the owner as well</h2>
<p>Orders are not the only thing a node writes. It also reports account status, balances and snapshots, and those batched account writes carry the owner in their <code>WHERE</code>:</p>
<pre><code class="language-sql">UPDATE trading_accounts t SET status = s.status, ...
FROM (...) s
WHERE t.id = s.id AND t.node_id = $1   -- simplified
</code></pre>
<p>A node that has lost an account cannot overwrite what the new owner just wrote, even if its write is still in a queue.</p>
<h2>Letting go</h2>
<ul>
<li><strong>Clean shutdown:</strong> the node sets <code>node_id = NULL</code> on everything it owns, so others take over at once.</li>
<li><strong>Crash:</strong> nothing is released, and the accounts move after the lease expires, so within 60 seconds of the last renewal.</li>
<li><strong>Account switched off:</strong> released in the same cycle's transaction.</li>
<li><strong>An account the node no longer owns:</strong> the next cycle sees that it is not in the result and disconnects the broker session.</li>
</ul>
<p>That 60 seconds is the failover time. A shorter lease means faster failover, but the fence shrinks with it, so a short stall is more likely to eat the margin. For a copier a minute is acceptable, because a gap is repaired by the reconcile and a duplicate is not.</p>
<h2>What this does not guarantee</h2>
<p>I would rather say this than have a reader find it.</p>
<ul>
<li><strong>It is a lease with a margin, not a fencing token.</strong> A real fencing token is checked by the resource, and a broker's trade server will not check mine. If a process freezes for more than the 15 s margin <em>between</em> the lease check and the send (a stalled VM, for example), it could still send once. The margin covers ordinary pauses, not a frozen machine.</li>
<li><strong>A backstop exists, but I would not call it proof.</strong> Copy orders carry a per-link magic number and a comment with the master's ticket (<code>mc&lt;masterTicket&gt;</code>). A reconcile on the new owner matches what is already open against the master's positions through that marker, so a takeover does not blindly open everything again.</li>
<li><strong>I use the wall clock.</strong> <code>DateTime.UtcNow</code> can jump if someone steps the system time. A monotonic clock (<code>Stopwatch</code>) is the more careful choice for "how long since the last renewal", and it is on my list.</li>
<li><strong>There is no automated test with two real nodes racing for the same rows.</strong> The SQL has run in production, which is not the same thing.</li>
</ul>
<h2>Takeaways</h2>
<ol>
<li>If the thing to be owned is already a row in your database, put the ownership on the row. Two columns and one statement replaced a coordination service.</li>
<li><code>SKIP LOCKED</code> gives you contention-free claiming. It does nothing about a node that is <em>slow</em> or <em>cut off</em>.</li>
<li>Make the owner stop earlier than the lease ends, and measure that time from <strong>before</strong> the renewal query.</li>
<li>Fence the writes as well as the actions. A node that lost an account should not be able to change it.</li>
<li>Assume that someone will drop work. Build the thing that repairs the gap first.</li>
</ol>
<p>I build <a href="https://snapcopier.com">SnapCopier</a>, a cloud trade copier for MT4 and MT5. The code above is from its node.</p>
]]></content:encoded></item></channel></rss>