<?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>Postgres Advisory Locks Are Not the Lock You Think</title>
      <dc:creator>晖莫</dc:creator>
      <pubDate>Mon, 14 Sep 2026 13:57:15 +0000</pubDate>
      <link>https://dev.to/_66d02d0cc1ece7d1137c5f/postgres-advisory-locks-are-not-the-lock-you-think-3ig3</link>
      <guid>https://dev.to/_66d02d0cc1ece7d1137c5f/postgres-advisory-locks-are-not-the-lock-you-think-3ig3</guid>
      <description>&lt;p&gt;At 03:40 my nightly reconcile job ran twice. Two workers, same &lt;code&gt;pg_advisory_lock(42)&lt;/code&gt;, both proceeded, and the refunds went out twice. The lock was real. The code was correct. It still did nothing, because each worker held a different Postgres backend behind PgBouncer in transaction mode.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;pg_advisory_lock&lt;/code&gt; looks like a distributed lock. It is not one. It is a mutex owned by a database session.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the lock actually is
&lt;/h2&gt;

&lt;p&gt;A session-level advisory lock lives in the backend process that ran the statement. &lt;code&gt;pg_advisory_lock(key)&lt;/code&gt; blocks until it gets the lock. &lt;code&gt;pg_try_advisory_lock(key)&lt;/code&gt; returns true or false right away. The lock is held until &lt;code&gt;pg_advisory_unlock(key)&lt;/code&gt; runs or that backend disconnects. It is reentrant: two calls on the same session need two unlocks, and a stray &lt;code&gt;pg_advisory_unlock&lt;/code&gt; on a lock you do not hold returns false and logs a warning while changing nothing.&lt;/p&gt;

&lt;p&gt;Now the failure modes, in the order I hit them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The pool owns the session, not you.&lt;/strong&gt; In transaction mode each transaction can land on a different backend. My worker acquired the lock in transaction one and released it in transaction two. The unlock ran on a backend that never held it, returned false, and I had been filtering that warning out of the logs for months. The original backend kept the lock for whichever client got it next. New workers blocked, or with the try variant, skipped their turn forever.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disconnects release the lock.&lt;/strong&gt; A failover, a restart, or a dropped TCP connection kills the backend and the lock with it. A worker that believes it holds the lock now does not, and nothing tells it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No fencing token.&lt;/strong&gt; Even with one stable connection, the lock says nothing about the write that happens after it. A worker can be paused by GC, a VM freeze, or a network partition past its lock's lifetime, wake up, and write anyway. The lock was never what stopped the second writer. Only the resource can do that.&lt;/p&gt;

&lt;h2&gt;
  
  
  What advisory locks are good for
&lt;/h2&gt;

&lt;p&gt;Short mutual exclusion inside one database, inside one transaction. That is the honest scope.&lt;/p&gt;

&lt;p&gt;Use &lt;code&gt;pg_advisory_xact_lock&lt;/code&gt; or &lt;code&gt;pg_try_advisory_xact_lock&lt;/code&gt;. They release at &lt;code&gt;COMMIT&lt;/code&gt; or &lt;code&gt;ROLLBACK&lt;/code&gt;, so a crashed client cannot leak them, and you write no unlock code at all.&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="c1"&gt;-- Non-blocking: false means another transaction is already doing this work.&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pg_try_advisory_xact_lock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hashtext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'nightly-reconcile'&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;got_it&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- Application: if got_it is false, COMMIT and return.&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;ledger&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;reconciled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;reconciled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&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;That pattern is fine for guarding a migration, serializing a trigger, or stopping two cron entries from running the same statement at once. It needs direct connections, or a pooler in session mode, and a transaction measured in seconds, not minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to use when you need a lease
&lt;/h2&gt;

