<?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: Ismail Iusifli</title>
    <description>The latest articles on DEV Community by Ismail Iusifli (@alt-f6).</description>
    <link>https://dev.to/alt-f6</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%2F4202480%2F1dd9ca3f-0d47-400b-a86f-a0b20e358525.jpg</url>
      <title>DEV Community: Ismail Iusifli</title>
      <link>https://dev.to/alt-f6</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/alt-f6"/>
    <language>en</language>
    <item>
      <title>Postgres race conditions live in a window of a few milliseconds</title>
      <dc:creator>Ismail Iusifli</dc:creator>
      <pubDate>Tue, 10 Nov 2026 08:48:26 +0000</pubDate>
      <link>https://dev.to/alt-f6/postgres-race-conditions-live-in-a-window-of-a-few-milliseconds-5dbm</link>
      <guid>https://dev.to/alt-f6/postgres-race-conditions-live-in-a-window-of-a-few-milliseconds-5dbm</guid>
      <description>&lt;p&gt;I wrote a lost-update test, ran it, and found something I had not expected. The race was not hiding. It had a narrow window, and my tests either landed inside it or missed it completely depending on a few milliseconds of timing.&lt;/p&gt;

&lt;p&gt;Here is the experiment. Two transactions read the same balance, subtract 10, and write the result back. No locks, so the correct final balance is 80 and a lost update leaves 90. The second transaction starts a fixed number of milliseconds after the first. I ran each setting 100 times against a local PostgreSQL 16 with Node 22, on two machines: a Windows 11 laptop and a Linux box.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Start offset of tx 2&lt;/th&gt;
&lt;th&gt;Windows: plain run&lt;/th&gt;
&lt;th&gt;Windows: fuzzer&lt;/th&gt;
&lt;th&gt;Linux: plain run&lt;/th&gt;
&lt;th&gt;Linux: fuzzer&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0 ms&lt;/td&gt;
&lt;td&gt;100 / 100&lt;/td&gt;
&lt;td&gt;96–98 / 100&lt;/td&gt;
&lt;td&gt;95 / 100&lt;/td&gt;
&lt;td&gt;88 / 100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 ms&lt;/td&gt;
&lt;td&gt;96 / 100&lt;/td&gt;
&lt;td&gt;92–96 / 100&lt;/td&gt;
&lt;td&gt;81 / 100&lt;/td&gt;
&lt;td&gt;88 / 100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2 ms&lt;/td&gt;
&lt;td&gt;72 / 100&lt;/td&gt;
&lt;td&gt;92–95 / 100&lt;/td&gt;
&lt;td&gt;21 / 100&lt;/td&gt;
&lt;td&gt;87–89 / 100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5 / 100&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;93–94 / 100&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0 / 100&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;85–86 / 100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;15 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1 / 100&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;91 / 100&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0 / 100&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;78–90 / 100&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Lost updates out of 100 runs. The fuzzer columns show the range over two seeds (42 and 7) with a maximum injected delay of 30 ms.&lt;/p&gt;

&lt;p&gt;If both transactions start together, as they do in a &lt;code&gt;Promise.all&lt;/code&gt; test, the plain run finds the bug nearly every time on both machines. A few milliseconds later the picture changes. At a 5 ms offset the plain run found it 5 times out of 100 on one machine and never on the other, and at 15 ms it found it once or never. The width of the window depends on the machine: at a 2 ms offset the plain run found the bug 72 times on the Windows laptop and 21 times on the Linux box. On the Linux box a query through the proxy takes about 0.2 ms, so a four-statement transaction is over in roughly a millisecond.&lt;/p&gt;

&lt;p&gt;Production code does not obviously start its transactions together. A connection pool hands out a client, a request handler does some work, a function awaits something before &lt;code&gt;BEGIN&lt;/code&gt;. Gaps of a few milliseconds are plausible, though I have not measured them in a real service, and they put the race outside the window your test can see. The fuzzer columns are the tool I will describe below. It adds random delays before queries, so the two transactions overlap even when they start far apart. At 0 ms it does slightly worse than the plain run, because its random delays sometimes pull two transactions apart. It is built for the offsets where the plain run is blind.&lt;/p&gt;

