<?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: 晖莫</title>
    <description>The latest articles on DEV Community by 晖莫 (@_66d02d0cc1ece7d1137c5f).</description>
    <link>https://dev.to/_66d02d0cc1ece7d1137c5f</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%2F4123424%2F345c490a-4083-4698-8148-14de1df63395.png</url>
      <title>DEV Community: 晖莫</title>
      <link>https://dev.to/_66d02d0cc1ece7d1137c5f</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/_66d02d0cc1ece7d1137c5f"/>
    <language>en</language>
    <item>
      <title>Pagination That Skips Rows Is an Off-by-One in the Data Model</title>
      <dc:creator>晖莫</dc:creator>
      <pubDate>Sun, 25 Oct 2026 13:00:03 +0000</pubDate>
      <link>https://dev.to/_66d02d0cc1ece7d1137c5f/pagination-that-skips-rows-is-an-off-by-one-in-the-data-model-48af</link>
      <guid>https://dev.to/_66d02d0cc1ece7d1137c5f/pagination-that-skips-rows-is-an-off-by-one-in-the-data-model-48af</guid>
      <description>&lt;p&gt;A customer exported their transaction history and got 4,127 rows. The database said 4,203. Nobody could reproduce it, because the export had paginated through a table that was being written to at the time.&lt;/p&gt;

&lt;h2&gt;
  
  
  OFFSET is a count, not a position
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;transactions&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;account_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;1&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="k"&gt;OFFSET&lt;/span&gt; &lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;OFFSET 2000&lt;/code&gt; means "skip the first 2000 rows of a result I am computing right now". If three rows were inserted at the top between page 20 and page 21, every subsequent page shifts by three and the reader never sees three rows. Delete three instead and three rows appear twice.&lt;/p&gt;

&lt;p&gt;The cost is the other half of the problem: &lt;code&gt;OFFSET 200000&lt;/code&gt; still computes 200,000 rows and throws them away. On a busy table, page 2,000 takes seconds while page 1 takes milliseconds — a latency curve your monitoring will attribute to "the database".&lt;/p&gt;

&lt;h2&gt;
  
  
  Keyset pagination, done properly
&lt;/h2&gt;

&lt;p&gt;The fix is to paginate on a value, not a count:&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;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount_cents&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;transactions&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;account_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;1&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;(created_at, id)&lt;/code&gt; row comparison is the important part, and it is where most attempts go wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A cursor on &lt;code&gt;created_at&lt;/code&gt; alone skips ties.&lt;/strong&gt; If forty transactions share a timestamp — a batch job, a backfill, a &lt;code&gt;now()&lt;/code&gt; default that resolves per transaction — then &lt;code&gt;WHERE created_at &amp;lt; $2&lt;/code&gt; skips all forty, or &lt;code&gt;&amp;lt;=&lt;/code&gt; returns them again on the next page. Any cursor that is not unique is a page that either loses rows or repeats them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The ORDER BY must match the tuple exactly, in the same direction.&lt;/strong&gt; &lt;code&gt;ORDER BY created_at DESC, id DESC&lt;/code&gt; with &lt;code&gt;(created_at, id) &amp;lt; (...)&lt;/code&gt; is consistent. Mixing &lt;code&gt;DESC&lt;/code&gt; on one and &lt;code&gt;ASC&lt;/code&gt; on the other produces a filter that does not describe the sort, and Postgres will happily return rows in an order nobody asked for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The index has to cover the tuple in that order.&lt;/strong&gt; &lt;code&gt;(account_id, created_at DESC, id DESC)&lt;/code&gt; makes the query a range scan with no sort step. Without it, keyset pagination is still correct but you are sorting the whole partition on every page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is a data-model bug
&lt;/h2&gt;

&lt;p&gt;Both variants are the same mistake: the application assumes the query has defined an order when it has only defined a partial one. With &lt;code&gt;OFFSET&lt;/code&gt;, the assumption is that the set does not change between pages. With a non-unique cursor, the assumption is that equal keys are distinguishable. Neither is true, and neither failure is visible in a test that pages through a static fixture.&lt;/p&gt;

&lt;p&gt;The test that catches it pages &lt;em&gt;while&lt;/em&gt; writing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_pagination_does_not_skip_or_duplicate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&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;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="n"&gt;cursor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&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="n"&gt;page&lt;/span&gt; &lt;span class="o"&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;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/transactions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cursor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;items&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;
        &lt;span class="nf"&gt;insert_a_new_transaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# the thing OFFSET cannot survive
&lt;/span&gt;        &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;items&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="n"&gt;cursor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;next_cursor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;a row was returned twice&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT id FROM transactions&lt;/span&gt;&lt;span class="sh"&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 second assertion is the one that fails on &lt;code&gt;OFFSET&lt;/code&gt;: rows inserted above the cursor never appear, so the sets differ.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical notes
&lt;/h2&gt;

&lt;p&gt;Return an opaque cursor (base64 of the tuple), not a page number. Clients treat numbers as indexes and will compute page 3 themselves, which puts the pagination contract back in their hands.&lt;/p&gt;

&lt;p&gt;Decide what happens when the sort key is mutable. Keyset pagination assumes &lt;code&gt;created_at&lt;/code&gt; never changes; a row edited to the top of the list can be seen twice. If the ordering column can change, order by &lt;code&gt;(created_at, id)&lt;/code&gt; from an append-only table, or accept that the cursor describes a snapshot of that column.&lt;/p&gt;

&lt;p&gt;The export that started this came back with 4,203 rows after the change, and the query time was flat across all 43 pages instead of climbing from 8ms to 900ms. Same database, same index budget. The only thing that moved was the arithmetic.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write about production failures in Postgres, queues, and distributed systems.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://preview.mailerlite.io/forms/2637234/198694548860306511/share" rel="noopener noreferrer"&gt;Subscribe by email&lt;/a&gt; · &lt;a href="https://dev.to/feed/_66d02d0cc1ece7d1137c5f"&gt;RSS&lt;/a&gt; · &lt;a href="https://bsky.app/profile/mh2333.bsky.social" rel="noopener noreferrer"&gt;Bluesky&lt;/a&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>sql</category>
      <category>api</category>
      <category>backend</category>
    </item>
    <item>
      <title>Retrying on a 429 Is the Wrong Response to Backpressure</title>
      <dc:creator>晖莫</dc:creator>
      <pubDate>Sat, 24 Oct 2026 13:00:03 +0000</pubDate>
      <link>https://dev.to/_66d02d0cc1ece7d1137c5f/retrying-on-a-429-is-the-wrong-response-to-backpressure-2pde</link>
      <guid>https://dev.to/_66d02d0cc1ece7d1137c5f/retrying-on-a-429-is-the-wrong-response-to-backpressure-2pde</guid>
      <description>&lt;p&gt;We hit our own rate limit at 200 requests per second, and the retry logic turned it into 600. The retries did not cause the incident. They caused the incident to last four hours instead of twenty minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three codes, three meanings
&lt;/h2&gt;

&lt;p&gt;Most retry helpers treat "retryable" as one bucket. It is three, and they want opposite things.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;429 Too Many Requests.&lt;/strong&gt; You are over your quota. The server is not overloaded; it is protecting itself from &lt;em&gt;you specifically&lt;/em&gt;, and it usually tells you exactly how long to wait. Retrying immediately is a request to be told no again. The correct response is to honour &lt;code&gt;Retry-After&lt;/code&gt; and reduce your own rate — a client-side token bucket that the 429 is the feedback signal for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;503 Service Unavailable.&lt;/strong&gt; The server is under pressure from everyone. Retrying is correct, but only with backoff and jitter, and only a bounded number of times. Here the retry is a hope that the load shifts; a retry storm makes the load worse but the 503 is not addressed to you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Timeouts and connection resets.&lt;/strong&gt; The outcome is unknown. The request may have been processed. Retrying is &lt;em&gt;required&lt;/em&gt; for the client's behaviour and &lt;em&gt;dangerous&lt;/em&gt; for the system, which is why this is the only case that demands an idempotency key rather than a backoff policy.&lt;/p&gt;

