<?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: Ezra Wu</title>
    <description>The latest articles on DEV Community by Ezra Wu (@qwertyboy0325).</description>
    <link>https://dev.to/qwertyboy0325</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%2F4068295%2F64eae13b-30f9-45bb-a9ad-b9ac93c11a5a.jpg</url>
      <title>DEV Community: Ezra Wu</title>
      <link>https://dev.to/qwertyboy0325</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/qwertyboy0325"/>
    <language>en</language>
    <item>
      <title>FOR UPDATE SKIP LOCKED Was Not Enough: A Stale Failure Write Race in an Inbox Processor</title>
      <dc:creator>Ezra Wu</dc:creator>
      <pubDate>Tue, 11 Aug 2026 04:06:35 +0000</pubDate>
      <link>https://dev.to/qwertyboy0325/for-update-skip-locked-was-not-enough-a-stale-failure-write-race-in-an-inbox-processor-3o4d</link>
      <guid>https://dev.to/qwertyboy0325/for-update-skip-locked-was-not-enough-a-stale-failure-write-race-in-an-inbox-processor-3o4d</guid>
      <description>&lt;p&gt;&lt;em&gt;Why a post-rollback failure transition needs its own serialization boundary.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I found this while auditing a reliability claim in a small inbox processor.&lt;/p&gt;

&lt;p&gt;The processor used PostgreSQL, multiple worker instances, and &lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt;. On its normal path, one worker claimed a row, applied a local database effect, set &lt;code&gt;processed_on_utc&lt;/code&gt;, and committed everything in a single transaction.&lt;/p&gt;

&lt;p&gt;That path was correct.&lt;/p&gt;

&lt;p&gt;The problem appeared after a failed attempt.&lt;/p&gt;

&lt;p&gt;Worker A could fail and roll back, releasing its row lock. Worker B could then claim the same row, apply the effect, mark it &lt;code&gt;processed&lt;/code&gt;, and commit successfully. After that, A could record its earlier failure in a separate transaction and overwrite B's visible status with &lt;code&gt;retrying&lt;/code&gt; or &lt;code&gt;dead_letter&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The local database effect was still applied once. The corruption was in retry state, dead-letter state, and the metrics operators would use during an incident.&lt;/p&gt;

&lt;p&gt;In this article, “exactly once” refers only to the local database effect committed in the same transaction as &lt;code&gt;processed_on_utc&lt;/code&gt;. It does not cover external effects such as HTTP calls, email, payments, or webhooks.&lt;/p&gt;

&lt;h2&gt;
  
  
  The path we had reviewed
&lt;/h2&gt;

&lt;p&gt;The inbox table represented messages that had been received but not yet fully applied. A worker selected one eligible row with a query conceptually like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SELECT ...
WHERE id = ?
  AND processed_on_utc IS NULL
FOR UPDATE SKIP LOCKED
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once a worker held the row lock, it ran the dispatcher and committed the local database effect together with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;processed_on_utc = now()
status = 'processed'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The expected behavior was:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;another worker could not claim the same row while the first worker held the lock;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;duplicate delivery still produced one inbox row;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;the local database effect and processed marker either committed together or both rolled back.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The existing tests covered those cases. A second worker skipped a row already claimed by the first worker. Duplicate delivery produced one inbox record and one local effect.&lt;/p&gt;

&lt;p&gt;At that point, the processor was described as applying its local effect exactly once and avoiding spurious failure state.&lt;/p&gt;

&lt;p&gt;The local-effect part was supported by the implementation. The spurious-failure part was not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure path used a different transaction
&lt;/h2&gt;

&lt;p&gt;When dispatch succeeded, the worker completed the row in the transaction that held the &lt;code&gt;SKIP LOCKED&lt;/code&gt; claim:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;claim row
  -&amp;gt; dispatch
  -&amp;gt; write local database effect
  -&amp;gt; set processed_on_utc and status = 'processed'
  -&amp;gt; commit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When dispatch threw, that transaction rolled back and released the row lock.&lt;/p&gt;

&lt;p&gt;Afterward, the processor called &lt;code&gt;RecordFailureAsync&lt;/code&gt; in a separate transaction. Before the fix, it effectively did this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;read row by id
  -&amp;gt; increment retry_count
  -&amp;gt; set status = 'retrying' or 'dead_letter'
  -&amp;gt; commit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That second transaction re-read the row, but it did not re-lock it and did not validate whether a failure transition was still valid for the row's current state.&lt;/p&gt;

&lt;p&gt;In particular, it did not check whether another worker had already set &lt;code&gt;processed_on_utc&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The stale part was not necessarily the database read.&lt;/p&gt;