&lt;p&gt;This is a toy scenario, one row and no locking, so almost any widening of the window finds the bug. The point is not that the number is impressive. The point is that a green test does not tell you the race is absent, only that the test did not reach it.&lt;/p&gt;

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

&lt;p&gt;pg-interleave is a proxy that speaks the PostgreSQL wire protocol (v3.0). It sits between your code and Postgres, and a test controls it over a small HTTP API. The server is written in Rust on tokio and the client is a TypeScript SDK. It is alpha software, MIT licensed, currently v0.7.1.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;test code ──HTTP :9090──▶ control plane ─┐
                                          ▼
pg / any client ──:5433──▶ proxy ──────▶ PostgreSQL :5432
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because it understands the protocol, it can hold one specific statement while other connections keep running. Nothing in the application changes: you point the database URL at port 5433 instead of 5432.&lt;/p&gt;

&lt;p&gt;Here is the same lost update, but deterministic. Client A reads 100 and plans to write 80. We freeze A's &lt;code&gt;UPDATE&lt;/code&gt; inside the proxy, let B run a full transaction, then release A.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;Client&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="s2"&gt;"pg"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;PgInterleaveClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="s2"&gt;"pg-interleave"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"postgres://postgres:postgres@127.0.0.1:5433/postgres"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;chaos&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PgInterleaveClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"http://127.0.0.1:9090"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;clientA&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;connectionString&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;url&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="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;clientB&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;connectionString&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;url&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="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;clientA&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&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="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;clientB&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&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;// Hold A's UPDATE in the proxy. Postgres has not seen it yet.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;barrier&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;chaos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;intercept&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;clientA&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;onQueryMatch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="no"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="no"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; \&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"pause_before_query"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;times&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&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="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;clientA&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"BEGIN"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;readA&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;clientA&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"SELECT balance FROM accounts WHERE id = 1"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;balanceA&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;readA&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 80&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;txA&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;clientA&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"UPDATE accounts SET balance = $1 WHERE id = 1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;balanceA&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="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;clientA&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"COMMIT"&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="p"&gt;)&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="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;barrier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wait&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;// A's UPDATE is now frozen in the proxy&lt;/span&gt;

&lt;span class="c1"&gt;// B runs a complete transaction while A is frozen&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;clientB&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"BEGIN"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;readB&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;clientB&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"SELECT balance FROM accounts WHERE id = 1"&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;clientB&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"UPDATE accounts SET balance = $1 WHERE id = 1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;readB&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;30&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;// 70&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;clientB&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"COMMIT"&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;barrier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resume&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;// A's stale write lands on top of B's commit&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;txA&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;clientB&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"SELECT balance FROM accounts WHERE id = 1"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nb"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;res&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;balance&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 80, but the correct total is 50&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;sleep&lt;/code&gt;, no timing luck: the order is the same on every run. The repository also has the &lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt; version of this scenario (&lt;code&gt;packages/sdk-ts/src/lost-update.test.ts&lt;/code&gt;), where B waits on A's row lock and the result is correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  Torn commits and retry loops
&lt;/h2&gt;

&lt;p&gt;Reordering statements is half of it. The other half is what happens when a connection dies at the worst moment: after Postgres has committed, before the client hears about it.&lt;/p&gt;

&lt;p&gt;The proxy can do exactly that. With &lt;code&gt;drop_connection_on_commit&lt;/code&gt; it lets the commit finish on the server and then closes the client's socket. The client sees a network error for a transaction that actually succeeded.&lt;/p&gt;

&lt;p&gt;Here is a function that inserts a $50 order, wrapped in a generic retry helper:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pRetry&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="s2"&gt;"p-retry"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;Client&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="s2"&gt;"pg"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;PgInterleaveClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="s2"&gt;"pg-interleave"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"postgres://postgres:postgres@127.0.0.1:5433/postgres"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;chaos&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PgInterleaveClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"http://127.0.0.1:9090"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;attempts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;createOrder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;attempts&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;connectionString&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;url&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="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"error"&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="o"&gt;=&amp;gt;&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="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// the dropped socket emits an 'error' event&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&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="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attempts&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&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;chaos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;intercept&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="n"&gt;onQueryMatch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="no"&gt;COMMIT&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"drop_connection_on_commit"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="n"&gt;times&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&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="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;try&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;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"BEGIN"&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;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"INSERT INTO billing_orders (account_id, amount) VALUES (101, 50)"&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;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"COMMIT"&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="k"&gt;finally&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;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&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="k"&gt;catch&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="o"&gt;=&amp;gt;&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="p"&gt;;&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;pRetry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;createOrder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;retries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;minTimeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;50&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;// SELECT * FROM billing_orders WHERE account_id = 101  -&amp;gt;  2 rows, $100 charged&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first attempt commits and the socket is cut. &lt;code&gt;p-retry&lt;/code&gt; sees an error, assumes nothing happened, and tries again. The table now has two rows.&lt;/p&gt;