&lt;p&gt;If your retry wrapper cannot tell these apart, it is guessing. Ours looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&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="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;429&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;resp&lt;/span&gt;
    &lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two seconds after a 429 is two more requests inside the window that just closed. With three attempts and 200 concurrent workers, the number of requests in flight tripled.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we changed
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Retry on 429 became "do not retry, slow down".&lt;/strong&gt; The caller got a token bucket sized to the documented limit, shared across the process, refilled per second. A 429 now drains the bucket and the request waits for a token instead of failing and retrying. Concurrency to the upstream dropped from burst-shaped to flat.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;Retry-After&lt;/code&gt; is respected even when it is impolite.&lt;/strong&gt; An upstream answering &lt;code&gt;Retry-After: 30&lt;/code&gt; is a thirty-second pause for that key, not a suggestion. The alternative is a ban.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bounded queue with a deadline in front.&lt;/strong&gt; The requests that used to retry now sit in a short queue with a hard deadline. When the queue is full, the caller gets a 429 &lt;em&gt;from us&lt;/em&gt; immediately, which is honest and cheap. When the deadline passes, the work is dropped rather than executed late — a payment captured forty seconds after the user gave up is a worse outcome than a clean failure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Retries got a cap per key, not just per request.&lt;/strong&gt; Three attempts per request, but also a total budget per idempotency key. Without that, a client retrying a failing request adds its own attempts on top of ours, and the two multiplication tables compound.&lt;/p&gt;

&lt;h2&gt;
  
  
  The counter-intuitive part
&lt;/h2&gt;

&lt;p&gt;The instinct under load is to try harder: more attempts, shorter sleeps, more parallelism. Every one of those increases pressure on the thing that is signalling distress. Backpressure is the system telling you its capacity, and the correct response to a capacity signal is to send less traffic, not the same traffic more persistently.&lt;/p&gt;

&lt;p&gt;Two numbers worth knowing for any dependency:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Your steady-state request rate&lt;/strong&gt;, so a 429 is a comparison and not a surprise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The p99 latency of a successful request&lt;/strong&gt;, which sets the minimum sane timeout. A 500ms timeout on a dependency with a 2s p99 turns latency into errors and errors into retries.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We ended with roughly the same throughput as before the incident, at a third of the peak request count. The retries were never doing useful work. They were just loud.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write about production failures in Postgres, queues, and distributed systems.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://preview.mailerlite.io/forms/2637234/198694548860306511/share" rel="noopener noreferrer"&gt;Subscribe by email&lt;/a&gt; · &lt;a href="https://dev.to/feed/_66d02d0cc1ece7d1137c5f"&gt;RSS&lt;/a&gt; · &lt;a href="https://bsky.app/profile/mh2333.bsky.social" rel="noopener noreferrer"&gt;Bluesky&lt;/a&gt;&lt;/p&gt;

</description>
      <category>reliability</category>
      <category>api</category>
      <category>backend</category>
      <category>performance</category>
    </item>
    <item>
      <title>Your Graceful Shutdown Drops the Request It Was Supposed to Finish</title>
      <dc:creator>晖莫</dc:creator>
      <pubDate>Fri, 23 Oct 2026 13:00:14 +0000</pubDate>
      <link>https://dev.to/_66d02d0cc1ece7d1137c5f/your-graceful-shutdown-drops-the-request-it-was-supposed-to-finish-48da</link>
      <guid>https://dev.to/_66d02d0cc1ece7d1137c5f/your-graceful-shutdown-drops-the-request-it-was-supposed-to-finish-48da</guid>
      <description>&lt;p&gt;Deploys were producing about a dozen 502s each, concentrated in a two-second window, and only on requests that took longer than five seconds. The service had &lt;code&gt;preStop&lt;/code&gt; hooks, a &lt;code&gt;terminationGracePeriodSeconds&lt;/code&gt;, and a &lt;code&gt;SIGTERM&lt;/code&gt; handler. All of it was there. The order was wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  What shutdown actually did
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;shutdown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;signum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stop_accepting&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That reads like "stop taking new work, let in-flight work finish, exit". The third line is the problem: &lt;code&gt;sys.exit&lt;/code&gt; does not wait for anything. It raises &lt;code&gt;SystemExit&lt;/code&gt; in the main thread, which unwinds the stack and ends the process with open transactions, open sockets, and any in-flight requests still being handled on worker threads.&lt;/p&gt;

&lt;p&gt;Five seconds happened to be shorter than the slowest write path. So the requests that mattered — the ones holding an open transaction — were exactly the ones killed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Graceful shutdown is four ordered steps
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Fail readiness before you stop serving.&lt;/strong&gt; The load balancer needs time to notice. If the readiness probe runs every ten seconds, the pod must fail it and then wait at least one full interval before refusing connections. Otherwise the LB keeps sending traffic for up to ten seconds after you stopped accepting it, and those requests fail at the TCP layer. This is not a shutdown problem, it is a "the LB never heard you" problem, and it is the most common cause of 502s during rollout.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Stop accepting new connections, keep the listener open just long enough.&lt;/strong&gt; In Kubernetes, &lt;code&gt;preStop: sleep 10&lt;/code&gt; before &lt;code&gt;SIGTERM&lt;/code&gt; is the crude version and it works. The precise version is a readiness endpoint that returns 503 immediately on the signal, a sleep of two probe intervals, then the socket closes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Drain in-flight requests with a counter, not a sleep.&lt;/strong&gt; Track started/finished requests around the handler. Wait until the counter is zero or the deadline expires. A sleep is a guess about your tail latency; the counter is the actual number.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Close the database pool last, and never mid-transaction.&lt;/strong&gt; The pool's close should wait for checked-out connections. If a handler is between &lt;code&gt;BEGIN&lt;/code&gt; and &lt;code&gt;COMMIT&lt;/code&gt;, killing the process rolls back work the client already believes succeeded — and if there is no idempotency key, the client retries and you get a double write instead of a clean failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  The budget has to be smaller than the killer
&lt;/h2&gt;

&lt;p&gt;The orchestrator sends &lt;code&gt;SIGKILL&lt;/code&gt; at the end of the grace period, and that is not negotiable. So the app's own deadline must be strictly shorter:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;Budget&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Readiness turns 503&lt;/td&gt;
&lt;td&gt;t+0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Two probe intervals&lt;/td&gt;
&lt;td&gt;t+0 → t+20s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Drain in-flight&lt;/td&gt;
&lt;td&gt;t+20s → t+45s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Close pool, flush logs&lt;/td&gt;
&lt;td&gt;t+45s → t+50s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;terminationGracePeriodSeconds&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;60s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If the drain deadline is 30s and the grace period is 30s, you have written code that assumes the drain always finishes instantly. It does not. The failing requests are always the slow ones — that is what "slowest" means.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to test
&lt;/h2&gt;

&lt;p&gt;Shutdown is testable and almost nobody tests it. Send &lt;code&gt;SIGTERM&lt;/code&gt; to a running instance under load, and assert two things: zero requests return a connection error, and every request that was mid-transaction either committed or was cleanly rolled back and retried.&lt;/p&gt;

&lt;p&gt;The version that finally worked here was eleven lines: a signal handler that flips readiness, a counter around the handler, a drain loop with a deadline, and a &lt;code&gt;pool.close()&lt;/code&gt; that waits. The 502s went to zero. The original five-second sleep was not too short — it was in the wrong place, doing the job of a counter.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write about production failures in Postgres, queues, and distributed systems.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://preview.mailerlite.io/forms/2637234/198694548860306511/share" rel="noopener noreferrer"&gt;Subscribe by email&lt;/a&gt; · &lt;a href="https://dev.to/feed/_66d02d0cc1ece7d1137c5f"&gt;RSS&lt;/a&gt; · &lt;a href="https://bsky.app/profile/mh2333.bsky.social" rel="noopener noreferrer"&gt;Bluesky&lt;/a&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>reliability</category>
      <category>backend</category>
      <category>deployment</category>
    </item>
    <item>
      <title>The Backfill That Ran Twice and Doubled a Balance</title>
      <dc:creator>晖莫</dc:creator>
      <pubDate>Thu, 22 Oct 2026 13:00:06 +0000</pubDate>
      <link>https://dev.to/_66d02d0cc1ece7d1137c5f/the-backfill-that-ran-twice-and-doubled-a-balance-cec</link>
      <guid>https://dev.to/_66d02d0cc1ece7d1137c5f/the-backfill-that-ran-twice-and-doubled-a-balance-cec</guid>
      <description>&lt;p&gt;The backfill was 300 lines of a script somebody wrote carefully. At row 1.2 million it hit a deadlock, the process died, and the operator did the obvious thing: ran it again. Every account in the first 1.2 million got its adjustment applied a second time.&lt;/p&gt;

&lt;p&gt;Nobody noticed for three days, until support asked why some customers had a credit balance larger than their lifetime spend.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the bug
&lt;/h2&gt;

&lt;p&gt;The script did this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;account&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;accounts_needing_backfill&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;UPDATE accounts SET balance_cents = balance_cents + %s WHERE id = %s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;credit_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is an increment, not an assignment. It has no memory of whether it has run before, so running it twice is not a retry — it is a second credit. The script was safe exactly once.&lt;/p&gt;

&lt;p&gt;The failure was not the deadlock. The failure was that recovery from the deadlock was "run it again", and the script could not tell the difference between the first run and the second.&lt;/p&gt;

