<?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: Danial Asghar</title>
    <description>The latest articles on DEV Community by Danial Asghar (@danial_asghar05).</description>
    <link>https://dev.to/danial_asghar05</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%2F4021530%2Fa96f0b22-759e-44bf-ac8f-de6a58ce8f2f.png</url>
      <title>DEV Community: Danial Asghar</title>
      <link>https://dev.to/danial_asghar05</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/danial_asghar05"/>
    <language>en</language>
    <item>
      <title>Designing Idempotent Background Workers for LLM Calls in Rails</title>
      <dc:creator>Danial Asghar</dc:creator>
      <pubDate>Thu, 09 Jul 2026 12:04:00 +0000</pubDate>
      <link>https://dev.to/danial_asghar05/designing-idempotent-background-workers-for-llm-calls-in-rails-obb</link>
      <guid>https://dev.to/danial_asghar05/designing-idempotent-background-workers-for-llm-calls-in-rails-obb</guid>
      <description>&lt;p&gt;Background jobs become much harder once an LLM is involved.&lt;br&gt;
A normal Sidekiq retry is usually harmless. An LLM retry can mean duplicate API cost, different model output, and inconsistent application state.&lt;br&gt;
This article walks through how I designed an idempotent Rails worker so retries become safe instead of expensive.&lt;/p&gt;

&lt;p&gt;A support ticket comes in: &lt;em&gt;"I was double-charged $49 for my subscription, please refund the duplicate."&lt;/em&gt; The triage worker picks it up, calls the model, and comes back with &lt;code&gt;billing&lt;/code&gt;, &lt;code&gt;high&lt;/code&gt; urgency, and a refund draft — then the worker is killed, after that model call but before the write commits. Sidekiq does what it's supposed to do: it retries. Because nothing was persisted, the retry calls the model again and pays a second time; and if that ticket was enqueued twice and both jobs run at once, each can classify and each can write. Once outbound webhook dispatch ships — it's on the roadmap — the same double-run would fire the client's callback twice.&lt;/p&gt;

&lt;p&gt;The fix is &lt;strong&gt;idempotency&lt;/strong&gt;: making a background worker safe to run more than once, so that a repeat run produces the same result and the same side effects as a single run — not a second OpenAI charge and not a different classification. A ticket about a duplicate charge, resolved by a system that duplicates its own work: that is the failure this post is about.&lt;/p&gt;

&lt;p&gt;Moving an LLM call out of the request path and behind a queue is the right call — it's the core decision in the &lt;a href="https://danialasghar.com/projects/ai-support-ticket-triage-engine" rel="noopener noreferrer"&gt;AI Support Ticket Triage Engine&lt;/a&gt;, a service I built and open-sourced — and it keeps ticket ingestion fast — a &lt;code&gt;202 Accepted&lt;/code&gt; in single-digit milliseconds in local measurement (the handler only inserts and enqueues), not a measured production SLA — instead of coupling it to a 1–10 second model call. But that queue changes the reliability contract in a way that's easy to miss: &lt;strong&gt;the moment work runs in a background job, it runs &lt;em&gt;at least once&lt;/em&gt;, not exactly once.&lt;/strong&gt; Every worker has to be safe to run more than one time. With an LLM in the loop, "more than once" isn't just a correctness bug — it's a billing line item and a data-consistency problem.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why an LLM makes double-execution worse
&lt;/h2&gt;

&lt;p&gt;At-least-once delivery is not a Sidekiq flaw; it's the guarantee — &lt;a href="https://github.com/sidekiq/sidekiq/wiki/Best-Practices" rel="noopener noreferrer"&gt;Sidekiq gives you at-least-once, not exactly-once, semantics&lt;/a&gt;. A worker can be retried after a timeout, a deploy, an OOM kill, or a network blip between finishing the job and acknowledging it. For an ordinary job — recalculate a counter, resize an image — running twice is usually harmless or trivially fixable.&lt;/p&gt;

&lt;p&gt;An LLM call breaks both of those comforts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It costs money per call.&lt;/strong&gt; A blind retry re-bills a token-metered API for work you already paid for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's non-deterministic at the margins.&lt;/strong&gt; Depending on sampling settings, a model-version change, or provider-side changes, the same ticket can come back with a &lt;em&gt;different&lt;/em&gt; category, urgency, or summary. So a naive retry doesn't just duplicate a row — it can overwrite a good classification with a different one, for reasons no human reviewing the ticket would recognize.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That second property is the one people underestimate. You cannot dedupe on the response, because the response isn't guaranteed stable. Idempotency has to be enforced on the &lt;em&gt;work&lt;/em&gt;, not the output.&lt;/p&gt;
&lt;h2&gt;
  
  
  What idempotency means for an LLM worker