&lt;p&gt;If the work outlives a transaction, or crosses processes you do not control, put the lease in a table. A row with an expiry and a fencing token gives you something a session lock cannot: a value the resource itself can check.&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;TABLE&lt;/span&gt; &lt;span class="n"&gt;job_lease&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;job_name&lt;/span&gt;   &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;owner&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="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;fencing&lt;/span&gt;    &lt;span class="nb"&gt;bigint&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;expires_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&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="c1"&gt;-- Claim. One atomic statement, no session state, safe behind any pooler.&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;job_lease&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;owner&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fencing&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expires_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'nightly-reconcile'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;worker_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'5 minutes'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;CONFLICT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job_name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt;
  &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;owner&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;EXCLUDED&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;owner&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="n"&gt;fencing&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;job_lease&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fencing&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="n"&gt;expires_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;EXCLUDED&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;expires_at&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;job_lease&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;expires_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;RETURNING&lt;/span&gt; &lt;span class="n"&gt;fencing&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- No row returned: a live lease exists, back off.&lt;/span&gt;
&lt;span class="c1"&gt;-- Row returned: you own the lease and the new fencing token.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The token only matters if the resource enforces it. Store the last token on the row you mutate and reject anything older:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;refunds&lt;/span&gt;
   &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;processed_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;last_fencing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;fencing&lt;/span&gt;
 &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;refund_id&lt;/span&gt;
   &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;fencing&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;last_fencing&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero rows updated means a stale worker tried to write. Log it and stop. Renew the lease with the same three-way check, and treat a zero-row renewal as an immediate stop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;job_lease&lt;/span&gt;
   &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;expires_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'5 minutes'&lt;/span&gt;
 &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;job_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'nightly-reconcile'&lt;/span&gt;
   &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;owner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;worker_id&lt;/span&gt;
   &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;fencing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;fencing&lt;/span&gt;
&lt;span class="n"&gt;RETURNING&lt;/span&gt; &lt;span class="n"&gt;expires_at&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set the expiry from data, not vibes. Log &lt;code&gt;clock_timestamp()&lt;/code&gt; at the start and end of the critical section in production, read the high percentile after a week, and make the lease several times longer than that while renewing at a fraction of it. If a run can occasionally take much longer than usual, chunk the work and renew between chunks.&lt;/p&gt;

&lt;p&gt;I still use &lt;code&gt;pg_advisory_xact_lock&lt;/code&gt;. I use it for what it is: a lock for the length of one transaction, on a connection I control. Anything longer, or anything that has to survive a reconnect, gets a row and a token.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>advisorylocks</category>
      <category>distributed</category>
      <category>sql</category>
    </item>
    <item>
      <title>Your Retry Logic Is a Load Amplifier</title>
      <dc:creator>晖莫</dc:creator>
      <pubDate>Mon, 14 Sep 2026 13:55:45 +0000</pubDate>
      <link>https://dev.to/_66d02d0cc1ece7d1137c5f/your-retry-logic-is-a-load-amplifier-58da</link>
      <guid>https://dev.to/_66d02d0cc1ece7d1137c5f/your-retry-logic-is-a-load-amplifier-58da</guid>
      <description>&lt;p&gt;Our cache cluster blipped for four seconds at 09:14. It came back at 09:14:04 and immediately fell over again, and this time it stayed down for nineteen minutes. Nothing was wrong with the cache. We had DDoS'd ourselves with our own retry policy, from roughly 900 pods running the same library.&lt;/p&gt;

&lt;p&gt;The code looked responsible. It was the pattern every blog post recommends:&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;random&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;backoff_delay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base&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="n"&gt;cap&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;30.0&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# exponential backoff, no jitter
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why synchrony is the bug
&lt;/h2&gt;

&lt;p&gt;Here is the mechanism I missed for a long time. Every client that fails at the same wall-clock instant computes the &lt;em&gt;same&lt;/em&gt; next-attempt time, because the delay is a pure function of the attempt number. Pods don't have independent attempt counters in the interesting case — they all fell over together when the dependency degraded, so they're all on attempt 0, then all on attempt 1, and so on.&lt;/p&gt;

&lt;p&gt;The result is not a smooth smear of retries. It's a train of discrete spikes. At t+0.5s, all 900 pods retry. The remaining capacity absorbs maybe half of them. The other half fail, and now they all retry together at t+1.5s. Each wave is bigger than the load the dependency can take, and the waves line up exactly at the moments the dependency is weakest — because a saturated service recovers slower than it degrades.&lt;/p&gt;