&lt;p&gt;The retry helper did what it was told. The transaction was not safe to repeat, because it had no idempotency key. The usual fix is a unique constraint plus &lt;code&gt;INSERT ... ON CONFLICT (idempotency_key) DO NOTHING&lt;/code&gt;. A mock cannot show you this, because a mocked connection drop does not leave a real committed row behind.&lt;/p&gt;

&lt;h2&gt;
  
  
  The schedule fuzzer
&lt;/h2&gt;

&lt;p&gt;Barriers are for interleavings you already thought of. The fuzzer is for the ones you did not. Start the proxy with &lt;code&gt;--fuzz&lt;/code&gt; and it delays queries (including &lt;code&gt;BEGIN&lt;/code&gt; and &lt;code&gt;COMMIT&lt;/code&gt;) by a random amount up to &lt;code&gt;--fuzz-max-delay-ms&lt;/code&gt;, so concurrent transactions reach the database in different orders from run to run.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pg-interleave --fuzz --seed 42 --fuzz-max-delay-ms 30
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you omit &lt;code&gt;--seed&lt;/code&gt;, the proxy picks one and logs it. Each connection gets its own random stream derived from the seed and the connection id (SplitMix64), so the same seed gives every connection the same sequence of delays. Client-side timing still varies by a few milliseconds, so rerunning with a seed gives a close result rather than an identical one. That is visible in the Linux column above: seeds 42 and 7 differed by up to 12 runs out of 100 at the 15 ms offset.&lt;/p&gt;

&lt;p&gt;Fuzzing samples schedules. A green run means those schedules passed, not that the code is free of races.&lt;/p&gt;

&lt;h3&gt;
  
  
  The 44 ms trap
&lt;/h3&gt;

&lt;p&gt;My first benchmark was wrong, and the way it was wrong is worth knowing about. An early version of my README claimed that the fuzzer uncovers 100% of lost updates. That number was an artifact. The proxy was adding about 44 ms to every query, which is consistent with Nagle's algorithm interacting with delayed ACKs on the proxy's sockets. With queries that slow, each transaction lasted long enough that the two overlapped whether or not the fuzzer was on, so the comparison meant nothing.&lt;/p&gt;

&lt;p&gt;Setting &lt;code&gt;TCP_NODELAY&lt;/code&gt; on both the client-facing and the upstream socket dropped the added latency to about 0.2 ms per query, and the plain baseline at a 5 ms offset fell to between 0 and 5 out of 100, depending on the machine. If you build a proxy, measure what the proxy itself costs before you trust any timing-dependent result.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing it on pg-boss, and the bug in my own tool
&lt;/h2&gt;

&lt;p&gt;To see how it behaves against software I did not write, I built a test suite for &lt;a href="https://github.com/timgit/pg-boss" rel="noopener noreferrer"&gt;pg-boss&lt;/a&gt; v12.38.1, a job queue on top of Postgres. It has eight tests, and none of them found a bug in pg-boss:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A synthetic &lt;code&gt;40P01&lt;/code&gt; deadlock injected into the fetch statement: the rule fired once, the worker kept polling and processed the job.&lt;/li&gt;
&lt;li&gt;A torn commit on job completion: the job ended up &lt;code&gt;completed&lt;/code&gt; and the handler ran exactly once.&lt;/li&gt;
&lt;li&gt;The completion statement held in the proxy: the job stays &lt;code&gt;active&lt;/code&gt; in the database while it is held and becomes &lt;code&gt;completed&lt;/code&gt; after release.&lt;/li&gt;
&lt;li&gt;A handler that sleeps past the expiry timeout: the job is picked up again (&lt;code&gt;retry_count&lt;/code&gt; 1) and runs twice, which is the documented at-least-once behavior.&lt;/li&gt;
&lt;li&gt;Ten concurrent &lt;code&gt;send()&lt;/code&gt; calls with the same singleton key: one row.&lt;/li&gt;
&lt;li&gt;Twenty and fifty concurrent jobs: each ran exactly once.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The more interesting result was about my own tool. My first version of the deadlock test passed, and it should not have. The deadlock was never injected.&lt;/p&gt;

