<?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>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>
    <item>
      <title>The Dashboard Nobody Trusts: A Metric Without an Owner</title>
      <dc:creator>晖莫</dc:creator>
      <pubDate>Mon, 28 Sep 2026 12:59:52 +0000</pubDate>
      <link>https://dev.to/_66d02d0cc1ece7d1137c5f/the-dashboard-nobody-trusts-a-metric-without-an-owner-323m</link>
      <guid>https://dev.to/_66d02d0cc1ece7d1137c5f/the-dashboard-nobody-trusts-a-metric-without-an-owner-323m</guid>
      <description>&lt;p&gt;The checkout dashboard has a panel called &lt;code&gt;payment_gateway_queue_depth&lt;/code&gt;. It has been at 0 for eleven months. Nobody noticed, because nobody looks at it. It was added at 2am during an incident, and the person who added it left the team in March.&lt;/p&gt;

&lt;p&gt;That is the normal story. A panel appears when we are scared, it answers one question once, and then it stays on the dashboard forever. Within a year it is flat, wrong, or so noisy that people scroll past it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does knowing it change what you do at 3am?
&lt;/h2&gt;

&lt;p&gt;This is the only test that matters. Take a panel and ask: if this number were alarming right now, what would I do differently? If the answer is "nothing, but it would be interesting," the panel is decoration.&lt;/p&gt;

&lt;p&gt;I use a blunter version with my team. Pick the metric. Now finish this sentence: "If this spikes, I will ..." If you cannot fill in the blank with a command, a rollback, or a page to another team, the metric is not operational. It is trivia with a graph attached.&lt;/p&gt;

&lt;p&gt;The same test deletes alerts. An alert that fires and gets ignored is worse than no alert, because it trains everyone to treat the pager as background noise. If an alert has fired more than a handful of times and nobody acted on any of them, it is a deletion candidate. Either fix the threshold so it only fires when action is needed, or delete the rule and keep the graph for humans to read during incidents.&lt;/p&gt;

&lt;h2&gt;
  
  
  Collected because it was easy
&lt;/h2&gt;

&lt;p&gt;Most dead metrics got there the same way: the exporter emitted it for free, so we scraped it. Request rate per endpoint, response size percentiles, cache hit ratio by region — all collected, none attached to a decision.&lt;/p&gt;

&lt;p&gt;There is a real difference between a metric with a decision attached and one collected because it was easy. The first one has a threshold, an owner, and a runbook. The second one has a dashboard slot.&lt;/p&gt;

&lt;p&gt;Service-level indicators sit on the decision side. They describe what the user experiences: successful checkouts per minute, search latency under 500ms, payment authorization success rate. Vanity throughput graphs sit on the other side. Total requests served tells you the system is busy. It does not tell you whether anyone got what they came for.&lt;/p&gt;

&lt;p&gt;I have watched a service serve 40% more requests than the week before while its success rate dropped, and the throughput graph looked like a win. The SLI panel next to it was the one that mattered.&lt;/p&gt;

&lt;p&gt;When a service has no SLI, that is the panel to build. Not another throughput chart.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attach an owner and a line of intent
&lt;/h2&gt;

&lt;p&gt;Every panel gets two pieces of metadata, in the dashboard config itself, not in a wiki page nobody reads.&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="na"&gt;panels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;payment_gateway_queue_depth&lt;/span&gt;
    &lt;span class="na"&gt;owner&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;payments-team&lt;/span&gt;
    &lt;span class="na"&gt;intent&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;If&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;depth&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;gt;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;500&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;for&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;10m,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;page&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;payments&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;on-call&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;check&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;gateway&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;health."&lt;/span&gt;
    &lt;span class="na"&gt;query&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sum by (gateway) (payment_gateway_queue_depth)&lt;/span&gt;
    &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PaymentGatewayQueueBacklog&lt;/span&gt;
    &lt;span class="na"&gt;last_reviewed&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2026-09-14&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;owner&lt;/code&gt; field is a real team, not a person, because people rotate. The &lt;code&gt;intent&lt;/code&gt; line is the "what would we do about this" sentence, written down. If nobody can write that line, the panel does not get created.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;last_reviewed&lt;/code&gt; is the part that keeps the whole thing alive. Once a quarter, walk the dashboard and read those dates. Anything older than two quarters gets either a fresh intent line from its owner or a delete commit. A stale panel is a claim that someone is watching. That claim is false, and it is worse than an empty dashboard because it makes us believe we have coverage.&lt;/p&gt;