&lt;/h2&gt;

&lt;p&gt;Concretely: calling &lt;code&gt;perform(ticket_id)&lt;/code&gt; any number of times must leave the system in the same state, and produce the same side effects, as calling it once. Two distinct things need that protection, and they fail differently:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The write&lt;/strong&gt; — the classification, urgency, summary, and suggested reply persisted to the ticket.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The side effects&lt;/strong&gt; — today, just the persisted draft; and, on the roadmap, the outbound webhook to the client's callback URL.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The engine protects the write today with the completion gate. The outbound side effect arrives with the webhook roadmap, and it will need its own per-delivery idempotency key rather than leaning on that gate.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Sequential retries become a no-op after completion, while concurrent duplicates require an atomic claim to prevent multiple workers from processing the same ticket.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  The shipped gate: status-based idempotency on the UUID
&lt;/h2&gt;

&lt;p&gt;The worker keys idempotency on the ticket's UUID and its completion state. A retry — or an accidental duplicate enqueue — for a ticket that's already been triaged returns immediately instead of running a second classification.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TriageWorker&lt;/span&gt;
  &lt;span class="kp"&gt;include&lt;/span&gt; &lt;span class="no"&gt;Sidekiq&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Job&lt;/span&gt;

  &lt;span class="n"&gt;sidekiq_options&lt;/span&gt; &lt;span class="ss"&gt;retry: &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;queue: :triage&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;perform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ticket_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;ticket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Ticket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ticket_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Idempotency gate: a retry (or duplicate enqueue) for an already-triaged&lt;/span&gt;
    &lt;span class="c1"&gt;# ticket is a no-op, not a second classification and not a second bill.&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ticket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;completed?&lt;/span&gt;

    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Ai&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;TriageService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ticket&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;   &lt;span class="c1"&gt;# OpenAI, Structured Outputs&lt;/span&gt;

    &lt;span class="n"&gt;ticket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="ss"&gt;status:          :completed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;category:        &lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;urgency:         &lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urgency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;summary:         &lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;suggested_reply: &lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;suggested_reply&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;rescue&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;
    &lt;span class="c1"&gt;# Don't swallow the failure to "avoid" a duplicate. Record it and let the&lt;/span&gt;
    &lt;span class="c1"&gt;# retry happen — a failed ticket is visible and re-runnable.&lt;/span&gt;
    &lt;span class="n"&gt;ticket&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;status: :failed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;error_message: &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;class&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;message&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;rescue&lt;/code&gt; is deliberate: it records the failure and re-raises so the bounded retries take over. With &lt;code&gt;retry: 3&lt;/code&gt;, Sidekiq re-runs the job with exponential backoff; once those attempts are exhausted the job lands in Sidekiq's Dead Set — a dead-letter queue you can inspect and replay, not a silent loss. Note the honest limitation of this MVP: the rescue is unclassified — it retries &lt;em&gt;every&lt;/em&gt; error the same way. A permanent failure (a bad request, a deterministic truncation on an over-long ticket) will therefore retry and re-bill on each of the three attempts before it dies to the Dead Set. Separating transient failures from permanent ones is a later pass, not something this worker does yet.&lt;/p&gt;

&lt;p&gt;Two decisions here are worth stating explicitly, because each had an alternative I rejected:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keying on the UUID rather than a sequential ID.&lt;/strong&gt; The job carries the ticket's UUID, which is generated at insert time in the request handler. Because it exists before the enqueue, there's no window where a job references a row that isn't fully written, and no racing on enqueue. (UUIDs also avoid leaking record counts through the API, which is a separate win covered in the case study.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A status gate rather than dedup-on-response.&lt;/strong&gt; The obvious-seeming approach — hash the model's output and skip duplicates — is impossible here, because the output isn't deterministic. The status gate sidesteps that entirely: it asks "has this ticket already reached a terminal success state?" and only that. The other rejected alternative, "just let it re-run," is what produces the double-classification in the opening scenario.&lt;/p&gt;

