<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>rdq | Blog</title><description>Retry &amp; Dead-letter Queues for any broker, any storage, any language.</description><link>https://srjn45.github.io/</link><language>en</language><item><title>Introducing rdq: retry &amp; dead-letter queues for any broker, any storage, any language</title><link>https://srjn45.github.io/rdq/blog/introducing-rdq/</link><guid isPermaLink="true">https://srjn45.github.io/rdq/blog/introducing-rdq/</guid><description>Every event-driven system eventually asks the same question — a handler failed, now what? Today you answer it with broker-locked, language-locked, hand-rolled plumbing, and the DLQ you land in is a graveyard with no failure context and no safe replay. rdq is the missing bolt-on for that one narrow job.

</description><pubDate>Mon, 27 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Every event-driven system eventually runs into the same question: &lt;strong&gt;a message handler failed —
now what?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Today, teams answer it the hard way. You hand-roll a retry topic per Kafka consumer. You wire up
broker-specific dead-letter queues — SQS redrive policies, RabbitMQ dead-letter exchanges — each
with its own quirks. Or you reach for a heavyweight durable-execution platform and take on a new
programming model and a dedicated cluster to solve one narrow problem. The result is duplicated,
broker-locked, language-locked plumbing. And when a message finally &lt;em&gt;does&lt;/em&gt; land in a DLQ, the
on-call engineer opens it to find a bare payload: no error, no stack trace, no history, and no
safe way to replay it once the bug is fixed.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;rdq&lt;/strong&gt; is the tool that should have existed for that job. It’s a durable retry engine with
first-class dead-letter queues, and its entire promise is small enough to say in one breath: hand
it a failed unit of work, and it guarantees one of exactly two outcomes.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The call &lt;strong&gt;eventually succeeds&lt;/strong&gt;, retried on your configured backoff schedule, or&lt;/li&gt;
&lt;li&gt;it lands in a &lt;strong&gt;dead-letter queue with its full failure history&lt;/strong&gt; — every attempt’s error,
stack trace, and timestamp — where it can be inspected, fixed, and redriven.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;That’s it. rdq is not a broker, not a workflow engine, and it never sits on the hot path of your
successful messages.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-problem-is-more-general-than-messaging&quot;&gt;The problem is more general than messaging&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The framing that unlocked the design: &lt;em&gt;a function was called with some arguments, and it failed.&lt;/em&gt;
A Kafka handler, an SQS consumer, a plain &lt;code dir=&quot;auto&quot;&gt;func(args)&lt;/code&gt; — they’re all the same shape. What you
actually need is something that durably remembers &lt;strong&gt;the function reference and its arguments&lt;/strong&gt;,
re-invokes the call on a policy, and — if it never succeeds — parks it somewhere inspectable and
replayable.&lt;/p&gt;
&lt;p&gt;Once you frame it that way, the broker stops mattering. rdq never talks to Kafka or SQS. It
accepts failures from &lt;em&gt;any&lt;/em&gt; source and returns one of two terminal outcomes. That’s what lets one
tool serve Kafka, SQS, Redpanda, RabbitMQ, AutoMQ — or no broker at all.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;four-decisions-that-define-rdq&quot;&gt;Four decisions that define rdq&lt;/h2&gt;&lt;/div&gt;
&lt;div&gt;&lt;h3 id=&quot;1-bring-your-own-storage&quot;&gt;1. Bring your own storage&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;rdq adds &lt;strong&gt;no new stateful infrastructure&lt;/strong&gt;. Retry queues and DLQs live in a datastore you
already operate. PostgreSQL is the v1 reference backend; Redis, MongoDB, and others plug in
through a documented &lt;a href=&quot;https://srjn45.github.io/rdq/concepts/storage-spi/&quot;&gt;storage SPI&lt;/a&gt; that ships with a public compliance
test-kit, so third parties can build and verify their own plugins.&lt;/p&gt;
&lt;p&gt;This is the difference between “adopt a new database to get retries” and “point rdq at the
Postgres you’re already running.” The second one is a Tuesday afternoon.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;2-broker-agnostic-off-the-hot-path&quot;&gt;2. Broker-agnostic, off the hot path&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;rdq never sits between your producer and consumer. It receives work only &lt;em&gt;after&lt;/em&gt; something has
already failed. Your throughput on the happy path is untouched; rdq scales with your &lt;em&gt;failure&lt;/em&gt;
rate, which is (hopefully) a much smaller number.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;3-polyglot-from-day-one&quot;&gt;3. Polyglot from day one&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;There is one &lt;a href=&quot;https://srjn45.github.io/rdq/concepts/wire-envelope/&quot;&gt;language-neutral wire envelope&lt;/a&gt; — JSON, with the
payload as opaque bytes plus a content type — shared by both SDKs, the server API, and every
storage plugin. v1 ships a &lt;strong&gt;Go SDK&lt;/strong&gt;, a &lt;strong&gt;Java SDK&lt;/strong&gt;, and a &lt;strong&gt;REST/gRPC API&lt;/strong&gt; for everything
else. A task submitted from Go can be redriven and executed through the server by a totally
different service. No language-specific serialization is allowed in the core.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;4-the-dlq-is-a-product-not-a-graveyard&quot;&gt;4. The DLQ is a product, not a graveyard&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;This is the part most tools get wrong. In rdq, the complete failure history travels &lt;em&gt;with&lt;/em&gt; the
task into the DLQ. You can browse and filter by queue, error type, handler, and time range. You
can redrive a single task or bulk-redrive by filter — &lt;em&gt;“every &lt;code dir=&quot;auto&quot;&gt;payments.charge&lt;/code&gt; task that failed
with &lt;code dir=&quot;auto&quot;&gt;TimeoutException&lt;/code&gt; after 14:00”&lt;/em&gt; — with the retry policy reset. Single-task redrive can even
edit the payload first. And &lt;strong&gt;every mutation is audit-logged&lt;/strong&gt;: who, when, what filter. The
&lt;a href=&quot;https://srjn45.github.io/rdq/guides/dlq-and-redrive/&quot;&gt;DLQ analysis &amp;#x26; redrive guide&lt;/a&gt; walks through it.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;one-core-two-hosts&quot;&gt;One core, two hosts&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The engine — task model, retry policies, storage SPI, DLQ semantics, wire format — is a single
core shipped in two form factors, both in v1:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Embedded SDK&lt;/strong&gt; (Go and Java): the core runs inside your application process; handlers are
in-process functions. Zero extra infrastructure beyond your storage.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Standalone service&lt;/strong&gt; (&lt;code dir=&quot;auto&quot;&gt;rdq-server&lt;/code&gt;, in Go): the same core behind REST/gRPC intake; handlers
are remote &lt;strong&gt;callbacks&lt;/strong&gt; (HTTP or gRPC) registered per queue. A central retry hub any language
can use over the wire.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;And they compose. Because the client and worker artifacts are split, you can &lt;strong&gt;submit here and
execute there&lt;/strong&gt; — enqueue from a lightweight producer, let a fleet of workers (or the server)
drain the queue. A callback that times out is just another failed attempt; it flows through the
identical retry/DLQ path as an in-process error. The full picture is in
&lt;a href=&quot;https://srjn45.github.io/rdq/concepts/architecture/&quot;&gt;Architecture&lt;/a&gt;.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;correctness-without-a-coordinator&quot;&gt;Correctness without a coordinator&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;rdq has no leader election and no cluster membership. Every process — embedded workers and
&lt;code dir=&quot;auto&quot;&gt;rdq-server&lt;/code&gt; nodes alike — is &lt;strong&gt;stateless&lt;/strong&gt;, and the storage backend is the only coordination
point. Workers claim due tasks &lt;strong&gt;atomically&lt;/strong&gt; (Postgres uses &lt;code dir=&quot;auto&quot;&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt;), and every
claim carries a &lt;strong&gt;lease&lt;/strong&gt;. If a worker dies mid-task — &lt;code dir=&quot;auto&quot;&gt;kill -9&lt;/code&gt;, a crash, a network partition —
the lease expires and any other worker reclaims the task. Nothing is lost, and no two workers ever
run the same task concurrently.&lt;/p&gt;
&lt;p&gt;That’s the whole scaling story: add a node to go faster; node death is a non-event. Your
availability inherits your storage backend’s HA — Postgres replicas, Redis Sentinel, Mongo
replica sets — because rdq adds no stateful component of its own. The
&lt;a href=&quot;https://srjn45.github.io/rdq/concepts/task-lifecycle/&quot;&gt;task lifecycle&lt;/a&gt; has the details.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-honest-limits&quot;&gt;The honest limits&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;rdq is deliberately narrow, and the non-goals are part of the design:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;At-least-once, not exactly-once.&lt;/strong&gt; Your handlers must be idempotent. We say this loudly and
often because it’s the one contract you can’t ignore.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Not a workflow engine.&lt;/strong&gt; One task equals one function call. No sagas, no signals, no
multi-step orchestration.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No ordering guarantee by default.&lt;/strong&gt; Retrying out-of-band inherently breaks partition
ordering; per-key ordered retry is a post-v1 opt-in.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you need durable multi-step orchestration, you want Temporal. If you need retries and a DLQ you
can actually operate, you want rdq.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;where-its-going&quot;&gt;Where it’s going&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;v1 is the core engine, the Go and Java SDKs, &lt;code dir=&quot;auto&quot;&gt;rdq-server&lt;/code&gt;, the PostgreSQL plugin, the &lt;code dir=&quot;auto&quot;&gt;rdq&lt;/code&gt; CLI,
Prometheus metrics, and DLQ browse/redrive/audit. On the &lt;a href=&quot;https://srjn45.github.io/rdq/reference/roadmap/&quot;&gt;roadmap&lt;/a&gt;: a web
UI for DLQ analysis, Redis and MongoDB plugins, Python and TypeScript SDKs, per-key ordered retry,
and broker-native intake adapters that dead-letter on behalf of legacy apps with zero code change.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;try-it&quot;&gt;Try it&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The fastest path is the &lt;a href=&quot;https://srjn45.github.io/rdq/start/quickstart/&quot;&gt;Quickstart&lt;/a&gt;: submit a task, run a worker, watch
it retry and then dead-letter, then redrive it after a fix. If you just want the mental model
first, start with &lt;a href=&quot;https://srjn45.github.io/rdq/start/what-is-rdq/&quot;&gt;What is rdq?&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;rdq is open source under Apache-2.0. The code — and the design docs behind every decision above —
live on &lt;a href=&quot;https://github.com/srjn45/rdq&quot;&gt;GitHub&lt;/a&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;rdq — Retry &amp;#x26; Dead-letter Queues for any broker, any storage, any language.&lt;/em&gt;&lt;/p&gt;</content:encoded><category>announcement</category><category>dead-letter-queue</category><category>reliability</category><category>kafka</category></item><item><title>No two workers, one task: how rdq claims work — and the UNIQUE collision redrive taught us</title><link>https://srjn45.github.io/rdq/blog/no-two-workers-same-task/</link><guid isPermaLink="true">https://srjn45.github.io/rdq/blog/no-two-workers-same-task/</guid><description>rdq spreads work across a fleet of stateless workers with no leader and no membership protocol. The entire correctness argument rests on two primitives — an atomic claim and a lease. This is how they work, and the story of a UNIQUE collision that only appeared when a redriven task&apos;s lease expired.