&lt;p&gt;The graph that is flat at 0 is the dangerous one. It looks healthy. It looks calm. It has not received a single data point since the exporter changed its metric name in the spring.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measure the decay instead of guessing
&lt;/h2&gt;

&lt;p&gt;Do not estimate how many panels are dead. Measure it. For each panel, query the underlying series over the last 30 days and count series with zero variance. For each alert rule, count firings and correlate them with the incident channel in the same window. Alerts that fire with no corresponding human action are your deletion list.&lt;/p&gt;

&lt;p&gt;Then delete. Not archive — delete, so the next person who opens the dashboard sees only things that have an owner and a reason.&lt;/p&gt;

&lt;p&gt;A dashboard is a set of promises that someone is watching. Keep the promises you can keep, and remove the rest. The panel that nobody owns does not monitor anything. It just makes the silence look intentional.&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>observability</category>
      <category>monitoring</category>
      <category>sre</category>
      <category>alerting</category>
    </item>
    <item>
      <title>The Unique Index That Makes Your Endpoint Retry-Safe</title>
      <dc:creator>晖莫</dc:creator>
      <pubDate>Sun, 27 Sep 2026 12:59:53 +0000</pubDate>
      <link>https://dev.to/_66d02d0cc1ece7d1137c5f/the-unique-index-that-makes-your-endpoint-retry-safe-35o9</link>
      <guid>https://dev.to/_66d02d0cc1ece7d1137c5f/the-unique-index-that-makes-your-endpoint-retry-safe-35o9</guid>
      <description>&lt;p&gt;The payment form double-submitted on a flaky connection. Two POSTs hit &lt;code&gt;/payments&lt;/code&gt; with the same idempotency key. One row landed in the table. The client got &lt;code&gt;200 OK&lt;/code&gt; both times. I asked why the second insert did not fail, and got three different answers from three engineers.&lt;/p&gt;

&lt;p&gt;The real answer was in a migration from before any of us joined.&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;payments_idempotency_key_idx&lt;/span&gt;
    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;payments&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;idempotency_key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That index, and nothing in the Python, is what made the endpoint safe to retry.&lt;/p&gt;

&lt;h2&gt;
  
  
  The constraint is the retry policy
&lt;/h2&gt;

&lt;p&gt;The handler 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;def&lt;/span&gt; &lt;span class="nf"&gt;create_payment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&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="n"&gt;cur&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;INSERT INTO payments (idempotency_key, account_id, amount_cents)
               VALUES (%s, %s, %s)
               ON CONFLICT DO NOTHING&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;req&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="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;account_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;req&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="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;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&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;rollback&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&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;accepted&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;Note the bare &lt;code&gt;ON CONFLICT DO NOTHING&lt;/code&gt;. It names no target and no index. That clause compiles whether or not a unique constraint exists. When one exists, the duplicate is absorbed. When none exists, the same code inserts a second row and returns success. The endpoint's retry safety lives entirely in the schema, not in the code.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;except Exception&lt;/code&gt; makes it worse. It catches the genuine constraint violation just as quietly as it catches a dropped connection, so an absorbed duplicate and a rolled-back write produce the same response.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "it happens to work" is the problem
&lt;/h2&gt;

&lt;p&gt;Nothing in that function announces an idempotency contract. The index looks like ordinary data hygiene, the kind someone adds during a cleanup ticket. So it gets treated as removable.&lt;/p&gt;

&lt;p&gt;Two changes break it. Someone drops the index to cut write amplification on a hot table, and the bare &lt;code&gt;ON CONFLICT DO NOTHING&lt;/code&gt; starts silently inserting duplicates. Or someone rewrites the branch to &lt;code&gt;DO UPDATE&lt;/code&gt; so the response can carry the stored row, and a retry now overwrites an amount that already settled.&lt;/p&gt;

&lt;p&gt;Neither change fails a test, because the test sends one request and asserts one row. That assertion passes with the index, without it, and with &lt;code&gt;DO UPDATE&lt;/code&gt;. The suite was measuring the wrong thing the whole time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Absorbed is not the same as correct
&lt;/h2&gt;

&lt;p&gt;An absorbed duplicate means the database kept one row. It says nothing about what the client received. If the handler builds a fresh UUID per call, the retry gets a different resource id for the same charge, and any client that reconciles by id now holds two. If the response is just &lt;code&gt;{"status": "accepted"}&lt;/code&gt;, the client learns nothing about which write won.&lt;/p&gt;