&lt;p&gt;The failure mode has a name worth knowing: a retry storm, or metastable failure. The system doesn't return to its original state when the trigger is removed, because the retries themselves are the load.&lt;/p&gt;

&lt;p&gt;I confirmed it by logging not the retry count but the &lt;em&gt;histogram of retry timestamps&lt;/em&gt; across the fleet. A healthy client pool produces a flat distribution. Ours had three sharp bars, 500ms apart. That histogram is the measurement you want — it's cheap, and it tells you immediately whether you have a synchrony problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Full jitter and decorrelated jitter
&lt;/h2&gt;

&lt;p&gt;The fix is to make the delay a random variable instead of a function. AWS's architecture blog popularized the comparison, and full jitter is the one I use by default:&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;random&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;sleep_full_jitter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base&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="n"&gt;cap&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;30.0&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# pick uniformly from [0, capped exponential]
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uniform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;sleep_decorrelated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base&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="n"&gt;cap&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;30.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prev&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# sleep = min(cap, random(base, prev * 3))
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uniform&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;prev&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Full jitter spreads arrivals uniformly across the window. Decorrelated jitter is stateful: each delay is drawn between &lt;code&gt;base&lt;/code&gt; and three times the previous delay, which gives a random walk that widens over time and tends to keep the average load lower on long outages. Full jitter is simpler and has no per-attempt state to get wrong. Decorrelated wins when retries can last for minutes and you want the tail to stretch.&lt;/p&gt;

&lt;p&gt;The important property of both: the expected &lt;em&gt;rate&lt;/em&gt; of retries stays roughly constant rather than spiking, even if the number of clients is large. That's the whole point. You are converting a synchronizing process into a dispersing one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retry budgets beat per-call counts
&lt;/h2&gt;

&lt;p&gt;Jitter alone wasn't enough for us, because the request amplifier was still unbounded. The real control is a retry budget: cap retries as a &lt;em&gt;fraction of total requests&lt;/em&gt; per client, not per call.&lt;/p&gt;

&lt;p&gt;Instead of "each call may retry 3 times," say "this client may issue retries equal to 10% of its successful requests, refilled continuously." Google's SRE book describes this as a retry budget; Finagle exposes it as &lt;code&gt;RetryBudget&lt;/code&gt;. The property you get is proportionality: when the dependency is healthy, retries are cheap and plentiful. When it's degraded and most requests fail, the budget drains and retries stop almost entirely. It's a negative feedback loop where per-call counts are a fixed multiplier.&lt;/p&gt;

&lt;p&gt;A budget also bounds the worst case. With 3 retries per call at 5 layers of the stack, one user request can become 4^5 = 1024 calls. I have seen this exact multiplication in a service mesh, and it is not theoretical.&lt;/p&gt;

&lt;h2&gt;
  
  
  Circuit breakers and idempotency
&lt;/h2&gt;

&lt;p&gt;Two things have to be true or none of this works.&lt;/p&gt;

&lt;p&gt;The circuit breaker trips on &lt;em&gt;failure rate over a rolling window&lt;/em&gt;, and it must have a half-open state that admits a small number of probes. Without it, retries keep the load on a dead dependency while the breaker's state machine still reports closed. With it, the breaker and the budget do different jobs: the budget limits how much extra traffic you generate, the breaker stops generating traffic at all.&lt;/p&gt;

&lt;p&gt;Idempotency is the harder one. Retrying a non-idempotent &lt;code&gt;POST /payments&lt;/code&gt; doesn't amplify load, it duplicates money. Every retried operation needs an idempotency key that the server deduplicates on, and the client needs to send the &lt;em&gt;same&lt;/em&gt; key on retry, not a fresh one. If you can't make an operation idempotent, don't retry it — return the error and let the caller decide.&lt;/p&gt;

&lt;p&gt;The version of &lt;code&gt;backoff_delay&lt;/code&gt; I run today is roughly fifteen lines, and the diff that mattered was adding &lt;code&gt;random.uniform(0, ...)&lt;/code&gt;. That one call was the difference between a four-second blip and a nineteen-minute outage.&lt;/p&gt;

