<?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: ndmt1at21</title>
    <description>The latest articles on DEV Community by ndmt1at21 (@ndmt1at21).</description>
    <link>https://dev.to/ndmt1at21</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F753256%2F78ea8512-651d-4986-bb06-bc9ebe850985.jpeg</url>
      <title>DEV Community: ndmt1at21</title>
      <link>https://dev.to/ndmt1at21</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ndmt1at21"/>
    <language>en</language>
    <item>
      <title>A Go outbox library that runs inside your own DB transaction</title>
      <dc:creator>ndmt1at21</dc:creator>
      <pubDate>Tue, 26 May 2026 04:09:41 +0000</pubDate>
      <link>https://dev.to/ndmt1at21/a-go-outbox-library-that-runs-inside-your-own-db-transaction-g1b</link>
      <guid>https://dev.to/ndmt1at21/a-go-outbox-library-that-runs-inside-your-own-db-transaction-g1b</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;TL;DR — &lt;a href="https://github.com/ndmt1at21/tickr" rel="noopener noreferrer"&gt;&lt;code&gt;tickr&lt;/code&gt;&lt;/a&gt; is a Go library. It stores messages in one Postgres table. You add messages &lt;strong&gt;inside your own database transaction&lt;/strong&gt;. A worker pool in the same Go process reads them back using &lt;code&gt;SELECT … FOR UPDATE SKIP LOCKED&lt;/code&gt; and runs your handler. It ships with Prometheus metrics, OpenTelemetry tracing, and a Grafana dashboard. No broker. No second datastore.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The problem: two writes, one of them will fail
&lt;/h2&gt;

&lt;p&gt;Most services I've worked on end up with code like this:&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="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;CreateOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&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="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;o&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="k"&gt;return&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;Publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"order.created"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// 👀&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It looks fine. It is not. The two writes are not in the same transaction. There is no way to make a Postgres commit and a Kafka publish succeed or fail together. So you get one of these bugs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;DB commit works, broker publish fails → other services never hear about the order.&lt;/li&gt;
&lt;li&gt;Broker publish works, DB rolls back → other services react to an order that does not exist.&lt;/li&gt;
&lt;li&gt;The process crashes between the two → either of the above, at random.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The standard fix is the &lt;strong&gt;transactional outbox pattern&lt;/strong&gt;. You write the message into a table in the &lt;em&gt;same&lt;/em&gt; transaction as the business row. A separate process reads that table and sends the message. The send itself can still fail, but now you can retry it from a durable record. That gives you at-least-once delivery, even if the process crashes.&lt;/p&gt;

&lt;p&gt;The usual setup is Debezium reading the Postgres write-ahead log and pushing into Kafka. That works. It is also a lot of moving parts for a team that only wanted reliable &lt;code&gt;order.created&lt;/code&gt; events.&lt;/p&gt;

&lt;h2&gt;
  
  
  What if the outbox table just &lt;em&gt;was&lt;/em&gt; the queue?
&lt;/h2&gt;

&lt;p&gt;If your handlers run in Go anyway, you do not need to push the message to Kafka only to read it back into Go. The outbox table can be the queue. A worker pool in the same service can read it directly. That is the bet &lt;a href="https://github.com/ndmt1at21/tickr" rel="noopener noreferrer"&gt;&lt;code&gt;tickr&lt;/code&gt;&lt;/a&gt; makes.&lt;/p&gt;