&lt;p&gt;A correct replay returns the same answer twice: same status, same body, same id. That is the property clients actually depend on, and it is the property you should assert.&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_retry_returns_same_body&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;db&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;body&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;k-1&lt;/span&gt;&lt;span class="sh"&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;account_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;7&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_cents&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;first&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;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;/payments&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="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;second&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;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;/payments&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="n"&gt;body&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;first&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="n"&gt;second&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;200&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;first&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="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;second&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;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 count(*) FROM payments WHERE idempotency_key = &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;k-1&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="mi"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run that against the handler above and the row assertion passes while the body assertion fails if you generate the id per call. That failure is the point. It tells you the guarantee was only ever half there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding the accidental ones
&lt;/h2&gt;

&lt;p&gt;Grep the migrations for &lt;code&gt;UNIQUE&lt;/code&gt; and &lt;code&gt;CREATE UNIQUE INDEX&lt;/code&gt;. Grep the handlers for &lt;code&gt;ON CONFLICT&lt;/code&gt; and for &lt;code&gt;IntegrityError&lt;/code&gt;. Every place that catches a unique violation is declaring a retry contract it never wrote down.&lt;/p&gt;

&lt;p&gt;Then ask one question per handler: does any test send the same request twice? If not, the guarantee is unverified and undefended. Write the two-request test first. If it passes, you have pinned the behaviour to the schema and to a test at the same time. If it fails, you just found a latent double-write before a customer did.&lt;/p&gt;

&lt;p&gt;Name the constraint in a comment next to the conflict clause. Not for documentation's sake, but so the next person weighing write throughput against dropping that index can see what it is holding up. A unique index that is load-bearing should read like one.&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>idempotency</category>
      <category>sql</category>
      <category>testing</category>
      <category>postgres</category>
    </item>
    <item>
      <title>Your Test Suite Is Slow Because It Tests Postgres</title>
      <dc:creator>晖莫</dc:creator>
      <pubDate>Sat, 26 Sep 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/_66d02d0cc1ece7d1137c5f/your-test-suite-is-slow-because-it-tests-postgres-2k6o</link>
      <guid>https://dev.to/_66d02d0cc1ece7d1137c5f/your-test-suite-is-slow-because-it-tests-postgres-2k6o</guid>
      <description>&lt;p&gt;I quit running the test suite on my laptop. Not deliberately. I just stopped typing the command, because by the time it finished I had lost the thread of whatever I was fixing. Standup would end, I would push, and wait for CI to tell me what I broke. That is the real cost of a slow suite: not the wall-clock time, but the gap between writing a line and knowing whether it works.&lt;/p&gt;

&lt;p&gt;Ours was slow for a boring reason. Every test opened its own connection, applied the migrations, and talked to a real Postgres instance. A test that checked whether an email address was normalized still paid for a connection, because the fixture chain handed it a session it never used.&lt;/p&gt;

&lt;h2&gt;
  
  
  Find out what the tests are actually waiting on
&lt;/h2&gt;

&lt;p&gt;The number to measure is not total runtime. It is per-test setup cost. Time each fixture, or wrap connection setup in a timer and log it, then sort the results. What you want is the ratio between time spent arranging infrastructure and time spent running the code under test. If setup dominates, the suite is measuring Postgres, not your logic. &lt;code&gt;pytest --durations=20&lt;/code&gt; names the slowest tests, but add your own fixture timing too, because the fixture is where the money goes.&lt;/p&gt;

&lt;p&gt;Then read each test and ask one question: does this test fail if the SQL is wrong? Not "does it touch a table" — does it assert something only a database can tell you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which tests actually need a database
&lt;/h2&gt;

&lt;p&gt;A test needs Postgres if it exercises one of three things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a query — the WHERE clause, a JOIN, an index assumption, an ORDER BY that depends on collation&lt;/li&gt;
&lt;li&gt;a constraint — a unique index, a foreign key, a check constraint, a NOT NULL&lt;/li&gt;
&lt;li&gt;a transaction boundary — rollback on error, isolation, locking, a deferred constraint&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything else is using the database for convenience. If a test loads a row through a repository, calls a function on it, and asserts on the return value, the database was an expensive way to construct an object. That test wants a plain object.&lt;/p&gt;