&lt;p&gt;pg-boss writes its fetch statement across several lines. Abridged from its source:&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;pgboss&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;job_common&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt;
  &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'active'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;started_on&lt;/span&gt; &lt;span class="o"&gt;=&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My pattern was a single-line regex, &lt;code&gt;UPDATE ... SET state = 'active'&lt;/code&gt;, and in a regex &lt;code&gt;.&lt;/code&gt; does not cross a newline. The rule never matched, the proxy forwarded the query untouched, the worker behaved normally, and the test went green. A rule that silently does not fire looks exactly like a system that handled the fault. I only learned about it when a reviewer ran my pattern against the real multi-line statement. A second problem sat next to it: the SDK passed &lt;code&gt;RegExp.source&lt;/code&gt; to the proxy, which dropped flags such as &lt;code&gt;/i&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Two changes came out of that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The SDK now compiles a JavaScript &lt;code&gt;RegExp&lt;/code&gt; with the dotAll flag and keeps &lt;code&gt;i&lt;/code&gt; and &lt;code&gt;m&lt;/code&gt;, so &lt;code&gt;.*&lt;/code&gt; crosses newlines.&lt;/li&gt;
&lt;li&gt;Every rule handle has &lt;code&gt;assertFired(n)&lt;/code&gt;, which throws if the rule did not fire exactly &lt;code&gt;n&lt;/code&gt; times.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;deadlock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;chaos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;injectDeadlock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;onQueryMatch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="no"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;pgboss&lt;/span&gt;\&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;job_common&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="no"&gt;SET&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;\&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;*=&lt;/span&gt;\&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="s1"&gt;'active'&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;times&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&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="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// ... run the scenario ...&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;deadlock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assertFired&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// this test fails if the fault never happened&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now an ORM that reformats its SQL breaks the test loudly instead of making it pass for the wrong reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limitations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Plaintext only.&lt;/strong&gt; The proxy answers &lt;code&gt;N&lt;/code&gt; to &lt;code&gt;SSLRequest&lt;/code&gt;, so &lt;code&gt;sslmode=require&lt;/code&gt; does not work. &lt;code&gt;disable&lt;/code&gt; and &lt;code&gt;prefer&lt;/code&gt; do.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Flush&lt;/code&gt; pipelines.&lt;/strong&gt; Drivers that rely on explicit &lt;code&gt;Flush&lt;/code&gt; frames before sending &lt;code&gt;Sync&lt;/code&gt; can stall the proxy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data-modifying CTEs.&lt;/strong&gt; The AST selector looks at the top-level statement, so &lt;code&gt;WITH u AS (UPDATE ...) SELECT ...&lt;/code&gt; is classified as &lt;code&gt;SELECT&lt;/code&gt;. Use a regex for those.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PgBouncer in transaction mode.&lt;/strong&gt; Backend PIDs change between transactions, so route rules with SQL comment tags instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shared state.&lt;/strong&gt; Rules and barriers live in the proxy process. Run test files serially and call &lt;code&gt;reset()&lt;/code&gt; between tests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No authentication on the control plane.&lt;/strong&gt; Keep it on &lt;code&gt;127.0.0.1&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One toy benchmark, results vary by machine.&lt;/strong&gt; The table above is a single scenario, and the race window was a different width on my two machines. I have not measured how the fuzzer behaves on a large real application.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alpha.&lt;/strong&gt; CI runs on PostgreSQL 14, 15 and 16. APIs may change.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When not to use it
&lt;/h2&gt;

