<?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: Anna Sbrodova</title>
    <description>The latest articles on DEV Community by Anna Sbrodova (@asbrodova).</description>
    <link>https://dev.to/asbrodova</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%2F4179335%2F3bee8c86-21ba-4aaa-ba07-2543d52d2548.jpg</url>
      <title>DEV Community: Anna Sbrodova</title>
      <link>https://dev.to/asbrodova</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/asbrodova"/>
    <language>en</language>
    <item>
      <title>The Redelivery Trap: Building a Safe Transactional Inbox for Google Pub/Sub</title>
      <dc:creator>Anna Sbrodova</dc:creator>
      <pubDate>Mon, 12 Oct 2026 13:57:48 +0000</pubDate>
      <link>https://dev.to/asbrodova/the-redelivery-trap-building-a-safe-transactional-inbox-for-google-pubsub-36ei</link>
      <guid>https://dev.to/asbrodova/the-redelivery-trap-building-a-safe-transactional-inbox-for-google-pubsub-36ei</guid>
      <description>&lt;p&gt;Pub/Sub will deliver the same message twice. Not often, but reliably enough that if your consumer charges a client, it will eventually charge them twice.&lt;/p&gt;

&lt;p&gt;In &lt;a href="https://dev.to/asbrodova/the-dual-write-problem-building-a-scalable-transactional-outbox-for-google-pubsub-h2n"&gt;part one&lt;/a&gt; we built a Postgres-backed outbox that guarantees every event from BigQuery &lt;strong&gt;reaches&lt;/strong&gt; Pub/Sub. It deliberately chose at-least-once delivery, so this part is the other half: making the consumer safe when the same message arrives more than once.&lt;/p&gt;

&lt;h2&gt;
  
  
  The naive pattern: "blind trust"
&lt;/h2&gt;

&lt;p&gt;A typical GKE worker or Cloud Run service:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Listens&lt;/strong&gt; to a Pub/Sub subscription&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pulls&lt;/strong&gt; a message&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Executes&lt;/strong&gt; the business logic (updates database state, calls a payment gateway)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Acknowledges&lt;/strong&gt; the broker&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  The failure mode
&lt;/h3&gt;

&lt;p&gt;![The failure mode: the "blind trust" pattern]&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0x882nixjsb33mdskds1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0x882nixjsb33mdskds1.png" alt=" " width="800" height="437"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If the network blips &lt;em&gt;after&lt;/em&gt; the business logic completes but &lt;em&gt;before&lt;/em&gt; the ack reaches Pub/Sub, the broker redelivers the same message, possibly to another pod. The database gets hit again, and this time it's a duplicate.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If losing a message means you forgot to charge a client $50k, processing it twice means you just double-billed them. Try explaining to compliance that it wasn't fraud, just a highly available network retry.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  The solution: a transactional inbox
&lt;/h2&gt;

&lt;p&gt;Instead of trusting the network, make the database the arbiter of truth: an &lt;code&gt;inbox&lt;/code&gt; table with a unique constraint on the message's idempotency key.&lt;/p&gt;

&lt;p&gt;Everything happens in one atomic database transaction:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The gatekeeper (deduplication):&lt;/strong&gt; the worker tries to insert the message's key into the &lt;code&gt;inbox&lt;/code&gt; table.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The execution (business logic):&lt;/strong&gt; if the insert succeeds, the message is new. Run the business logic in the &lt;em&gt;same transaction&lt;/em&gt;, commit, then ack. If the insert finds the key already there, skip the business logic and just ack, so the broker stops retrying.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxn1n2t1a6y1uvpcqnc27.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxn1n2t1a6y1uvpcqnc27.png" alt="The solution: the idempotent transactional inbox" width="800" height="437"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  Which ID to deduplicate on
&lt;/h3&gt;

&lt;p&gt;It's tempting to use Pub/Sub's own &lt;code&gt;msg.ID&lt;/code&gt;. That only catches &lt;strong&gt;broker redeliveries&lt;/strong&gt;. Pub/Sub assigns a new message ID every time something is published, so if the outbox from part one publishes the same row twice — which at-least-once allows — the two copies arrive with different IDs and both get processed.&lt;/p&gt;

&lt;p&gt;Deduplicate on a key the &lt;strong&gt;producer&lt;/strong&gt; controls instead. The outbox already has one: its row ID. Publish it as a message attribute and use that.&lt;/p&gt;
&lt;h3&gt;
  
  
  The key piece: &lt;code&gt;ON CONFLICT DO NOTHING&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Pushing deduplication into Postgres removes race conditions across the whole GKE cluster. If two pods receive the same message at once, the second insert waits on the unique index until the first transaction finishes, then inserts nothing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// The producer's idempotency key, set by the outbox as a message attribute.&lt;/span&gt;