</description><pubDate>Mon, 27 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;rdq runs a fleet of workers against a shared datastore with &lt;strong&gt;no leader election and no cluster
membership.&lt;/strong&gt; There’s no coordinator process to ask “whose task is this?” — and that’s on
purpose. The entire correctness argument rests on two primitives, both borrowed from the storage
backend: an &lt;strong&gt;atomic claim&lt;/strong&gt; and a &lt;strong&gt;lease&lt;/strong&gt;. Get those two right and the rest of the system —
“add a node to scale,” “a &lt;code dir=&quot;auto&quot;&gt;kill -9&lt;/code&gt; is a non-event” — falls out for free.&lt;/p&gt;
&lt;p&gt;This post is the detail behind &lt;a href=&quot;https://srjn45.github.io/rdq/concepts/task-lifecycle/&quot;&gt;the task lifecycle&lt;/a&gt;: how a claim
works, why the lease is the whole fault-tolerance story, and a subtle bug that only surfaced when
a &lt;strong&gt;redriven&lt;/strong&gt; task’s lease expired.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-one-rule&quot;&gt;The one rule&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Everything reduces to a single correctness bar:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;No two workers may ever claim the same task.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Not “rarely.” Not “we detect and reconcile.” Never. If two workers can both believe they own a
task, you get concurrent double-execution on top of the at-least-once contract, and no amount of
idempotency guidance saves you from the surprise.&lt;/p&gt;
&lt;p&gt;rdq refuses to solve this with a coordinator, because a coordinator is a stateful component with
its own HA story — exactly the thing rdq promises &lt;em&gt;not&lt;/em&gt; to add. Instead, the claim is delegated to
whatever the storage backend already does atomically.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-atomic-claim&quot;&gt;The atomic claim&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;A worker’s inner loop is: &lt;em&gt;find due tasks, take one, run it.&lt;/em&gt; “Find due” is easy — &lt;code dir=&quot;auto&quot;&gt;status = PENDING AND next_attempt_at &amp;#x3C;= now()&lt;/code&gt;. The hard part is “take one” without racing every other
worker doing the same query at the same millisecond.&lt;/p&gt;
&lt;p&gt;Each storage plugin owns its own atomic-claim mechanics, and this is a hard requirement of the
&lt;a href=&quot;https://srjn45.github.io/rdq/concepts/storage-spi/&quot;&gt;storage SPI&lt;/a&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;PostgreSQL&lt;/strong&gt; — &lt;code dir=&quot;auto&quot;&gt;SELECT … FOR UPDATE SKIP LOCKED&lt;/code&gt;. The row lock makes the claim atomic;
&lt;code dir=&quot;auto&quot;&gt;SKIP LOCKED&lt;/code&gt; means a worker steps over rows another worker has already locked instead of
blocking on them. Ten workers hit the same query and walk away with ten &lt;em&gt;different&lt;/em&gt; tasks.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Redis&lt;/strong&gt; — an atomic sorted-set pop, scored by &lt;code dir=&quot;auto&quot;&gt;next_attempt_at&lt;/code&gt;, in a Lua script.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;MongoDB&lt;/strong&gt; — &lt;code dir=&quot;auto&quot;&gt;findAndModify&lt;/code&gt;, which claims and updates in one atomic step.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In Postgres the claim looks roughly like this:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;UPDATE&lt;/span&gt;&lt;span&gt; tasks&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;SET&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;status&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;IN_FLIGHT&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;lease_expires_at &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;now&lt;/span&gt;&lt;span&gt;() &lt;/span&gt;&lt;span&gt;+&lt;/span&gt;&lt;span&gt; $lease&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;WHERE&lt;/span&gt;&lt;span&gt; id &lt;/span&gt;&lt;span&gt;IN&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;SELECT&lt;/span&gt;&lt;span&gt; id &lt;/span&gt;&lt;span&gt;FROM&lt;/span&gt;&lt;span&gt; tasks&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;WHERE&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;queue&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; $&lt;/span&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;AND&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;status&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;PENDING&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;AND&lt;/span&gt;&lt;span&gt; next_attempt_at &lt;/span&gt;&lt;span&gt;&amp;#x3C;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;now&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;ORDER BY&lt;/span&gt;&lt;span&gt; next_attempt_at&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;FOR&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;UPDATE&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;SKIP&lt;/span&gt;&lt;span&gt; LOCKED&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;LIMIT&lt;/span&gt;&lt;span&gt; $batch&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;RETURNING &lt;/span&gt;&lt;span&gt;*&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;div&gt;&lt;div aria-live=&quot;polite&quot;&gt;&lt;/div&gt;&lt;/div&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The &lt;code dir=&quot;auto&quot;&gt;SELECT … FOR UPDATE SKIP LOCKED&lt;/code&gt; does the claiming; the surrounding &lt;code dir=&quot;auto&quot;&gt;UPDATE&lt;/code&gt; flips the task
to &lt;code dir=&quot;auto&quot;&gt;IN_FLIGHT&lt;/code&gt; and stamps a lease in the same transaction. There is no window between “I saw it”
and “I own it.”&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-lease-is-the-fault-tolerance-story&quot;&gt;The lease is the fault-tolerance story&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;An atomic claim stops two &lt;em&gt;live&lt;/em&gt; workers from grabbing the same task. But what about a worker that
claims a task and then dies — OOM-killed, crashed, partitioned off the network — while the task
sits &lt;code dir=&quot;auto&quot;&gt;IN_FLIGHT&lt;/code&gt;? Without more, that task is stuck forever, owned by a ghost.&lt;/p&gt;
&lt;p&gt;That’s what the &lt;strong&gt;lease&lt;/strong&gt; is for. Every claim stamps &lt;code dir=&quot;auto&quot;&gt;lease_expires_at&lt;/code&gt;. A worker is expected to
finish (or heartbeat) before the lease runs out. If it doesn’t, the task becomes reclaimable:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;-- reclaim: an IN_FLIGHT task whose lease has expired is fair game again&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;WHERE&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;status&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;IN_FLIGHT&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;AND&lt;/span&gt;&lt;span&gt; lease_expires_at &lt;/span&gt;&lt;span&gt;&amp;#x3C;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;now&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;div&gt;&lt;div aria-live=&quot;polite&quot;&gt;&lt;/div&gt;&lt;/div&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The reclaiming worker treats the expiry as a failed attempt — the handler exceeded its visibility
timeout — and reschedules per policy. A &lt;code dir=&quot;auto&quot;&gt;kill -9&lt;/code&gt; mid-task doesn’t lose work and doesn’t hang the
queue; it just costs one lease interval. This is why rdq can say node death is a non-event: the
lease turns a crash into an ordinary retry.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;where-it-got-subtle-lease_expired-meets-redrive&quot;&gt;Where it got subtle: LEASE_EXPIRED meets redrive&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Here’s the part that only shows up in production.&lt;/p&gt;
&lt;p&gt;rdq keeps a full &lt;strong&gt;attempt history&lt;/strong&gt; — one row per execution attempt, and that history is what
travels into the DLQ. Attempts are keyed by &lt;code dir=&quot;auto&quot;&gt;UNIQUE(task_id, attempt_no)&lt;/code&gt;: attempt 1, attempt 2,
and so on, one row each. A lease expiry isn’t invisible — it records a synthetic &lt;code dir=&quot;auto&quot;&gt;LEASE_EXPIRED&lt;/code&gt;
attempt in that same history, so the DLQ shows &lt;em&gt;why&lt;/em&gt; a task took an extra lap.&lt;/p&gt;
&lt;p&gt;Now stack two features that were each correct in isolation:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Redrive&lt;/strong&gt; takes a dead task and re-enqueues it with its &lt;strong&gt;policy reset&lt;/strong&gt; — it starts
attempting again.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Lease expiry&lt;/strong&gt; records a &lt;code dir=&quot;auto&quot;&gt;LEASE_EXPIRED&lt;/code&gt; attempt using the task’s attempt number.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The bug: when we recorded the &lt;code dir=&quot;auto&quot;&gt;LEASE_EXPIRED&lt;/code&gt; attempt, we derived &lt;code dir=&quot;auto&quot;&gt;attempt_no&lt;/code&gt; from the task’s
live &lt;code dir=&quot;auto&quot;&gt;attempt_count&lt;/code&gt;. For a fresh task that’s fine. But a &lt;strong&gt;redriven&lt;/strong&gt; task has already
accumulated attempt rows from its first life. Reset the counter on redrive, let the next attempt’s
lease expire, and the &lt;code dir=&quot;auto&quot;&gt;LEASE_EXPIRED&lt;/code&gt; write computes an &lt;code dir=&quot;auto&quot;&gt;attempt_no&lt;/code&gt; that &lt;strong&gt;already exists&lt;/strong&gt; in the
history from before the redrive — and slams straight into the &lt;code dir=&quot;auto&quot;&gt;UNIQUE(task_id, attempt_no)&lt;/code&gt;
constraint. The reclaim transaction aborts. The task can’t be reclaimed. The one path that’s
supposed to &lt;em&gt;rescue&lt;/em&gt; stuck work was the path that jammed.&lt;/p&gt;
&lt;p&gt;It’s a lovely little bug because both features are individually correct; only their composition —
&lt;em&gt;a redriven task whose subsequent lease expires&lt;/em&gt; — reveals the shared assumption that attempt
numbers are monotonic within a single logical life.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-fix-derive-the-number-from-history-not-from-a-counter&quot;&gt;The fix: derive the number from history, not from a counter&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The fix was to stop trusting the live counter and instead &lt;strong&gt;compute the next &lt;code dir=&quot;auto&quot;&gt;attempt_no&lt;/code&gt; from
the attempt history itself&lt;/strong&gt; — &lt;code dir=&quot;auto&quot;&gt;MAX(attempt_no) + 1&lt;/code&gt; for that task — so a redriven task’s
&lt;code dir=&quot;auto&quot;&gt;LEASE_EXPIRED&lt;/code&gt; row always lands &lt;em&gt;after&lt;/em&gt; everything already recorded, never on top of it. History
is the source of truth for “what number comes next,” because history is the thing the constraint
is actually protecting.&lt;/p&gt;
&lt;p&gt;The general lesson has outlived the specific bug: &lt;strong&gt;when a monotonic sequence has a UNIQUE
constraint, derive the next value from what’s already persisted, not from a mutable counter that a
feature like redrive is allowed to reset.&lt;/strong&gt; The counter and the history disagreed exactly at the
seam between two features, and the database — correctly — refused to let them.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;why-this-is-the-architecture-not-a-detail&quot;&gt;Why this is the architecture, not a detail&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;It would be easy to file all of this under “implementation.” It isn’t. The claim-and-lease model
&lt;em&gt;is&lt;/em&gt; the reason rdq can make its headline promises:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Stateless workers&lt;/strong&gt; — because the claim lives in storage, a worker holds no authoritative
state. Any worker can pick up any task.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Horizontal scaling&lt;/strong&gt; — because &lt;code dir=&quot;auto&quot;&gt;SKIP LOCKED&lt;/code&gt; (and its Redis/Mongo equivalents) hands
concurrent workers disjoint tasks, “scale up” is “start another process.”&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fault tolerance for free&lt;/strong&gt; — because the lease turns any crash into a reclaimable task, node
death needs no special handling.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;HA inherited, not invented&lt;/strong&gt; — because the only coordination point is the backend, rdq’s
availability is your Postgres/Redis/Mongo availability, and rdq adds nothing stateful of its own.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Every storage plugin has to clear the same bar — no double-claims, ever — which is why the SPI
ships with a &lt;strong&gt;compliance test-kit&lt;/strong&gt; that includes a multi-worker chaos test: &lt;code dir=&quot;auto&quot;&gt;kill -9&lt;/code&gt; a worker
mid-processing and assert the task is reclaimed after its lease expires, with no double-execution.
A plugin that can’t pass that isn’t a plugin.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;read-on&quot;&gt;Read on&lt;/h2&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://srjn45.github.io/rdq/concepts/task-lifecycle/&quot;&gt;Tasks, attempts &amp;#x26; the lifecycle&lt;/a&gt; — the full state machine.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://srjn45.github.io/rdq/concepts/storage-spi/&quot;&gt;Storage SPI &amp;#x26; compliance kit&lt;/a&gt; — what every backend must guarantee.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://srjn45.github.io/rdq/concepts/architecture/&quot;&gt;Architecture — one core, two hosts&lt;/a&gt; — where claims fit in.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;rdq is open source under Apache-2.0 — &lt;a href=&quot;https://github.com/srjn45/rdq&quot;&gt;github.com/srjn45/rdq&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;</content:encoded><category>internals</category><category>postgres</category><category>concurrency</category><category>reliability</category></item></channel></rss>