&lt;p&gt;The line is not "unit versus integration" by file layout. A test that calls &lt;code&gt;UserRepository.get(id)&lt;/code&gt; against a real connection is an integration test even when it lives in &lt;code&gt;tests/unit/&lt;/code&gt; and never starts an HTTP client. Naming a directory does not change what the process does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shared schema, transaction per test
&lt;/h2&gt;

&lt;p&gt;The fix that buys back most of the time is not deleting the database. It is applying the schema once and isolating each test in a transaction that rolls back.&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="nd"&gt;@pytest.fixture&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;session&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;engine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;create_engine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TEST_DATABASE_URL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;Base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# migrations run once per session
&lt;/span&gt;    &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt;
    &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dispose&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="nd"&gt;@pytest.fixture&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;session&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;connection&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;transaction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;begin&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bind&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;join_transaction_mode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;create_savepoint&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;session&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;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rollback&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# nothing survives the test
&lt;/span&gt;    &lt;span class="n"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The schema is built once. Each test gets a connection and a transaction, and rollback is the cleanup. No truncate loop between tests, no migrations re-run per test, no ordering dependencies leaking through leftover rows. Tests that need to observe a real commit opt out with an explicit marker, so the expensive path is visible in the file instead of hidden in a fixture.&lt;/p&gt;

&lt;p&gt;One caveat: code that opens its own connection will not see the test's uncommitted rows. Make the session injectable, or move those tests into the small set that runs against a real server. Do not paper over it with a shared connection.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tests that genuinely need Postgres
&lt;/h2&gt;

&lt;p&gt;Keep them. Do not mock the query planner. A small set of tests that hit real Postgres and assert on real SQL behavior is worth more than a pile of tests that pretend, but it should be a set you can name, not the default for everything in the suite.&lt;/p&gt;

&lt;p&gt;Mark them (&lt;code&gt;@pytest.mark.postgres&lt;/code&gt;), run them on every push in CI, and keep the fast tier on every save locally. The default command runs the fast tests; the database tests are one flag away. When they fail, the failure means something about the SQL, which is exactly the signal you wanted from the database in the first place.&lt;/p&gt;

&lt;p&gt;Calling something a unit test does not make it fast, and importing a repository does not make it an integration test. What matters is whether a real database is the only thing that can fail.&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>testing</category>
      <category>pytest</category>
      <category>postgres</category>
      <category>sql</category>
    </item>
    <item>
      <title>Cache invalidation is a domain problem, not a TTL setting</title>
      <dc:creator>晖莫</dc:creator>
      <pubDate>Fri, 25 Sep 2026 12:59:57 +0000</pubDate>
      <link>https://dev.to/_66d02d0cc1ece7d1137c5f/cache-invalidation-is-a-domain-problem-not-a-ttl-setting-1kc3</link>
      <guid>https://dev.to/_66d02d0cc1ece7d1137c5f/cache-invalidation-is-a-domain-problem-not-a-ttl-setting-1kc3</guid>
      <description>&lt;p&gt;Support pasted a screenshot at 9:40am: a customer's invoice page showed a balance of $0.00, and a second tab showed $412.60. Same account, same minute. No deploy that morning. The database was right. Our cache was wrong, and nothing in the system was responsible for telling it so.&lt;/p&gt;

&lt;p&gt;We had a TTL. That is the part that stings. Every key expired in five minutes, which felt careful at the time, and for two years it was fine because nobody reconciled two tabs at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  A TTL is a bet, not a correctness mechanism
&lt;/h2&gt;

&lt;p&gt;A TTL says: I believe this answer stays true for N seconds. That is a guess about your write patterns, disguised as a config value. It has no idea that &lt;code&gt;invoices.balance.8842&lt;/code&gt; was just invalidated by a payment webhook. When a TTL-driven cache is wrong, it is not broken — it is behaving exactly as designed and answering a question about the past.&lt;/p&gt;

&lt;p&gt;The honest framing: a cache is a stored copy of a decision you already made. The database decided the balance was $412.60. Our cache held a copy of the decision that said $0.00, made before the payment landed. The only interesting question is which writes make that copy wrong, and which code paths know about those writes.&lt;/p&gt;

&lt;p&gt;If you want to know how much staleness you actually tolerate, measure it. Write the timestamp of the cached write into the value, and log &lt;code&gt;now - write_time&lt;/code&gt; on every read of a key that later turns out stale. That distribution tells you what your TTL is really costing. Do not guess the number; sample it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The write path is the only thing that knows
&lt;/h2&gt;