&lt;p&gt;If you can write your scenario as plain sequential &lt;code&gt;await&lt;/code&gt;s on two clients, you do not need a proxy. PostgreSQL's own &lt;code&gt;isolationtester&lt;/code&gt; explores step permutations across sessions and is a better fit when you are testing the database engine itself. Toxiproxy works at the TCP level, which is the right layer for latency and dropped connections but cannot tell a &lt;code&gt;SELECT&lt;/code&gt; from a &lt;code&gt;COMMIT&lt;/code&gt;. pg-interleave is for the case in between: application code you do not want to modify, where you want to stop one specific statement.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm install -D pg-interleave
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run the proxy, either from a &lt;a href="https://github.com/alt-f6/pg-interleave/releases" rel="noopener noreferrer"&gt;release binary&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./pg-interleave --listen-addr 127.0.0.1:5433 --target-addr 127.0.0.1:5432
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or with Docker (the arguments replace the image defaults, so pass all three):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run -d --name pg-interleave \
  --add-host=host.docker.internal:host-gateway \
  -p 127.0.0.1:5433:5433 -p 127.0.0.1:9090:9090 \
  ghcr.io/alt-f6/pg-interleave:latest \
  --listen-addr 0.0.0.0:5433 \
  --control-addr 0.0.0.0:9090 \
  --target-addr host.docker.internal:5432
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Point your test database URL at port 5433. To reproduce the table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; examples &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm install
&lt;span class="c"&gt;# plain run&lt;/span&gt;
&lt;span class="nv"&gt;OFFSETS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0,1,2,5,15 npx tsx lost_update_benchmark.ts
&lt;span class="c"&gt;# with the fuzzer (start the proxy with: --fuzz --seed 42 --fuzz-max-delay-ms 30)&lt;/span&gt;
&lt;span class="nv"&gt;OFFSETS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0,1,2,5,15 npx tsx lost_update_benchmark.ts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The code is at &lt;a href="https://github.com/alt-f6/pg-interleave" rel="noopener noreferrer"&gt;github.com/alt-f6/pg-interleave&lt;/a&gt;. If you try it and something breaks, please open an issue. I would especially like to hear about drivers or ORMs where the proxy misbehaves.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>testing</category>
      <category>node</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Every step was correct. The student still got refunded twice.</title>
      <dc:creator>Ismail Iusifli</dc:creator>
      <pubDate>Fri, 23 Oct 2026 11:32:32 +0000</pubDate>
      <link>https://dev.to/alt-f6/every-step-was-correct-the-student-still-got-refunded-twice-3bjg</link>
      <guid>https://dev.to/alt-f6/every-step-was-correct-the-student-still-got-refunded-twice-3bjg</guid>
      <description>&lt;p&gt;&lt;em&gt;How a randomized invariant test found a billing bug none of my example tests could, and why the fix needed a lock.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;An admin cancels a lesson, so the student's charge is reversed. Later a teacher tidies up the attendance journal and clears the mark, and the charge is reversed again. The student ends up credited for a lesson they never paid for.&lt;/p&gt;

&lt;p&gt;Each of those three operations was correct on its own, and every example test I'd written passed. A test that threw random operations at a real database found the bug on its first run. Here's how.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;I'm the sole engineer of the platform behind an online school with 300+ active students a month. Teachers and admins mark attendance, and every billable mark charges the student for the lesson. Lessons get re-marked, cleared and cancelled, often by different people at the same time.&lt;/p&gt;

&lt;p&gt;I pulled the money-handling core out into a public repo, &lt;a href="https://github.com/alt-f6/edtech-billing-core" rel="noopener noreferrer"&gt;edtech-billing-core&lt;/a&gt;, rewritten on plain PostgreSQL so every lock and constraint is visible. Two design rules matter for this story.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The ledger is append-only.&lt;/strong&gt; A database trigger rejects &lt;code&gt;UPDATE&lt;/code&gt; and &lt;code&gt;DELETE&lt;/code&gt; on ledger rows. You never edit a charge: you append a reversal, then a new charge if needed. For each student and lesson there's a &lt;em&gt;net&lt;/em&gt;, the sum of its rows, and it should always be either 0 or minus the lesson's price.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Writes can't form a lock cycle.&lt;/strong&gt; Marks and clears lock the student row (&lt;code&gt;FOR NO KEY UPDATE&lt;/code&gt;), then the lesson row (&lt;code&gt;FOR SHARE&lt;/code&gt;). A cancellation locks only the lesson (&lt;code&gt;FOR UPDATE&lt;/code&gt;). When it writes reversals, the foreign-key checks take &lt;code&gt;KEY SHARE&lt;/code&gt; on each student, and &lt;code&gt;NO KEY UPDATE&lt;/code&gt; doesn't block that, so there's no cycle and no deadlock. In practice: two writes for the same student run one after the other, marks on the same lesson coexist, and a cancellation waits for marks already in flight.&lt;/p&gt;