&lt;p&gt;This gate makes a &lt;strong&gt;sequential&lt;/strong&gt; retry after success a clean no-op. That covers the overwhelmingly common case — a worker that succeeds, then gets retried because the ack was lost. But it has an edge, and pretending otherwise would be the un-senior move.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where a status gate isn't enough: the check-then-act race
&lt;/h2&gt;

&lt;p&gt;The shipped gate covers retries in &lt;em&gt;series&lt;/em&gt;. It does not, on its own, cover duplicates in &lt;em&gt;parallel&lt;/em&gt;. &lt;code&gt;return if ticket.completed?&lt;/code&gt; is a read followed by a write, with a gap between them. If the &lt;em&gt;same&lt;/em&gt; ticket is enqueued twice and both jobs run at once — a duplicate webhook, a double-click, a buggy client — both workers can read &lt;code&gt;completed? == false&lt;/code&gt;, both call OpenAI (two bills), and both write.&lt;/p&gt;

&lt;p&gt;The next step to close that gap — &lt;em&gt;not in the current build&lt;/em&gt; — is to stop asking a question and instead make an atomic claim, at the one place that can adjudicate a race: the database.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;perform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ticket_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="c1"&gt;# Atomically claim the work. update_all returns the number of rows it changed,&lt;/span&gt;
  &lt;span class="c1"&gt;# so exactly one worker can win the pending/failed -&amp;gt; processing transition.&lt;/span&gt;
  &lt;span class="c1"&gt;# update_all bypasses enum casting, so map the value explicitly.&lt;/span&gt;
  &lt;span class="n"&gt;claimed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Ticket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;id: &lt;/span&gt;&lt;span class="n"&gt;ticket_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;status: &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:pending&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:failed&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
                  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;status: &lt;/span&gt;&lt;span class="no"&gt;Ticket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;statuses&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:processing&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="ss"&gt;updated_at: &lt;/span&gt;&lt;span class="no"&gt;Time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;current&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;if&lt;/span&gt; &lt;span class="n"&gt;claimed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;zero?&lt;/span&gt;   &lt;span class="c1"&gt;# another worker already owns or finished this ticket&lt;/span&gt;

  &lt;span class="n"&gt;ticket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Ticket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ticket_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Ai&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;TriageService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ticket&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;

  &lt;span class="n"&gt;ticket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;status: :completed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;category: &lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;# ...&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;rescue&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;
  &lt;span class="no"&gt;Ticket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;id: &lt;/span&gt;&lt;span class="n"&gt;ticket_id&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="ss"&gt;status: &lt;/span&gt;&lt;span class="no"&gt;Ticket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;statuses&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:failed&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="ss"&gt;error_message: &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;class&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;message&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;raise&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the guard is a single &lt;a href="https://www.postgresql.org/docs/current/mvcc.html" rel="noopener noreferrer"&gt;atomic&lt;/a&gt; &lt;code&gt;UPDATE ... WHERE status IN (...)&lt;/code&gt;. Whoever flips the row to &lt;code&gt;processing&lt;/code&gt; first wins; the loser sees &lt;code&gt;claimed.zero?&lt;/code&gt; and returns before spending a token. A &lt;code&gt;failed&lt;/code&gt; ticket is included in the claimable set so genuine retries still re-run. You can layer a unique-jobs plugin on top to dedupe at &lt;em&gt;enqueue&lt;/em&gt; time, but treat that as an optimization — the durable guarantee lives in the database, not in Redis.&lt;/p&gt;

&lt;p&gt;This introduces a failure mode of its own: a worker that claims &lt;code&gt;processing&lt;/code&gt; and then crashes leaves the row stuck there — the claimable set (&lt;code&gt;pending&lt;/code&gt;/&lt;code&gt;failed&lt;/code&gt;) won't re-select it, so nothing retries it. Closing that requires a reaper — a heartbeat or visibility timeout that returns abandoned &lt;code&gt;processing&lt;/code&gt; rows to &lt;code&gt;pending&lt;/code&gt;. It belongs to the same future hardening as the claim itself, not to the shipped gate.&lt;/p&gt;