&lt;p&gt;Reads cannot invalidate. A read has no idea whether another writer is mid-transaction. The write path — the one place that changes the underlying decision — is the only code that knows the old answer is now garbage.&lt;/p&gt;

&lt;p&gt;We ran cache-aside: the reader did &lt;code&gt;GET&lt;/code&gt;, missed, queried Postgres, then &lt;code&gt;SETEX&lt;/code&gt;. It looked clean. The bug was ownership. Nobody owned &lt;code&gt;invoices.balance.{id}&lt;/code&gt;. The reader created it. The payment service, which actually changed the balance, had never heard of the key. So the payment service wrote to Postgres and the cache kept serving its stale copy until the TTL swept it away.&lt;/p&gt;

&lt;p&gt;Write-through inverts that. The writer updates the store and the cache in one place, so the key has an owner:&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;record_payment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;account_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;conn&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;with&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;transaction&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 invoices SET balance = balance - %s WHERE account_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;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;account_id&lt;/span&gt;&lt;span class="p"&gt;),&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 balance FROM invoices WHERE account_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;account_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="c1"&gt;# The writer owns the key. Reads never create it.
&lt;/span&gt;    &lt;span class="n"&gt;redis&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invoices.balance.&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;account_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;balance&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invoice.changed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;account_id&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;ex=300&lt;/code&gt; is now a safety net for a missed write, not the correctness story. That is the difference. When invalidation is owned by the writer, the TTL becomes a backstop you hope never fires.&lt;/p&gt;

&lt;p&gt;Be careful with delete-on-write too. &lt;code&gt;DEL&lt;/code&gt; then re-populate races: a reader can miss, read the pre-commit row, and &lt;code&gt;SETEX&lt;/code&gt; the old value back on top of your fresh write. Delete outside the transaction and re-populate from the committed row, or version the key.&lt;/p&gt;

&lt;h2&gt;
  
  
  Expiry is a coordinated attack on your database
&lt;/h2&gt;

&lt;p&gt;The other failure nobody plans for: everyone's key expires at the same moment. A popular key with a 300-second TTL, written at 10:00:00 during a batch job, expires at 10:05:00 for every reader at once. All of them miss, all of them hit Postgres, and the database that was comfortably handling 200 reads per second suddenly takes the full load.&lt;/p&gt;

&lt;p&gt;Three things helped. Jitter the TTL so keys written together do not die together. Collapse concurrent misses onto a single in-flight fetch so one request rebuilds and the rest wait on it. And for the hottest keys, refresh in the background before expiry so the miss never happens in the request path. Test this deliberately: expire your top key by hand during peak traffic and watch the database.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make invalidation an event, not a cron
&lt;/h2&gt;

&lt;p&gt;The fix that finally held was deleting the sweeper job. We had a cron that walked recent invoices and refreshed their cache entries every five minutes. It was a second source of truth about when data changed, and it was always slightly behind the writes it was chasing.&lt;/p&gt;

&lt;p&gt;Instead, the payment service publishes &lt;code&gt;invoice.changed&lt;/code&gt; with the account id. A subscriber deletes the balance key. The domain already had a name for this moment — a payment was recorded — so the event existed before the cache did. The cache became a consumer of a fact the business cared about, not a timer with an opinion.&lt;/p&gt;