&lt;p&gt;With those two rules in place, I had example tests for every operation: mark, re-mark, clear, cancel. They all passed. Then I wrote a different kind of test.&lt;/p&gt;

&lt;h2&gt;
  
  
  A test that doesn't know the answer
&lt;/h2&gt;

&lt;p&gt;Example tests check outcomes you already thought of. The bug I was worried about was the one I hadn't thought of, so I wrote a test that throws random operations at the ledger and checks rules that must hold &lt;em&gt;no matter what happened&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The rules are invariants, each written as a SQL query that returns the rows breaking it. An empty result means the rule holds:&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="c1"&gt;-- "a cancelled lesson nets to zero for every student"&lt;/span&gt;
&lt;span class="k"&gt;SELECT&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;student_id&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;ledger_transaction&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;class_session&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="k"&gt;ON&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;id&lt;/span&gt; &lt;span class="o"&gt;=&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;class_session_id&lt;/span&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;is_cancelled&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&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;student_id&lt;/span&gt;&lt;span class="p"&gt;,&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;class_session_id&lt;/span&gt;
&lt;span class="k"&gt;HAVING&lt;/span&gt; &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&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;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are eight of them, for example: a lesson's reversals never exceed its charges, a free lesson never nets to a charge, each provider payment is credited at most once.&lt;/p&gt;

&lt;p&gt;The test itself:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;creates 4 students and 10 lessons with random prices, some of them free, and puts one student on a billing freeze;&lt;/li&gt;
&lt;li&gt;runs 200 random operations for each of 8 seeds: marks, clears, cancellations, payments, and sometimes a redelivered payment to exercise idempotency;&lt;/li&gt;
&lt;li&gt;runs them &lt;strong&gt;five at a time in parallel&lt;/strong&gt;, against a real PostgreSQL;&lt;/li&gt;
&lt;li&gt;checks all eight invariants after every batch, and that the ledger never shrinks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The randomness is seeded, so a failure replays exactly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;prng&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;seed&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1664525&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1013904223&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="mi"&gt;32&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One more rule keeps the test honest: the only error it accepts is touching a lesson that's already cancelled. A lost race or a deadlock fails the test, and a guard at the end checks that at least 100 operations really succeeded, so the test can't pass by refusing everything.&lt;/p&gt;

&lt;h2&gt;
  
  
  It failed on the first run
&lt;/h2&gt;

&lt;p&gt;The invariant that broke was &lt;em&gt;"a cancelled lesson nets to zero for every student"&lt;/em&gt;. Replaying the seed gave this sequence. Here it is as ledger rows for one student and one lesson priced at 500:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;Row appended&lt;/th&gt;
&lt;th&gt;Net&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Student marked present&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;LESSON_CHARGE&lt;/code&gt; −500&lt;/td&gt;
&lt;td&gt;−500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lesson cancelled&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;CHARGE_REVERSAL&lt;/code&gt; +500&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Attendance cleared&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;CHARGE_REVERSAL&lt;/code&gt; +500&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+500&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The net should be 0. Instead the student was credited 500 for a lesson they were never charged for.&lt;/p&gt;

&lt;p&gt;Each step on its own was correct. Marking charges. Cancelling reverses outstanding charges. Clearing reverses the lesson's charge. The bug only exists in that one order.&lt;/p&gt;

&lt;p&gt;Here's the part that surprised me: &lt;strong&gt;this isn't a race.&lt;/strong&gt; The three steps reproduce one call at a time, in order. What found it was the randomness, not the parallelism: random sequences hit orders I'd never write by hand. The parallel part of the test is there for the other class of bug, the real races, and as the next section shows, the fix for this one created exactly such a race.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, and why it needs a lock
&lt;/h2&gt;