&lt;h2&gt;
  
  
  A backfill must be a function of the data
&lt;/h2&gt;

&lt;p&gt;The property to aim for: &lt;strong&gt;running it N times produces the same rows as running it once.&lt;/strong&gt; There are three standard ways to get it, and they trade off differently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Compute the absolute value from the source of truth.&lt;/strong&gt; If the credit is derivable, set it instead of adding it:&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;accounts&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;balance_cents&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&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;amount_cents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;ledger_entries&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;account_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the strongest version, because the second run is not just safe, it is &lt;em&gt;corrective&lt;/em&gt; — if any earlier run was wrong, this one fixes it. It also fails loudly when the ledger cannot explain the balance, which is the signal you want.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mark rows in the same transaction as the change.&lt;/strong&gt;&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;accounts&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;balance_cents&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;balance_cents&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;backfilled_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;backfilled_at&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;ANY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The guard column and the mutation commit together, so a crash leaves either both or neither. The next run skips what is done. This costs a column, and the column is worth it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Record the work in a progress table&lt;/strong&gt; when you cannot touch the target table's shape: &lt;code&gt;(backfill_name, row_id, applied_at)&lt;/code&gt; with a unique key on the first two. Insert-and-update in one transaction; the unique violation is the "already done" signal.&lt;/p&gt;

&lt;p&gt;All three share the same idea: the script needs a durable record of what it has done, and that record must move in the same transaction as the effect.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the operator should have had
&lt;/h2&gt;

&lt;p&gt;Two things would have caught this in minutes instead of three days.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A checksum before and after.&lt;/strong&gt; Before the backfill, record &lt;code&gt;sum(balance_cents)&lt;/code&gt; and the row count. After it, compare against the expected delta. If the delta is double what the business case says, stop. That is one query against a number the finance team already knows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An idempotency test in the script's own test suite.&lt;/strong&gt; Run the backfill twice against a fixture and assert the result equals running it once. That test is six lines and it fails on the version above:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_backfill_is_idempotent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;run_backfill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;once&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scalar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT sum(balance_cents) FROM accounts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;run_backfill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scalar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT sum(balance_cents) FROM accounts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;once&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The rule
&lt;/h2&gt;

&lt;p&gt;Any script that mutates money must be able to run twice, and the second run must be a no-op. Not "should be safe" — must be, and tested, because the alternative is discovered by a customer.&lt;/p&gt;

&lt;p&gt;The general version: a backfill is a migration of data, and a migration you cannot re-run is a migration whose failure mode is a manual investigation. Write the guard first. The increment is the bug; the deadlock was just the witness.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write about production failures in Postgres, queues, and distributed systems.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://preview.mailerlite.io/forms/2637234/198694548860306511/share" rel="noopener noreferrer"&gt;Subscribe by email&lt;/a&gt; · &lt;a href="https://dev.to/feed/_66d02d0cc1ece7d1137c5f"&gt;RSS&lt;/a&gt; · &lt;a href="https://bsky.app/profile/mh2333.bsky.social" rel="noopener noreferrer"&gt;Bluesky&lt;/a&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>data</category>
      <category>reliability</category>
      <category>migrations</category>
    </item>
    <item>
      <title>The Migration That Passed in Staging Because Staging Was Empty</title>
      <dc:creator>晖莫</dc:creator>
      <pubDate>Wed, 21 Oct 2026 13:00:06 +0000</pubDate>
      <link>https://dev.to/_66d02d0cc1ece7d1137c5f/the-migration-that-passed-in-staging-because-staging-was-empty-2910</link>
      <guid>https://dev.to/_66d02d0cc1ece7d1137c5f/the-migration-that-passed-in-staging-because-staging-was-empty-2910</guid>
      <description>&lt;p&gt;The migration ran in 40ms in staging and took the checkout table down for six minutes in production. Both statements in it were correct. Staging just had nothing to say about either of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two failures that look like one
&lt;/h2&gt;

&lt;p&gt;The migration was:&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;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;fulfilled_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;fulfilled_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;shipped_at&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;shipped_at&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;fulfilled_at&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a table with eleven rows, that is a round trip. On 40 million rows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;code&gt;UPDATE&lt;/code&gt; rewrote every row with a non-null &lt;code&gt;shipped_at&lt;/code&gt;. That is a lot of WAL, a lot of dead tuples, and a lock on all of them.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SET NOT NULL&lt;/code&gt; had to scan the table to validate, holding &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; for the duration. No reads, no writes, no queries at all on that table while it ran.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Staging was created from a migration chain, not from data. Every table was empty or nearly so. Both operations completed before anyone could notice they were the wrong shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a data-shaped database would have said
&lt;/h2&gt;

&lt;p&gt;The point of staging is not "the SQL is syntactically valid" — a linter does that. The point is to answer three questions that only data can answer:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;How many rows does this touch?&lt;/strong&gt; If the answer is "all of them", the migration needs to be batched.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How long does the lock last?&lt;/strong&gt; Measure it, do not guess. Add a statement timeout and see it fire.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What does the new constraint do to existing rows?&lt;/strong&gt; An empty table can satisfy any constraint.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A production-shaped staging database answers all three in the same 40 seconds the empty one took. A masked dump of a few million rows is enough; it does not need to be recent or complete, it needs to have the same shape of distribution.&lt;/p&gt;

&lt;h2&gt;
  
  
  The migration we should have written
&lt;/h2&gt;

&lt;p&gt;Three separate deploys, each boring.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Add the column, nullable.&lt;/strong&gt; No rewrite on a modern Postgres, no lock beyond the catalog update.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Backfill in batches with a bound.&lt;/strong&gt;&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;orders&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;fulfilled_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;shipped_at&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;fulfilled_at&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;shipped_at&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
    &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it until it returns zero rows. Each batch is a short transaction, so autovacuum can keep up and a failure resumes where it stopped. Log the row count per batch; a batch that suddenly takes ten times longer is the warning that the table changed under you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Add the constraint as a validated-none index first.&lt;/strong&gt;&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;CREATE&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;CONCURRENTLY&lt;/span&gt; &lt;span class="n"&gt;orders_fulfilled_at_idx&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fulfilled_at&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For &lt;code&gt;NOT NULL&lt;/code&gt; there is no &lt;code&gt;CONCURRENTLY&lt;/code&gt;, but there is a two-step: add a &lt;code&gt;CHECK (fulfilled_at IS NOT NULL) NOT VALID&lt;/code&gt; constraint, which is instant and only applies to new rows, then &lt;code&gt;VALIDATE CONSTRAINT&lt;/code&gt; in its own transaction. Validation takes a &lt;code&gt;SHARE UPDATE EXCLUSIVE&lt;/code&gt; lock — reads and writes continue.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cheap version
&lt;/h2&gt;

&lt;p&gt;If you cannot get a production-shaped staging database, at least make the migration fail loudly on the wrong size:&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;SET&lt;/span&gt; &lt;span class="n"&gt;lock_timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'3s'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;statement_timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'30s'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A migration that times out in staging and gets rewritten is infinitely cheaper than one that holds a table for six minutes. And add one assertion to the deploy: count the rows the migration expects to touch, and compare it with the count it actually touched. Eleven is a number that should have looked wrong.&lt;/p&gt;

&lt;p&gt;Staging that cannot disagree with you is not a test. It is a syntax check with a database attached.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write about production failures in Postgres, queues, and distributed systems.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://preview.mailerlite.io/forms/2637234/198694548860306511/share" rel="noopener noreferrer"&gt;Subscribe by email&lt;/a&gt; · &lt;a href="https://dev.to/feed/_66d02d0cc1ece7d1137c5f"&gt;RSS&lt;/a&gt; · &lt;a href="https://bsky.app/profile/mh2333.bsky.social" rel="noopener noreferrer"&gt;Bluesky&lt;/a&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>migrations</category>
      <category>testing</category>
      <category>database</category>
    </item>
    <item>
      <title>The Lock You Did Not Know You Were Taking</title>
      <dc:creator>晖莫</dc:creator>
      <pubDate>Tue, 20 Oct 2026 13:00:06 +0000</pubDate>
      <link>https://dev.to/_66d02d0cc1ece7d1137c5f/the-lock-you-did-not-know-you-were-taking-41le</link>
      <guid>https://dev.to/_66d02d0cc1ece7d1137c5f/the-lock-you-did-not-know-you-were-taking-41le</guid>
      <description>&lt;p&gt;&lt;code&gt;GET /invoices/{id}&lt;/code&gt; went from 180ms p99 to 2.4s over a Tuesday afternoon, and no deploy had touched it. Nothing about the endpoint writes: it loads an invoice, checks a feature flag, and returns JSON. It was taking row locks anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  A read that writes