&lt;p&gt;That reframing is the whole job. Stop asking what TTL to pick. Ask which writes make this answer wrong, and who in your code knows they happened. If the answer is "a cron job, eventually," you have a staleness budget, not a cache.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the comments added
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://dev.to/compoundlabs"&gt;@compoundlabs&lt;/a&gt; found the hole in the &lt;code&gt;record_payment&lt;/code&gt; snippet above, and it is worth fixing rather than hand-waving: &lt;code&gt;redis.set&lt;/code&gt; runs &lt;em&gt;after&lt;/em&gt; &lt;code&gt;with conn.transaction()&lt;/code&gt; closes, so the commit and the invalidation are two separate failure domains. A crash in between leaves the old balance in the cache, and the only thing that saves you is &lt;code&gt;ex=300&lt;/code&gt; — which means that window was still a staleness budget, just a short one. I presented the TTL as a backstop while quietly depending on it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix is a transactional outbox.&lt;/strong&gt; Write the intent to invalidate in the same transaction that changes the balance, and let something else publish it:&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;record_payment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;account_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;conn&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;with&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;transaction&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 invoices SET balance = balance - %s WHERE account_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;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;account_id&lt;/span&gt;&lt;span class="p"&gt;),&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;INSERT INTO outbox (topic, payload) VALUES (%s, %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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invoice.changed&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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;account_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;account_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;A relay tails &lt;code&gt;outbox&lt;/code&gt;, publishes &lt;code&gt;invoice.changed&lt;/code&gt;, then marks the row sent. The intent to invalidate now commits atomically with the fact that made it necessary, so there is no window where the database moved and nothing was told.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Delete, do not set.&lt;/strong&gt; Outbox delivery is at-least-once, so the consumer has to be idempotent. Deleting a key is; &lt;code&gt;SET&lt;/code&gt;ting a recomputed value is not, because a delayed duplicate can put an &lt;em&gt;older&lt;/em&gt; balance back on top of a newer one. That is the strongest argument for invalidating instead of writing — and as a bonus, a consumer that only deletes does not care about duplicates or out-of-order delivery at all. The moment you cache a value derived from the event, you owe each key a version and an "ignore anything older than what I already applied" rule.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The window does not close, it moves.&lt;/strong&gt; Reads during relay lag still see the old value, so the thing to alert on is relay lag and rows piling up in &lt;code&gt;outbox&lt;/code&gt; — not cache hit rate. Keep the TTL as the backstop for a relay that is simply down, but stop describing it as the mechanism.&lt;/p&gt;

&lt;p&gt;If you cannot add a table to that database, make the &lt;em&gt;read&lt;/em&gt; safe instead: version the cached value from a column the write already bumps, and have the reader refuse a value older than the row it can see. That is an outbox in disguise, and it needs no relay.&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>caching</category>
      <category>backend</category>
      <category>redis</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Zero-Downtime Migrations Need Two Deploys, Not a Clever Script</title>
      <dc:creator>晖莫</dc:creator>
      <pubDate>Thu, 24 Sep 2026 12:59:59 +0000</pubDate>
      <link>https://dev.to/_66d02d0cc1ece7d1137c5f/zero-downtime-migrations-need-two-deploys-not-a-clever-script-3nep</link>
      <guid>https://dev.to/_66d02d0cc1ece7d1137c5f/zero-downtime-migrations-need-two-deploys-not-a-clever-script-3nep</guid>
      <description>&lt;p&gt;Our checkout page started timing out at 14:02. Not slowly — completely. Every request that touched the orders table hung, connection pool filled, and the whole app went down behind it. The migration I had just run took 40 milliseconds. That was the part that confused me for a while.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ALTER TABLE orders ADD COLUMN currency text NOT NULL DEFAULT 'usd';&lt;/code&gt; is fast. It also takes an &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; lock on &lt;code&gt;orders&lt;/code&gt;. That lock does not care how fast the statement runs. It cares that every read and write already in flight has to finish first, and everything arriving after it queues behind. A 40ms statement plus a 3-second queue of waiting queries is a 3-second outage on that table. Multiply by the retry storm and the pool is gone.&lt;/p&gt;

&lt;p&gt;The lock is the outage. Statement speed is almost irrelevant.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape that actually works
&lt;/h2&gt;

&lt;p&gt;The broken mental model is "one migration, made atomic and careful." The real constraint is different: during a rollout, old instances and new instances are both live. Both run against the same schema for some window. A migration that only works for the new code breaks the old code, and a migration that only works for the old code breaks the new code. Neither version of a single-script plan survives that window.&lt;/p&gt;

&lt;p&gt;So the change has to be split. Expand, then contract. Both halves are boring on their own, and that is the point.&lt;/p&gt;

&lt;h2&gt;
  
  
  Expand: add, write both, backfill, read new
&lt;/h2&gt;

&lt;p&gt;Expand is the additive half. Add the new column or table without breaking anyone, then move traffic over one step at a time.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Step 1: nullable, no default rewrite, no long lock.&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;currency&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Step 2: a NOT VALID constraint takes only a brief lock and&lt;/span&gt;
&lt;span class="c1"&gt;-- does not scan the table. Existing rows are not checked yet.&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;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;orders_currency_not_null&lt;/span&gt;
  &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;currency&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;NOT&lt;/span&gt; &lt;span class="k"&gt;VALID&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the code deploys in stages. First a release that writes both the old and the new field, still reading the old one. Then a backfill, in batches, in the background. Then a release that reads the new field and keeps dual-writing. Then a release that stops writing the old field.&lt;/p&gt;