&lt;span class="c"&gt;// Don't use msg.ID: Pub/Sub assigns a new one on every publish.&lt;/span&gt;
&lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Attributes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"outbox_id"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="c"&gt;// 1. Begin a single atomic database transaction.&lt;/span&gt;
&lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Begin&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;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&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;err&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c"&gt;// Always defer a rollback so an early return can't leak the connection.&lt;/span&gt;
&lt;span class="c"&gt;// If tx.Commit() succeeds later, this becomes a safe no-op.&lt;/span&gt;
&lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Rollback&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c"&gt;// 2. Try to record the key.&lt;/span&gt;
&lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;`
    INSERT INTO transactional_inbox (idempotency_key, created_at)
    VALUES ($1, NOW())
    ON CONFLICT (idempotency_key) DO NOTHING;
`&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;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&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;err&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;rowsAffected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RowsAffected&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;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&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;err&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;// 3. Zero rows means this message was already processed.&lt;/span&gt;
&lt;span class="c"&gt;// Still ack, so the broker stops redelivering it.&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;rowsAffected&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;broker&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Acknowledge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;// 4. Run the business logic inside the SAME transaction.&lt;/span&gt;
&lt;span class="c"&gt;// if err := executeBusinessLogic(tx); err != nil {&lt;/span&gt;
&lt;span class="c"&gt;//     return err // the deferred rollback undoes everything&lt;/span&gt;
&lt;span class="c"&gt;// }&lt;/span&gt;

&lt;span class="c"&gt;// 5. Commit, then ack.&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Commit&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&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;err&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;broker&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Acknowledge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the pod crashes between the commit and the ack, the message is redelivered, the insert finds the key, and the worker simply acks. Nothing runs twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not Pub/Sub's exactly-once delivery?
&lt;/h2&gt;

&lt;p&gt;Pub/Sub can guarantee that a message you've successfully acknowledged won't be redelivered. That narrows the window but doesn't close it. If the transaction commits and then the ack fails or the ack deadline expires, the message still comes back. And it can't help with duplicates the producer created by publishing twice. The inbox handles both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not a cache instead?
&lt;/h2&gt;

&lt;p&gt;I often hear: "Why add database overhead? Just deduplicate in Redis."&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Caches get evicted; ledgers are forever.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If the Redis cluster restarts or a key expires early, your line of defence is gone. Idempotency isn't a buzzword here. It's the firewall protecting your revenue.&lt;/p&gt;

&lt;p&gt;When the monthly reconciliation runs, your CFO doesn't care about network partitions or broker limits. They care that the ledger balances. Anchoring deduplication in the same ACID database that holds the financial state guarantees each message is applied exactly once, and the client is billed correctly.&lt;/p&gt;

</description>
      <category>go</category>
      <category>googlecloud</category>
      <category>distributedsystems</category>
      <category>postgres</category>
    </item>
    <item>
      <title>The Dual-Write Problem: Building a Scalable Transactional Outbox for Google Pub/Sub</title>
      <dc:creator>Anna Sbrodova</dc:creator>
      <pubDate>Mon, 12 Oct 2026 12:57:23 +0000</pubDate>
      <link>https://dev.to/asbrodova/the-dual-write-problem-building-a-scalable-transactional-outbox-for-google-pubsub-h2n</link>
      <guid>https://dev.to/asbrodova/the-dual-write-problem-building-a-scalable-transactional-outbox-for-google-pubsub-h2n</guid>
      <description>&lt;p&gt;If a service writes data and then publishes an event about it, there is a window where one succeeds and the other doesn't. That's the dual-write problem, and in a billing system it costs real money.&lt;/p&gt;

&lt;p&gt;This is how we closed that window for a BigQuery → Pub/Sub pipeline, using a Postgres outbox and a generic Go dispatcher on GKE.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;Imagine a billing platform. The source of truth is &lt;strong&gt;BigQuery&lt;/strong&gt;, where the heavy analytical lifting happens, but downstream services need real-time updates through &lt;strong&gt;Google Pub/Sub&lt;/strong&gt; to trigger invoices, emails and ledger updates.&lt;/p&gt;

&lt;p&gt;The goal is simple: get data from BigQuery to Pub/Sub.&lt;/p&gt;

&lt;h2&gt;
  
  
  The naive pattern: "the direct shot"
&lt;/h2&gt;

&lt;p&gt;Most teams start with a Cloud Run Job or a cron task:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Query BigQuery&lt;/li&gt;
&lt;li&gt;Loop through the rows&lt;/li&gt;
&lt;li&gt;Call &lt;code&gt;pubsub.Publish()&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Job finishes. Done.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcxgp30x0phuzeearcdzl.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcxgp30x0phuzeearcdzl.jpeg" alt="The naive pattern: simple to implement, but vulnerable to data loss during network instability or broker downtime" width="470" height="142"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The question that matters: what happens if the network flickers at step 3?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If the producer crashes, or the Pub/Sub API hiccups, you get a dual-write failure. The data was processed, but the message is gone. In a billing system, "we lost a few messages" means "we forgot to charge a client $50k."&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Kafka and RabbitMQ users:&lt;/strong&gt; the same architecture applies. The core challenge is distributed consistency, not the broker.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The solution: a transactional outbox
&lt;/h2&gt;