&lt;/h2&gt;

&lt;p&gt;The handler had grown a helper during a previous incident:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;load_invoice_for_display&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;invoice_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT * FROM invoices WHERE id = %s FOR UPDATE&lt;/span&gt;&lt;span class="sh"&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;invoice_id&lt;/span&gt;&lt;span class="p"&gt;,),&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fetchone&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;NotFound&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="c1"&gt;# ... feature flag lookup, an outbound call to the tax service ...
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Someone added &lt;code&gt;FOR UPDATE&lt;/code&gt; because a different caller wanted a read-modify-write and this helper looked like the place to put it. The read-only caller inherited the lock and never knew.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;FOR UPDATE&lt;/code&gt; takes a row-level exclusive lock. It blocks other &lt;code&gt;FOR UPDATE&lt;/code&gt;, &lt;code&gt;UPDATE&lt;/code&gt;, and &lt;code&gt;DELETE&lt;/code&gt; on those rows, and it blocks them for the rest of the transaction. So the question that decides the damage is not "how long is this query" but "how long is this transaction".&lt;/p&gt;

&lt;p&gt;In this case the transaction also called the tax service over HTTP, with a 2-second timeout. Every request held two row locks while waiting on a remote dependency. Under load, requests queued behind each other on the same invoice ids, which is why p99 collapsed while average latency barely moved.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding it
&lt;/h2&gt;

&lt;p&gt;Two queries answer almost every version of this question. First, who is waiting:&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;SELECT&lt;/span&gt; &lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wait_event_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wait_event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pg_blocking_pids&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;blocked_by&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;query_start&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;waiting&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_activity&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;wait_event_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'Lock'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;waiting&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Second, what is holding the lock and for how long:&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;SELECT&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;pid&lt;/span&gt;&lt;span class="p"&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;locktype&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;granted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;xact_start&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;tx_age&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;left&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_locks&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;pg_stat_activity&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;pg_class&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&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;relation&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="k"&gt;mode&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;'ExclusiveLock'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'RowExclusiveLock'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;tx_age&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;xact_start&lt;/code&gt; is the column that matters. A transaction that has been open for 4 seconds is the problem, not the statement it is running right now.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we changed
&lt;/h2&gt;

&lt;p&gt;Three things, in order of impact.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The read stopped taking a lock.&lt;/strong&gt; It did not need one. &lt;code&gt;FOR UPDATE&lt;/code&gt; is a write lock borrowed by a read path, and the path that needed it got its own function.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The transaction stopped containing a network call.&lt;/strong&gt; The tax service call moved outside the transaction boundary: read, commit, call, then a second short transaction if the result needed writing. A transaction should describe one unit of database work, not one unit of work in general.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The remaining read-modify-write used the weakest lock that works.&lt;/strong&gt; For a row you are going to update but whose key you are not changing, &lt;code&gt;FOR NO KEY UPDATE&lt;/code&gt; is enough, and it does not block foreign-key inserts that reference the row. &lt;code&gt;FOR SHARE&lt;/code&gt; if you only need the row not to change underneath you. Take the strongest lock only when you actually need it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern to grep for
&lt;/h2&gt;

&lt;p&gt;Any &lt;code&gt;FOR UPDATE&lt;/code&gt; inside a function called from a read path. Then check the transaction boundary around it: tracing helpers, log shipping that opens a transaction, and retry decorators that wrap a whole function are the usual way a lock ends up spanning a remote call.&lt;/p&gt;

&lt;p&gt;Two rules survived this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A lock lives as long as the transaction, not as long as the statement.&lt;/strong&gt; Optimising the query that takes it is treating the symptom.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If a function is called from a read path, it must not be able to write.&lt;/strong&gt; The way to enforce that is not a comment. It is a separate connection with a read-only role for anything that claims to be a read.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The endpoint was never slow. It was waiting in line behind itself.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write about production failures in Postgres, queues, and distributed systems.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://preview.mailerlite.io/forms/2637234/198694548860306511/share" rel="noopener noreferrer"&gt;Subscribe by email&lt;/a&gt; · &lt;a href="https://dev.to/feed/_66d02d0cc1ece7d1137c5f"&gt;RSS&lt;/a&gt; · &lt;a href="https://bsky.app/profile/mh2333.bsky.social" rel="noopener noreferrer"&gt;Bluesky&lt;/a&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>locking</category>
      <category>performance</category>
      <category>database</category>
    </item>
    <item>
      <title>The Runbook That Sent Me to a Dashboard That No Longer Exists</title>
      <dc:creator>晖莫</dc:creator>
      <pubDate>Sun, 04 Oct 2026 13:00:02 +0000</pubDate>
      <link>https://dev.to/_66d02d0cc1ece7d1137c5f/the-runbook-that-sent-me-to-a-dashboard-that-no-longer-exists-4cej</link>
      <guid>https://dev.to/_66d02d0cc1ece7d1137c5f/the-runbook-that-sent-me-to-a-dashboard-that-no-longer-exists-4cej</guid>
      <description>&lt;p&gt;The alert fired at 02:14. Payments queue depth climbing, no consumers draining it. The runbook told me to check the "Payments Worker Health" dashboard in Grafana. It did not exist. Someone had renamed it to &lt;code&gt;svc-payments-consumer&lt;/code&gt;; the runbook still pointed at the old name.&lt;/p&gt;

&lt;p&gt;Fine. Annoying, but survivable. Two steps later it told me to restart the consumer with &lt;code&gt;payments-worker restart --queue=main&lt;/code&gt;. The binary is now &lt;code&gt;ledger-consumer&lt;/code&gt;. That command has not existed for months.&lt;/p&gt;

&lt;p&gt;I typed it anyway. &lt;code&gt;command not found&lt;/code&gt;. I sat at 02:19 with a paging incident and a document that had lied to me twice. Then I closed the runbook and stopped reading it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cost is not the two wrong steps
&lt;/h2&gt;

&lt;p&gt;Here is what happened next. Everything else in the runbook — checking broker backlog before restarting anything, the DLQ inspection, the note that a stuck consumer is usually a broker problem — I skipped. I went to memory and guesswork.&lt;/p&gt;

&lt;p&gt;I restarted the consumer. That was the wrong move. The broker had partitioned and the consumer was holding a lock it could not release. Restarting made it worse. The runbook had a section warning about exactly that. I never got to it.&lt;/p&gt;

&lt;p&gt;That is the real failure mode. It is not that step four is stale. It is that a responder who has been lied to twice treats the remaining eight steps as suspect, including the correct ones. A bad runbook destroys the value of the good parts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why they rot
&lt;/h2&gt;

&lt;p&gt;Runbooks get written once, right after an incident, by the person with the most context. That person already understood the system, so they wrote an index into their own head. "Check the Payments Worker Health dashboard" made sense to them, not to a stranger.&lt;/p&gt;

&lt;p&gt;Then the system moved. Dashboards get renamed during cleanup. Commands get consolidated when a repo splits. Nobody deletes the old line, because deleting it is not part of any ticket, and whoever would notice never needs the runbook.&lt;/p&gt;

&lt;p&gt;No bug, no alerting on documentation drift. It drifts until an incident finds out.&lt;/p&gt;

&lt;h2&gt;
  
  
  What makes a runbook survive
&lt;/h2&gt;

&lt;p&gt;A step survives if a tired person can run it and see, without judgment, whether it worked. That means executable and verifiable:&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="c"&gt;# Is the consumer actually draining? Expect lag to fall over ~30s.&lt;/span&gt;
ledger-consumer status &lt;span class="nt"&gt;--queue&lt;/span&gt; payments &lt;span class="nt"&gt;--format&lt;/span&gt; json | jq &lt;span class="s1"&gt;'.lag_seconds'&lt;/span&gt;

&lt;span class="c"&gt;# Broker-side backlog. If this is climbing while consumer lag is flat,&lt;/span&gt;
&lt;span class="c"&gt;# the consumer is not the problem. Do not restart it.&lt;/span&gt;
rabbitmqctl list_queues name messages_ready messages_unacknowledged | &lt;span class="nb"&gt;grep &lt;/span&gt;payments
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The commands can be copied and run, and each has an expected result next to it. "Expect lag to fall" tells the responder what done looks like. No interpretation required.&lt;/p&gt;

&lt;p&gt;A link beats a description. Do not name a dashboard — paste the URL. Do not describe where the log lives — paste the query. A link that 404s is a signal you can act on. A name that quietly points at a renamed thing is a trap.&lt;/p&gt;

&lt;p&gt;Every runbook needs a named owner and a review date at the top. Not a team. A person:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;owner: @dana
reviewed: 2026-08-14
review_by: 2027-02-14
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The date converts an invisible problem into a calendar item, and tells a responder how much to trust the page. A runbook reviewed four months ago is different from one reviewed three years ago.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Contact X" is not a step
&lt;/h2&gt;