&lt;p&gt;The backfill is where I have caused the second-worst incidents. A single &lt;code&gt;UPDATE orders SET currency = 'usd' WHERE currency IS NULL&lt;/code&gt; on a large table rewrites every row, generates a huge amount of WAL, and holds row locks for the duration. On a primary with streaming replicas, that WAL is what your replicas have to replay. Push too much and replication lag climbs. Replicas fall behind the primary, and read-your-own-write breaks for users hitting them.&lt;/p&gt;

&lt;p&gt;Batch it, sleep between batches, and watch lag as the loop runs, not after.&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;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;rows&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;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 currency = &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;usd&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;
        WHERE id IN (
            SELECT id FROM orders
            WHERE currency IS NULL
            ORDER BY id LIMIT 1000
            FOR UPDATE SKIP LOCKED
        )
        RETURNING 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;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;break&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="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;replica_lag_seconds&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;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;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;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Measure the batch size against your own table. What matters is that you check replication lag inside the loop and slow down or stop when it grows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lock timeouts are the safety net
&lt;/h2&gt;

&lt;p&gt;Before any DDL, set a lock timeout in the same transaction:&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;BEGIN&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;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;'15s'&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;currency&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the &lt;code&gt;ALTER&lt;/code&gt; cannot get its lock within 3 seconds, it fails instead of blocking every query behind it. A failed migration is a retry. A queued &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; lock is an outage. I would rather rerun a migration than explain a 14:02 incident again.&lt;/p&gt;

&lt;p&gt;This does not make the migration safe by itself. It makes the failure mode small.&lt;/p&gt;

&lt;h2&gt;
  
  
  Contract: drop last, after nothing reads it
&lt;/h2&gt;

&lt;p&gt;Dropping is the easy half to get wrong, because it feels finished the moment the new code ships. It is not. Drop the old column only after you have confirmed no running code reads it and no query in your logs still references it. In a rolling deploy with long-lived instances, "the new release is out" and "old instances are gone" are different facts.&lt;/p&gt;

&lt;p&gt;Break the work across two deploys at minimum, usually more. The clever single script is the wrong shape because it assumes one instant where the schema and all running code shift together. That instant does not exist in a rolling deploy, and every design that pretends it does is a lock waiting for traffic.&lt;/p&gt;

&lt;p&gt;Check the lock mode of any statement you plan to run, keep the transaction short, and put a timeout on it. Then do the other half next week.&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>deploys</category>
      <category>locking</category>
    </item>
    <item>
      <title>Your /health Endpoint Is Lying Because It Never Touches a Dependency</title>
      <dc:creator>晖莫</dc:creator>
      <pubDate>Wed, 23 Sep 2026 12:59:56 +0000</pubDate>
      <link>https://dev.to/_66d02d0cc1ece7d1137c5f/your-health-endpoint-is-lying-because-it-never-touches-a-dependency-2c4d</link>
      <guid>https://dev.to/_66d02d0cc1ece7d1137c5f/your-health-endpoint-is-lying-because-it-never-touches-a-dependency-2c4d</guid>
      <description>&lt;p&gt;Errors spiked at 14:07 and the graphs made no sense. Success rate dropped to about 70%, not zero. Some requests succeeded, some returned 500 with a Postgres connection timeout, and a few completed normally. Our dashboard showed three healthy instances the entire time. Every probe had passed. The load balancer was still routing traffic to an app that had been unable to talk to its database for twenty minutes.&lt;/p&gt;

&lt;p&gt;That is the failure mode. A health check that only proves the process exists keeps a broken instance in rotation, and the outage shows up as random errors instead of a clean removal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Liveness and readiness answer different questions
&lt;/h2&gt;

&lt;p&gt;Liveness asks: should the supervisor restart this process? Readiness asks: should the load balancer send it traffic? If one endpoint answers both, you get one of two bad outcomes.&lt;/p&gt;

&lt;p&gt;When the check is shallow, it tells the supervisor "no restart needed" — correct, the process really is fine. Then it tells the load balancer the same thing, and that answer is wrong. The binary answer cannot be right for both readers.&lt;/p&gt;

&lt;p&gt;When the check is deep and shared, a dead database makes every instance unready, so every instance gets pulled, and the load balancer has nowhere to send traffic. That is a cascading failure dressed up as a health check.&lt;/p&gt;