&lt;p&gt;Here is the producer side:&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="n"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;pool&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="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&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="n"&gt;ctx&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;_&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="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;`INSERT INTO orders ...`&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;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;tickr&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;_&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;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Enqueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pgstore&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WrapTx&lt;/span&gt;&lt;span class="p"&gt;(&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;tickr&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Type&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;           &lt;span class="s"&gt;"order.created"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Payload&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;        &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;IdempotencyKey&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;tickr&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsDuplicate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;err&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;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;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at the second argument to &lt;code&gt;Enqueue&lt;/code&gt;: it takes the &lt;strong&gt;caller's &lt;code&gt;pgx.Tx&lt;/code&gt;&lt;/strong&gt;. Not its own. That is the whole point. If your business &lt;code&gt;INSERT&lt;/code&gt; rolls back, the outbox row rolls back with it. If it commits, the outbox row commits with it. The two cannot get out of sync.&lt;/p&gt;

&lt;p&gt;This is the main difference between tickr and other Go job queues like &lt;a href="https://github.com/riverqueue/river" rel="noopener noreferrer"&gt;River&lt;/a&gt;, &lt;a href="https://github.com/vgarvardt/gue" rel="noopener noreferrer"&gt;Gue&lt;/a&gt;, or &lt;a href="https://github.com/hibiken/asynq" rel="noopener noreferrer"&gt;Asynq&lt;/a&gt;. They are good libraries, but each one owns its own connection. You cannot tie the enqueue to your application write in a single transaction.&lt;/p&gt;

&lt;h2&gt;
  
  
  The consumer side
&lt;/h2&gt;

&lt;p&gt;Handlers are typed:&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="n"&gt;reg&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;tickr&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewRegistry&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tickr&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;On&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"order.created"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&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;tickr&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;InboundMessage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="n"&gt;OrderCreated&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&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;chargeCustomer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;tickr&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithMaxAttempts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;5&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;tickr&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithAttemptTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Second&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;tickr&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewWorker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tickr&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WorkerConfig&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Storage&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Registry&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;reg&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;tickr.On[T]&lt;/code&gt; decodes the JSON payload into &lt;code&gt;T&lt;/code&gt; before your function runs. If the payload is broken, the message goes straight to the dead-letter queue — it will not decode on the next retry either, so retrying it would waste attempts.&lt;/p&gt;

&lt;p&gt;The worker reads messages in batches with this SQL:&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;tickr_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;'HANDLING'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;claimed_by&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&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;claimed_until&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;lease&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;tickr_messages&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'CREATED'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'RETRYING'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;scheduled_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;scheduled_at&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="k"&gt;LIMIT&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;batch&lt;/span&gt;
 &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;RETURNING&lt;/span&gt; &lt;span class="p"&gt;...;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;SKIP LOCKED&lt;/code&gt; lets you run many workers at the same time. Two workers reading the table never block each other. The slower one just sees fewer rows in its batch.&lt;/p&gt;

&lt;h3&gt;
  
  
  Status machine, with history
&lt;/h3&gt;

&lt;p&gt;Each message moves through these states:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CREATED ──claim──▶ HANDLING ──nil────▶ SUCCESS
                     │
                     ├─ err (attempt&amp;lt;max) ─▶ FAILED ─▶ RETRYING ─▶ HANDLING
                     │
                     ├─ err (attempt==max) ▶ FAILED ─▶ DEAD
                     │
                     ├─ DeadLetter()       ─▶ DEAD
                     │
                     └─ ctx.Canceled       ─▶ CREATED | RETRYING (attempt not increased)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every state change writes a row into &lt;code&gt;tickr_history&lt;/code&gt;. The history is never read on the hot path. It is there so when production wakes you up at 3am asking "why did this message die?", you can run one SQL query and see every attempt, every error, and the worker that handled it.&lt;/p&gt;

&lt;p&gt;If you do not want the audit cost, &lt;code&gt;WithHistoryPolicy(HistoryOff)&lt;/code&gt; skips those inserts. More on the trade-off below.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lease auto-extension
&lt;/h3&gt;

&lt;p&gt;The feature I like most: &lt;code&gt;WithAttemptTimeout(60*time.Second)&lt;/code&gt; works correctly even when the lease is only 30 seconds. The engine runs a small goroutine that extends &lt;code&gt;claimed_until&lt;/code&gt; every &lt;code&gt;Lease/3&lt;/code&gt; while your handler runs. If the extension ever fails — say another worker takes the row because the network died — the handler's context is cancelled so it stops writing.&lt;/p&gt;

&lt;p&gt;When a worker crashes hard (SIGKILL), the reclaimer moves the orphan rows back to &lt;code&gt;RETRYING&lt;/code&gt; once the lease expires. The attempt counter stays as it was. So a message that crashes the process every time eventually ends up in &lt;code&gt;DEAD&lt;/code&gt; instead of looping forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where tickr wins, where it loses
&lt;/h2&gt;

&lt;p&gt;The repo has a &lt;a href="https://github.com/ndmt1at21/tickr/tree/main/benchmarks" rel="noopener noreferrer"&gt;&lt;code&gt;benchmarks/&lt;/code&gt;&lt;/a&gt; module that runs the same workload against River, Gue, Watermill SQL, and Asynq. The honest summary on a single-host Docker setup:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Library&lt;/th&gt;
&lt;th&gt;Enqueue (msgs/sec)&lt;/th&gt;
&lt;th&gt;Drain (msgs/sec)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Watermill SQL&lt;/td&gt;
&lt;td&gt;106,802&lt;/td&gt;
&lt;td&gt;40,010&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gue&lt;/td&gt;
&lt;td&gt;95,045&lt;/td&gt;
&lt;td&gt;1,835&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;River&lt;/td&gt;
&lt;td&gt;52,854&lt;/td&gt;
&lt;td&gt;5,991&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;tickr (HistoryOff)&lt;/td&gt;
&lt;td&gt;39,133&lt;/td&gt;
&lt;td&gt;4,809&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Asynq&lt;/td&gt;
&lt;td&gt;4,370&lt;/td&gt;
&lt;td&gt;2,655&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A few things I want to be upfront about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Watermill leads both lists because it is not a job queue.&lt;/strong&gt; It is pub/sub with an offsets table. An ack is one row update, not N row updates. Different guarantees, different workload.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;tickr is slower than River on drain (about 80%).&lt;/strong&gt; Each ack writes two rows: an UPDATE on &lt;code&gt;tickr_messages&lt;/code&gt; and an INSERT into &lt;code&gt;tickr_history&lt;/code&gt; for the SUCCESS transition. One CTE folds them into a single round-trip, but the write volume is still about 2x River's. &lt;code&gt;HistoryOff&lt;/code&gt; recovers 13%. The next planned change — batched ack via &lt;code&gt;pgx.SendBatch&lt;/code&gt; — should close most of the gap.&lt;/li&gt;
&lt;li&gt;These are &lt;strong&gt;single-host Docker numbers&lt;/strong&gt;. On a real Postgres cluster with PgBouncer and tuned autovacuum, the absolute numbers go up a lot. The repo's &lt;a href="https://github.com/ndmt1at21/tickr#throughput" rel="noopener noreferrer"&gt;throughput section&lt;/a&gt; shows the baseline config for 1M msg/min.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If raw drain throughput is your only concern, River is faster today on my laptop. If you want "enqueue inside my own transaction, &lt;em&gt;and&lt;/em&gt; observability built in", that is what tickr is for.&lt;/p&gt;

&lt;p&gt;Full numbers and methodology: &lt;a href="https://github.com/ndmt1at21/tickr/blob/main/BENCHMARKS.md" rel="noopener noreferrer"&gt;BENCHMARKS.md&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Observability is built in
&lt;/h2&gt;

&lt;p&gt;This was the part I did not want to ship without. Adding observability to an outbox after the fact is painful.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prometheus&lt;/strong&gt; — pass a &lt;code&gt;metrics/prom&lt;/code&gt; adapter into &lt;code&gt;ClientConfig.Metrics&lt;/code&gt; and &lt;code&gt;WorkerConfig.Metrics&lt;/code&gt;. You get counters and histograms for every useful number: enqueue rate, handler outcomes, queue depth by status, claim batch size, reclaimed leases, in-flight handlers. The repo has a Grafana dashboard at &lt;code&gt;grafana/tickr-dashboard.json&lt;/code&gt; you can import.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OpenTelemetry&lt;/strong&gt; — the tracer puts W3C &lt;code&gt;traceparent&lt;/code&gt; into &lt;code&gt;Message.Headers&lt;/code&gt; at enqueue time. It reads them back when the worker picks up the message. So one trace covers &lt;strong&gt;producer → outbox → consumer&lt;/strong&gt;, even if the consumer runs hours later (for example, after a retry). Span attributes follow the OTel messaging conventions, so Tempo, Jaeger, or Honeycomb all work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Built-in transport handlers&lt;/strong&gt; — if your handler just forwards the message to an HTTP webhook or a gRPC method, &lt;code&gt;handlers/http&lt;/code&gt; and &lt;code&gt;handlers/grpc&lt;/code&gt; are one-liners. They handle status code classification (which codes retry, which dead-letter), Retry-After headers, idempotency-key forwarding, and trace propagation. Each one lives in its own Go module, so its deps stay out of your core &lt;code&gt;go.mod&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this is not a good fit
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://github.com/ndmt1at21/tickr#limitations" rel="noopener noreferrer"&gt;Limitations&lt;/a&gt; section in the README has the full list. The two that matter most:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Producer and outbox must share a database.&lt;/strong&gt; The guarantee depends on a single transaction. If your business data lives in MySQL and you want the outbox in Postgres, this library cannot help you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;At-least-once only.&lt;/strong&gt; Your handlers must be idempotent. The library gives you &lt;code&gt;InboundMessage.IdempotencyKey&lt;/code&gt; and the message ID. You build dedup on top. The orders example shows one common pattern: a side table keyed by &lt;code&gt;(handler_name, idempotency_key)&lt;/code&gt; written inside the handler's own transaction.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If those are not a problem, the rest is production-shaped. Integration tests run against real Postgres via testcontainers. The storage interface has a conformance suite that any new adapter has to pass. The &lt;code&gt;examples/orders&lt;/code&gt; directory spins up the full stack (service, Postgres, Prometheus, Grafana, Tempo) with one &lt;code&gt;docker compose up&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;go get github.com/ndmt1at21/tickr@latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or clone and run the example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/ndmt1at21/tickr
&lt;span class="nb"&gt;cd &lt;/span&gt;tickr/examples/orders
docker compose up &lt;span class="nt"&gt;--build&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then in another terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST http://localhost:8080/orders &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'content-type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"order_id":"o-1","customer_id":"c-1","total":42.50}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You will see the order land in Postgres, the outbox row in &lt;code&gt;tickr_messages&lt;/code&gt;, the handler run, and the full trace in Tempo at &lt;code&gt;http://localhost:3000&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Repo: &lt;strong&gt;&lt;a href="https://github.com/ndmt1at21/tickr" rel="noopener noreferrer"&gt;github.com/ndmt1at21/tickr&lt;/a&gt;&lt;/strong&gt; — MIT licensed. Issues and PRs are welcome. If you have shipped an outbox in production before, I would love feedback on the history retention design and the partitioning notes in &lt;a href="https://github.com/ndmt1at21/tickr/blob/main/ARCHITECTURE.md" rel="noopener noreferrer"&gt;ARCHITECTURE.md&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>go</category>
      <category>postgres</category>
      <category>microservices</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