&lt;p&gt;"Page the payments team" is not a step. It is an admission that the runbook has nothing to say, and it fails at the moment it matters: the named person is asleep, on a plane, or already in the incident channel asking what you tried.&lt;/p&gt;

&lt;p&gt;Replace it with what you would ask them to do. If the answer is "ask Dana to run the replay tool," then the step is how to run the replay tool, what a good run looks like, and what to do if it fails. If Dana is genuinely the only one who can do it safely, write the threshold that makes it Dana's problem and what to do meanwhile. Escalation is a fallback, not a step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix it during the incident, not after
&lt;/h2&gt;

&lt;p&gt;Runbooks rot because updating them is a separate chore with its own ticket, and separate chores lose. Do not make it separate. Make the runbook part of the fix.&lt;/p&gt;

&lt;p&gt;My rule now: the incident is not closed until the runbook the responder actually used has been corrected. Not in a follow-up. Before the postmortem, before the channel goes quiet. If you hit a stale step, fix it in the same session while the context is still in your head.&lt;/p&gt;

&lt;p&gt;Concretely, the closing checklist becomes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every step you ran: did it work as written?&lt;/li&gt;
&lt;li&gt;Every step that failed: corrected now, with a command that exists.&lt;/li&gt;
&lt;li&gt;Any new step you improvised: added.&lt;/li&gt;
&lt;li&gt;Owner and review date bumped.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Anyone who fought an incident has already debugged the documentation. That knowledge lasts about an hour. Spend it in the same session, on the same page. Otherwise the next responder at 02:14 gets the same broken dashboard, the same dead command, and one less reason to trust the rest of it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write about production failures in Postgres, queues, and distributed systems.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://preview.mailerlite.io/forms/2637234/198694548860306511/share" rel="noopener noreferrer"&gt;Subscribe by email&lt;/a&gt; · &lt;a href="https://dev.to/feed/_66d02d0cc1ece7d1137c5f"&gt;RSS&lt;/a&gt; · &lt;a href="https://bsky.app/profile/mh2333.bsky.social" rel="noopener noreferrer"&gt;Bluesky&lt;/a&gt;&lt;/p&gt;

</description>
      <category>runbooks</category>
      <category>oncall</category>
      <category>sre</category>
      <category>incident</category>
    </item>
    <item>
      <title>Configuration Drift Is a Production Incident With a Long Fuse</title>
      <dc:creator>晖莫</dc:creator>
      <pubDate>Sat, 03 Oct 2026 13:00:02 +0000</pubDate>
      <link>https://dev.to/_66d02d0cc1ece7d1137c5f/configuration-drift-is-a-production-incident-with-a-long-fuse-23b7</link>
      <guid>https://dev.to/_66d02d0cc1ece7d1137c5f/configuration-drift-is-a-production-incident-with-a-long-fuse-23b7</guid>
      <description>&lt;p&gt;The checkout pods came back up after a routine node rotation and started returning 502s on every request that touched the payments path. Same image tag, same application version, same deploy log as the week before. The pods were healthy. The CPU was flat. The payment client was timing out against a provider that answered every health check we threw at it.&lt;/p&gt;

&lt;p&gt;We restarted pods. We rolled back a deploy that turned out to be unrelated. We checked the provider status page twice. The answer was an environment variable that only existed on the nodes we had just replaced.&lt;/p&gt;

&lt;h2&gt;
  
  
  The change nobody wrote down
&lt;/h2&gt;

&lt;p&gt;Months before that morning, the payment provider had a slow afternoon. Requests were hanging on a client timeout longer than our load balancer was willing to wait, so every slow response turned into a 502 at the edge. A deploy through the pipeline meant review, a build, and a canary. An operator with console access ran one command against the running deployment instead, raising the client timeout, and watched the error rate fall.&lt;/p&gt;

&lt;p&gt;It worked. The graph went green. Everyone moved on to the next page. The variable lived in the running environment and nowhere else.&lt;/p&gt;

&lt;p&gt;That is how drift accumulates. It is rarely one dramatic edit. It is a &lt;code&gt;kubectl set env&lt;/code&gt; here, a console slider there, an env var added to a single task definition because staging had the wrong value, a cron schedule someone patched directly on the box because the repo version had a typo in it. Each change is small, each is justified in the moment, and each is invisible to anyone who was not in the room.&lt;/p&gt;

&lt;p&gt;The bill arrives later, and it arrives somewhere else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the incident makes no sense
&lt;/h2&gt;

&lt;p&gt;Drift failures are confusing because every signal you normally trust is silent. The code did not change, so the deploy log is clean. The image tag did not change, so the registry tells you nothing. Nobody touched the config repo, so the diff you would normally read is empty.&lt;/p&gt;

&lt;p&gt;What changed is the ground under the code. A node rotation, a scale-up, an instance refresh, a region failover — anything that builds new instances from the repo's version of the truth instead of the running environment's version. The old instances still carry the hotfix. The new ones do not. For a while you run a mixed fleet, which is the worst version of the problem, because two pods behind the same service will answer the same request differently.&lt;/p&gt;

&lt;p&gt;The symptom usually looks like a hard failure with a soft explanation. A timeout that comes back from the dead. A feature flag that turns itself off. A connection pool that quietly shrinks to its original size. If you catch yourself saying "it works on the old pods," you are looking at drift.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compare the running config to the repo
&lt;/h2&gt;

&lt;p&gt;The only comparison that finds drift is running state against committed state. Documentation describes intent at the time someone wrote it, and that someone may have left the company. The repo is the source. Everything else is hearsay.&lt;/p&gt;

&lt;p&gt;So make the running environment describe itself, and diff that description against the repo:&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="c"&gt;# Dump the live environment and diff it against the committed one.&lt;/span&gt;
kubectl &lt;span class="nb"&gt;exec &lt;/span&gt;deploy/payments &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nb"&gt;env&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/live.env
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'^[A-Z_]+='&lt;/span&gt; config/payments.env | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/repo.env
diff &lt;span class="nt"&gt;-u&lt;/span&gt; /tmp/repo.env /tmp/live.env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it on a schedule, not while you are on fire. Anything present in &lt;code&gt;/tmp/live.env&lt;/code&gt; and missing from &lt;code&gt;/tmp/repo.env&lt;/code&gt; is a finding, and so is any value that differs. The same shape works for database parameters, feature-flag defaults, and infrastructure a console manages.&lt;/p&gt;

&lt;p&gt;Here is the file that diff compares against, and the value that leaked out of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# config/payments.yaml — what the service is supposed to run with.&lt;/span&gt;
&lt;span class="na"&gt;payment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;clientTimeoutMs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8000&lt;/span&gt;
  &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
  &lt;span class="na"&gt;circuitBreaker&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The console edit set &lt;code&gt;clientTimeoutMs&lt;/code&gt; to 20000. The repo still says 8000. Every pod created from the repo gets 8000, and the provider is still slow.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule that stops it
&lt;/h2&gt;

&lt;p&gt;If it is not in the repo, it does not exist. That is the whole rule. A setting that lives only inside a running process is not configuration. It is an undocumented mutation with an expiry date, and the expiry date is the next restart.&lt;/p&gt;

&lt;p&gt;The escape hatch is real, so write it down before you need it. Emergencies happen, and sometimes changing a value by hand at 2 a.m. is the correct call. The hatch has a price: every manual change gets a commit opened against the repo in the same shift, linked in the incident channel, with the reason in the message. The incident does not close until that commit merges. No commit, no fix.&lt;/p&gt;

&lt;p&gt;Then drift becomes visible in the one place people actually look. Reviewers see the change. The next operator inherits the reason, not just the value. And the next node rotation deploys an environment that matches the working one, because the working one was never a secret.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write about production failures in Postgres, queues, and distributed systems.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://preview.mailerlite.io/forms/2637234/198694548860306511/share" rel="noopener noreferrer"&gt;Subscribe by email&lt;/a&gt; · &lt;a href="https://dev.to/feed/_66d02d0cc1ece7d1137c5f"&gt;RSS&lt;/a&gt; · &lt;a href="https://bsky.app/profile/mh2333.bsky.social" rel="noopener noreferrer"&gt;Bluesky&lt;/a&gt;&lt;/p&gt;