&lt;p&gt;Keep them separate. This is a Kubernetes example but the split applies to any orchestrator or proxy.&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="na"&gt;livenessProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;httpGet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/livez&lt;/span&gt;
    &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;
  &lt;span class="na"&gt;periodSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
  &lt;span class="na"&gt;timeoutSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
  &lt;span class="na"&gt;failureThreshold&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;readinessProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;httpGet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/readyz&lt;/span&gt;
    &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;
  &lt;span class="na"&gt;periodSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
  &lt;span class="na"&gt;timeoutSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
  &lt;span class="na"&gt;failureThreshold&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/livez&lt;/code&gt; returns 200 if the event loop can serve an HTTP response. Nothing else. It never queries the database, never calls a downstream service, and never allocates a connection pool.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;/readyz&lt;/code&gt; checks the things that must work for a request to succeed. It is allowed to fail, and failing means removal from rotation, not a restart.&lt;/p&gt;

&lt;p&gt;The first time I split these apart, the restart storms stopped. The app had been OOM-restarting because the deep check ran on every liveness tick and opened database connections the collector never released. A probe that does real work has real cost, and paying that cost every ten seconds on every instance adds up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Timeouts are the whole check
&lt;/h2&gt;

&lt;p&gt;A readiness probe with no timeout is worse than no probe. If the database call hangs, the probe hangs, the orchestrator waits, and traffic keeps flowing to the stuck instance for the entire timeout window.&lt;/p&gt;

&lt;p&gt;Give the check a hard deadline shorter than the probe's own timeout, and fail closed:&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;asyncpg&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;readyz&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="k"&gt;try&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;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="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.5&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;fetchval&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 1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&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;status&lt;/span&gt;&lt;span class="sh"&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;ready&lt;/span&gt;&lt;span class="sh"&gt;"&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;asyncpg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PostgresError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;TimeoutError&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;exc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;503&lt;/span&gt;&lt;span class="p"&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;status&lt;/span&gt;&lt;span class="sh"&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;unready&lt;/span&gt;&lt;span class="sh"&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;reason&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;type&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;__name__&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Measure what your database actually returns under normal load — p99 of &lt;code&gt;SELECT 1&lt;/code&gt; on a warm pool — then set the probe deadline above that and far below your request timeout. Do not guess the number. Log the probe duration and read the histogram.&lt;/p&gt;

&lt;p&gt;Cache the result too. If the load balancer hits &lt;code&gt;/readyz&lt;/code&gt; from ten addresses per second, do not run ten queries per second. Cache the verdict for one or two seconds. The cache TTL becomes your detection latency, so keep it short, and document it.&lt;/p&gt;

&lt;p&gt;Return 503, not 200 with a JSON body that says &lt;code&gt;"status": "degraded"&lt;/code&gt;. Load balancers read status codes. A 200 with bad news inside is a 200.&lt;/p&gt;

&lt;h2&gt;
  
  
  Degraded is not down
&lt;/h2&gt;

&lt;p&gt;The hardest case is a dependency that is slow, not dead. The database accepts connections but queries take four seconds instead of forty milliseconds. Marking the instance unready here removes capacity exactly when the system needs all of it, and the next instance inherits the same slow database.&lt;/p&gt;

&lt;p&gt;Shed load instead of failing. Keep &lt;code&gt;/readyz&lt;/code&gt; at 200, and expose a second signal the application itself uses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;App&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;admit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&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;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Latency&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;degradedThreshold&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;atomic&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LoadInt64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inflight&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;maxInflight&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;2&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;ErrShedLoad&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Return 429 or 503 with &lt;code&gt;Retry-After&lt;/code&gt; on the shed requests. The instance stays in rotation, serves what it can, and stops piling work onto a dependency that is already behind. Readiness stays green. The error rate is visible and bounded instead of total.&lt;/p&gt;

&lt;h2&gt;
  
  
  The process being up is not a health signal
&lt;/h2&gt;

&lt;p&gt;An HTTP 200 on &lt;code&gt;/health&lt;/code&gt; tells you a socket accepted a connection and a handler returned. It says nothing about the database, the cache, the queue, or the disk. It is a liveness signal at best, and most teams already get liveness for free from the orchestrator's process supervisor.&lt;/p&gt;

&lt;p&gt;Before you trust the next green dashboard, do this: kill the network path from one instance to its database, then watch. If traffic keeps arriving, your health check is lying. If every instance drops out at once, it is lying in the other direction.&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>healthchecks</category>
      <category>reliability</category>
      <category>devops</category>
      <category>http</category>
    </item>
  </channel>
</rss>