</description>
      <category>retries</category>
      <category>backoff</category>
      <category>reliability</category>
      <category>distributed</category>
    </item>
    <item>
      <title>Why the index I added made my query slower</title>
      <dc:creator>晖莫</dc:creator>
      <pubDate>Mon, 14 Sep 2026 13:50:54 +0000</pubDate>
      <link>https://dev.to/_66d02d0cc1ece7d1137c5f/why-the-index-i-added-made-my-query-slower-4ga2</link>
      <guid>https://dev.to/_66d02d0cc1ece7d1137c5f/why-the-index-i-added-made-my-query-slower-4ga2</guid>
      <description>&lt;p&gt;I added &lt;code&gt;CREATE INDEX ON events (status)&lt;/code&gt; on a Friday. By Monday the dashboard query that looks for pending events was slower than before I touched it. The index was not corrupt, the query was not wrong, and nothing had been deployed in between. The planner had just picked a plan that costs more on this table.&lt;/p&gt;

&lt;h2&gt;
  
  
  The plan changed, not the data
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;status&lt;/code&gt; has four values, and &lt;code&gt;pending&lt;/code&gt; is most of the rows, especially right after a backlog. Postgres will still reach for an index when the predicate is not selective, because the planner decides from the row estimate in &lt;code&gt;pg_statistic&lt;/code&gt;, which is built by sampling the table during &lt;code&gt;ANALYZE&lt;/code&gt;. If the estimate says "few rows", it chooses a bitmap index scan or a plain index scan. Then it fetches heap pages in index order, one random read at a time, rechecks visibility on every tuple, and throws away most of what it read. A sequential scan reads the same pages in physical order and never opens the index at all.&lt;/p&gt;

&lt;p&gt;Two more versions of the same mistake caught me earlier.&lt;/p&gt;

&lt;p&gt;A composite index on &lt;code&gt;(tenant_id, created_at)&lt;/code&gt; did nothing for the queries that filtered on &lt;code&gt;created_at&lt;/code&gt; alone. Postgres cannot cheaply skip a leading column, so the index sat there costing writes and disk while the plan stayed sequential. Put the column you range-filter on first.&lt;/p&gt;

&lt;p&gt;Stale statistics after a bulk load. I inserted a large batch and ran the app immediately. Autovacuum had not analyzed the table yet, so the estimates were from before the load and the planner was reasoning about a table that no longer existed.&lt;/p&gt;

&lt;p&gt;Index bloat is the third one. After enough updates and deletes, index pages fill with dead entries, the index grows past what the live data warrants, and index-only scans stop being index-only because the visibility map is not set. Check &lt;code&gt;pg_stat_user_indexes.idx_scan&lt;/code&gt; for indexes nothing reads, and &lt;code&gt;pg_stat_all_tables.n_dead_tup&lt;/code&gt; with &lt;code&gt;last_autovacuum&lt;/code&gt; for tables autovacuum is losing ground on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Confirming it with EXPLAIN (ANALYZE, BUFFERS)
&lt;/h2&gt;

&lt;p&gt;Guessing is how I got here. Run the actual query:&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;EXPLAIN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ANALYZE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BUFFERS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;VERBOSE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'pending'&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;50&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read four things. The node type: &lt;code&gt;Seq Scan&lt;/code&gt; turning into &lt;code&gt;Bitmap Heap Scan&lt;/code&gt; or &lt;code&gt;Index Scan&lt;/code&gt; is the change you are hunting. The &lt;code&gt;rows=&lt;/code&gt; on the estimated line against the &lt;code&gt;rows=&lt;/code&gt; on the actual line for the same node, where a wide gap means the planner was working from bad statistics. &lt;code&gt;Rows Removed by Filter&lt;/code&gt;, where a count close to the rows returned means the index is discarding nearly everything it read. And &lt;code&gt;Heap Fetches&lt;/code&gt; under an &lt;code&gt;Index Only Scan&lt;/code&gt;, where a high count means the visibility map is stale, usually after heavy writes.&lt;/p&gt;