</description>
      <category>configuration</category>
      <category>drift</category>
      <category>devops</category>
      <category>sre</category>
    </item>
    <item>
      <title>Your edge rate limit is not protecting the database</title>
      <dc:creator>晖莫</dc:creator>
      <pubDate>Fri, 02 Oct 2026 13:00:03 +0000</pubDate>
      <link>https://dev.to/_66d02d0cc1ece7d1137c5f/your-edge-rate-limit-is-not-protecting-the-database-52kf</link>
      <guid>https://dev.to/_66d02d0cc1ece7d1137c5f/your-edge-rate-limit-is-not-protecting-the-database-52kf</guid>
      <description>&lt;p&gt;Our search endpoint's p99 fell off a cliff on a Tuesday afternoon. The edge dashboard was calm: request rate per IP flat, 429s near zero, WAF quiet. Postgres was logging &lt;code&gt;FATAL: remaining connection slots are reserved&lt;/code&gt;, and every service on that database was stuck waiting for a pool connection. I had spent the morning tuning the wrong layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Request rate and query cost are different units
&lt;/h2&gt;

&lt;p&gt;The edge counts requests per IP per second. That is a scalar. One request equals one unit, no matter what the request does.&lt;/p&gt;

&lt;p&gt;The database counts concurrent queries and what each one costs. A request that returns a memoized ID is nearly free. A request that scans a large table with &lt;code&gt;LIKE '%term%'&lt;/code&gt;, or runs a report with several joins and an unindexed &lt;code&gt;ORDER BY&lt;/code&gt;, holds a backend process, memory, and often a lock for seconds. A thousand cheap requests and a dozen expensive ones are the same number to the edge and completely different numbers to Postgres. Your limit can be perfectly enforced and still irrelevant, because the thing you capped is not the thing that ran out.&lt;/p&gt;

&lt;p&gt;The mismatch is worse than it looks. The edge limit is per second. The database ceiling is concurrent connections, a fixed integer set at boot, minus the superuser reserve. Once those slots are taken, no request gets one until another finishes. That is a queue with a hard wall, and the wall is reached by duration multiplied by concurrency, not by rate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Per-IP limits do nothing against one valid token
&lt;/h2&gt;

&lt;p&gt;A per-IP rule assumes the load is spread across many addresses. The incidents I have been paged for came from one client with one valid bearer token. A mobile app retrying a failed export. An internal job fanning a report out across every tenant. A customer's integration polling a list endpoint with a filter that defeats the index.&lt;/p&gt;

&lt;p&gt;One IP. One identity. Well under any sane per-IP threshold. Each request burning hundreds of milliseconds of database time. The edge sees a polite client. The pool sees a stampede.&lt;/p&gt;

&lt;h2&gt;
  
  
  Caching is the real protection for expensive reads
&lt;/h2&gt;

&lt;p&gt;If a query is expensive and its inputs repeat, do not throttle it. Do not run it. A cache protects the database because it removes the work instead of declining it.&lt;/p&gt;

&lt;p&gt;The catch is the key. A cache keyed on the URL with a good global hit ratio can still miss on every request to the one endpoint that matters, because each request carries a different combination of sort, page, and date range. Key on the normalized query and put the filter set in the key. Bound the cardinality, or you have built a slower database with worse durability.&lt;/p&gt;

&lt;p&gt;For reads that genuinely cannot be cached, the fallback is a cheaper query: an index that matches the filter, a materialized view refreshed on a schedule, a precomputed count. Measure before guessing. Run &lt;code&gt;EXPLAIN (ANALYZE, BUFFERS)&lt;/code&gt; with real parameters and look at actual rows and actual time. Those two numbers decide whether the answer is a cache, an index, or an admission limit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Put the limit where the resource is
&lt;/h2&gt;

&lt;p&gt;The limit belongs next to the pool, not at the edge. A semaphore in front of the connection pool caps concurrency at the exact resource that runs out.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;

&lt;span class="n"&gt;DB_CONCURRENCY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;
&lt;span class="n"&gt;db_gate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Semaphore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DB_CONCURRENCY&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fetch_report&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;db_gate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                 &lt;span class="c1"&gt;# the real ceiling: concurrent queries
&lt;/span&gt;        &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;acquire&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;conn&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;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SET LOCAL statement_timeout = &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;2s&lt;/span&gt;&lt;span class="sh"&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;await&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things are doing the work here. The semaphore is a ceiling on concurrent queries, not on requests per second, so it fails closed exactly when the database would. &lt;code&gt;statement_timeout&lt;/code&gt; means a query that goes wrong dies inside Postgres instead of holding a backend forever. Set it at the transaction level so it does not leak into the next checkout of that connection.&lt;/p&gt;

&lt;p&gt;For anything heavier, add a cost-based admission check: ask the planner for an estimate, and if the projected cost exceeds a budget, push the job to a queue or reject it with a clear error rather than starting work you cannot finish.&lt;/p&gt;

&lt;p&gt;Then instrument the resource, not the edge. Watch &lt;code&gt;pg_stat_activity&lt;/code&gt; for state, wait event, and query start time. A long-running query is load. A session sitting in &lt;code&gt;idle in transaction&lt;/code&gt; is a leak, and no rate limit will fix it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The edge limit is still worth having
&lt;/h2&gt;

&lt;p&gt;None of this makes the edge rule useless. It absorbs the dumb traffic: credential stuffing, scrapers, a retry loop that never authenticates. It is cheap, it is global, and it keeps noise away from your application. Keep it.&lt;/p&gt;

&lt;p&gt;Just stop treating it as the last line of defence. The edge limit protects the edge. The semaphore, the statement timeout, and the cache protect the database. Count the thing that runs out.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write about production failures in Postgres, queues, and distributed systems.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://preview.mailerlite.io/forms/2637234/198694548860306511/share" rel="noopener noreferrer"&gt;Subscribe by email&lt;/a&gt; · &lt;a href="https://dev.to/feed/_66d02d0cc1ece7d1137c5f"&gt;RSS&lt;/a&gt; · &lt;a href="https://bsky.app/profile/mh2333.bsky.social" rel="noopener noreferrer"&gt;Bluesky&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ratelimiting</category>
      <category>database</category>
      <category>postgres</category>
      <category>caching</category>
    </item>
    <item>
      <title>The Retry You Added to the Write Path Made Duplicates Worse</title>
      <dc:creator>晖莫</dc:creator>
      <pubDate>Thu, 01 Oct 2026 13:00:04 +0000</pubDate>
      <link>https://dev.to/_66d02d0cc1ece7d1137c5f/the-retry-you-added-to-the-write-path-made-duplicates-worse-3koj</link>
      <guid>https://dev.to/_66d02d0cc1ece7d1137c5f/the-retry-you-added-to-the-write-path-made-duplicates-worse-3koj</guid>
      <description>&lt;p&gt;The duplicate charges showed up in support tickets, both the same shape: one order, two rows in &lt;code&gt;payments&lt;/code&gt;, same amount, same card, a few hundred milliseconds apart. The gateway logged two POSTs with different request ids and no shared key. Our retry wrapper had done exactly what we told it to.&lt;/p&gt;

&lt;p&gt;The retry policy came from a shared HTTP client that had only ever wrapped reads. &lt;code&gt;max_attempts=3&lt;/code&gt;, exponential backoff, retry on timeout, retry on connection errors, retry on any 5xx. Someone reused the call site for a write, and nothing in that client objected. The policy was correct for GETs and wrong for POSTs, and the code could not tell the difference.&lt;/p&gt;

&lt;h2&gt;
  
  
  A timeout is not a failure
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;ReadTimeout&lt;/code&gt; means the client stopped waiting. It does not mean the server stopped working. The usual sequence is: TCP connect succeeded, request line, headers and body went out, the server started processing, the response came back late or never. The socket gave up on our side. On their side the row was already committed.&lt;/p&gt;

&lt;p&gt;On a read path that mistake costs one extra query, so nobody notices. On a write path it costs one extra order. Same exception, same handler, different blast radius.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before the request left, and after
&lt;/h2&gt;

&lt;p&gt;These are different failures and they need different handling. Name the exception rather than catching &lt;code&gt;Exception&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;ConnectTimeout&lt;/code&gt; or &lt;code&gt;ConnectionRefusedError&lt;/code&gt; on a fresh connection happens before anything reached the server. Nothing was written. Retrying is reasonable, with one caveat: make sure no bytes of the request body were sent, because a &lt;code&gt;ConnectionError&lt;/code&gt; raised after a partial send is not the same event.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;ReadTimeout&lt;/code&gt;, or &lt;code&gt;RemoteDisconnected&lt;/code&gt; after the body was sent, is the unknown case. The server may have processed the request completely. It may have processed it and then failed while writing the response. There is no local signal that separates "never arrived" from "arrived and committed." The two situations collapse into one exception class, which is exactly why a blanket decorator is unsafe.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retrying a 500 from a peer
&lt;/h2&gt;

&lt;p&gt;A 500 is not evidence that nothing happened. It is evidence that something broke after the request arrived. The handler ran. The write may have committed and the error may have come from a post-commit step: sending a receipt, updating a search index, serializing a response. Retrying that POST inserts the row a second time.&lt;/p&gt;