&lt;p&gt;To sleep at night, we decoupled &lt;strong&gt;extraction&lt;/strong&gt; from &lt;strong&gt;dispatching&lt;/strong&gt;, with a &lt;strong&gt;Postgres-backed outbox&lt;/strong&gt; as a reliable staging area.&lt;/p&gt;

&lt;p&gt;Instead of one fragile step, there are two resilient ones:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The producer (Cloud Run Job)&lt;/strong&gt; queries BigQuery and writes the &lt;em&gt;serialized bytes&lt;/em&gt; into a &lt;code&gt;pubsub_outbox&lt;/code&gt; table in Cloud SQL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The dispatcher (GKE service)&lt;/strong&gt; is a "dumb" pipe that picks up those bytes and makes sure they reach the broker.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe7q048p1494lzmm9a0xz.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe7q048p1494lzmm9a0xz.jpeg" alt="A decoupled, source-agnostic architecture: the Postgres outbox is the source of truth for the GKE dispatcher, wherever the data originated" width="800" height="158"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not poll BigQuery directly? It's a FinOps problem
&lt;/h2&gt;

&lt;p&gt;I'm often asked why the dispatcher doesn't just poll BigQuery. As a tech lead, I look at the bill.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;BigQuery is built for analysis.&lt;/strong&gt; On-demand pricing charges per terabyte scanned. Polling every 10 seconds to ask "is there new data?" burns budget for nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Latency.&lt;/strong&gt; BigQuery DML updates can take seconds. Postgres does it in milliseconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Concurrency.&lt;/strong&gt; BigQuery has no &lt;code&gt;SELECT ... FOR UPDATE SKIP LOCKED&lt;/code&gt;. Run two producer instances and they fight over the same rows.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The key piece: &lt;code&gt;SKIP LOCKED&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The Go dispatcher lets Postgres handle concurrency. Each pod claims a batch of rows that no other pod can see, so the dispatcher scales horizontally on GKE without two pods ever claiming the same row.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Fetch 100 messages, lock them so no one else sees them,&lt;/span&gt;
&lt;span class="c1"&gt;-- and skip anything already being handled by another pod.&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;pubsub_outbox&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt;
    &lt;span class="n"&gt;lock_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;worker_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;locked_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="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;IN&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="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pubsub_outbox&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;completed_at&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
      &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;topic_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;target_topic&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;lock_id&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;next_retry_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="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;ASC&lt;/span&gt;
    &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;
    &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;SKIP&lt;/span&gt; &lt;span class="n"&gt;LOCKED&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;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One thing worth being precise about: this gives &lt;strong&gt;at-least-once&lt;/strong&gt; delivery, not exactly-once. If a pod publishes a message and crashes before marking the row complete, the row gets published again. That's the right trade-off for billing, since a duplicate is recoverable and a lost message isn't, but it means the consumer has to handle duplicates. That's part two of this series.&lt;/p&gt;

&lt;h2&gt;
  
  
  The generic advantage: infrastructure vs. logic
&lt;/h2&gt;

&lt;p&gt;Because the payload is stored as &lt;code&gt;BYTEA&lt;/code&gt; (raw bytes), the dispatcher becomes a universal delivery engine. It doesn't care where the data came from, only that it gets delivered.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Business logic, from any source:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Analytical sources:&lt;/strong&gt; a scheduled Cloud Run Job queries BigQuery and writes large datasets into the outbox.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Operational sources:&lt;/strong&gt; an upstream microservice (checkout, billing) inserts into the outbox as part of its own local database transaction, so the state change and the event are committed together.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Delivery infrastructure, zero-touch:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Nobody touches the dispatcher code. To support a new data stream, deploy the &lt;em&gt;same&lt;/em&gt; Docker image to GKE with a new &lt;code&gt;TOPIC_NAME&lt;/code&gt; environment variable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The result:&lt;/strong&gt; the team stops reinventing the reliability wheel. Every new data stream gets at-least-once delivery, exponential backoff and dead-letter handling out of the box.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Next in the series: &lt;a href="https://dev.to/asbrodova/the-redelivery-trap-building-a-safe-transactional-inbox-for-google-pubsub-36ei"&gt;the consumer side&lt;/a&gt; — what happens when Pub/Sub delivers the same message twice, and how a transactional inbox makes that safe.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>googlecloud</category>
      <category>postgres</category>
      <category>distributedsystems</category>
      <category>go</category>
    </item>
  </channel>
</rss>