&lt;p&gt;The obvious fix is a rule: clearing refuses a cancelled lesson, because the cancellation already reversed the charge.&lt;/p&gt;

&lt;p&gt;But the rule alone opens a real race. If the clear checks &lt;code&gt;is_cancelled&lt;/code&gt;, then a cancellation commits, then the clear writes its reversal, the double refund is back. So the check has to run under the lesson's share lock, the lock a cancellation must wait for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;clearAttendance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;classSessionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;studentId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;inTransaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// FOR NO KEY UPDATE on the student&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;lockStudent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;studentId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// FOR SHARE on the lesson&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;lockSessionShared&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;classSessionId&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="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;is_cancelled&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="nc"&gt;BillingRuleError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Cannot clear attendance for a cancelled lesson&lt;/span&gt;&lt;span class="dl"&gt;"&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;// ... reverse the charge&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;While the clear holds &lt;code&gt;FOR SHARE&lt;/code&gt; on the lesson, a cancellation, which needs &lt;code&gt;FOR UPDATE&lt;/code&gt;, waits. When the clear commits, the cancellation reads the nets after its lock wait, finds nothing outstanding, and writes nothing. If the cancellation gets there first, the clear sees &lt;code&gt;is_cancelled&lt;/code&gt; and refuses. Either way, the net stays at zero.&lt;/p&gt;

&lt;p&gt;Both cases are now permanent tests: the plain sequence, and the race. The race test fails if the share lock is removed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not just SERIALIZABLE?
&lt;/h2&gt;

&lt;p&gt;It's the first question people ask, so two answers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It wouldn't have caught this bug.&lt;/strong&gt; The double refund happens with the steps run one after another, which is a perfectly serializable history. Isolation levels protect you from bad interleavings, not from wrong rules.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I measured it.&lt;/strong&gt; My production code wraps billing in &lt;code&gt;SERIALIZABLE&lt;/code&gt; and also takes &lt;code&gt;FOR UPDATE&lt;/code&gt; on the student, and the first version of this extraction copied that. A concurrency probe during code review showed roughly 9 in 10 concurrent writes aborting with serialization failures, and &lt;code&gt;FOR UPDATE&lt;/code&gt; deadlocking against a cancellation's foreign-key checks. You can retry serialization failures, but when most writes fail under ordinary contention, retries become the workload. With one explicit lock order under &lt;code&gt;READ COMMITTED&lt;/code&gt;, every concurrent call in the burst tests succeeds. That one deserves its own post.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took from it
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Example tests check what you thought of.&lt;/strong&gt; I had tests for clearing and tests for cancelling. I didn't have one for clearing &lt;em&gt;after&lt;/em&gt; cancelling, because I hadn't thought of it. A randomized test doesn't need you to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Write invariants, not expected values.&lt;/strong&gt; "The balance should be 500" only holds for a case you designed. "A cancelled lesson nets to zero" holds after &lt;em&gt;any&lt;/em&gt; sequence, which is what makes it useful against sequences you didn't design.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test on the real database.&lt;/strong&gt; The guarantees here live in locks, constraints and a trigger. A mocked database would have passed this test forever.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Make failures replayable.&lt;/strong&gt; A random test that fails once and never again is noise. With a seed, the failure became a three-step story I could read, fix, and turn into two permanent tests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check that your tests can fail.&lt;/strong&gt; The repo has a mutation script that removes each defence in turn (either lock, the cancelled-lesson guard, the idempotency key and more) and reruns the suite. All 12 mutants are killed, so I know the tests aren't passing by accident.&lt;/p&gt;

&lt;p&gt;The full code, the invariant test and the mutation script are in &lt;a href="https://github.com/alt-f6/edtech-billing-core" rel="noopener noreferrer"&gt;edtech-billing-core&lt;/a&gt;. If you build billing, or anything else where a number has to be right, I'd like to hear how you test it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I'm Ismail, a backend engineer focused on billing, payments and integrations. &lt;a href="https://iusifli.dev" rel="noopener noreferrer"&gt;iusifli.dev&lt;/a&gt; · &lt;a href="https://linkedin.com/in/ismail-iusifli" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>testing</category>
      <category>typescript</category>
    </item>
  </channel>
</rss>