&lt;p&gt;A 502 or 503 from a proxy is more ambiguous. The upstream may never have been reached, or it may have received the request and answered too slowly for the proxy. Unless you own that layer and know its behavior, treat it as unknown too.&lt;/p&gt;

&lt;p&gt;The only condition under which retrying a write is safe is idempotency: the server can distinguish a repeat from a new intent. That requires a key that travels with the original request, not one generated when the retry fires.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the key part of the request
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# wrong: a fresh key on every attempt, so the retry looks like a new charge
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&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="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;uuid4&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;return&lt;/span&gt; &lt;span class="n"&gt;gateway&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/charges&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount&lt;/span&gt;&lt;span class="sh"&gt;"&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="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Idempotency-Key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="nf"&gt;except &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ReadTimeout&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;ConnectionError&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;

&lt;span class="c1"&gt;# right: the key comes from the intent and is stable across attempts
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&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="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;charge:%s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&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;return&lt;/span&gt; &lt;span class="n"&gt;gateway&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/charges&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount&lt;/span&gt;&lt;span class="sh"&gt;"&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="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Idempotency-Key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;ConnectTimeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;                      &lt;span class="c1"&gt;# nothing was sent
&lt;/span&gt;        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;ReadTimeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;UnknownWrite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# arrived, outcome unknown
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second version makes the retry a repeat of the same request. The gateway sees &lt;code&gt;charge:4471&lt;/code&gt; again, finds its stored result, and returns that instead of taking the money twice. If the endpoint does not implement the key, no client-side change makes the retry safe.&lt;/p&gt;

&lt;h2&gt;
  
  
  Treat a timed-out write as unknown
&lt;/h2&gt;

&lt;p&gt;The honest state after a &lt;code&gt;ReadTimeout&lt;/code&gt; on a POST is unknown. Not failed. Code that models it as failed retries, and the retry is what creates the duplicate.&lt;/p&gt;

&lt;p&gt;What to do instead:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Retry only writes you have made idempotent, and derive the key from the operation: order id, job id, a value you already persist. Never generate it inside the retry loop.&lt;/li&gt;
&lt;li&gt;Classify before you retry. Retry a connect failure that provably happened before the body was sent. Do not retry a response timeout, a 500 from a peer that received the request, or an ambiguous 502 unless the operation is idempotent.&lt;/li&gt;
&lt;li&gt;On an unknown write, stop and reconcile. Read the resource back by the key you sent, or query the server's idempotency record, before doing anything else.&lt;/li&gt;
&lt;li&gt;Store the key on the write path so a retry after a process restart uses the same value. A key built in memory does not survive a crash.&lt;/li&gt;
&lt;li&gt;If the endpoint has no idempotency key and you cannot add one, the only safe retry is one where no request was sent. Everything else needs a reconciliation job or a human.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To see how often this bites you, measure the timeout and 5xx rates on write endpoints separately from read endpoints, and count duplicate-key violations or duplicate rows per endpoint. A write endpoint with a nonzero timeout rate and no idempotency key is already producing duplicates. They show up as support tickets before they show up in your dashboards.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write about production failures in Postgres, queues, and distributed systems.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://preview.mailerlite.io/forms/2637234/198694548860306511/share" rel="noopener noreferrer"&gt;Subscribe by email&lt;/a&gt; · &lt;a href="https://dev.to/feed/_66d02d0cc1ece7d1137c5f"&gt;RSS&lt;/a&gt; · &lt;a href="https://bsky.app/profile/mh2333.bsky.social" rel="noopener noreferrer"&gt;Bluesky&lt;/a&gt;&lt;/p&gt;

</description>
      <category>retry</category>
      <category>idempotency</category>
      <category>api</category>
      <category>backend</category>
    </item>
    <item>
      <title>ORDER BY Without a Tiebreaker Is a Flaky Test Generator</title>
      <dc:creator>晖莫</dc:creator>
      <pubDate>Wed, 30 Sep 2026 13:00:04 +0000</pubDate>
      <link>https://dev.to/_66d02d0cc1ece7d1137c5f/order-by-without-a-tiebreaker-is-a-flaky-test-generator-2l51</link>
      <guid>https://dev.to/_66d02d0cc1ece7d1137c5f/order-by-without-a-tiebreaker-is-a-flaky-test-generator-2l51</guid>
      <description>&lt;p&gt;I had a test that asserted &lt;code&gt;orders.first().id == 9917&lt;/code&gt;. It passed locally for months. Then it failed in CI, twice in a row, then passed again on a rerun. Nobody had touched the query.&lt;/p&gt;

&lt;p&gt;The query was this:&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;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total_cents&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;customer_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;1&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test seeded three orders for one customer inside a single transaction. All three got &lt;code&gt;created_at = now()&lt;/code&gt;. &lt;code&gt;now()&lt;/code&gt; is &lt;code&gt;transaction_timestamp()&lt;/code&gt; — it does not advance during a transaction. So three rows shared the exact same timestamp to the microsecond.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ORDER BY created_at DESC&lt;/code&gt; gives one value. Three rows tie on it. Postgres picks whichever it happens to read first. That order depends on the plan, the physical row order, the visibility map, and whether it chose an index scan or a seq scan. Local Postgres and CI Postgres do not make the same choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  The standard does not promise you anything here
&lt;/h2&gt;

&lt;p&gt;SQL does not define which row comes back when the sort key ties. The &lt;code&gt;ORDER BY&lt;/code&gt; clause establishes a partial order, not a total one. Two rows with equal keys are "equal" for sorting purposes, and the engine may emit them in any order. No standard text says otherwise.&lt;/p&gt;

&lt;p&gt;This is not a Postgres bug. It is Postgres behaving correctly. The bug is in the query, and then in the test that trusted it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;LIMIT 1&lt;/code&gt; makes it worse. Without the limit you would see the instability — the row order would visibly shuffle between runs. &lt;code&gt;LIMIT 1&lt;/code&gt; hides it and turns it into a coin flip that lands on heads in your terminal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pagination is where this stops being a test problem
&lt;/h2&gt;

&lt;p&gt;I hit this again in a feed endpoint. Keyset pagination over &lt;code&gt;created_at&lt;/code&gt; alone:&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;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;posts&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A backfill wrote a chunk of posts with the same &lt;code&gt;created_at&lt;/code&gt;, all from one job running &lt;code&gt;now()&lt;/code&gt; in one transaction. The first page ended at &lt;code&gt;created_at = '2026-09-14 10:03:22.481913+00'&lt;/code&gt;. The next page asked for everything strictly before that value. Every post sharing that timestamp got skipped. Users saw a gap in the feed and I spent an afternoon blaming the client.&lt;/p&gt;

&lt;p&gt;Flip the comparison to &lt;code&gt;&amp;lt;=&lt;/code&gt; and you get the mirror failure: the boundary rows repeat on every page. Offset pagination has the same shape. &lt;code&gt;OFFSET 20&lt;/code&gt; counts rows in an order the database never promised, so a row inserted or reordered between requests can push another row across the page boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Add a unique column to the sort key
&lt;/h2&gt;

&lt;p&gt;The fix is one line and it is not optional. Every &lt;code&gt;ORDER BY&lt;/code&gt; that feeds a &lt;code&gt;LIMIT&lt;/code&gt;, an &lt;code&gt;OFFSET&lt;/code&gt;, or a cursor needs a unique tiebreaker. The primary key is right there.&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;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the sort key is unique. &lt;code&gt;(created_at, id)&lt;/code&gt; is a total order over the table. Ties cannot exist, so the engine has no freedom left to exercise. &lt;code&gt;LIMIT 1&lt;/code&gt; returns exactly one row, deterministically.&lt;/p&gt;

&lt;p&gt;Pagination needs the same treatment in the predicate, not just the sort:&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;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;posts&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Row-value comparison does the lexicographic work in one expression. Pass the last row's &lt;code&gt;created_at&lt;/code&gt; and &lt;code&gt;id&lt;/code&gt; as the cursor. Nothing is skipped, nothing repeats.&lt;/p&gt;

&lt;p&gt;For the index, an index on &lt;code&gt;(created_at DESC, id DESC)&lt;/code&gt; matches the sort directly. Check with &lt;code&gt;EXPLAIN (ANALYZE, BUFFERS)&lt;/code&gt; — if you see a &lt;code&gt;Sort&lt;/code&gt; node above the scan, the index is not matching your order and you are paying for it on every page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make it a rule
&lt;/h2&gt;