&lt;p&gt;The stale part was the decision carried forward from the earlier failed attempt:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This attempt failed, so the row should move to retrying or dead-letter.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That decision could become invalid before the failure recorder wrote anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  The interleaving
&lt;/h2&gt;

&lt;p&gt;The race required two legitimate worker instances, A and B.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;A claims the inbox row
A dispatch throws
A rolls back
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At this point, A no longer held the lock. The row still had &lt;code&gt;processed_on_utc IS NULL&lt;/code&gt;, so another worker could claim it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;B claims the same row
B dispatches successfully
B writes the local database effect
B marks the row processed
B commits
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The row was now complete.&lt;/p&gt;

&lt;p&gt;Then A resumed its failure bookkeeping:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;A opens RecordFailureAsync
A reads the row by id
A does not lock the row or check processed_on_utc
A writes retrying or dead_letter
A increments retry_count
A commits
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the deterministic reproduction, B had already committed &lt;code&gt;processed&lt;/code&gt; before A entered &lt;code&gt;RecordFailureAsync&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A did not need an outdated snapshot to cause the bug. Even after another transaction had completed the row, the failure recorder still applied a retry or dead-letter transition without checking whether that transition remained valid.&lt;/p&gt;

&lt;p&gt;The final row could have:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;processed_on_utc IS NOT NULL
status = 'retrying'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;processed_on_utc IS NOT NULL
status = 'dead_letter'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What this did and did not break
&lt;/h2&gt;

&lt;p&gt;The local database effect was not applied more than once.&lt;/p&gt;

&lt;p&gt;The claim query required &lt;code&gt;processed_on_utc IS NULL&lt;/code&gt;. Once B had committed the processed marker, future apply attempts could no longer claim the row. A's failure recorder changed retry state, but it did not clear &lt;code&gt;processed_on_utc&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So this was not a duplicate-execution bug.&lt;/p&gt;

&lt;p&gt;The corruption was in the state used to operate and observe the system:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;A successfully completed message could appear to be retrying.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The processed counter could be incremented by B while the retry or dead-letter counter was incremented by A for the same message.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A message that had completed successfully could receive a dead-letter record if A had already exhausted its retry budget.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A due-message scan could keep selecting a row whose visible status was &lt;code&gt;retrying&lt;/code&gt;, while the actual claim query rejected it because it had already been processed.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;An operator could investigate or reprocess work that was never actually unresolved.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Retry state, dead-letter records, and metrics are operational inputs. Once they can no longer distinguish unfinished work from completed work, they become misleading during an incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the original concurrency tests did not catch it
&lt;/h2&gt;

&lt;p&gt;The existing concurrency test verified that worker B could not claim the row while worker A still held the lock.&lt;/p&gt;

&lt;p&gt;That is useful, but it covers only the apply transaction.&lt;/p&gt;

&lt;p&gt;The relevant interval was later:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;A rollback
  -&amp;gt; lock released
  -&amp;gt; B claims and completes the row
  -&amp;gt; A records failure in a new transaction
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A test that only proves &lt;code&gt;SKIP LOCKED&lt;/code&gt; prevents simultaneous claims does not exercise this gap.&lt;/p&gt;

&lt;p&gt;The processor had two concurrency-sensitive state transitions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;apply the message effect and mark the row processed;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;record a failed attempt and move the row toward retry or dead-letter.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Only the first transition had been treated as a state transition that needed serialization.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making the race deterministic
&lt;/h2&gt;

&lt;p&gt;I did not want a test that depended on scheduler timing.&lt;/p&gt;

&lt;p&gt;In the normal implementation, rollback and failure recording happened back-to-back. There was no convenient &lt;code&gt;await&lt;/code&gt; between them that would reliably let another worker run.&lt;/p&gt;

&lt;p&gt;I added a small internal test seam named:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AfterRollbackBeforeRecordFailureForTests
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is a no-op in production. The test uses it after A's transaction has rolled back but before A enters &lt;code&gt;RecordFailureAsync&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The test sequence became deterministic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. A claims the row and dispatch fails.
2. A rolls back, releasing the lock.
3. The test seam lets B claim the row.
4. B applies the local database effect and commits processed state.
5. A resumes and records its failure.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The regression test verifies that after the full sequence:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;the local database effect exists once;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;the inbox row remains &lt;code&gt;processed&lt;/code&gt;;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;processed_on_utc&lt;/code&gt; remains set;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;retry_count&lt;/code&gt; remains &lt;code&gt;0&lt;/code&gt;;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;no dead-letter row exists.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On the pre-fix implementation, the final status was &lt;code&gt;retrying&lt;/code&gt; rather than &lt;code&gt;processed&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The failing version was committed intentionally as &lt;a href="https://github.com/qwertyboy0325/modulith-reliability-kit/commit/6bd3018" rel="noopener noreferrer"&gt;&lt;code&gt;6bd3018&lt;/code&gt;&lt;/a&gt;, so the test can still be checked out and reproduced. The fix followed in &lt;a href="https://github.com/qwertyboy0325/modulith-reliability-kit/commit/7d37540" rel="noopener noreferrer"&gt;&lt;code&gt;7d37540&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;RecordFailureAsync&lt;/code&gt; needed to treat its write as a new state transition, not as an unconditional continuation of the earlier failed attempt.&lt;/p&gt;