&lt;p&gt;The atomic claim closes the concurrent-duplicate gap at the worker. If you need the &lt;em&gt;enqueue itself&lt;/em&gt; to be exactly-once — no job lost, and none sent for a ticket whose transaction never committed — the &lt;a href="https://microservices.io/patterns/data/transactional-outbox.html" rel="noopener noreferrer"&gt;transactional outbox pattern&lt;/a&gt; is the next level up: write an outbox row in the same transaction as the ticket and let a separate process enqueue from it. That's a larger change and a post of its own; for this pipeline, the status gate plus an atomic claim is enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  A subtle case: the model succeeded, the write didn't
&lt;/h2&gt;

&lt;p&gt;One more case is worth naming because it's the expensive one, even though it's beyond the current build. The worker calls OpenAI successfully, then the &lt;code&gt;update!&lt;/code&gt; fails — a deadlock, a connection reset, a validation error. The job raises and retries. On the retry, the classification is gone, so the engine calls the model again and pays a second time for a response it already had.&lt;/p&gt;

&lt;p&gt;The engine already persists every model call — the input, the raw response, the validation result — to a per-ticket audit trail for replay and debugging. That same audit trail is a natural foundation for closing this gap: if the raw response were recorded before the classification write is attempted, a retry after a failed write could reuse the stored response instead of re-billing. This is a future improvement, not current behavior — and not a trivial one: it depends on the ordering and transaction guarantees between the audit write and the classification write, plus a lookup-and-reuse path on retry. For the trail to be a reliable basis at all, the audit write and the result write need to share a transaction; otherwise a crash between them leaves the record incomplete for exactly the tickets that failed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common idempotency mistakes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A per-attempt identity.&lt;/strong&gt; If the value you key on changes every run, it isn't a stable identity — it's a UUID generator. Derive it from the domain entity (the ticket) so every attempt agrees on which work this is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;find_or_create_by&lt;/code&gt; without a unique index.&lt;/strong&gt; Under concurrency it races exactly like the check-then-act gap above. The uniqueness has to be enforced by the database, not by a Ruby conditional.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treating the LLM call as the idempotency boundary.&lt;/strong&gt; The call isn't the thing that must happen once; the &lt;em&gt;side effect&lt;/em&gt; is. Guard the write and the reply, not the API request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Swallowing errors to avoid duplicates.&lt;/strong&gt; Rescuing and returning quietly hides real failures and gives you a ticket stuck in &lt;code&gt;pending&lt;/code&gt; forever. Record the error to &lt;code&gt;error_message&lt;/code&gt;, set &lt;code&gt;failed&lt;/code&gt;, and let the retry work — a visible failure is a debuggable one.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Tradeoffs
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Eventual consistency.&lt;/strong&gt; Triage lands seconds after ingestion, and the UI shows a triaging state in the meantime. This is the deliberate price of a write path that doesn't depend on the model — the same tradeoff the case study accepts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An extra state and an extra write.&lt;/strong&gt; The atomic-claim approach adds a &lt;code&gt;processing&lt;/code&gt; state and one small &lt;code&gt;UPDATE&lt;/code&gt; per job. That's cheap insurance against concurrent double-billing, but it is not free, and for a strictly single-producer enqueue path the simpler status gate may be all you need.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Response caching adds storage.&lt;/strong&gt; Reusing the audit record to dodge re-billing means retaining raw responses, which has a retention and privacy cost. Worth it when calls are metered; skip it for a free local model.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Failure modes
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Failure&lt;/th&gt;
&lt;th&gt;What happens&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Retry after a successful run (ack lost)&lt;/td&gt;
&lt;td&gt;Gate sees &lt;code&gt;completed&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Returns immediately — no second call, no second write&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Duplicate enqueue, run in parallel&lt;/td&gt;
&lt;td&gt;Status gate has a check-then-act gap here&lt;/td&gt;
&lt;td&gt;Not closed by the status gate alone; the atomic claim above makes only one worker win&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Provider timeout or rate limit&lt;/td&gt;
&lt;td&gt;Job raises; Sidekiq retries with backoff&lt;/td&gt;
&lt;td&gt;Ingestion already committed, so no ticket is lost&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Truncated, refused, or errored response&lt;/td&gt;
&lt;td&gt;Incomplete or absent payload is caught before the write; error recorded&lt;/td&gt;
&lt;td&gt;Bad output is rejected, not persisted; job retries&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Crash after model call, before write&lt;/td&gt;
&lt;td&gt;Job retries and re-calls the model&lt;/td&gt;
&lt;td&gt;No ticket lost or corrupted; re-billing is the cost (response reuse is a future improvement)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Crash after write, before ack&lt;/td&gt;
&lt;td&gt;Sidekiq retries the completed job&lt;/td&gt;
&lt;td&gt;Idempotency gate makes the retry a no-op&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On that fourth row, be precise about what the model can actually do wrong: strict &lt;a href="https://platform.openai.com/docs/guides/structured-outputs" rel="noopener noreferrer"&gt;Structured Outputs&lt;/a&gt; guarantees the &lt;em&gt;shape&lt;/em&gt; of the payload, so arbitrary schema-shape drift isn't a failure mode. What remains is truncation (a length-capped, incomplete response), a refusal, an API error, or output that is schema-valid but semantically wrong. Validation still runs before any write — it guards the database contract against the incomplete and absent cases, and the audit trail preserves the semantically-wrong ones for review.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing and monitoring idempotent workers
&lt;/h2&gt;