&lt;p&gt;A tiebreaker is not a stylistic preference. It belongs in review. Two habits make it stick for me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If I write &lt;code&gt;ORDER BY&lt;/code&gt;, I write the unique column in the same keystroke. &lt;code&gt;ORDER BY created_at DESC&lt;/code&gt; alone looks unfinished now.&lt;/li&gt;
&lt;li&gt;Any test that reads &lt;code&gt;.first()&lt;/code&gt; or &lt;code&gt;.last()&lt;/code&gt; from a query must sort on a unique key, or it is not testing the query — it is testing the plan.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I also stopped seeding multiple rows with &lt;code&gt;now()&lt;/code&gt; when the test cares about their order. &lt;code&gt;clock_timestamp()&lt;/code&gt; advances; &lt;code&gt;now()&lt;/code&gt; does not. Better yet, set explicit timestamps in the fixture so the data has the order the test claims to assert.&lt;/p&gt;

&lt;p&gt;The failure mode is quiet. Nothing errors, no constraint fires, no log line appears. You get a wrong row, a missing row, or a green test that owes you a red one later. Add the tiebreaker.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write about production failures in Postgres, queues, and distributed systems.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://preview.mailerlite.io/forms/2637234/198694548860306511/share" rel="noopener noreferrer"&gt;Subscribe by email&lt;/a&gt; · &lt;a href="https://dev.to/feed/_66d02d0cc1ece7d1137c5f"&gt;RSS&lt;/a&gt; · &lt;a href="https://bsky.app/profile/mh2333.bsky.social" rel="noopener noreferrer"&gt;Bluesky&lt;/a&gt;&lt;/p&gt;

</description>
      <category>sql</category>
      <category>postgres</category>
      <category>testing</category>
      <category>ci</category>
    </item>
    <item>
      <title>Blue-green deploys do not save you from a bad migration</title>
      <dc:creator>晖莫</dc:creator>
      <pubDate>Tue, 29 Sep 2026 13:00:05 +0000</pubDate>
      <link>https://dev.to/_66d02d0cc1ece7d1137c5f/blue-green-deploys-do-not-save-you-from-a-bad-migration-27oh</link>
      <guid>https://dev.to/_66d02d0cc1ece7d1137c5f/blue-green-deploys-do-not-save-you-from-a-bad-migration-27oh</guid>
      <description>&lt;p&gt;The deploy went green in ninety seconds. Then the error rate on blue hit 100%, and every request touching &lt;code&gt;/orders&lt;/code&gt; returned &lt;code&gt;column "orders.status_v2" does not exist&lt;/code&gt;. Two healthy app stacks, no working rollback.&lt;/p&gt;

&lt;h2&gt;
  
  
  Blue-green only ever protected one thing
&lt;/h2&gt;

&lt;p&gt;Blue-green gives you two copies of your application code and a router that flips between them. It does not give you two copies of your database, because you cannot cheaply fork a live Postgres cluster with continuous writes and merge the forks later. Both environments point at the same host, the same schema, the same tables.&lt;/p&gt;

&lt;p&gt;So the moment &lt;code&gt;migrate&lt;/code&gt; finishes, blue runs new code against a new schema and green runs old code against that same new schema. The swap is atomic. The schema change is not.&lt;/p&gt;

&lt;p&gt;Here is the migration that took us down:&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;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;status_v2&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Order matters. Green still selects &lt;code&gt;status&lt;/code&gt;. Between the &lt;code&gt;DROP&lt;/code&gt; and the moment green stops serving, every green query fails. Green keeps serving for as long as the router takes to drain, plus every in-flight request, plus every retry. The rollback plan said "flip back to blue." Blue was already broken by a migration that ran before the flip.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rollback means two different things
&lt;/h2&gt;

&lt;p&gt;Code rollback is a pointer change: flip the router, and the old artifact still exists. It works only while the data underneath still fits.&lt;/p&gt;

&lt;p&gt;Schema rollback is not symmetric. The reverse of &lt;code&gt;DROP COLUMN&lt;/code&gt; is &lt;code&gt;ADD COLUMN&lt;/code&gt;, and the values are gone. You cannot un-drop a column, un-truncate a table, or un-run an &lt;code&gt;UPDATE&lt;/code&gt; that rewrote rows in place. The database has one state, it moves forward, and it has no previous release to point at.&lt;/p&gt;

&lt;p&gt;There is a third, worse case: the migration fails halfway. Postgres runs most DDL in a transaction, so a failed &lt;code&gt;ALTER TABLE&lt;/code&gt; inside &lt;code&gt;BEGIN&lt;/code&gt; rolls back cleanly. But a file with several statements, some &lt;code&gt;CONCURRENTLY&lt;/code&gt;, or explicit commits between steps leaves a schema that is neither the old shape nor the new one. That is the state nobody rehearsed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reversible schema, irreversible data
&lt;/h2&gt;

&lt;p&gt;The distinction I should have drawn before that deploy.&lt;/p&gt;

&lt;p&gt;A schema change is reversible when old code can still run against the new schema. Adding a nullable column, an index, a table — all reversible, because old code ignores what it does not know about.&lt;/p&gt;

&lt;p&gt;A data change is irreversible when the migration rewrites or removes values. Dropping a column, a lossy type cast, a backfill of computed values — the new shape is not derived from the old one, so no reverse migration reconstructs it.&lt;/p&gt;

&lt;p&gt;A reversible schema change often accompanies an irreversible data change. That is the trap. &lt;code&gt;ADD COLUMN status_v2&lt;/code&gt; alone is safe. &lt;code&gt;ADD COLUMN&lt;/code&gt; plus a backfill plus a &lt;code&gt;DROP COLUMN&lt;/code&gt; in one file is a one-way door.&lt;/p&gt;

&lt;h2&gt;
  
  
  Expand, contract, flag
&lt;/h2&gt;

&lt;p&gt;The safe path has three releases, not two.&lt;/p&gt;

&lt;p&gt;Release one: expand. Add the new column as nullable, and dual-write in application code. Old code reads the old column and keeps working. New code writes both. Nothing breaks if you roll back, because both shapes exist.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;write_order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;UPDATE orders SET status = %s, status_v2 = %s WHERE id = %s&lt;/span&gt;&lt;span class="sh"&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;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;legacy_status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order&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;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&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;Release two: backfill in batches, outside the deploy, with a query you can stop. Backfill must be restartable. Run it in chunks, log the last processed id, and make a rerun a no-op.&lt;/p&gt;

&lt;p&gt;Release three: switch reads to the new column behind a feature flag. The flag is the rollback. If reads break, flip the flag, not the deploy. Only after the flag has been on for a full traffic cycle do you contract — drop the old column in a boring release where the code no longer references it.&lt;/p&gt;

&lt;p&gt;Do the flag before the drop, not after. A flag is reversible in seconds; a &lt;code&gt;DROP COLUMN&lt;/code&gt; is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the migration is already half-applied
&lt;/h2&gt;

&lt;p&gt;Stop the deploy. Do not roll back the app, and do not run a second migration to "fix" the first. Both make the schema harder to reason about.&lt;/p&gt;

&lt;p&gt;Find out what actually landed. In Postgres, read the real state instead of trusting your migration table:&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;SELECT&lt;/span&gt; &lt;span class="k"&gt;column_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;is_nullable&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;columns&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;table_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'orders'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;ordinal_position&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then check whether the migration is holding a lock. &lt;code&gt;pg_stat_activity&lt;/code&gt; and &lt;code&gt;pg_locks&lt;/code&gt; show a blocked &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; waiting on a long-running read. Kill the blocker if it is a stale report query, or your next deploy deadlocks.&lt;/p&gt;

&lt;p&gt;Then pick a direction and commit. If the old shape still exists, disable the new code path with the flag and leave the schema alone — you are back to release one. If the old shape is gone, the only path is forward: ship code matching the current schema, patch the failing queries today, and accept that you are deploying under pressure. Restore from a backup only if you can afford to lose everything written since the migration, and measure that window first.&lt;/p&gt;

&lt;p&gt;The lesson I keep: the database is not a deployment target. It is a shared, single-state dependency, and every migration is a change to production data. Rehearse the migration on a copy with real row counts, and time how long the lock is held.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write about production failures in Postgres, queues, and distributed systems.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://preview.mailerlite.io/forms/2637234/198694548860306511/share" rel="noopener noreferrer"&gt;Subscribe by email&lt;/a&gt; · &lt;a href="https://dev.to/feed/_66d02d0cc1ece7d1137c5f"&gt;RSS&lt;/a&gt; · &lt;a href="https://bsky.app/profile/mh2333.bsky.social" rel="noopener noreferrer"&gt;Bluesky&lt;/a&gt;&lt;/p&gt;

</description>
      <category>database</category>
      <category>deployments</category>
      <category>postgres</category>
      <category>migrations</category>
    </item>
  </channel>
</rss>