&lt;p&gt;The fixed failure path opens its own transaction and re-claims the row with a blocking &lt;code&gt;FOR UPDATE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;begin transaction

select row for update

if processed_on_utc is not null:
    commit no-op
    return

increment retry_count

if retry limit reached:
    move to dead-letter
else:
    schedule retry

commit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The choice of a blocking &lt;code&gt;FOR UPDATE&lt;/code&gt; is deliberate.&lt;/p&gt;

&lt;p&gt;If another worker is still mid-apply, the failure recorder should wait for that worker to commit or roll back, then observe the row's final state. Using &lt;code&gt;SKIP LOCKED&lt;/code&gt; again would leave the failure recorder unable to determine whether the row was still unresolved or merely being processed elsewhere.&lt;/p&gt;

&lt;p&gt;If another worker has already marked the row processed, failure recording becomes a no-op.&lt;/p&gt;

&lt;p&gt;This re-claim is intentionally short-lived in this reference. It protects only the row-local failure transition after dispatch has returned. The shipped dispatcher models a same-database local effect; this is not a recommendation to keep database transactions open across network I/O.&lt;/p&gt;

&lt;p&gt;A production adaptation that performs long-running external work would need separate bounds for worker concurrency, lock-wait time, failure recovery, and connection-pool capacity. Adding a lock timeout may be appropriate in such a system, but it does not remove the need to define what retries a failed failure-recording transition.&lt;/p&gt;

&lt;h2&gt;
  
  
  A conditional update is another valid implementation
&lt;/h2&gt;

&lt;p&gt;The explicit re-claim is not the only way to encode this invariant in PostgreSQL.&lt;/p&gt;

&lt;p&gt;A conditional update can also guard the transition:&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;inbox_messages&lt;/span&gt;
&lt;span class="k"&gt;SET&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;'retrying'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;retry_count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;retry_count&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&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="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;processed_on_utc&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under PostgreSQL's default &lt;code&gt;READ COMMITTED&lt;/code&gt; isolation level, if this update conflicts with a concurrent worker's row update, it waits for that worker to commit or roll back, then evaluates its condition against the current committed row version.&lt;/p&gt;

&lt;p&gt;If worker B has already set &lt;code&gt;processed_on_utc&lt;/code&gt;, the update affects zero rows.&lt;/p&gt;

&lt;p&gt;That is a valid alternative for this race.&lt;/p&gt;

&lt;p&gt;This reference keeps the explicit re-claim because failure recording does more than set one column. It evaluates retry policy from the current retry count, chooses between retry and dead-letter, writes retry metadata, may insert a dead-letter record, and emits the associated operational signals in the same transaction.&lt;/p&gt;

&lt;p&gt;An &lt;code&gt;UPDATE ... RETURNING&lt;/code&gt; or CTE-based implementation could reduce a database round trip while preserving the same invariant. That would be an implementation refinement, not a change to the concurrency model.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I changed in the reliability claim
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt; did what it was supposed to do: it serialized row claims for the apply path.&lt;/p&gt;

&lt;p&gt;The mistake was assuming that this guarantee extended into a later transaction.&lt;/p&gt;

&lt;p&gt;A lock protects the critical section that actually holds the lock. It does not protect a decision made earlier and written later after the lock has been released.&lt;/p&gt;

&lt;p&gt;For retry handling, compensation, dead-lettering, or other bookkeeping that runs in a separate transaction, the later operation needs to derive its transition from current state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;re-lock
-&amp;gt; read current state
-&amp;gt; decide from that state
-&amp;gt; write only when the transition is still valid
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The full case study includes the deterministic regression test, the intentionally red commit, the final fix, and the explicitly bounded guarantee:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/qwertyboy0325/modulith-reliability-kit/blob/main/docs/09-lessons-learned/inbox-stale-failure-write-race.md" rel="noopener noreferrer"&gt;Read the Inbox Stale-Failure Write Race case study&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The complete reference implementation is available in &lt;a href="https://github.com/qwertyboy0325/modulith-reliability-kit" rel="noopener noreferrer"&gt;modulith-reliability-kit&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>backend</category>
      <category>database</category>
      <category>postgres</category>
    </item>
  </channel>
</rss>