&lt;p&gt;The shipped gate is verifiable in one test: invoke &lt;code&gt;perform&lt;/code&gt; twice &lt;strong&gt;in series&lt;/strong&gt; for the same ticket and assert exactly one persisted classification/result. Because the engine stubs the OpenAI client with pure-Ruby mocks and never hits the network in tests, you can assert the client was called at most once across both invocations — which is the real contract for the sequential-retry case the completion gate exists to cover.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;concurrent&lt;/strong&gt;-duplicate case is a separate test, and it belongs with the atomic claim as future work: two workers racing the same ticket can't be exercised by sequential calls or by stubs alone — it needs real database concurrency (parallel connections contending on the &lt;code&gt;pending → processing&lt;/code&gt; transition), so it's a heavier integration test than the sequential one. Testing non-deterministic pipelines deterministically is a topic worth its own treatment.&lt;/p&gt;

&lt;p&gt;In production, idempotency shows up in the metrics you already have. Sidekiq surfaces retry counts and queue depth; a rising retry rate or a growing count of &lt;code&gt;failed&lt;/code&gt; tickets is your signal that the provider is degraded or a classification is repeatedly rejected. Because failures are auditable per ticket, you diagnose them from stored data rather than by re-hitting the model.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's shipped vs. what's planned
&lt;/h2&gt;

&lt;p&gt;To keep the boundary honest, here's the split.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Shipped in the engine today:&lt;/strong&gt; the async boundary that keeps ingestion to single-digit milliseconds in local measurement, Structured Outputs validated against a JSON schema before any write, the UUID-plus-completion-state idempotency gate, bounded retries (&lt;code&gt;retry: 3&lt;/code&gt;) with exponential backoff into Sidekiq's Dead Set, and a per-ticket audit trail of every model call.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On the project roadmap:&lt;/strong&gt; outbound webhook dispatch to client callback URLs, and automatic provider fallback to Anthropic Claude when OpenAI rate-limits or times out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hardening I'd add next (not yet built):&lt;/strong&gt; the atomic &lt;code&gt;pending → processing&lt;/code&gt; claim for concurrent duplicates, reusing the audit trail to skip re-billing after a failed write, and a transactional outbox for exactly-once enqueue.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lesson
&lt;/h2&gt;

&lt;p&gt;The hard part of an AI feature isn't the prompt — it's the contract around a model you don't control. Treat it as an unreliable external dependency, put it behind a queue, and then accept the consequence of that queue: &lt;strong&gt;every worker will run more than once, so every worker must be safe to.&lt;/strong&gt; Key idempotency on a stable identifier, make the state transition atomic where concurrency is possible, and treat your audit trail as the foundation for making retries cheaper. Do that, and non-determinism stays contained instead of leaking into your data.&lt;/p&gt;




&lt;p&gt;If you're building AI features on Rails, I'd love to hear how you're handling retries and idempotency.&lt;br&gt;
Do you rely on completion gates, database locks, unique jobs, or another pattern?&lt;br&gt;
Feel free to share your approach below.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Further reading&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🌐 Original article: &lt;a href="https://danialasghar.com/blog/idempotent-ai-workers-rails/" rel="noopener noreferrer"&gt;https://danialasghar.com/blog/idempotent-ai-workers-rails/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;💻 Source code: &lt;a href="https://github.com/danialasghar5/ai-support-triage" rel="noopener noreferrer"&gt;https://github.com/danialasghar5/ai-support-triage&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>rails</category>
      <category>ruby</category>
      <category>sidekiq</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