&lt;p&gt;Buffers tell you more than wall-clock time. &lt;code&gt;shared hit&lt;/code&gt; and &lt;code&gt;shared read&lt;/code&gt; count the 8 kB pages the plan touched. A plan that touches more pages loses on a cold cache even when its estimated cost looks lower. Run the statement twice: the first run shows reads, the second shows hits, and neither is the same thing as the cost model.&lt;/p&gt;

&lt;p&gt;For a real comparison, capture &lt;code&gt;EXPLAIN (ANALYZE, BUFFERS)&lt;/code&gt; before the index exists and again after, on the same data, and compare buffer counts. Do not trust the estimated cost column on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do instead
&lt;/h2&gt;

&lt;p&gt;Make the index match the query, or drop it. A partial index is usually the answer when the hot predicate is a small slice of the 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;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;CONCURRENTLY&lt;/span&gt; &lt;span class="n"&gt;events_pending_created_at_idx&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="p"&gt;(&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="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&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;That index holds only the rows the query wants, so it stays small, and it can serve the &lt;code&gt;ORDER BY&lt;/code&gt; as well as the filter. &lt;code&gt;CONCURRENTLY&lt;/code&gt; avoids blocking writes, at the cost of a slower build and a possible invalid index if the build fails — check &lt;code&gt;pg_index.indisvalid&lt;/code&gt; before assuming it worked.&lt;/p&gt;

&lt;p&gt;The rest of the toolbox: put the filtered column first in a composite index, add &lt;code&gt;INCLUDE&lt;/code&gt; columns when you want an index-only scan, run &lt;code&gt;ANALYZE&lt;/code&gt; yourself after a bulk load rather than waiting for autovacuum, and use &lt;code&gt;CREATE STATISTICS&lt;/code&gt; when two columns are correlated and the single-column estimates mislead the planner. Then verify the fix the same way you found the problem. After the change, &lt;code&gt;EXPLAIN (ANALYZE, BUFFERS)&lt;/code&gt; should show fewer shared buffers than the plan it replaced. If it does not, the index is not paying for its write cost, and &lt;code&gt;DROP INDEX CONCURRENTLY&lt;/code&gt; is the honest fix.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>sql</category>
      <category>performance</category>
      <category>indexing</category>
    </item>
    <item>
      <title>Idempotency keys are the cheapest reliability feature you can ship</title>
      <dc:creator>晖莫</dc:creator>
      <pubDate>Sun, 13 Sep 2026 17:31:21 +0000</pubDate>
      <link>https://dev.to/_66d02d0cc1ece7d1137c5f/idempotency-keys-are-the-cheapest-reliability-feature-you-can-ship-35n4</link>
      <guid>https://dev.to/_66d02d0cc1ece7d1137c5f/idempotency-keys-are-the-cheapest-reliability-feature-you-can-ship-35n4</guid>
      <description>&lt;p&gt;Every payment integration eventually ships the same bug. A client sends &lt;code&gt;POST /charge&lt;/code&gt;, the request succeeds, the response times out in transit, the client retries, and the customer is billed twice. Nobody wrote incorrect logic. The bug lives in the gap between "the work happened" and "the caller learned that the work happened", and no amount of careful coding closes that gap on its own.&lt;/p&gt;

&lt;p&gt;Idempotency keys close it. The idea is small enough to explain in one sentence: the caller attaches a unique key to a request, and the server promises that the same key will produce the same outcome exactly once, no matter how many times it arrives.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why retries are not optional
&lt;/h2&gt;

&lt;p&gt;If you run anything over a network, retries are already happening. HTTP clients retry. Load balancers retry. Your own SDK retries on connection reset. Kubernetes restarts pods mid-request and the work is delivered again by a queue redelivery you did not configure.&lt;/p&gt;

&lt;p&gt;So the question is never "should we handle duplicates". The question is whether you handle them deliberately or discover them in a support ticket at 2am with a customer asking why their card was charged three times.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four lines that make it work
&lt;/h2&gt;

&lt;p&gt;The storage design is what people get wrong. An idempotency record needs to capture three things: the key, the request fingerprint, and the result. The result matters as much as the key, because a retry must return the &lt;em&gt;original response&lt;/em&gt;, not just refuse to repeat the work.&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;table&lt;/span&gt; &lt;span class="n"&gt;idempotency_keys&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;key&lt;/span&gt;            &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;request_hash&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="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;status&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;'in_progress'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;-- in_progress | done&lt;/span&gt;
  &lt;span class="n"&gt;response_body&lt;/span&gt;  &lt;span class="n"&gt;jsonb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;response_code&lt;/span&gt;  &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt;     &lt;span class="n"&gt;timestamptz&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="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;expires_at&lt;/span&gt;     &lt;span class="n"&gt;timestamptz&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;create&lt;/span&gt; &lt;span class="k"&gt;index&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;idempotency_keys&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expires_at&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;primary key&lt;/code&gt; on &lt;code&gt;key&lt;/code&gt; is the whole trick. It converts "have I seen this before?" from an application-level check into a database guarantee, which means it stays correct when two requests arrive at the same millisecond on different machines.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part nobody warns you about
&lt;/h2&gt;

&lt;p&gt;The naive flow — read the row, do the work, write the row — has a race. Two concurrent retries both read "no record", both charge the card, and both write. The primary key does not save you, because neither transaction tried to insert until after the damage was done.&lt;/p&gt;

&lt;p&gt;The fix is to make the insert the claim:&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;claim&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="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request_hash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&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;Claim&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;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;insert into idempotency_keys (key, request_hash, expires_at)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; values (%s, %s, now() + interval &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;24 hours&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;)&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;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request_hash&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;return&lt;/span&gt; &lt;span class="n"&gt;Claim&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ACQUIRED&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;UniqueViolation&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch_one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;select * from idempotency_keys where key = %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;key&lt;/span&gt;&lt;span class="p"&gt;,))&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;request_hash&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;request_hash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="c1"&gt;# Same key, different body: the caller has a bug. Refuse loudly.
&lt;/span&gt;            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;Conflict&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 reused with a different payload&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="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;status&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;done&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="n"&gt;Claim&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;REPLAY&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;response_code&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;response_body&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="n"&gt;Claim&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IN_PROGRESS&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three outcomes, and each one deserves a different HTTP status. &lt;code&gt;ACQUIRED&lt;/code&gt; means do the work. &lt;code&gt;REPLAY&lt;/code&gt; means return the stored response verbatim, with the &lt;em&gt;original&lt;/em&gt; status code — a replayed 201 must not become a 200. &lt;code&gt;IN_PROGRESS&lt;/code&gt; means a concurrent attempt is running right now, and the correct answer is &lt;code&gt;409 Conflict&lt;/code&gt; with a &lt;code&gt;Retry-After&lt;/code&gt;, not a second charge.&lt;/p&gt;

&lt;p&gt;That &lt;code&gt;request_hash&lt;/code&gt; comparison is the detail that separates a robust implementation from a dangerous one. Without it, a buggy client that reuses a key for a different payload gets the previous response silently, and you have created a data corruption mechanism that looks like a reliability feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  Expiry is a product decision, not a technical one
&lt;/h2&gt;

&lt;p&gt;Keys cannot live forever. The window should cover the longest plausible retry chain: client-side backoff, a queue redelivery, and a human clicking the button again after lunch. Twenty-four hours covers all of that for most products. Card networks care much less about your key than you do — the money moving twice is the problem, not the row.&lt;/p&gt;

&lt;p&gt;Set &lt;code&gt;expires_at&lt;/code&gt; when you insert, delete on a schedule, and index the expiry column so the cleanup job is a range scan instead of a table scan. If you skip the index, the cleanup query becomes the next incident.&lt;/p&gt;

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

&lt;p&gt;Forty lines, one table, one index, one cron job. Compare that against the cost of a duplicate-charge incident: refunds, a reconciliation script written under pressure, a support conversation you cannot automate, and a customer who now checks their statement.&lt;/p&gt;

&lt;p&gt;Reconciliation is work you do after the fact with incomplete information. Idempotency keys are work you do before the fact with complete information. Ship the keys.&lt;/p&gt;

</description>
      <category>distributed</category>
      <category>api</category>
      <category>reliability</category>
      <category>backend</category>
    </item>
  </channel>
</rss>
