<?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: Kyryl</title>
    <description>The latest articles on DEV Community by Kyryl (@code_with_kyryl).</description>
    <link>https://dev.to/code_with_kyryl</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%2F1555526%2F850a315e-27a2-410d-85db-cb6a771c189b.jpg</url>
      <title>DEV Community: Kyryl</title>
      <link>https://dev.to/code_with_kyryl</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/code_with_kyryl"/>
    <language>en</language>
    <item>
      <title>Can Kafka Replace Redis for Cache Synchronization? A Production Answer</title>
      <dc:creator>Kyryl</dc:creator>
      <pubDate>Wed, 14 Oct 2026 08:40:04 +0000</pubDate>
      <link>https://dev.to/code_with_kyryl/can-kafka-replace-redis-for-cache-synchronization-a-production-answer-318i</link>
      <guid>https://dev.to/code_with_kyryl/can-kafka-replace-redis-for-cache-synchronization-a-production-answer-318i</guid>
      <description>&lt;p&gt;I recently came across a question on r/apachekafka that captures a common cache-synchronization&lt;br&gt;
problem:&lt;br&gt;
&lt;strong&gt;&lt;a href="https://www.reddit.com/r/apachekafka/comments/1vhv9nn/can_kafka_replace_redis_for_cache_synchronization/" rel="noopener noreferrer"&gt;Can Kafka Replace Redis for Cache Synchronization Across Multiple Spring Boot Pods?&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
  &lt;span&gt;The original question, paraphrased&lt;/span&gt;
  &lt;p&gt;Spring Boot on GCP, ~25 pods, Redis currently handles cache synchronization. Considering Kafka
  instead: publish an event when a cache entry changes, every pod consumes it and updates its own
  local cache. Is Kafka a good fit here? With 25 pods, does every pod get the update, or does
  Kafka hand it to only one consumer? Does each pod need its own consumer group? Has anyone
  actually run this — what are the pros and cons?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I have experience solving this class of problem. The approach described here — a compacted Kafka&lt;br&gt;
topic used to materialize a local cache in every pod — is a common pattern and has been running in&lt;br&gt;
production systems for years. It is not a universal replacement for Redis, but it is a practical&lt;br&gt;
option when its consistency and operational trade-offs fit the workload. This article explains the&lt;br&gt;
pattern, the details that matter in practice, and a small runnable example.&lt;/p&gt;


  &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcodewithkyryl.dev%2Fassets%2Fimg%2Farticles%2Fkafka-local-cache-flow.svg" alt="A configuration producer publishes to a compacted Kafka topic, which three consumer groups replay into separate local caches in Pod A, Pod B, and Pod C" width="1200" height="620"&gt;Each pod uses a separate consumer group, receives the complete topic, and serves reads from its own local cache.
  

&lt;h2&gt;
  
  
  The Problem: Shared Configuration on a Fast Read Path
&lt;/h2&gt;

&lt;p&gt;Assume the application has configuration or reference data that every instance needs often:&lt;br&gt;
feature rules, routing settings, tenant settings, or similar data. It needs to be quick to&lt;br&gt;
retrieve, but it must also change without redeploying every pod.&lt;/p&gt;

&lt;p&gt;A shared Redis cache is a natural solution. Every pod reads the same centrally managed value, so&lt;br&gt;
after an update there is one current view to consult. The trade-off is that each read leaves the&lt;br&gt;
process and depends on the cache service being available and responsive. Depending on the chosen&lt;br&gt;
cache strategy, a Redis miss can also cause an additional lookup: with cache-aside, for example,&lt;br&gt;
the application reads the source of truth, then populates Redis before the next request can use&lt;br&gt;
the cache.&lt;/p&gt;

&lt;p&gt;Kafka takes a different approach. An update is appended to a compacted topic, and every instance&lt;br&gt;
replays that topic into its own in-memory cache. Reads are then local map lookups. The replicas are&lt;br&gt;
eventually consistent: when a pod begins processing work, it may still have an older value while&lt;br&gt;
it catches up — or no value at all if consumption has failed. Later, I will show how to prevent a&lt;br&gt;
pod from doing real work until it has consumed the configuration. In return, Kafka provides a&lt;br&gt;
durable log for replay and recovery, keeps the read path inside the pod, and can support explicit&lt;br&gt;
schema-based serialization such as Avro, Protobuf, or JSON Schema.&lt;/p&gt;

&lt;p&gt;Neither Kafka nor Redis is immune to failure. But if Kafka is already a dependency, using it to&lt;br&gt;
distribute this configuration can avoid adding Redis as another runtime dependency and another&lt;br&gt;
system to operate. That reduces the number of moving parts and failure modes to consider; it does&lt;br&gt;
not make Kafka failures disappear. This is a worthwhile trade only when eventual consistency is&lt;br&gt;
acceptable for the data.&lt;/p&gt;

&lt;p&gt;Kafka can also decouple the service that publishes configuration from the services that consume&lt;br&gt;
it. They only need to agree on the record contract and its schema; consumers do not need a direct&lt;br&gt;
runtime dependency on the producer. With Redis, both sides typically need to agree on the key&lt;br&gt;
format and the serialized value — often the same DTO or a shared representation — and consumers&lt;br&gt;
must know where to read it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Aspect&lt;/th&gt;
&lt;th&gt;Redis (shared cache)&lt;/th&gt;
&lt;th&gt;Kafka (compacted topic → local cache)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Data view&lt;/td&gt;
&lt;td&gt;One centrally managed cache&lt;/td&gt;
&lt;td&gt;One materialized cache per instance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Consistency&lt;/td&gt;
&lt;td&gt;One shared cache view; source changes still require correct invalidation&lt;/td&gt;
&lt;td&gt;Eventual consistency between local replicas&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read path&lt;/td&gt;
&lt;td&gt;Network round-trip; misses may require a source-of-truth lookup&lt;/td&gt;
&lt;td&gt;In-process map lookup after the initial replay&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Recovery / new instances&lt;/td&gt;
&lt;td&gt;Populate or warm the cache&lt;/td&gt;
&lt;td&gt;Replay the compacted topic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Record history&lt;/td&gt;
&lt;td&gt;Not inherent to the cache&lt;/td&gt;
&lt;td&gt;Replayable current state; compaction removes superseded values, so this is not an audit log&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Service coupling&lt;/td&gt;
&lt;td&gt;Shared key/value format and Redis access&lt;/td&gt;
&lt;td&gt;Shared record schema; no direct producer dependency&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Operational cost&lt;/td&gt;
&lt;td&gt;Shared cache tier, plus Kafka if it is also needed&lt;/td&gt;
&lt;td&gt;Kafka storage, consumers, and one cache per instance&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This article focuses on how to build the Kafka side safely: broadcasting every update to every&lt;br&gt;
instance, rebuilding the cache on startup, and refusing to do real work until that rebuild is&lt;br&gt;
complete.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A note on the code&lt;/strong&gt;: the repository is a runnable demonstration, not a drop-in production&lt;br&gt;
library. I verified its Docker flow end to end: startup replay, readiness, live updates, and&lt;br&gt;
tombstone eviction. Before adopting the pattern, test it against your own volume, rebalance,&lt;br&gt;
serialization, retry, and outage scenarios.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Yes, with a caveat&lt;/strong&gt;: Kafka replaces Redis for &lt;em&gt;broadcasting&lt;/em&gt; state to every pod, not for
reading from one shared cache. Each pod gets its own eventually-consistent local copy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every pod needs its own Kafka consumer group.&lt;/strong&gt; Pods sharing a group split the partitions
(load-balanced); pods each in their own group each get the full topic (broadcast).&lt;/li&gt;
&lt;li&gt;The topic must be &lt;strong&gt;compacted&lt;/strong&gt;. Use more than one partition when startup replay needs
concurrency, and make the local cache &lt;strong&gt;thread-safe&lt;/strong&gt; when listener concurrency is enabled.&lt;/li&gt;
&lt;li&gt;Keep the dataset &lt;strong&gt;small and relatively low-volume&lt;/strong&gt;. This pattern fits feature flags, routing
rules, and service configuration—not a large or high-churn business dataset.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deletes are tombstones&lt;/strong&gt;: a record with a null value, not a special "delete" message type.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wait until startup replay is complete before interacting with the outside world.&lt;/strong&gt;
Do not serve endpoints, run scheduled jobs, process queue messages, or perform other externally
visible work from a partial local cache. Open readiness only after the replay is complete.&lt;/li&gt;
&lt;li&gt;Unique groups leave inactive offset metadata behind until Kafka's configured retention removes
it. Expect temporary clutter in consumer-group tooling and monitor the relevant broker settings.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;


&lt;h2&gt;
  
  
  How the Whole Flow Works
&lt;/h2&gt;

&lt;p&gt;There are two phases: &lt;strong&gt;rebuild the local replica&lt;/strong&gt;, then &lt;strong&gt;keep it updated&lt;/strong&gt;.&lt;/p&gt;


  &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcodewithkyryl.dev%2Fassets%2Fimg%2Farticles%2Fkafka-local-cache-lifecycle.svg" alt="Two-phase Kafka local-cache lifecycle: startup captures fixed partition targets and keeps readiness down while records are applied; steady state opens readiness, serves local reads, and continues applying later updates" width="1200" height="760"&gt;Startup has a fixed finish line. After every partition reaches it, the same listener keeps the ready pod's local cache updated.
  


&lt;ol&gt;
&lt;li&gt;A producer writes an upsert to the compacted topic. The Kafka record key is the configuration
key; its value is the latest configuration. A null value is a tombstone that means delete.&lt;/li&gt;
&lt;li&gt;A new pod starts with a new consumer group and &lt;code&gt;auto.offset.reset=earliest&lt;/code&gt;, so Kafka assigns
every topic partition to that pod's group and replay begins from the oldest retained offsets.&lt;/li&gt;
&lt;li&gt;During assignment, the readiness gate reads two numbers for every partition: the consumer's
current position and the partition's end offset. That end offset becomes the &lt;strong&gt;fixed startup
target&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;The listener applies each record to a thread-safe local map. Only after the listener returns
successfully does the tracking aspect advance that partition's processed position. A listener
failure therefore cannot make the gate claim that the failed record was applied.&lt;/li&gt;
&lt;li&gt;When every partition reaches its captured target, readiness changes to &lt;code&gt;UP&lt;/code&gt;. The deployment
platform may now route requests to the pod, and readiness-gated scheduled jobs may run. Reads
use the local map and do not call Kafka.&lt;/li&gt;
&lt;li&gt;The same consumer remains active after startup. New records update or delete local entries as
they arrive, so pods converge independently and are eventually consistent with one another.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The fixed target in step 3 is important. If a producer writes while replay is running, the new&lt;br&gt;
record does not move the startup finish line forever. The consumer still processes it through the&lt;br&gt;
normal listener path, but readiness is defined against the snapshot captured at assignment. What&lt;br&gt;
can make startup slow is a large existing log, not a stream of later low-volume updates. This is&lt;br&gt;
why the pattern is intended for small, low-churn upsert datasets such as feature flags, routing&lt;br&gt;
rules, and service configuration.&lt;/p&gt;


&lt;h2&gt;
  
  
  Publish the Current State in a Compacted Topic
&lt;/h2&gt;

&lt;p&gt;Start by creating the configuration topic with &lt;code&gt;cleanup.policy=compact&lt;/code&gt;. Compaction retains the&lt;br&gt;
latest record for each key, so a pod that starts later can replay the topic from the beginning and&lt;br&gt;
reconstruct the current configuration without asking another service to warm its cache.&lt;/p&gt;

&lt;p&gt;This works best for a small, relatively low-volume upsert topic: feature flags, routing rules,&lt;br&gt;
tenant settings, or similar configuration. It is not a good default for a large or high-churn&lt;br&gt;
dataset that every pod would need to replay and retain in memory.&lt;/p&gt;

&lt;p&gt;Create enough partitions to let one pod replay the topic concurrently with several consumer&lt;br&gt;
threads when startup time matters. The useful concurrency is capped by the partition count, and the&lt;br&gt;
local cache must be thread-safe when listener concurrency is configured above one. In the example,&lt;br&gt;
a one-shot &lt;code&gt;kafka-topic-init&lt;/code&gt; service creates and seeds the topic before the application starts.&lt;/p&gt;

&lt;p&gt;The next requirement is ensuring every pod receives that state.&lt;/p&gt;
&lt;h2&gt;
  
  
  Give Every Pod Its Own Consumer Group
&lt;/h2&gt;

&lt;p&gt;A Kafka consumer group has one independent view of a topic: within that group, each partition is&lt;br&gt;
assigned to one consumer. Therefore, pods that share a group split the work; pods in separate&lt;br&gt;
groups each consume the complete topic.&lt;/p&gt;


  &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcodewithkyryl.dev%2Fassets%2Fimg%2Farticles%2Fkafka-consumer-groups.svg" alt="Comparison of a shared Kafka consumer group, where partitions are split between pods, with one unique group per pod, where every pod receives all partitions" width="1200" height="650"&gt;A shared group divides the state. A unique group per pod gives every local cache the complete state.
  


&lt;p&gt;Give each pod a unique &lt;code&gt;group.id&lt;/code&gt;. In this example, a shared prefix identifies the deployment and&lt;br&gt;
Spring's &lt;code&gt;${random.uuid}&lt;/code&gt; supplies the per-instance suffix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;config-topic&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${CONFIG_TOPIC:local-cache-config}&lt;/span&gt;
  &lt;span class="na"&gt;kafka&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;bootstrap-servers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${KAFKA_BOOTSTRAP_SERVERS:localhost:9092}&lt;/span&gt;
    &lt;span class="na"&gt;consumer-group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${CONSUMER_GROUP_PREFIX:local-cache-demo}-${random.uuid}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every startup creates a new group and must rebuild its local cache. For that reason, set&lt;br&gt;
&lt;code&gt;auto.offset.reset&lt;/code&gt; to &lt;code&gt;earliest&lt;/code&gt; in the consumer properties. &lt;code&gt;latest&lt;/code&gt; would leave a new pod empty&lt;br&gt;
until the next update arrives.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;consumerProperties&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="c1"&gt;// other consumer properties...&lt;/span&gt;
        &lt;span class="nc"&gt;ConsumerConfig&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;AUTO_OFFSET_RESET_CONFIG&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"earliest"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the topic and broadcast behavior in place, each pod can now materialize the same local state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Materialize Updates in the Local Cache
&lt;/h2&gt;

&lt;p&gt;Once the topic contains the current state and every pod receives it, the listener is deliberately&lt;br&gt;
small: put normal records into the local cache and remove a key for a tombstone.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Slf4j&lt;/span&gt;
&lt;span class="nd"&gt;@Component&lt;/span&gt;
&lt;span class="nd"&gt;@RequiredArgsConstructor&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ConfigTopicListener&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;LocalConfigCache&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@ConfigConsumer&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"config-topic"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="nd"&gt;@KafkaListener&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;topics&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"${app.config-topic}"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;onMessage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ConsumerRecord&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;debug&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Tombstone for key '{}' — evicting from local cache"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
            &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;remove&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;put&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Normal records cover creates and updates. Deletion needs one additional convention.&lt;/p&gt;

&lt;h2&gt;
  
  
  Delete Configuration with Tombstones
&lt;/h2&gt;

&lt;p&gt;A record with the same key and a &lt;strong&gt;null value&lt;/strong&gt; is a &lt;strong&gt;tombstone&lt;/strong&gt;. Compaction retains it long&lt;br&gt;
enough for consumers to observe the delete, then eventually removes it. &lt;code&gt;delete.retention.ms&lt;/code&gt; is&lt;br&gt;
also the bound within which a consumer starting at offset zero must complete its scan to be&lt;br&gt;
guaranteed a valid final-state snapshot, as the&lt;br&gt;
&lt;a href="https://kafka.apache.org/42/configuration/broker-configs/#brokerconfigs_log.cleaner.delete.retention.ms" rel="noopener noreferrer"&gt;Kafka broker configuration reference&lt;/a&gt;&lt;br&gt;
explains. Configure that window from measured worst-case replay time; do not assume the default is&lt;br&gt;
automatically safe for a large topic.&lt;/p&gt;


  &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcodewithkyryl.dev%2Fassets%2Fimg%2Farticles%2Fkafka-tombstone-flow.svg" alt="A configuration administrator publishes a null value as a Kafka tombstone, and each pod consumes it and removes the corresponding key from its local cache" width="1200" height="500"&gt;The tombstone is broadcast like any other record; each pod interprets its null value as an instruction to remove the key.
  


&lt;p&gt;Hard deletion is appropriate only when the application should truly forget the configuration. If an&lt;br&gt;
inactive configuration must remain available for an audit, older data, or later reactivation,&lt;br&gt;
publish a normal record with &lt;code&gt;active: false&lt;/code&gt; or &lt;code&gt;deactivatedAt&lt;/code&gt; and keep it in the cache instead.&lt;br&gt;
If the entry count is small, retaining every configuration locally can be simpler. A compacted topic&lt;br&gt;
keeps the latest value per key, not every revision forever; use versioned keys or a separate history&lt;br&gt;
topic when complete history is required.&lt;/p&gt;

&lt;p&gt;The example uses a &lt;code&gt;ConcurrentHashMap&lt;/code&gt; for &lt;code&gt;LocalConfigCache&lt;/code&gt;. Spring Kafka starts with one&lt;br&gt;
consumer thread by default, but configured listener concurrency can update the same cache from&lt;br&gt;
multiple threads. The &lt;code&gt;@ConfigConsumer&lt;/code&gt; annotation on the listener connects it to the readiness&lt;br&gt;
gate described next.&lt;/p&gt;

&lt;p&gt;At this point every pod can build the correct local state. It still must not use that state until&lt;br&gt;
the replay has finished.&lt;/p&gt;
&lt;h2&gt;
  
  
  Don't Serve Traffic Until You've Caught Up
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;Do not receive traffic, run scheduled jobs, or perform other external work until the&lt;br&gt;
configuration has been consumed.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is the part that's easy to skip and expensive to skip. A pod that reports "ready" the moment&lt;br&gt;
it's assigned partitions is ready in name only — it might have replayed three records out of three&lt;br&gt;
thousand. Requests hit an almost-empty cache; a scheduled job runs off stale defaults; nobody&lt;br&gt;
notices until someone asks why pod 14 behaved differently for two minutes after a deploy.&lt;/p&gt;

&lt;p&gt;The example packages this concern as a small &lt;code&gt;readiness-gate&lt;/code&gt; module. When Kafka assigns&lt;br&gt;
partitions, the gate captures their current end offsets as the startup target. Each successfully&lt;br&gt;
processed record advances the tracked position for its partition. Readiness opens only after every&lt;br&gt;
partition reaches its captured target and no processing error remains. Empty topics work too:&lt;br&gt;
their starting positions already equal their end offsets.&lt;/p&gt;

&lt;p&gt;The target is a fixed snapshot. Records published after partition assignment do not extend it, so&lt;br&gt;
a continuously updated topic cannot keep the pod in startup forever. Once the captured target is&lt;br&gt;
reached, later records continue through the normal live-update path and remain subject to the&lt;br&gt;
eventual-consistency trade-off described below.&lt;/p&gt;


  &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcodewithkyryl.dev%2Fassets%2Fimg%2Farticles%2Fkafka-readiness-gate.svg" alt="Four readiness-gate stages: register the configuration consumer, capture fixed partition targets, replay records and advance positions only after success, then open readiness when all targets are reached" width="1200" height="610"&gt;The gate starts closed, tracks successful replay against fixed targets, and opens only when every assigned partition has caught up.
  


&lt;p&gt;The flow has four responsibilities. First, a marker annotation identifies the &lt;code&gt;@KafkaListener&lt;/code&gt;&lt;br&gt;
method whose &lt;code&gt;ConsumerRecord&lt;/code&gt; arguments the readiness gate tracks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Target&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ElementType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;METHOD&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@Retention&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RetentionPolicy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;RUNTIME&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nd"&gt;@interface&lt;/span&gt; &lt;span class="nc"&gt;ConfigConsumer&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;value&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Second, the tracker records the target end offsets captured on assignment and the next offset after&lt;br&gt;
each successfully processed record:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Component&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ConsumptionTracker&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;ConcurrentMap&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;ConsumerReplayProgress&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;progressByConsumerId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ConcurrentHashMap&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;gt;();&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;register&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;consumerId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;progressByConsumerId&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;computeIfAbsent&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;consumerId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ignored&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ConsumerReplayProgress&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;onPartitionsAssigned&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;consumerId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;TopicPartition&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;currentPositions&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;TopicPartition&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;targetEndOffsets&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;progressFor&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;consumerId&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;assign&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;currentPositions&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;targetEndOffsets&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;onRecordProcessed&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;consumerId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;ConsumerRecord&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;?,&lt;/span&gt; &lt;span class="o"&gt;?&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;progressFor&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;consumerId&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;recordSuccess&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;TopicPartition&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;topic&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;partition&lt;/span&gt;&lt;span class="o"&gt;()),&lt;/span&gt;
                &lt;span class="n"&gt;positionAfter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="nf"&gt;isFullyConsumed&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;progressByConsumerId&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isEmpty&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;progressByConsumerId&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;values&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;allMatch&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;ConsumerReplayProgress:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;isCaughtUp&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Kafka positions identify the next record to read, not the last one already processed.&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="nf"&gt;positionAfter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ConsumerRecord&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;?,&lt;/span&gt; &lt;span class="o"&gt;?&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Math&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;incrementExact&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;offset&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ConsumerReplayProgress&lt;/code&gt; owns the assigned partitions, while a small&lt;br&gt;
&lt;code&gt;PartitionReplayProgress&lt;/code&gt; owns one partition's current position, fixed target, and error. Keeping&lt;br&gt;
those responsibilities separate makes the readiness rule explicit: every assigned partition must&lt;br&gt;
reach its own target, and a failure is cleared only by a successful record from that same&lt;br&gt;
partition.&lt;/p&gt;

&lt;p&gt;An &lt;code&gt;@Around&lt;/code&gt; aspect reports every successfully processed &lt;code&gt;ConsumerRecord&lt;/code&gt; to the tracker&lt;br&gt;
automatically — the&lt;br&gt;
listener method itself never calls it directly, which is why &lt;code&gt;ConfigTopicListener.onMessage&lt;/code&gt; above&lt;br&gt;
has no readiness-related code in it at all, just the &lt;code&gt;@ConfigConsumer("config-topic")&lt;/code&gt; annotation.&lt;br&gt;
A separate registrar pre-registers every &lt;code&gt;@ConfigConsumer&lt;/code&gt; method at startup, before the first&lt;br&gt;
message arrives. A &lt;code&gt;ConsumerAwareRebalanceListener&lt;/code&gt; supplies assignments and their captured end&lt;br&gt;
offsets. That keeps readiness &lt;code&gt;DOWN&lt;/code&gt; from startup until the listener has processed through every&lt;br&gt;
target, and recalculates the target after a rebalance.&lt;/p&gt;

&lt;p&gt;Third, expose the gate through a normal &lt;code&gt;HealthIndicator&lt;/code&gt; in Spring Boot's &lt;strong&gt;readiness&lt;/strong&gt; health&lt;br&gt;
group. This composes the cache state with Boot's own readiness state and gives the deployment&lt;br&gt;
platform one stable endpoint to poll. It also avoids competing with Boot's application-availability&lt;br&gt;
events during startup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Component&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"configConsumption"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@RequiredArgsConstructor&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ConfigConsumptionHealthIndicator&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;HealthIndicator&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;ConsumptionTracker&lt;/span&gt; &lt;span class="n"&gt;tracker&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@Override&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Health&lt;/span&gt; &lt;span class="nf"&gt;health&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;details&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tracker&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;describe&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;tracker&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isFullyConsumed&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nc"&gt;Health&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;up&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;withDetails&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;details&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Health&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;down&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;withDetails&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;details&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;management&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;health&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;group[readiness]"&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;include&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;readinessState,configConsumption&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/actuator/health/readiness&lt;/code&gt; now reports &lt;code&gt;UP&lt;/code&gt; only after every assigned partition reaches its&lt;br&gt;
captured startup target. Configure the deployment platform to hold traffic until that endpoint&lt;br&gt;
returns success.&lt;/p&gt;

&lt;p&gt;The example's Docker Compose service uses the same endpoint as its &lt;code&gt;healthcheck&lt;/code&gt;, so Docker marks&lt;br&gt;
the application healthy only after the replay completes. This mirrors a deployment platform's&lt;br&gt;
availability check; Compose's health status itself does not control traffic routing.&lt;/p&gt;

&lt;p&gt;Fourth, gating scheduled jobs (or any other bean method) the same way, without repeating the&lt;br&gt;
&lt;code&gt;if (!tracker.isFullyConsumed()) return;&lt;/code&gt; check at every call site: a second annotation,&lt;br&gt;
&lt;code&gt;@ReadinessGated&lt;/code&gt;, plus its own small aspect that skips the call entirely until the gate opens —&lt;br&gt;
this is what &lt;code&gt;CacheSnapshotJob.logSnapshot&lt;/code&gt; above is actually annotated with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Aspect&lt;/span&gt;
&lt;span class="nd"&gt;@Component&lt;/span&gt;
&lt;span class="nd"&gt;@RequiredArgsConstructor&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ReadinessGateAspect&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;ConsumptionTracker&lt;/span&gt; &lt;span class="n"&gt;tracker&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@Around&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"@annotation(com.example.company.readinessgate.ReadinessGated)"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Object&lt;/span&gt; &lt;span class="nf"&gt;skipUntilReady&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ProceedingJoinPoint&lt;/span&gt; &lt;span class="n"&gt;joinPoint&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;throws&lt;/span&gt; &lt;span class="nc"&gt;Throwable&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;tracker&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isFullyConsumed&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;debug&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Skipping {} — readiness gate not open yet"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;joinPoint&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getSignature&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;toShortString&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;joinPoint&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;proceed&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same shape as the &lt;code&gt;@ConfigConsumer&lt;/code&gt; aspect earlier, just pointed the other direction: that one&lt;br&gt;
&lt;em&gt;reports&lt;/em&gt; activity to the tracker, this one &lt;em&gt;reads&lt;/em&gt; it and decides whether to let the call through&lt;br&gt;
at all. It only makes sense on &lt;code&gt;void&lt;/code&gt; methods — a skipped call returns &lt;code&gt;null&lt;/code&gt; — which is exactly&lt;br&gt;
what a &lt;code&gt;@Scheduled&lt;/code&gt; job is.&lt;/p&gt;
&lt;h3&gt;
  
  
  Applying the Readiness Gate
&lt;/h3&gt;

&lt;p&gt;The application must block every interaction with the outside world that depends on this&lt;br&gt;
configuration; otherwise, it can act on an empty or stale cache. The example implements two such&lt;br&gt;
gates, but the same principle applies more broadly.&lt;/p&gt;

&lt;p&gt;For HTTP traffic, the &lt;code&gt;ConfigConsumptionHealthIndicator&lt;/code&gt; keeps the readiness endpoint &lt;code&gt;DOWN&lt;/code&gt; until&lt;br&gt;
the replay is complete. Controllers do not need to repeat &lt;code&gt;tracker.isFullyConsumed()&lt;/code&gt; in every&lt;br&gt;
endpoint when the deployment platform uses that endpoint to withhold traffic. For example, in&lt;br&gt;
Kubernetes, use a &lt;code&gt;readinessProbe&lt;/code&gt; for routing and give a &lt;code&gt;startupProbe&lt;/code&gt; enough time for a large&lt;br&gt;
initial replay:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@GetMapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/api/cache"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;CacheEntry&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;snapshot&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a scheduled job, &lt;code&gt;@ReadinessGated&lt;/code&gt; prevents invocation until the same tracker is ready:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@ReadinessGated&lt;/span&gt;
&lt;span class="nd"&gt;@Scheduled&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fixedDelayString&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"${app.cache-snapshot-job.interval}"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;logSnapshot&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;info&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Local cache holds {} entries"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Other external interactions need an equivalent gate. For example, a queue listener that depends on&lt;br&gt;
this configuration should not begin processing until the cache is ready; arrange that at the&lt;br&gt;
listener/container level or with an acknowledgement strategy that does not acknowledge or lose&lt;br&gt;
messages while the gate is closed. This example provides gates for HTTP traffic and scheduled jobs&lt;br&gt;
only, but the same readiness signal can drive equivalent protection for queue consumers, outgoing&lt;br&gt;
calls, or any other configuration-dependent work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where This Pattern Bites
&lt;/h2&gt;

&lt;p&gt;The happy path is attractive: replay once, then serve local reads. The operational cost appears at&lt;br&gt;
the boundaries—startup, lag, retention, and failure. These are the decisions I would make explicit&lt;br&gt;
before choosing the pattern.&lt;/p&gt;

&lt;h3&gt;
  
  
  Every Restart Pays the Replay Cost
&lt;/h3&gt;

&lt;p&gt;A new group has no offsets, so every pod rebuilds from the beginning. The cost therefore grows with&lt;br&gt;
the records still present in the compacted log—not simply with the number of current keys, because&lt;br&gt;
compaction is asynchronous and old segments may remain for a while. Measure replay time with&lt;br&gt;
production-like data and give the &lt;code&gt;startupProbe&lt;/code&gt; enough budget. Kafka already fetches records in&lt;br&gt;
batches, so tune &lt;code&gt;max.poll.records&lt;/code&gt; and keep per-record work cheap. For larger replays, switch the&lt;br&gt;
listener to batch mode and apply each returned list in one invocation; the example's tracking&lt;br&gt;
aspect supports both individual &lt;code&gt;ConsumerRecord&lt;/code&gt; arguments and iterable batches. Add listener&lt;br&gt;
concurrency only when the topic has enough partitions to use it. Three partitions allow at most&lt;br&gt;
three consumer threads in one pod to make progress in parallel.&lt;br&gt;
The worst-case replay must also fit inside the topic's tombstone retention window, or a scan from&lt;br&gt;
offset zero is not guaranteed to reconstruct a valid snapshot.&lt;/p&gt;

&lt;p&gt;The captured targets do not move when producers append new records during replay, so continuous&lt;br&gt;
writes do not make startup unbounded. The existing log is what determines startup time. If that log&lt;br&gt;
is large enough to make readiness slow or unpredictable, the topic is probably outside this&lt;br&gt;
pattern's intended low-volume configuration use case.&lt;/p&gt;

&lt;h3&gt;
  
  
  Unique Groups Leave Temporary Metadata
&lt;/h3&gt;

&lt;p&gt;Each pod generates a new consumer group. After the pod disappears, its committed offsets remain&lt;br&gt;
visible until Kafka removes inactive group metadata according to broker retention settings. This is&lt;br&gt;
usually operational clutter, not an ever-growing live workload, but it affects dashboards and any&lt;br&gt;
automation that assumes every listed group is active. Use an identifiable prefix, monitor the&lt;br&gt;
broker's&lt;br&gt;
&lt;a href="https://kafka.apache.org/42/configuration/broker-configs/#brokerconfigs_offsets.retention.minutes" rel="noopener noreferrer"&gt;offset-retention policy&lt;/a&gt;,&lt;br&gt;
and filter inactive groups in tooling.&lt;/p&gt;

&lt;h3&gt;
  
  
  Large Configuration Does Not Belong in Every Pod
&lt;/h3&gt;

&lt;p&gt;This pattern is a good fit for a small, frequently read configuration set. If the configuration is&lt;br&gt;
large, keeping the whole dataset in local memory is usually a bad idea. Every pod needs enough heap&lt;br&gt;
for its own copy, and adding pods multiplies that memory cost. Keep large configuration in a shared&lt;br&gt;
store, or materialize only the small subset each service actually needs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Eventual Consistency Is Part of the Contract
&lt;/h3&gt;

&lt;p&gt;Each pod consumes updates independently, so two pods can briefly return different values. A slow&lt;br&gt;
consumer, rebalance, retry, or Kafka outage can extend that gap. This is acceptable for feature&lt;br&gt;
flags, routing hints, and other configuration that tolerates short-lived staleness; it is a poor fit&lt;br&gt;
for authorization decisions, inventory, balances, or any rule that must change everywhere at the&lt;br&gt;
same instant. Define the tolerated lag, monitor it, and make the stale-data behavior explicit to&lt;br&gt;
callers before choosing this design.&lt;/p&gt;

&lt;p&gt;These costs do not make the pattern wrong. They define its useful range: compact, replayable,&lt;br&gt;
eventually consistent reference data with a very hot read path and a team already comfortable&lt;br&gt;
operating Kafka.&lt;/p&gt;

&lt;p&gt;&lt;span&gt;Interactive production lab&lt;/span&gt;&lt;br&gt;
  &lt;/p&gt;
&lt;h2&gt;Can you keep this architecture alive?&lt;/h2&gt;
&lt;br&gt;
  &lt;p&gt;Diagnose four failures involving consumer groups, startup replay, readiness, and tombstones.&lt;br&gt;
  It takes about five minutes.&lt;/p&gt;
&lt;br&gt;
  &lt;a href="https://codewithkyryl.dev/labs/kafka-local-cache/" rel="noopener noreferrer"&gt;&lt;br&gt;
    Start the incident lab &lt;br&gt;
  &lt;/a&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;Code from this article is available at&lt;br&gt;
&lt;strong&gt;&lt;a href="https://github.com/javaAndScriptDeveloper/kafka-backed-local-read-replica-article" rel="noopener noreferrer"&gt;github.com/javaAndScriptDeveloper/kafka-backed-local-read-replica-article&lt;/a&gt;&lt;/strong&gt;.&lt;br&gt;
The repository's README contains the setup and run instructions, including how to inspect consumer&lt;br&gt;
groups, observe tombstone propagation, and scale the application locally.&lt;/p&gt;




&lt;h2&gt;
  
  
  Let's Talk
&lt;/h2&gt;

&lt;p&gt;If you're running—or have run—something similar, I would like to hear where the trade-offs landed&lt;br&gt;
for you. Contact links are in the footer.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Can Kafka replace Redis for cache synchronization across pods?
&lt;/h3&gt;

&lt;p&gt;Yes, for the specific job of &lt;strong&gt;broadcasting&lt;/strong&gt; state to every instance — it is a different tool doing a related job, not a drop-in replacement. Redis gives every pod one shared cache view, while Kafka gives every pod its &lt;strong&gt;own&lt;/strong&gt; eventually-consistent copy built by replaying a compacted topic. Use this pattern when local read latency matters and short-lived staleness after an update is acceptable.&lt;/p&gt;

&lt;h3&gt;
  
  
  With N pods, does every pod receive every Kafka message, or does Kafka split messages across them?
&lt;/h3&gt;

&lt;p&gt;It depends entirely on &lt;strong&gt;consumer group&lt;/strong&gt;, not on Kafka itself. Pods sharing one consumer group get partitions split between them (load-balanced) — each message goes to exactly one pod. Give every pod its &lt;strong&gt;own&lt;/strong&gt;, distinct consumer group and each group gets the full topic independently, so every pod sees every message. That's the broadcast pattern this article covers.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I give every pod its own Kafka consumer group?
&lt;/h3&gt;

&lt;p&gt;Combine a human-controlled prefix with Spring's per-instance &lt;code&gt;${random.uuid}&lt;/code&gt; value: &lt;code&gt;group-id: ${CONSUMER_GROUP_PREFIX}-${random.uuid}&lt;/code&gt;. Each application instance gets a unique group, so every pod consumes the full topic independently.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do you delete a key when the Kafka topic is compacted?
&lt;/h3&gt;

&lt;p&gt;Publish a &lt;strong&gt;tombstone&lt;/strong&gt;: a record with the same key and a &lt;strong&gt;null&lt;/strong&gt; value. Consumers that encounter it evict the key instead of caching null. Kafka eventually removes both the superseded value and the tombstone; size &lt;code&gt;delete.retention.ms&lt;/code&gt; so a full replay can finish within that window, which Kafka documents as the bound for reconstructing a valid snapshot from offset zero.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should scheduled jobs and traffic wait until Kafka consumption catches up?
&lt;/h3&gt;

&lt;p&gt;Yes. A pod that starts serving requests or running scheduled jobs before it has replayed the topic is working from an empty or partial cache. Keep readiness &lt;code&gt;DOWN&lt;/code&gt; until startup replay is complete. The example captures each partition's end offset when it is assigned and opens readiness only after every processed position reaches its target.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can continuous updates keep a new pod stuck in startup forever?
&lt;/h3&gt;

&lt;p&gt;No. The readiness gate captures a fixed end offset for each partition when Kafka assigns it. Records appended after that snapshot do not move the startup target; the pod processes them as normal eventually-consistent updates. Startup can still take a long time when the existing compacted log is large, which is one reason this pattern is best for small, low-volume upsert topics such as feature flags and service configuration.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://codewithkyryl.dev/2026/08/08/local-cache-via-kafka/" rel="noopener noreferrer"&gt;codewithkyryl.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kafka</category>
      <category>springboot</category>
      <category>java</category>
      <category>distributedsystems</category>
    </item>
    <item>
      <title>The Transactional Outbox Pattern: From a Single Task to a Production-Grade Async Engine</title>
      <dc:creator>Kyryl</dc:creator>
      <pubDate>Tue, 13 Oct 2026 08:40:09 +0000</pubDate>
      <link>https://dev.to/code_with_kyryl/the-transactional-outbox-pattern-from-a-single-task-to-a-production-grade-async-engine-1385</link>
      <guid>https://dev.to/code_with_kyryl/the-transactional-outbox-pattern-from-a-single-task-to-a-production-grade-async-engine-1385</guid>
      <description>&lt;p&gt;There is a class of bugs that never shows up in your unit tests and rarely appears in staging.&lt;br&gt;
It appears in production, at 2am, when your app saves a record to the database, then tries to&lt;br&gt;
call an external service — and one of them fails. One side committed. The other didn't. Nobody&lt;br&gt;
knows until a user complains.&lt;/p&gt;

&lt;p&gt;This is the &lt;strong&gt;dual-write problem&lt;/strong&gt;, and the Transactional Outbox Pattern is the standard answer&lt;br&gt;
to it. The idea is simple: instead of calling the external system directly, write a task into the&lt;br&gt;
same database transaction as your domain change. A background worker picks it up and executes it.&lt;br&gt;
The write is atomic. The task cannot be lost.&lt;/p&gt;

&lt;p&gt;What's less documented is everything that comes after the basic idea — what happens when tasks&lt;br&gt;
fail, when two workers race for the same row, when your app crashes mid-execution, when you have&lt;br&gt;
ten different task types living in one table, and when your stakeholders ask why the queue depth&lt;br&gt;
spiked at 3am last Tuesday.&lt;/p&gt;

&lt;p&gt;That's what this article covers.&lt;/p&gt;

&lt;p&gt;I'll work through a single concrete scenario — a &lt;strong&gt;report generation service&lt;/strong&gt; — and evolve it&lt;br&gt;
step by step from the simplest possible outbox to something I'd be comfortable running in&lt;br&gt;
production. Every code snippet is from a real working repo linked at the end.&lt;/p&gt;

&lt;p&gt;Stack: &lt;strong&gt;Java, Spring Boot, PostgreSQL&lt;/strong&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;Transactional Outbox Pattern&lt;/strong&gt; fixes the dual-write problem by writing a task row in the &lt;strong&gt;same DB transaction&lt;/strong&gt; as your domain change, then executing it from a background worker.&lt;/li&gt;
&lt;li&gt;Poll the outbox table with &lt;code&gt;SELECT ... FOR UPDATE SKIP LOCKED&lt;/code&gt; so multiple workers can run concurrently without double-processing.&lt;/li&gt;
&lt;li&gt;Production-grade requires more than the basic idea: a &lt;strong&gt;state machine&lt;/strong&gt;, &lt;strong&gt;retries with backoff&lt;/strong&gt;, &lt;strong&gt;heartbeats/leases&lt;/strong&gt; to recover crashed workers, &lt;strong&gt;ordering&lt;/strong&gt; guarantees, and &lt;strong&gt;monitoring&lt;/strong&gt; of queue depth.&lt;/li&gt;
&lt;li&gt;You don't need Kafka — &lt;strong&gt;PostgreSQL alone&lt;/strong&gt; is enough for reliable async execution.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;


&lt;h2&gt;
  
  
  The Scenario
&lt;/h2&gt;


&lt;pre data-lang="mermaid"&gt;&lt;code&gt;sequenceDiagram
    actor User as User (Backoffice)
    participant API as ReportGenerationService
    participant DB as PostgreSQL

    User-&amp;gt;&amp;gt;API: POST /reports (from, to)
    API-&amp;gt;&amp;gt;DB: INSERT report_generation_tasks (PENDING)
    DB--&amp;gt;&amp;gt;API: task saved
    API--&amp;gt;&amp;gt;User: 202 Accepted

    loop Scheduler every 1s
        API-&amp;gt;&amp;gt;DB: SELECT ... WHERE state = PENDING FOR UPDATE SKIP LOCKED
        DB--&amp;gt;&amp;gt;API: task row
        API-&amp;gt;&amp;gt;API: generateReport()
        API-&amp;gt;&amp;gt;DB: UPDATE state = COMPLETED
    end&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;We're building a CRM. Users can request monthly analytics reports from a backoffice UI.&lt;br&gt;
Generating a report means aggregating tens of millions of rows — it can take minutes.&lt;br&gt;
Obviously we can't do this synchronously in an HTTP request.&lt;/p&gt;

&lt;p&gt;The user clicks a button. The backend accepts the request, saves it, and responds immediately.&lt;br&gt;
A background worker picks up the task, runs the generation, and updates the status when done.&lt;/p&gt;

&lt;p&gt;This is the simplest case where an outbox makes sense: work that is genuinely async by nature,&lt;br&gt;
where the user already expects to wait.&lt;/p&gt;


&lt;h2&gt;
  
  
  Part 1 — The Basic Outbox
&lt;/h2&gt;
&lt;h3&gt;
  
  
  What Every Outbox Needs
&lt;/h3&gt;

&lt;p&gt;At its core, any outbox table needs three things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;State&lt;/strong&gt; — where is this task in its lifecycle?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Payload&lt;/strong&gt; — what exactly needs to be executed?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Timestamps&lt;/strong&gt; — when was it created, when did it last change?
&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TYPE&lt;/span&gt; &lt;span class="n"&gt;task_state&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="nb"&gt;ENUM&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'PENDING'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'COMPLETED'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;report_generation_tasks&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
                                       &lt;span class="n"&gt;id&lt;/span&gt;         &lt;span class="n"&gt;UUID&lt;/span&gt;       &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
                                       &lt;span class="k"&gt;state&lt;/span&gt;      &lt;span class="n"&gt;task_state&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;    &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;'PENDING'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                       &lt;span class="n"&gt;payload&lt;/span&gt;    &lt;span class="n"&gt;JSONB&lt;/span&gt;      &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                       &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="nb"&gt;TIMESTAMP&lt;/span&gt;  &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;    &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
                                       &lt;span class="n"&gt;updated_at&lt;/span&gt; &lt;span class="nb"&gt;TIMESTAMP&lt;/span&gt;  &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;    &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The payload for our use case is a date range:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Value&lt;/span&gt;
&lt;span class="nd"&gt;@Builder&lt;/span&gt;
&lt;span class="nd"&gt;@Jacksonized&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ReportGenerationPayload&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nc"&gt;Instant&lt;/span&gt; &lt;span class="n"&gt;from&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
  &lt;span class="nc"&gt;Instant&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The JPA entity:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Entity&lt;/span&gt;
&lt;span class="nd"&gt;@Table&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"report_generation_tasks"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@Data&lt;/span&gt;
&lt;span class="nd"&gt;@Builder&lt;/span&gt;
&lt;span class="nd"&gt;@NoArgsConstructor&lt;/span&gt;
&lt;span class="nd"&gt;@AllArgsConstructor&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ReportGenerationEntity&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Id&lt;/span&gt;
    &lt;span class="nd"&gt;@GeneratedValue&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strategy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;GenerationType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;UUID&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="nd"&gt;@Column&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;updatable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nullable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@Enumerated&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;EnumType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;STRING&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="nd"&gt;@Column&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nullable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;columnDefinition&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"task_state"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;TaskState&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@JdbcTypeCode&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SqlTypes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;JSON&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="nd"&gt;@Column&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nullable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;updatable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;columnDefinition&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"jsonb"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;ReportGenerationPayload&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@CreationTimestamp&lt;/span&gt;
    &lt;span class="nd"&gt;@Column&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nullable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;updatable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;Instant&lt;/span&gt; &lt;span class="n"&gt;createdAt&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@UpdateTimestamp&lt;/span&gt;
    &lt;span class="nd"&gt;@Column&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nullable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;Instant&lt;/span&gt; &lt;span class="n"&gt;updatedAt&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Submitting a task:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Service&lt;/span&gt;
&lt;span class="nd"&gt;@RequiredArgsConstructor&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ReportGenerationService&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;ReportGenerationRepository&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;submit&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ReportGenerationCommand&lt;/span&gt; &lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ReportGenerationEntity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TaskState&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;PENDING&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ReportGenerationPayload&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getFrom&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
                        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getTo&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
                        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The Worker
&lt;/h3&gt;

&lt;p&gt;A scheduled job polls for pending tasks and executes them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Component&lt;/span&gt;
&lt;span class="nd"&gt;@RequiredArgsConstructor&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ReportGenerationScheduledJob&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;ReportGenerationTaskService&lt;/span&gt; &lt;span class="n"&gt;taskService&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@Scheduled&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fixedDelay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;taskService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fetchAndProcess&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Service&lt;/span&gt;
&lt;span class="nd"&gt;@RequiredArgsConstructor&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ReportGenerationTaskService&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;ReportGenerationRepository&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;fetchAndProcess&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findFirstByStateOrderByCreatedAt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TaskState&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;PENDING&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ifPresent&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                    &lt;span class="n"&gt;generateReport&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
                    &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setState&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TaskState&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;COMPLETED&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
                    &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
                &lt;span class="o"&gt;});&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;generateReport&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ReportGenerationEntity&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// actual report generation logic&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;ReportGenerationRepository&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;JpaRepository&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ReportGenerationEntity&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;UUID&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Optional&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ReportGenerationEntity&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;findFirstByStateOrderByCreatedAt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TaskState&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works in a single-instance app with no failures. In the real world, it has two problems:&lt;br&gt;
two workers can claim the same task simultaneously, and tasks can fail without any recovery path.&lt;br&gt;
Let's fix both.&lt;/p&gt;


&lt;h2&gt;
  
  
  Part 2 — Concurrency: Two Workers, One Task
&lt;/h2&gt;

&lt;p&gt;When two scheduler threads — or two app instances — run the fetch query at the same moment,&lt;br&gt;
both can read the same &lt;code&gt;PENDING&lt;/code&gt; row and both will try to execute it. The result is duplicate work&lt;br&gt;
at best, corrupted state at worst.&lt;/p&gt;

&lt;p&gt;The fix is a database lock taken at fetch time. PostgreSQL's &lt;code&gt;SELECT ... FOR UPDATE SKIP LOCKED&lt;/code&gt;&lt;br&gt;
is purpose-built for exactly this use case: it locks the row for the current transaction and skips&lt;br&gt;
any rows that are already locked by another transaction, rather than waiting.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;ReportGenerationRepository&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;JpaRepository&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ReportGenerationEntity&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;UUID&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Lock&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;LockModeType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;PESSIMISTIC_WRITE&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="nd"&gt;@QueryHint&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;AvailableSettings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;JAKARTA_LOCK_TIMEOUT&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"-2"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// -2 = SKIP_LOCKED&lt;/span&gt;
    &lt;span class="nc"&gt;Optional&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ReportGenerationEntity&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;findFirstByStateOrderByCreatedAt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TaskState&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;SKIP_LOCKED&lt;/code&gt; hint value &lt;code&gt;-2&lt;/code&gt; is what Hibernate translates to the &lt;code&gt;SKIP LOCKED&lt;/code&gt; clause.&lt;br&gt;
Since there's no first-class annotation for this, it's worth extracting into a reusable annotation&lt;br&gt;
so you don't scatter the magic number across your repositories:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Retention&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RetentionPolicy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;RUNTIME&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@Target&lt;/span&gt;&lt;span class="o"&gt;({&lt;/span&gt;&lt;span class="nc"&gt;ElementType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;METHOD&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;ElementType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ANNOTATION_TYPE&lt;/span&gt;&lt;span class="o"&gt;})&lt;/span&gt;
&lt;span class="nd"&gt;@QueryHints&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@QueryHint&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;AvailableSettings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;JAKARTA_LOCK_TIMEOUT&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Timeouts&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;SKIP_LOCKED_MILLI&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;
&lt;span class="o"&gt;))&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nd"&gt;@interface&lt;/span&gt; &lt;span class="nc"&gt;SkipLocked&lt;/span&gt; &lt;span class="o"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Much cleaner. Now the repository reads like intent, not plumbing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@SkipLocked&lt;/span&gt;
&lt;span class="nd"&gt;@Lock&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;LockModeType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;PESSIMISTIC_WRITE&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nc"&gt;Optional&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ReportGenerationEntity&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;findFirstByStateOrderByCreatedAt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TaskState&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You &lt;em&gt;can&lt;/em&gt; keep the lock while processing by wrapping the whole fetch-execute-update cycle in a&lt;br&gt;
single transaction, but you generally should not:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Technically works, but do not use this pattern for outbox processing.&lt;/span&gt;
&lt;span class="nd"&gt;@Transactional&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;fetchAndProcess&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findFirstByStateOrderByCreatedAt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TaskState&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;PENDING&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ifPresent&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;generateReport&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
                &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setState&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TaskState&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;COMPLETED&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
                &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="o"&gt;});&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;pre data-lang="mermaid"&gt;&lt;code&gt;sequenceDiagram
    participant DB as PostgreSQL
    participant A as Worker A
    participant B as Worker B

    Note over DB: task row: PENDING (unlocked)

    par Worker A and Worker B fetch simultaneously
        A-&amp;gt;&amp;gt;DB: SELECT ... FOR UPDATE SKIP LOCKED
        B-&amp;gt;&amp;gt;DB: SELECT ... FOR UPDATE SKIP LOCKED
    end

    DB--&amp;gt;&amp;gt;A: row locked ✓ — returns task
    DB--&amp;gt;&amp;gt;B: row locked by A — SKIP LOCKED returns empty

    Note over B: no task found, exits cleanly

    A-&amp;gt;&amp;gt;A: generateReport()
    A-&amp;gt;&amp;gt;DB: UPDATE state = COMPLETED
    A-&amp;gt;&amp;gt;DB: COMMIT — lock released

    Note over DB: task row: COMPLETED (unlocked)&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;Even if &lt;code&gt;generateReport()&lt;/code&gt; is small enough that this technically works, running an outbox action&lt;br&gt;
under an open transaction is an antipattern. Transaction duration should not depend on outbox&lt;br&gt;
execution duration: the action can become slower over time, block on I/O, or call a downstream&lt;br&gt;
service, leaving a database connection and row lock held for an unpredictable period.&lt;/p&gt;

&lt;p&gt;Instead, claim the row in a short transaction by moving it to &lt;code&gt;PROCESSING&lt;/code&gt;, commit and release the&lt;br&gt;
lock, and only then execute the outbox action. Record &lt;code&gt;COMPLETED&lt;/code&gt; or &lt;code&gt;FAILED&lt;/code&gt; in another short&lt;br&gt;
transaction afterward. The next section implements that state transition and its recovery path.&lt;/p&gt;


&lt;h2&gt;
  
  
  Part 2.5 — Reducing Latency with Application Events
&lt;/h2&gt;

&lt;p&gt;The polling scheduler has an inherent latency floor. If your job fires every second, a task&lt;br&gt;
submitted at &lt;code&gt;t=0ms&lt;/code&gt; sits &lt;code&gt;PENDING&lt;/code&gt; until the next tick — up to 1000ms later. For most async&lt;br&gt;
workloads that's acceptable. For cases where you want near-immediate pickup — sending a&lt;br&gt;
transactional email, triggering a webhook — it's not.&lt;/p&gt;

&lt;p&gt;The pattern borrowed from Celery: save the outbox entity to the database, then immediately&lt;br&gt;
publish a Spring &lt;code&gt;ApplicationEvent&lt;/code&gt; carrying the new task's ID. A listener picks it up and&lt;br&gt;
dispatches processing right away, without waiting for the next scheduler tick.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;sequenceDiagram
    participant S as Service
    participant DB as PostgreSQL
    participant EL as Event Listener
    participant W as Worker

    rect rgb(240, 240, 240)
        Note over S,W: Polling approach — up to 1s latency
        S-&amp;gt;&amp;gt;DB: INSERT task (PENDING)
        Note over W: ... waiting for next scheduler tick (0–1000ms) ...
        W-&amp;gt;&amp;gt;DB: SELECT PENDING tasks
        DB--&amp;gt;&amp;gt;W: task found
        W-&amp;gt;&amp;gt;W: process task
    end

    rect rgb(230, 247, 240)
        Note over S,W: Application event approach — near-zero latency
        S-&amp;gt;&amp;gt;DB: INSERT task (PENDING)
        S-&amp;gt;&amp;gt;EL: publishEvent(taskId) — fires AFTER_COMMIT
        EL-&amp;gt;&amp;gt;W: trigger fetchAndProcess() immediately
        W-&amp;gt;&amp;gt;DB: SELECT PENDING tasks
        DB--&amp;gt;&amp;gt;W: task found
        W-&amp;gt;&amp;gt;W: process task
    end&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;The scheduler still runs as a safety net — it catches anything the event listener missed (app&lt;br&gt;
restart, listener exception, missed event). But the happy path bypasses the polling delay&lt;br&gt;
entirely.&lt;/p&gt;
&lt;h3&gt;
  
  
  Implementation
&lt;/h3&gt;

&lt;p&gt;Publish the event inside the service method, after saving:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Service&lt;/span&gt;
&lt;span class="nd"&gt;@RequiredArgsConstructor&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ReportGenerationService&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;ReportGenerationRepository&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;ApplicationEventPublisher&lt;/span&gt; &lt;span class="n"&gt;eventPublisher&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@Transactional&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;submit&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ReportGenerationCommand&lt;/span&gt; &lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;entity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ReportGenerationEntity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TaskState&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;PENDING&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ReportGenerationPayload&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getFrom&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
                        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getTo&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
                        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;

        &lt;span class="n"&gt;eventPublisher&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;publishEvent&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ReportGenerationTaskSubmittedEvent&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;()));&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="nf"&gt;ReportGenerationTaskSubmittedEvent&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;taskId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The listener reacts and triggers processing immediately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Component&lt;/span&gt;
&lt;span class="nd"&gt;@RequiredArgsConstructor&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ReportGenerationTaskEventListener&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;ReportGenerationTaskService&lt;/span&gt; &lt;span class="n"&gt;taskService&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@TransactionalEventListener&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;phase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TransactionPhase&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;AFTER_COMMIT&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;onTaskSubmitted&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ReportGenerationTaskSubmittedEvent&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;taskService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fetchAndProcess&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The Classic Mistake
&lt;/h3&gt;

&lt;p&gt;The most common error here is using &lt;code&gt;@EventListener&lt;/code&gt; instead of&lt;br&gt;
&lt;code&gt;@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;@EventListener&lt;/code&gt; fires the moment &lt;code&gt;publishEvent()&lt;/code&gt; is called — which is &lt;strong&gt;inside the open&lt;br&gt;
transaction&lt;/strong&gt;, before the &lt;code&gt;INSERT&lt;/code&gt; has committed. The listener calls &lt;code&gt;fetchAndProcess()&lt;/code&gt;,&lt;br&gt;
which queries the database for the task by ID. The row does not exist yet. The task is silently&lt;br&gt;
skipped, the event is lost, and you're left wondering why processing never started — even though&lt;br&gt;
the scheduler eventually picks it up seconds later and everything looks fine in the logs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ❌ fires inside the transaction — entity not visible to other connections yet&lt;/span&gt;
&lt;span class="nd"&gt;@EventListener&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;onTaskSubmitted&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ReportGenerationTaskSubmittedEvent&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;taskService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fetchAndProcess&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// ✅ fires after COMMIT — entity is visible, fetch succeeds&lt;/span&gt;
&lt;span class="nd"&gt;@TransactionalEventListener&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;phase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TransactionPhase&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;AFTER_COMMIT&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;onTaskSubmitted&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ReportGenerationTaskSubmittedEvent&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;taskService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fetchAndProcess&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;AFTER_COMMIT&lt;/code&gt; guarantees the listener runs only after the transaction that published the event&lt;br&gt;
has fully committed. By that point the row is visible to all connections and the fetch will&lt;br&gt;
find it.&lt;/p&gt;

&lt;p&gt;The scheduler remains unchanged — it acts as the recovery mechanism for any tasks the event&lt;br&gt;
listener missed. The two approaches complement each other: events for latency, polling for&lt;br&gt;
reliability.&lt;/p&gt;


&lt;h2&gt;
  
  
  Part 3 — Failures, Retries, and the PROCESSING State
&lt;/h2&gt;


&lt;pre data-lang="mermaid"&gt;&lt;code&gt;stateDiagram-v2
    [*] --&amp;gt; PENDING: submit()

    PENDING --&amp;gt; PROCESSING: worker claims task\n(TX1 commits)

    PROCESSING --&amp;gt; COMPLETED: generateReport() succeeds\n(TX2 commits)
    PROCESSING --&amp;gt; FAILED: generateReport() throws\n(TX2 commits)
    PROCESSING --&amp;gt; PENDING: app crash detected\nstuck-task recovery job resets

    FAILED --&amp;gt; PROCESSING: retry eligible\n(retryCount ≤ MAX and executeAt ≤ now)
    FAILED --&amp;gt; [*]: retryCount &amp;gt; MAX\n(dead — manual ops)

    COMPLETED --&amp;gt; [*]: cleanup job removes\nafter retention period&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;Outbox actions should not run under an open database transaction. The locked-transaction approach&lt;br&gt;
holds a database lock for the entire&lt;br&gt;
duration of &lt;code&gt;generateReport()&lt;/code&gt;. For a report that takes two minutes, that's two minutes of a&lt;br&gt;
locked row — a connection tied up, other DB operations potentially queuing behind it. With&lt;br&gt;
&lt;code&gt;SKIP LOCKED&lt;/code&gt; workers don't block each other on the lock, but long-running transactions still&lt;br&gt;
put pressure on the connection pool and make the database unhappy under load. A very small action&lt;br&gt;
may technically complete without causing visible trouble, but coupling transaction duration to&lt;br&gt;
outbox execution duration is still an antipattern.&lt;/p&gt;

&lt;p&gt;The fix is to separate claiming a task from executing it. Flip the task to &lt;code&gt;PROCESSING&lt;/code&gt; in a&lt;br&gt;
short transaction, release the lock, then do the actual work outside any transaction. This way&lt;br&gt;
the DB is only touched briefly at the start and briefly at the end — the expensive part happens&lt;br&gt;
in between without holding anything.&lt;/p&gt;

&lt;p&gt;First, extend the enum:&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;ALTER&lt;/span&gt; &lt;span class="k"&gt;TYPE&lt;/span&gt; &lt;span class="n"&gt;task_state&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="n"&gt;VALUE&lt;/span&gt; &lt;span class="s1"&gt;'PROCESSING'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TYPE&lt;/span&gt; &lt;span class="n"&gt;task_state&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="n"&gt;VALUE&lt;/span&gt; &lt;span class="s1"&gt;'FAILED'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;report_generation_tasks&lt;/span&gt;
    &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;retry_count&lt;/span&gt;  &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;error_message&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&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;retry_count&lt;/code&gt; tracks how many times execution was attempted. We cap retries at 10 — beyond that,&lt;br&gt;
something is genuinely broken and needs manual intervention. We'll wire alerting around this&lt;br&gt;
later. &lt;code&gt;error_message&lt;/code&gt; stores the last exception so you know &lt;em&gt;why&lt;/em&gt; a task is stuck without&lt;br&gt;
digging through logs.&lt;/p&gt;

&lt;p&gt;The Java enum grows with it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;enum&lt;/span&gt; &lt;span class="nc"&gt;TaskState&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="no"&gt;PENDING&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="no"&gt;PROCESSING&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="no"&gt;COMPLETED&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="no"&gt;FAILED&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The service now runs two separate short transactions with the actual work in between:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@RequiredArgsConstructor&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ReportGenerationTaskService&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;ReportGenerationRepository&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;TransactionTemplate&lt;/span&gt; &lt;span class="n"&gt;transactionTemplate&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="no"&gt;MAX_RETRY_COUNT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;fetchAndProcess&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;transactionTemplate&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;execute&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
                        &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findFirstEligibleTask&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                                &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TaskState&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;PENDING&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;TaskState&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;FAILED&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
                                &lt;span class="no"&gt;MAX_RETRY_COUNT&lt;/span&gt;
                        &lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;map&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;markProcessing&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ifPresentOrElse&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                        &lt;span class="n"&gt;task&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                            &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                                &lt;span class="n"&gt;generateReport&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
                                &lt;span class="n"&gt;markCompleted&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
                            &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                                &lt;span class="n"&gt;markFailed&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
                                &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Task {} failed on attempt {}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getRetryCount&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
                            &lt;span class="o"&gt;}&lt;/span&gt;
                        &lt;span class="o"&gt;},&lt;/span&gt;
                        &lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;debug&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"No eligible tasks to process"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;ReportGenerationEntity&lt;/span&gt; &lt;span class="nf"&gt;markProcessing&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ReportGenerationEntity&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setState&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TaskState&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;PROCESSING&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;markCompleted&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ReportGenerationEntity&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setState&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TaskState&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;COMPLETED&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;markFailed&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ReportGenerationEntity&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setState&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TaskState&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;FAILED&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setRetryCount&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getRetryCount&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setErrorMessage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getMessage&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt; &lt;span class="c1"&gt;// don't skip this — it's the only trace of what went wrong without digging through logs&lt;/span&gt;
        &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The query fetches both &lt;code&gt;PENDING&lt;/code&gt; and &lt;code&gt;FAILED&lt;/code&gt; tasks — a failed task with &lt;code&gt;retryCount &amp;lt; 10&lt;/code&gt; is&lt;br&gt;
just as eligible as a fresh one. Once retry count hits the cap, the task stops being fetched and&lt;br&gt;
sits as a permanent &lt;code&gt;FAILED&lt;/code&gt; record until someone looks at it. That's intentional — beyond 10&lt;br&gt;
attempts something is genuinely broken and needs eyes on it, not another retry.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@SkipLocked&lt;/span&gt;
&lt;span class="nd"&gt;@Lock&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;LockModeType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;PESSIMISTIC_WRITE&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@Query&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"""
    SELECT t FROM ReportGenerationEntity t
    WHERE t.state IN :states
      AND t.retryCount &amp;lt;= :maxRetryCount
    ORDER BY t.createdAt ASC
    LIMIT 1
    """&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nc"&gt;Optional&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ReportGenerationEntity&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;findFirstEligibleTask&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
    &lt;span class="nd"&gt;@Param&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"states"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;TaskState&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;states&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="nd"&gt;@Param&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"maxRetryCount"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;Integer&lt;/span&gt; &lt;span class="n"&gt;maxRetryCount&lt;/span&gt;
&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But wait — should a just-failed task really be picked up again on the very next scheduler tick,&lt;br&gt;
seconds later? If the downstream system is down, hammering it every second makes things worse.&lt;br&gt;
That's what the next section covers.&lt;/p&gt;
&lt;h2&gt;
  
  
  Part 4 — Exponential Backoff
&lt;/h2&gt;

&lt;p&gt;Without a delay between retries, a failed task re-enters the queue on the very next scheduler&lt;br&gt;
tick — usually seconds later. If the downstream system is down, you're hammering it at full&lt;br&gt;
polling rate. That's bad for the downstream system and bad for your DB.&lt;/p&gt;

&lt;p&gt;The fix is an &lt;code&gt;execute_at&lt;/code&gt; column: the task is not eligible until this timestamp is in the past.&lt;br&gt;
Fresh tasks get &lt;code&gt;execute_at = now()&lt;/code&gt; so they're picked up immediately. Failed tasks get&lt;br&gt;
&lt;code&gt;execute_at = now() + delay&lt;/code&gt;, where the delay grows with each retry.&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;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;report_generation_tasks&lt;/span&gt;
    &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;execute_at&lt;/span&gt; &lt;span class="nb"&gt;TIMESTAMP&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The delay schedule drives everything — &lt;code&gt;MAX_RETRY_COUNT&lt;/code&gt; is derived from it, not declared&lt;br&gt;
separately. This way the two can never drift out of sync:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Integer&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;RETRY_DELAYS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
    &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofMinutes&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
    &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofMinutes&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
    &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofMinutes&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
    &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofMinutes&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
    &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofHours&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
    &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofHours&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
    &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofHours&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
    &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofHours&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
    &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofHours&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
    &lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofDays&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="no"&gt;MAX_RETRY_COUNT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;RETRY_DELAYS&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add a new entry to &lt;code&gt;RETRY_DELAYS&lt;/code&gt; and the retry cap automatically updates. No magic number to&lt;br&gt;
keep in sync elsewhere.&lt;/p&gt;

&lt;p&gt;In &lt;code&gt;markFailed()&lt;/code&gt;, capture the &lt;em&gt;current&lt;/em&gt; retry count before incrementing — that selects the&lt;br&gt;
delay for the &lt;em&gt;next&lt;/em&gt; attempt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;markFailed&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ReportGenerationEntity&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;currentRetry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getRetryCount&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setState&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TaskState&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;FAILED&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setRetryCount&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;currentRetry&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setErrorMessage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getMessage&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setExecuteAt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Instant&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;plus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="no"&gt;RETRY_DELAYS&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getOrDefault&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;currentRetry&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;RETRY_DELAYS&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;RETRY_DELAYS&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;))));&lt;/span&gt;
    &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The scheduler keeps running at the same rate. It just skips tasks whose window hasn't opened.&lt;br&gt;
Failed tasks sit quietly until their time comes, then re-enter naturally.&lt;/p&gt;


&lt;h2&gt;
  
  
  Part 5 — Stuck Tasks and Heartbeats
&lt;/h2&gt;


&lt;pre data-lang="mermaid"&gt;&lt;code&gt;sequenceDiagram
    participant W as Worker
    participant DB as PostgreSQL
    participant R as Recovery Job

    W-&amp;gt;&amp;gt;DB: UPDATE state = PROCESSING (TX1 commits)
    W-&amp;gt;&amp;gt;W: generateReport() — running...

    Note over W: 💥 app crashes mid-execution

    Note over DB: task stuck in PROCESSING&amp;lt;br/&amp;gt;heartbeat_at stops updating

    loop Recovery job runs every 5 min
        R-&amp;gt;&amp;gt;DB: SELECT WHERE state = PROCESSING&amp;lt;br/&amp;gt;AND heartbeat_at &amp;lt; now() - 1h
        DB--&amp;gt;&amp;gt;R: stuck task found
        R-&amp;gt;&amp;gt;DB: UPDATE state = PENDING
        Note over DB: task back in queue — will be retried
    end&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;With the two-transaction approach, consider what happens if the app crashes after marking a task&lt;br&gt;
&lt;code&gt;PROCESSING&lt;/code&gt; but before marking it &lt;code&gt;COMPLETED&lt;/code&gt; or &lt;code&gt;FAILED&lt;/code&gt;. The DB shows &lt;code&gt;PROCESSING&lt;/code&gt;. Nobody is&lt;br&gt;
processing it. It sits there forever.&lt;/p&gt;

&lt;p&gt;The naive fix is a recovery job that finds tasks stuck in &lt;code&gt;PROCESSING&lt;/code&gt; for too long and resets&lt;br&gt;
them to &lt;code&gt;PENDING&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Transactional&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;recoverStuckTasks&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findFirstByStateAndUpdatedAtLessThan&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                    &lt;span class="nc"&gt;TaskState&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;PROCESSING&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                    &lt;span class="nc"&gt;Instant&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;minus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;ChronoUnit&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;HOURS&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ifPresent&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;warn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Resetting stuck task {} to PENDING"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
                &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setState&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TaskState&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;PENDING&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
                &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="o"&gt;});&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works for most cases. The problem: &lt;code&gt;updated_at&lt;/code&gt; changes whenever &lt;em&gt;anything&lt;/em&gt; changes on the&lt;br&gt;
row — including unrelated state transitions. A task legitimately processing for 55 minutes looks&lt;br&gt;
identical to a crashed task from the &lt;code&gt;updated_at&lt;/code&gt; perspective.&lt;/p&gt;

&lt;p&gt;The cleaner solution is a &lt;strong&gt;heartbeat&lt;/strong&gt;: a dedicated column that gets bumped periodically &lt;em&gt;only&lt;br&gt;
while the task is being actively processed&lt;/em&gt;. The recovery job now checks this column, not&lt;br&gt;
&lt;code&gt;updated_at&lt;/code&gt;. A task that hasn't heartbeated in the last hour is genuinely stuck.&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;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;report_generation_tasks&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;heartbeat_at&lt;/span&gt; &lt;span class="nb"&gt;TIMESTAMP&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The heartbeat runs on its own thread, inside its own transactions, independent of the processing&lt;br&gt;
logic. The cleanest implementation is a &lt;code&gt;try-with-resources&lt;/code&gt; — it starts when processing starts&lt;br&gt;
and stops on any exit path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;abstract&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Heartbeat&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;AutoCloseable&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;ScheduledExecutorService&lt;/span&gt; &lt;span class="n"&gt;executor&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;protected&lt;/span&gt; &lt;span class="nf"&gt;Heartbeat&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Runnable&lt;/span&gt; &lt;span class="n"&gt;heartbeatFn&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;executor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Executors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;newSingleThreadScheduledExecutor&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="n"&gt;executor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;scheduleAtFixedRate&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;heartbeatFn&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;TimeUnit&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;SECONDS&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Override&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;executor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;shutdownNow&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ReportGenerationTaskHeartbeat&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Heartbeat&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nc"&gt;ReportGenerationTaskHeartbeat&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;taskId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;ReportGenerationRepository&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;super&lt;/span&gt;&lt;span class="o"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;updateHeartbeatAt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;taskId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Instant&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="o"&gt;()));&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The repository method carries its own &lt;code&gt;@Transactional&lt;/code&gt; — no need to wrap the call manually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Modifying&lt;/span&gt;
&lt;span class="nd"&gt;@Transactional&lt;/span&gt;
&lt;span class="nd"&gt;@Query&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"UPDATE ReportGenerationTaskEntity t SET t.heartbeatAt = :now WHERE t.id = :id"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;updateHeartbeatAt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@Param&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"id"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nd"&gt;@Param&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"now"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;Instant&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wire it via a factory so the service stays clean:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Component&lt;/span&gt;
&lt;span class="nd"&gt;@RequiredArgsConstructor&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ReportGenerationTaskHeartbeatFactory&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;ReportGenerationRepository&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Heartbeat&lt;/span&gt; &lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;taskId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ReportGenerationTaskHeartbeat&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;taskId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The processing block now looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;fetchAndProcess&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;transactionTemplate&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;execute&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findFirstEligibleTask&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                    &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TaskState&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;PENDING&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;TaskState&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;FAILED&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
                    &lt;span class="no"&gt;MAX_RETRY_COUNT&lt;/span&gt;
            &lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;map&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;markProcessing&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ifPresentOrElse&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                    &lt;span class="n"&gt;task&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;heartbeat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;heartbeatFactory&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;start&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;()))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                            &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                                &lt;span class="n"&gt;generateReport&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
                                &lt;span class="n"&gt;markCompleted&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
                            &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                                &lt;span class="n"&gt;markFailed&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
                                &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Task {} failed"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
                            &lt;span class="o"&gt;}&lt;/span&gt;
                        &lt;span class="o"&gt;}&lt;/span&gt;
                    &lt;span class="o"&gt;},&lt;/span&gt;
                    &lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;debug&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"No eligible tasks to process"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The recovery job uses &lt;code&gt;heartbeat_at&lt;/code&gt; instead of &lt;code&gt;updated_at&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Modifying&lt;/span&gt;
&lt;span class="nd"&gt;@Transactional&lt;/span&gt;
&lt;span class="nd"&gt;@Query&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"UPDATE ReportGenerationEntity t SET t.heartbeatAt = :now WHERE t.id = :id"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;updateHeartbeatAt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@Param&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"id"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nd"&gt;@Param&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"now"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;Instant&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

&lt;span class="nc"&gt;Optional&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ReportGenerationEntity&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;findFirstByStateAndHeartbeatAtLessThan&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;TaskState&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Instant&lt;/span&gt; &lt;span class="n"&gt;threshold&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;updated_at&lt;/code&gt; reflects when the task state last changed. &lt;code&gt;heartbeat_at&lt;/code&gt; reflects whether the&lt;br&gt;
task is alive right now. Keeping them separate means you can answer both questions without&lt;br&gt;
ambiguity.&lt;/p&gt;


&lt;h2&gt;
  
  
  Part 6 — Multi-Type Outbox
&lt;/h2&gt;


&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TB
    subgraph before["❌ Before — separate table per type"]
        direction TB
        W1[ReportWorker] --&amp;gt; T1[(report_generation_tasks)]
        W2[EmailWorker]  --&amp;gt; T2[(email_notification_tasks)]
        W3[WebhookWorker]--&amp;gt; T3[(webhook_delivery_tasks)]
    end

    before --&amp;gt; after

    subgraph after["✅ After — one table, handler per type"]
        direction TB
        OT[(outbox_tasks\ntask_type column)] --&amp;gt; D[Dispatcher]
        D --&amp;gt; H1[ReportHandler]
        D --&amp;gt; H2[EmailHandler]
        D --&amp;gt; H3[WebhookHandler]
    end&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;Six months in, your service handles more than report generation. You need email notifications.&lt;br&gt;
Then data exports. Then webhook deliveries.&lt;/p&gt;

&lt;p&gt;You have two options: a separate table per task type, or one shared table with a &lt;code&gt;task_type&lt;/code&gt;&lt;br&gt;
discriminator. The shared table wins when task types are numerous and you're comfortable&lt;br&gt;
processing them from one place. The infrastructure — retries, heartbeats, backoff, monitoring —&lt;br&gt;
stays in one place. Adding a new task type is just adding a new handler.&lt;/p&gt;

&lt;p&gt;The tradeoffs worth knowing upfront:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A misbehaving new task type can slow down all other types — one bad handler burning retries
creates backpressure across the whole table.&lt;/li&gt;
&lt;li&gt;Ordering guarantees become harder — more on this below.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  The Schema
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TYPE&lt;/span&gt; &lt;span class="n"&gt;outbox_task_type&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="nb"&gt;ENUM&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'REPORT_GENERATION'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'EMAIL_NOTIFICATION'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;outbox_tasks&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;            &lt;span class="n"&gt;UUID&lt;/span&gt;             &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;task_type&lt;/span&gt;     &lt;span class="n"&gt;outbox_task_type&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;state&lt;/span&gt;         &lt;span class="n"&gt;task_state&lt;/span&gt;       &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;    &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;'PENDING'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt;       &lt;span class="n"&gt;JSONB&lt;/span&gt;            &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;retry_count&lt;/span&gt;   &lt;span class="nb"&gt;INTEGER&lt;/span&gt;          &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;    &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;error_message&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;execute_at&lt;/span&gt;    &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;      &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;    &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;heartbeat_at&lt;/span&gt;  &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt;    &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;      &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;    &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;updated_at&lt;/span&gt;    &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;      &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;    &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  The Handler Interface
&lt;/h3&gt;

&lt;p&gt;The key extensibility point — one implementation per task type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;OutboxTaskHandler&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;P&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;OutboxTaskType&lt;/span&gt; &lt;span class="nf"&gt;getType&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="nc"&gt;Class&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;P&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;payloadClass&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;taskId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;P&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;throws&lt;/span&gt; &lt;span class="nc"&gt;Exception&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;taskId&lt;/code&gt; is passed into every handler. You'll see why in the idempotency section.&lt;/p&gt;

&lt;p&gt;A concrete handler:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Component&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;EmailNotificationHandler&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;OutboxTaskHandler&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;EmailNotificationPayload&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Override&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;OutboxTaskType&lt;/span&gt; &lt;span class="nf"&gt;getType&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;OutboxTaskType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;EMAIL_NOTIFICATION&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Override&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Class&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;EmailNotificationPayload&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;payloadClass&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;EmailNotificationPayload&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Override&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;taskId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;EmailNotificationPayload&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;throws&lt;/span&gt; &lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;info&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Sending email to {} — task {}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getRecipient&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;taskId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="c1"&gt;// call email provider&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The Dispatcher
&lt;/h3&gt;

&lt;p&gt;The service collects all handler beans at startup, builds a lookup map, and routes each task by&lt;br&gt;
&lt;code&gt;taskType&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Service&lt;/span&gt;
&lt;span class="nd"&gt;@RequiredArgsConstructor&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OutboxTaskService&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;OutboxTaskRepository&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;ObjectMapper&lt;/span&gt; &lt;span class="n"&gt;objectMapper&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;OutboxTaskHandler&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;?&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;handlerList&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;OutboxTaskType&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;OutboxTaskHandler&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;?&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;handlers&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@PostConstruct&lt;/span&gt;
    &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;handlers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;handlerList&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;collect&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Collectors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toMap&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;OutboxTaskHandler:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;getType&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Function&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;identity&lt;/span&gt;&lt;span class="o"&gt;()));&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@SuppressWarnings&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"unchecked"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OutboxTaskEntity&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;throws&lt;/span&gt; &lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;handler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OutboxTaskHandler&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;)&lt;/span&gt; &lt;span class="n"&gt;handlers&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getTaskType&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;handler&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;IllegalStateException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                    &lt;span class="s"&gt;"No handler registered for task type: "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getTaskType&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="kt"&gt;var&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;objectMapper&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;readValue&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getPayload&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;payloadClass&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
        &lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;handle&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Registering a new task type is: add enum value, add handler bean, done. The dispatcher, the&lt;br&gt;
scheduler, the retry logic — none of it changes.&lt;/p&gt;


&lt;h2&gt;
  
  
  Part 7 — Ordering
&lt;/h2&gt;

&lt;p&gt;If your use case doesn't care about processing order — fire-and-forget notifications, independent&lt;br&gt;
background jobs — skip this section. But even then, fetching earlier-inserted tasks first is the&lt;br&gt;
more honest behaviour: tasks submitted first have been waiting longer, and processing them in&lt;br&gt;
submission order is what users would reasonably expect.&lt;/p&gt;

&lt;p&gt;Without an explicit &lt;code&gt;ORDER BY&lt;/code&gt;, PostgreSQL makes &lt;strong&gt;no guarantee&lt;/strong&gt; about row return order. Always&lt;br&gt;
sort by &lt;code&gt;created_at ASC&lt;/code&gt; in your fetch query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Query&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"""
    SELECT t FROM OutboxTaskEntity t
    WHERE t.state IN :states
      AND t.retryCount &amp;lt;= :maxRetryCount
      AND t.executeAt &amp;lt;= :now
    ORDER BY t.createdAt ASC
    LIMIT 1
    """&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nc"&gt;Optional&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;OutboxTaskEntity&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;findFirstEligibleTask&lt;/span&gt;&lt;span class="o"&gt;(...);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Back this with a partial index:&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;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_outbox_fetch&lt;/span&gt;
    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;outbox_tasks&lt;/span&gt; &lt;span class="p"&gt;(&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="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;state&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;'PENDING'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'FAILED'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The Clock Skew Trap
&lt;/h3&gt;

&lt;p&gt;First, be honest about whether this matters for your use case. If your tasks are independent —&lt;br&gt;
report generation, email sending, webhook delivery — processing order usually doesn't matter at&lt;br&gt;
all. Task A finishing before task B is fine regardless of which was submitted first. In that case&lt;br&gt;
this whole section is not your problem.&lt;/p&gt;

&lt;p&gt;If you do need FIFO — tasks for the same entity that must be processed in submission order —&lt;br&gt;
then read on.&lt;/p&gt;

&lt;p&gt;The issue is that &lt;code&gt;created_at&lt;/code&gt; is generated on the application side. Each app instance has its&lt;br&gt;
own system clock. If instance A is 200ms ahead of instance B, a task submitted by B at &lt;code&gt;t=100ms&lt;/code&gt;&lt;br&gt;
gets a &lt;code&gt;created_at&lt;/code&gt; that is earlier than a task submitted by A at &lt;code&gt;t=50ms&lt;/code&gt; — even though A's&lt;br&gt;
task was inserted first. The fetch query sorts by &lt;code&gt;created_at&lt;/code&gt; and picks up B's task first.&lt;br&gt;
Submission order is inverted.&lt;/p&gt;

&lt;p&gt;NTP keeps clocks close but not perfectly synchronized. Under normal conditions the drift is&lt;br&gt;
milliseconds. Under load, during VM migrations, or after a container restart, it can be more.&lt;/p&gt;

&lt;p&gt;The fix is to generate the ordering value on the database side — either let PostgreSQL assign&lt;br&gt;
&lt;code&gt;created_at&lt;/code&gt; via &lt;code&gt;DEFAULT now()&lt;/code&gt; rather than letting the application set it, or use a&lt;br&gt;
database-backed sequence which is guaranteed monotonically increasing regardless of which&lt;br&gt;
instance inserted the row. Here's the sequence approach:&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;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;outbox_tasks&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;seq&lt;/span&gt; &lt;span class="n"&gt;BIGSERIAL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Column&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;insertable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;updatable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;seq&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sort by &lt;code&gt;seq ASC&lt;/code&gt;. The value is assigned by a single authoritative source — the database — so&lt;br&gt;
it reflects true insertion order regardless of which instance submitted the task or what that&lt;br&gt;
instance's clock said.&lt;/p&gt;

&lt;p&gt;Keep &lt;code&gt;created_at&lt;/code&gt; for observability. Use &lt;code&gt;seq&lt;/code&gt; for ordering.&lt;/p&gt;

&lt;p&gt;If strict FIFO matters in your system — don't forget this.&lt;/p&gt;


&lt;h2&gt;
  
  
  Part 8 — Indexes That Keep the Table Fast at Scale
&lt;/h2&gt;

&lt;p&gt;Your outbox table is polled every second, often multiple times per second. Every row in it is&lt;br&gt;
either active work (&lt;code&gt;PENDING&lt;/code&gt;, &lt;code&gt;FAILED&lt;/code&gt;, &lt;code&gt;PROCESSING&lt;/code&gt;) or historical noise (&lt;code&gt;COMPLETED&lt;/code&gt;). Without the&lt;br&gt;
right indexes, each poll scans the entire table to find the handful of eligible rows.&lt;/p&gt;

&lt;p&gt;The right tool is a &lt;strong&gt;partial index&lt;/strong&gt; — one that only includes rows matching a predicate. When a&lt;br&gt;
task transitions to &lt;code&gt;COMPLETED&lt;/code&gt;, it falls out of the partial index automatically. The index stays&lt;br&gt;
small forever, regardless of how many tasks the table has ever processed.&lt;/p&gt;
&lt;h3&gt;
  
  
  The Fetch Index — the hot path
&lt;/h3&gt;

&lt;p&gt;Index for the fetch query:&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;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_outbox_fetch&lt;/span&gt;
    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;outbox_tasks&lt;/span&gt; &lt;span class="p"&gt;(&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="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;state&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;'PENDING'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'FAILED'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To test it: inserted 50,000 rows with only 7 in &lt;code&gt;PENDING&lt;/code&gt; state, enabled Hibernate SQL debug&lt;br&gt;
logging to get the exact generated query in the console, ran &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; over that SQL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Limit  (cost=0.14..27.50 rows=1 width=102) (actual time=0.082..0.084 rows=1 loops=1)
  -&amp;gt;  LockRows  (actual time=0.081..0.082 rows=1 loops=1)
        -&amp;gt;  Index Scan using idx_outbox_fetch on outbox_tasks
              (actual time=0.030..0.030 rows=1 loops=1)
Planning Time: 1.230 ms
Execution Time: 0.139 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;0.03ms to find the oldest eligible task. The 49,993 &lt;code&gt;COMPLETED&lt;/code&gt; rows were never read. Looks good to me.&lt;/p&gt;

&lt;p&gt;Two more indexes for the other two background operations:&lt;/p&gt;

&lt;p&gt;For the recovery job:&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;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_outbox_stuck&lt;/span&gt;
    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;outbox_tasks&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;heartbeat_at&lt;/span&gt; &lt;span class="k"&gt;ASC&lt;/span&gt;&lt;span class="p"&gt;,&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="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'PROCESSING'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the cleanup job:&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;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_outbox_cleanup&lt;/span&gt;
    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;outbox_tasks&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;updated_at&lt;/span&gt; &lt;span class="k"&gt;ASC&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'COMPLETED'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Part 9 — Cleanup
&lt;/h2&gt;

&lt;p&gt;Your outbox is an operational table. Every fetch query scans it. Old rows are dead weight.&lt;/p&gt;

&lt;p&gt;The rule: only delete &lt;strong&gt;&lt;code&gt;COMPLETED&lt;/code&gt;&lt;/strong&gt; tasks. &lt;code&gt;FAILED&lt;/code&gt; tasks are your audit trail. Leave them alone.&lt;br&gt;
Handle them manually or via a separate ops process when needed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Modifying&lt;/span&gt;
&lt;span class="nd"&gt;@Transactional&lt;/span&gt;
&lt;span class="nd"&gt;@Query&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"""
    DELETE FROM OutboxTaskEntity t
    WHERE t.state IN :states
      AND t.updatedAt &amp;lt; :before
    """&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;deleteByStateInAndUpdatedAtBefore&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
    &lt;span class="nd"&gt;@Param&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"states"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;TaskState&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;states&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="nd"&gt;@Param&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"before"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;Instant&lt;/span&gt; &lt;span class="n"&gt;before&lt;/span&gt;
&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="no"&gt;CLEANUP_RETENTION_DAYS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="nd"&gt;@Transactional&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;cleanup&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;cutoff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Instant&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;minus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;CLEANUP_RETENTION_DAYS&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;ChronoUnit&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;DAYS&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;deleted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;deleteByStateInAndUpdatedAtBefore&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TaskState&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;COMPLETED&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt; &lt;span class="n"&gt;cutoff&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;info&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Cleanup: removed {} completed tasks older than {} days"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;deleted&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;CLEANUP_RETENTION_DAYS&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;How long to retain: I've seen everything from 1 day to 3 months. If you're unsure — keep longer.&lt;br&gt;
The cost of retaining old rows is a slightly larger table. The cost of deleting them too early&lt;br&gt;
is losing your debugging history on the day you need it most.&lt;/p&gt;


&lt;h2&gt;
  
  
  Part 10 — Idempotency: Making Retries Safe
&lt;/h2&gt;

&lt;p&gt;Nothing in this architecture guarantees exactly-once execution. The task runs and the state&lt;br&gt;
update to &lt;code&gt;COMPLETED&lt;/code&gt; are two separate operations. If the app crashes after execution but before&lt;br&gt;
the state update commits, the task will be retried. It will run again with the same inputs.&lt;/p&gt;

&lt;p&gt;This is &lt;strong&gt;at-least-once&lt;/strong&gt; delivery — it's the only guarantee available here. The question is&lt;br&gt;
whether a duplicate execution causes harm.&lt;/p&gt;

&lt;p&gt;If your task is inherently idempotent (generating the same report twice produces the same report),&lt;br&gt;
you're done. If it has side effects (sends an email, charges a card), a duplicate is a problem.&lt;/p&gt;

&lt;p&gt;The fix: use the outbox task's own &lt;code&gt;id&lt;/code&gt; as an idempotency key inside the handler. The task ID is&lt;br&gt;
stable across retries — it never changes for the same row. The handler checks whether work for&lt;br&gt;
this ID was already recorded. If yes, it's a no-op.&lt;/p&gt;

&lt;p&gt;In our case, report generation ends by inserting a row into &lt;code&gt;generated_reports&lt;/code&gt; — that's the&lt;br&gt;
record of work done. We add a &lt;code&gt;UNIQUE&lt;/code&gt; constraint on &lt;code&gt;idempotency_key&lt;/code&gt; so that even if two&lt;br&gt;
instances race, only one INSERT wins. The other gets a constraint violation and treats it as a&lt;br&gt;
successful no-op:&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;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;generated_reports&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;               &lt;span class="n"&gt;UUID&lt;/span&gt;        &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;idempotency_key&lt;/span&gt;  &lt;span class="n"&gt;UUID&lt;/span&gt;        &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;report_path&lt;/span&gt;      &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;     &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt;       &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;    &lt;span class="k"&gt;DEFAULT&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;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;uq_generated_reports_idempotency_key&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;idempotency_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Component&lt;/span&gt;
&lt;span class="nd"&gt;@RequiredArgsConstructor&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ReportGenerationHandler&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;OutboxTaskHandler&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ReportGenerationPayload&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;GeneratedReportRepository&lt;/span&gt; &lt;span class="n"&gt;reports&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@Override&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;taskId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;ReportGenerationPayload&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reports&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;existsByIdempotencyKey&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;taskId&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;info&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Report already generated for task {} — skipping"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;taskId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;

        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;reportPath&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;runReportGeneration&lt;/span&gt;&lt;span class="o"&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;reports&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;GeneratedReport&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;idempotencyKey&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;taskId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;reportPath&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reportPath&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your task calls an external API instead of writing to the DB, check whether that API supports&lt;br&gt;
idempotency keys — most payment processors and transactional email providers do. Pass the outbox&lt;br&gt;
task ID as the idempotency key in whatever form the API requires (header, body field, query param).&lt;br&gt;
The remote service handles deduplication on their end.&lt;/p&gt;


&lt;h2&gt;
  
  
  Part 11 — Monitoring
&lt;/h2&gt;

&lt;p&gt;The outbox runs silently in the background. When it works, nobody notices. When it breaks,&lt;br&gt;
nobody notices that either — at least not immediately. Stuck tasks pile up. Downstream services&lt;br&gt;
stop receiving events. By the time someone looks at logs it's been two hours.&lt;/p&gt;

&lt;p&gt;Three signals cover the failure space completely, per the Google SRE model:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Latency&lt;/strong&gt; — how long does each task take from pick-up to completion? Spike here means the handler is slow or the DB is under pressure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Throughput&lt;/strong&gt; — how many tasks per unit time are finishing? Drops to zero means the scheduler isn't running or no tasks are being claimed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Error rate&lt;/strong&gt; — what fraction of attempts end in failure? Above ~5% means a downstream dependency is degraded and the retry backlog is growing.&lt;/li&gt;
&lt;/ul&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%2Fcodewithkyryl.dev%2Fassets%2Fimg%2Foutbox-grafana-dashboard.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%2Fcodewithkyryl.dev%2Fassets%2Fimg%2Foutbox-grafana-dashboard.png" alt="Grafana outbox dashboard" width="799" height="294"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  Instrumentation
&lt;/h3&gt;

&lt;p&gt;Add to &lt;code&gt;build.gradle.kts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nf"&gt;implementation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"org.springframework.boot:spring-boot-starter-actuator"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;implementation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"io.micrometer:micrometer-registry-prometheus"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Register the &lt;code&gt;@Timed&lt;/code&gt; AOP aspect once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Bean&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;TimedAspect&lt;/span&gt; &lt;span class="nf"&gt;timedAspect&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;MeterRegistry&lt;/span&gt; &lt;span class="n"&gt;registry&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;TimedAspect&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;registry&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep metric names as constants and increment from a dedicated component — not scattered across&lt;br&gt;
service methods:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Component&lt;/span&gt;
&lt;span class="nd"&gt;@RequiredArgsConstructor&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OutboxMetrics&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

  &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="no"&gt;TASK_PROCESSED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"outbox.task.processed"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="no"&gt;OUTCOME_TAG&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"outcome"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="no"&gt;COMPLETED&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"completed"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="no"&gt;FAILED&lt;/span&gt;         &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"failed"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;MeterRegistry&lt;/span&gt; &lt;span class="n"&gt;meterRegistry&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;ReportGenerationRepository&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;recordCompleted&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;meterRegistry&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;counter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;TASK_PROCESSED&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;OUTCOME_TAG&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;COMPLETED&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;increment&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
  &lt;span class="o"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;recordFailed&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;meterRegistry&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;counter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;TASK_PROCESSED&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;OUTCOME_TAG&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;FAILED&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;increment&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
  &lt;span class="o"&gt;}&lt;/span&gt;

  &lt;span class="nd"&gt;@PostConstruct&lt;/span&gt;
  &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;registerGauge&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Gauge&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"outbox.tasks.pending"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;repo&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;countByStateIn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TaskState&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;PENDING&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;TaskState&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;FAILED&lt;/span&gt;&lt;span class="o"&gt;)))&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Tasks waiting to be processed"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;register&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;meterRegistry&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
  &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The service then just calls &lt;code&gt;metrics.recordCompleted()&lt;/code&gt; and &lt;code&gt;metrics.recordFailed()&lt;/code&gt; — no raw&lt;br&gt;
strings in business logic.&lt;/p&gt;

&lt;p&gt;After startup, &lt;code&gt;curl localhost:8080/actuator/prometheus | grep outbox&lt;/code&gt; gives you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;outbox_task_duration_seconds{quantile="0.5"}  0.062
outbox_task_duration_seconds{quantile="0.95"} 0.145
outbox_task_duration_seconds{quantile="0.99"} 0.312
outbox_task_processed_total{outcome="completed"} 40.0
outbox_task_processed_total{outcome="failed"}    2.0
outbox_tasks_pending 0.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Alerts
&lt;/h3&gt;

&lt;p&gt;Dashboards are for humans watching screens. Alerts are for humans who are asleep.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Alert&lt;/th&gt;
&lt;th&gt;Fires when&lt;/th&gt;
&lt;th&gt;For&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;OutboxQueueBacklog&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;pending &amp;gt; 50&lt;/td&gt;
&lt;td&gt;5m&lt;/td&gt;
&lt;td&gt;Processing can't keep up with ingest&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;OutboxHighErrorRate&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;error rate &amp;gt; 5%&lt;/td&gt;
&lt;td&gt;2m&lt;/td&gt;
&lt;td&gt;Handler or downstream degraded&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;OutboxSlowProcessing&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;p99 latency &amp;gt; 30s&lt;/td&gt;
&lt;td&gt;5m&lt;/td&gt;
&lt;td&gt;DB contention or slow external call&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;OutboxStuckTasks&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;pending &amp;gt; 0 and zero completions in 10m&lt;/td&gt;
&lt;td&gt;10m&lt;/td&gt;
&lt;td&gt;Scheduler crashed or all workers stuck&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Tune thresholds to your own load. A queue depth of 50 may be normal for a high-throughput&lt;br&gt;
service. For a low-volume internal tool, even 5 tasks sitting for 5 minutes warrants a look.&lt;br&gt;
The signal logic is the same; only the numbers change.&lt;/p&gt;


&lt;h2&gt;
  
  
  Production Checklist
&lt;/h2&gt;

&lt;p&gt;Before shipping any outbox to production:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;☐ Indexes are efficient — partial indexes on PENDING/FAILED and PROCESSING states
☐ EXPLAIN ANALYZE confirms index usage on the fetch query
☐ Ordering is not a problem — ORDER BY seq or created_at, backed by index
☐ Processed data cleanup job running, COMPLETED rows removed after retention period
☐ Idempotency handled inside handlers with side effects
☐ Exponential backoff wired — or confirmed not needed for this use case
☐ Metrics in place — queue depth, throughput, error rate, latency
☐ Alerts configured — backlog, error rate, latency, stuck tasks
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The Ecosystem — You Don't Always Have to Build This
&lt;/h2&gt;

&lt;p&gt;Everything described in this article is something you can build yourself — and there is real&lt;br&gt;
value in doing so at least once. You understand the failure modes, the index choices, the&lt;br&gt;
ordering traps, because you designed around them. That knowledge doesn't go away when you switch&lt;br&gt;
to a library.&lt;/p&gt;

&lt;p&gt;But in production, you often don't need to maintain this yourself.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/kagkarlsson/db-scheduler" rel="noopener noreferrer"&gt;db-scheduler&lt;/a&gt;&lt;/strong&gt; — Gustav Karlsson's&lt;br&gt;
persistent cluster-friendly scheduler for Java. This is the library I keep coming back to and&lt;br&gt;
the one whose internals most closely match what we built in this article. It uses a single&lt;br&gt;
database table, pessimistic locking with &lt;code&gt;SKIP LOCKED&lt;/code&gt;, heartbeats to detect stuck tasks, and&lt;br&gt;
a clean task/handler interface. When I design self-rolled outboxes, I use db-scheduler as the&lt;br&gt;
reference implementation — the interfaces, the patterns, the failure handling are all sound. If&lt;br&gt;
you're starting a new project and want a battle-tested foundation rather than rolling your own,&lt;br&gt;
start here. The abstractions are thin enough that you'll recognize everything from this article&lt;br&gt;
in the source code.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;&lt;a href="https://docs.celeryq.dev/" rel="noopener noreferrer"&gt;Celery&lt;/a&gt;&lt;/strong&gt; — the de-facto standard for async task execution in&lt;br&gt;
the Python ecosystem. Celery supports PostgreSQL as a result backend and Redis or RabbitMQ as a&lt;br&gt;
broker. The &lt;code&gt;AFTER_COMMIT&lt;/code&gt; pattern from Part 2.5 applies here too — push to Celery only after&lt;br&gt;
the transaction commits, not inside it.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;&lt;a href="https://www.quartz-scheduler.org/" rel="noopener noreferrer"&gt;Quartz Scheduler&lt;/a&gt;&lt;/strong&gt; — the long-standing Java scheduling&lt;br&gt;
library with JDBC-backed job persistence. More general than an outbox, with cron expressions,&lt;br&gt;
triggers, and job stores. The operational overhead is higher than db-scheduler and the API is&lt;br&gt;
verbose by modern standards, but it's proven at scale with first-class Spring integration via&lt;br&gt;
&lt;code&gt;spring-boot-starter-quartz&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;Most of what's in this article isn't clever engineering — it's accumulated scar tissue. The&lt;br&gt;
&lt;code&gt;PROCESSING&lt;/code&gt; state exists because someone's app crashed. The heartbeat exists because&lt;br&gt;
&lt;code&gt;updated_at&lt;/code&gt; lied. The partial index exists because a full table scan at polling frequency&lt;br&gt;
compounds quietly until it doesn't. The &lt;code&gt;AFTER_COMMIT&lt;/code&gt; listener exists because someone published&lt;br&gt;
an event inside a transaction and spent hours debugging why it never fired.&lt;/p&gt;

&lt;p&gt;Each piece is boring on its own. Together they make the difference between an outbox that works&lt;br&gt;
in development and one you can trust at 3am.&lt;/p&gt;




&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;Code from this article is available at &lt;strong&gt;&lt;a href="https://github.com/javaAndScriptDeveloper/outboxes-article" rel="noopener noreferrer"&gt;github.com/javaAndScriptDeveloper/outboxes-article&lt;/a&gt;&lt;/strong&gt;. Everything runs locally with a single command.&lt;/p&gt;




&lt;h2&gt;
  
  
  Let's Talk
&lt;/h2&gt;

&lt;p&gt;If you hit something I missed, or you've solved one of these problems differently in your own&lt;br&gt;
systems — I'd genuinely like to hear it. Contact links are in the footer.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What is the Transactional Outbox Pattern?
&lt;/h3&gt;

&lt;p&gt;The Transactional Outbox Pattern solves the dual-write problem by writing a task row into the &lt;strong&gt;same database transaction&lt;/strong&gt; as your domain change instead of calling an external system directly. A background worker then reads the outbox table and executes the task. Because the domain write and the task write commit atomically, the task can never be lost even if the external call or the app crashes.&lt;/p&gt;

&lt;h3&gt;
  
  
  What problem does the outbox pattern solve?
&lt;/h3&gt;

&lt;p&gt;It solves the &lt;strong&gt;dual-write problem&lt;/strong&gt;: when an operation must both update the database and call an external system (publish an event, send an email, call an API), there is no shared transaction across the two. If one succeeds and the other fails you get inconsistent state. The outbox makes the second action part of the database transaction, so they succeed or fail together.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need Kafka or a message broker to use the outbox pattern?
&lt;/h3&gt;

&lt;p&gt;No. The outbox pattern works with just a relational database. A scheduled worker can poll the outbox table directly using &lt;code&gt;SELECT ... FOR UPDATE SKIP LOCKED&lt;/code&gt;. A broker like Kafka is optional — you can publish from the outbox, but PostgreSQL alone is enough for reliable async execution.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do multiple workers avoid processing the same outbox row?
&lt;/h3&gt;

&lt;p&gt;Use &lt;code&gt;SELECT ... FOR UPDATE SKIP LOCKED&lt;/code&gt; when claiming rows. Each worker locks the rows it picks and &lt;code&gt;SKIP LOCKED&lt;/code&gt; makes other workers skip already-locked rows instead of blocking, so multiple instances can poll the same table concurrently without double-processing.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do you handle failed tasks and retries in an outbox?
&lt;/h3&gt;

&lt;p&gt;Give each task a state machine (PENDING → PROCESSING → COMPLETED/FAILED), a retry counter, and a &lt;code&gt;next_attempt_at&lt;/code&gt; timestamp for exponential backoff. A heartbeat / lease timestamp lets you reclaim tasks orphaned by a crashed worker, and a max-retry threshold moves poison messages to a dead-letter state for manual inspection.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://codewithkyryl.dev/2026/06/07/outboxes/" rel="noopener noreferrer"&gt;codewithkyryl.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>pattern</category>
      <category>postgres</category>
      <category>springboot</category>
      <category>java</category>
    </item>
    <item>
      <title>🔀 Your Agent Is Paying an LLM to Return a Boolean</title>
      <dc:creator>Kyryl</dc:creator>
      <pubDate>Tue, 22 Sep 2026 21:03:17 +0000</pubDate>
      <link>https://dev.to/code_with_kyryl/your-agent-is-paying-an-llm-to-return-a-boolean-35bk</link>
      <guid>https://dev.to/code_with_kyryl/your-agent-is-paying-an-llm-to-return-a-boolean-35bk</guid>
      <description>&lt;p&gt;Count the model calls in your agent loop. Most of them do not write anything.&lt;/p&gt;

&lt;p&gt;Is this ticket urgent. Which tool should run next. Is this shell command safe. Is the task finished. Should this go to the cheap model or the expensive one. Every one of those is a boolean or an enum. And the usual way to answer them is a generative model, a prompt that ends in "respond with only YES or NO", and a parser that hopes the model listened.&lt;/p&gt;

&lt;p&gt;It works. It is also the wrong tool for the shape of the question.&lt;/p&gt;

&lt;h2&gt;
  
  
  The glue code nobody talks about
&lt;/h2&gt;

&lt;p&gt;If you have built an agent harness, you have written this code. A prompt that begs for a single word. A regex or a &lt;code&gt;strip().upper() == "YES"&lt;/code&gt; check. A retry when the model answers "Yes, because the deploy failed twice..." and your parser chokes. A second retry when it wraps the answer in markdown.&lt;/p&gt;

&lt;p&gt;The backend version of this mistake is familiar. It is calling a full remote service for something a lookup table answers. Or running &lt;code&gt;LIKE '%x%'&lt;/code&gt; where an equality check on an indexed column would do. The capability is there, so you use it, and you pay for the generality on every single call.&lt;/p&gt;

&lt;p&gt;In an agent loop that cost compounds. Each step is another round trip to a model that is built to write paragraphs, asked to write one token.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Jev does differently
&lt;/h2&gt;

&lt;p&gt;Last week LangChain published a post, &lt;a href="https://www.langchain.com/blog/building-a-harness-with-jev" rel="noopener noreferrer"&gt;Building a harness with Jev&lt;/a&gt;, by Sydney Runkle and Hunter Lovell. Jev comes from TypeSafe AI and they call it a "System One model". It does not generate text. You send it a &lt;code&gt;state&lt;/code&gt; (the context) and a set of typed questions, and it returns typed answers with a probability attached.&lt;/p&gt;

&lt;p&gt;Three question types:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Choice&lt;/code&gt; picks one of several options, with a probability for each.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Score&lt;/code&gt; rates against ordered levels like low, medium, high.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Noul&lt;/code&gt; is a plain yes or no.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Adding more questions to one call barely moves the latency. You pay only for the tokens of the extra questions. So one call can answer "is it urgent", "which team owns it" and "how risky is it" at once.&lt;/p&gt;

&lt;p&gt;The basic call from the post:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;langchain_typesafe&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Noul&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TypeSafeClassifier&lt;/span&gt;

&lt;span class="n"&gt;classifier&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TypeSafeClassifier&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;classifier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invoke&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;state&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;The deploy failed twice and customers are seeing 500s. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Can someone look now?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;questions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;urgent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Noul&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;instructions&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Does this need attention right now?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="n"&gt;urgency&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nouls&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;urgent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;noul&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No "answer with only YES or NO". No parsing. The contract is the type.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two places it fits in a harness
&lt;/h2&gt;

&lt;p&gt;The post shows two middlewares, both under &lt;code&gt;langchain_typesafe.experimental&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The first is model routing. The classifier reads the request and picks the cheapest model that can handle it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;langchain.agents&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;create_agent&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;langchain_typesafe.experimental.middleware&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;ModelChoice&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ModelRouterMiddleware&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;router&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ModelRouterMiddleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;choices&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fast&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ModelChoice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;openai:luna&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;criteria&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Direct lookups, extraction, and localized changes.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;powerful&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ModelChoice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;openai:sol&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;criteria&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Architecture and high-stakes decisions.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;instructions&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Choose the least costly model that can complete the task.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;create_agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;openai:gpt-5.6-luna&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;middleware&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;router&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second is an auto-mode guardrail. Before a tool call runs, the classifier decides whether it looks risky:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;langchain.agents&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;create_agent&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;langchain_typesafe.experimental.middleware&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;AutoModeMiddleware&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;guardrail&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;AutoModeMiddleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bash&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;create_agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;openai:gpt-5.6-luna&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;middleware&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;guardrail&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that sounds familiar, it is the same idea behind the auto-mode classifiers in Claude Code and Cursor. Something small and fast sits in front of the tool call and decides whether a human needs to see it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The probability is the real feature
&lt;/h2&gt;

&lt;p&gt;A generative yes/no gives you a string. You can ask the model how confident it is, but that is just more generated text, and it is not a number you can put a policy on.&lt;/p&gt;

&lt;p&gt;A calibrated classifier gives you a probability. That turns a prompt into a routing policy you own. The post does not show how to read the probability off the response, so here is the shape of the policy with the field access left abstract:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;CONFIDENT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.80&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# p = probability the classifier assigned to its answer
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;CONFIDENT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;act&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;answer&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;skip&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="c1"&gt;# not sure: pay for the big model, or ask a human
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;escalate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;0.80&lt;/code&gt; is now a knob. Log every decision with its probability, look at the escalation rate and the error rate, move the threshold. You cannot do that with a prompt. You can only reword it and hope.&lt;/p&gt;

&lt;p&gt;This is the actual shift. The cheaper model is the small story. The bigger one is a split between two workloads that agent harnesses have been treating as one. Classification wants a typed answer and a confidence. Generation wants text. Different jobs, different tools.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest trade-off
&lt;/h2&gt;

&lt;p&gt;I have not run Jev in production. Everything above is about the pattern, and here is what I would want to check before trusting the product.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The numbers are the vendor's best case.&lt;/strong&gt; TypeSafe AI claims "up to 200x faster inference and 400x lower cost than comparable LLMs on classification tasks". "Up to" on their benchmarks is not your workload. Replay a week of your own agent decisions through it and measure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Calibration is the whole pitch, and it is a claim.&lt;/strong&gt; The threshold logic only works if 0.9 actually means right about nine times in ten on your data. If it does not, the knob is decoration. Checking that needs a labeled sample of real decisions. Most teams do not have one, and building it is the real cost of adopting this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is experimental.&lt;/strong&gt; Both middlewares live under &lt;code&gt;experimental&lt;/code&gt;. New vendor, new model, sitting in the critical path of every agent step. Plan for the API to move.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The guardrail has the usual blind spot.&lt;/strong&gt; A classifier that flags risky bash knows what "risky" looks like in general. It does not know your repo. A bulk delete in a generated folder is harmless for you and scary in general. A routine-looking command against the wrong database is the opposite. Same problem every auto-mode classifier has.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It does not replace the LLM.&lt;/strong&gt; The post says so itself: Jev is not a drop-in replacement for an LLM. Anything open-ended still needs a generative model. You are adding a second model to operate, monitor and version, not removing the first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I would start
&lt;/h2&gt;

&lt;p&gt;Not with the guardrail. Start with the cheapest, most reversible decision in your loop: model routing, or "is this task done". Wire the classifier in shadow mode next to your current LLM check, log both answers with the classifier's probability, and compare them for a week. If the two agree on the easy cases and the probability drops on the cases where they disagree, calibration is doing its job on your data. Then move the threshold into the live path.&lt;/p&gt;

&lt;p&gt;If they disagree randomly, you learned that cheaply, and your old regex is still there.&lt;/p&gt;




&lt;p&gt;How many of your agent's LLM calls are really classification, and have you tried splitting them out yet?&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>langchain</category>
      <category>architecture</category>
    </item>
    <item>
      <title>🩸 acks=all Still Lost My Data: Kafka Unclean Leader Election</title>
      <dc:creator>Kyryl</dc:creator>
      <pubDate>Sat, 22 Aug 2026 16:31:02 +0000</pubDate>
      <link>https://dev.to/code_with_kyryl/acksall-still-lost-my-data-kafka-unclean-leader-election-129</link>
      <guid>https://dev.to/code_with_kyryl/acksall-still-lost-my-data-kafka-unclean-leader-election-129</guid>
      <description>&lt;p&gt;You did everything the durability docs told you to. &lt;code&gt;acks=all&lt;/code&gt;. Replication factor 3. You even remembered &lt;code&gt;min.insync.replicas&lt;/code&gt;. Then a broker failover ate a handful of records that your producer had already been told were committed.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;acks=all&lt;/code&gt; did not lie to you. A different setting did.&lt;/p&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%2F6v1mm8qd68c8ybl5igao.gif" 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%2F6v1mm8qd68c8ybl5igao.gif" alt="Unclean leader election: a stale replica is promoted and committed writes vanish" width="540" height="960"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What acks=all actually promises
&lt;/h2&gt;

&lt;p&gt;When you produce with &lt;code&gt;acks=all&lt;/code&gt;, the leader does not acknowledge the write until every replica in the &lt;strong&gt;in-sync replica set&lt;/strong&gt; (the ISR) has it. Combined with &lt;code&gt;min.insync.replicas=2&lt;/code&gt;, that means at least two replicas hold the record before your producer sees success. That is a real, strong guarantee.&lt;/p&gt;

&lt;p&gt;The word doing all the work is &lt;em&gt;in-sync&lt;/em&gt;. The guarantee is only as durable as the membership of that set, and that membership is not fixed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step one: the ISR shrinks
&lt;/h2&gt;

&lt;p&gt;A follower has one job: keep pulling from the leader and stay caught up. When it cannot keep the pace, for a long enough window (&lt;code&gt;replica.lag.time.max.ms&lt;/code&gt;), the leader removes it from the ISR.&lt;/p&gt;

&lt;p&gt;Followers fall behind for boring reasons. A GC pause. A slow disk. A saturated network link. A node under memory pressure. None of these are exotic; they happen in normal operation.&lt;/p&gt;

&lt;p&gt;Here is the subtle part. When the ISR shrinks from three replicas to two, writes keep committing. As long as the surviving set still meets &lt;code&gt;min.insync.replicas&lt;/code&gt;, the producer never notices. The topic looks healthy. But the redundancy you were counting on is quietly gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step two: the leader dies
&lt;/h2&gt;

&lt;p&gt;Now the leader for that partition fails. Kafka must elect a new leader. It has two options.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Promote a replica that was &lt;strong&gt;in the ISR&lt;/strong&gt;. That replica has every committed record, so nothing is lost. This is a &lt;em&gt;clean&lt;/em&gt; election.&lt;/li&gt;
&lt;li&gt;If no in-sync replica is available, either wait, or promote a replica that was &lt;strong&gt;not&lt;/strong&gt; in sync.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That decision is controlled by a single broker/topic setting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;unclean.leader.election.enable&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step three: the flag pulls the trigger
&lt;/h2&gt;

&lt;p&gt;With &lt;code&gt;unclean.leader.election.enable=true&lt;/code&gt;, when there is no in-sync replica to promote, Kafka picks a lagging one anyway to keep the partition available.&lt;/p&gt;

&lt;p&gt;That replica is missing the newest committed records, the ones that only lived on the ISR members that are now gone. The moment it becomes leader, it is the source of truth. Every other replica that rejoins &lt;strong&gt;truncates its log&lt;/strong&gt; to match the new leader. The records that were ahead of the new leader's high-water mark are discarded across the whole partition.&lt;/p&gt;

&lt;p&gt;Those were committed writes. Your producer got a success response for them. They are now gone, and nothing in the client will ever tell you.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ISR = {1, 2, 3}   acks=all commits, all good
broker 3 lags     ISR = {1, 2}, writes still commit
leader (1) dies   no in-sync replica free
unclean election  broker 3 (stale) becomes leader
                  brokers truncate to broker 3's offset
                  -&amp;gt; committed records 4,5 lost
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;unclean.leader.election.enable&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;false&lt;/span&gt;
&lt;span class="py"&gt;min.insync.replicas&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;2&lt;/span&gt;
&lt;span class="c"&gt;# producer
&lt;/span&gt;&lt;span class="py"&gt;acks&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;all&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With unclean election off, if there is no in-sync replica to promote, the partition goes &lt;strong&gt;offline&lt;/strong&gt; and waits for one to come back. Producers get an error and retry or buffer. You lose availability for that partition, but you never serve or acknowledge data you cannot back up.&lt;/p&gt;

&lt;p&gt;Keep &lt;code&gt;min.insync.replicas&lt;/code&gt; at 2 (with RF 3) so a single lagging follower cannot drop you below the durability floor. Produce with &lt;code&gt;acks=all&lt;/code&gt; so the two guarantees actually compose.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest trade-off
&lt;/h2&gt;

&lt;p&gt;This is not free. You are explicitly choosing &lt;strong&gt;consistency over availability&lt;/strong&gt; for that partition. If enough replicas are unhealthy at once, the partition stops accepting writes until Kafka has an in-sync replica to trust. For a payments ledger or an event source that must never lose a record, that is exactly what you want. For a firehose of metrics where a short gap is fine and downtime is not, you might genuinely prefer the opposite.&lt;/p&gt;

&lt;p&gt;The mistake is not picking availability. The mistake is picking it &lt;em&gt;by accident&lt;/em&gt;, because the setting defaulted that way on an old cluster and nobody revisited it. Durability you did not choose on purpose is not durability.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;acks=all&lt;/code&gt; protects you while the ISR is healthy. Unclean leader election is what happens when it is not. Decide, deliberately, whether a partition should go dark or go stale when the replicas you trusted are gone.&lt;/p&gt;

&lt;p&gt;Which way is your most important topic configured, and did someone actually choose it?&lt;/p&gt;

</description>
      <category>kafka</category>
      <category>java</category>
      <category>distributedsystems</category>
      <category>backend</category>
    </item>
    <item>
      <title>Your Familiar Java Classes Just Lost Identity 🧬</title>
      <dc:creator>Kyryl</dc:creator>
      <pubDate>Wed, 12 Aug 2026 15:42:56 +0000</pubDate>
      <link>https://dev.to/code_with_kyryl/your-familiar-java-classes-just-lost-identity-2kkk</link>
      <guid>https://dev.to/code_with_kyryl/your-familiar-java-classes-just-lost-identity-2kkk</guid>
      <description>&lt;p&gt;Project Valhalla's JEP 401 is integrated into JDK 28 as a preview. It needs &lt;code&gt;--enable-preview&lt;/code&gt; at both compile and runtime, so nothing you ship today changes. But once this graduates, a class you already use constantly is going to stop having identity, and the compiler is not going to tell you.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a value class actually changes
&lt;/h2&gt;

&lt;p&gt;A value class in JEP 401 gets four behavioral changes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Fields are implicitly final.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;==&lt;/code&gt; compares field values instead of object identity.&lt;/li&gt;
&lt;li&gt;Construction must fully initialize every field before the constructor returns.&lt;/li&gt;
&lt;li&gt;Instance methods on a value class cannot be &lt;code&gt;synchronized&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Several existing JDK classes become value classes the moment the preview flag is on. Primitive wrappers, &lt;code&gt;Integer&lt;/code&gt;, &lt;code&gt;Long&lt;/code&gt;, and friends, are named examples. So is &lt;code&gt;LocalDate&lt;/code&gt;. Backward compatibility is preserved with the flag off, so this is opt-in territory today. But &lt;code&gt;Integer&lt;/code&gt; and &lt;code&gt;LocalDate&lt;/code&gt; are about as core to a Java codebase as it gets, which is exactly why this matters more than a typical preview feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why value classes exist at all
&lt;/h2&gt;

&lt;p&gt;Identity has a cost. Every identity-bearing object carries a header, cannot be unboxed cleanly, and cannot be packed into arrays or CPU registers the way a primitive can. The JVM has to always go through a pointer.&lt;/p&gt;

&lt;p&gt;For types where nobody actually relies on identity, &lt;code&gt;Integer&lt;/code&gt; and &lt;code&gt;LocalDate&lt;/code&gt; being the textbook cases, that cost buys nothing. Nobody writes code that depends on two &lt;code&gt;LocalDate&lt;/code&gt; instances representing January 1st being the &lt;em&gt;same object&lt;/em&gt;, only that they represent the same date. Removing identity where nobody needs it lets the JVM flatten these objects and inline them instead of always chasing a pointer. That is the performance case for Valhalla in one sentence.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first thing that breaks: &lt;code&gt;==&lt;/code&gt;
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before JDK 28 preview&lt;/span&gt;
&lt;span class="nc"&gt;LocalDate&lt;/span&gt; &lt;span class="n"&gt;d1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LocalDate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="nc"&gt;LocalDate&lt;/span&gt; &lt;span class="n"&gt;d2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LocalDate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

&lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;out&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;println&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d1&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;d2&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// false, identity compare, two different objects&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// JDK 28, --enable-preview, LocalDate is now a value class&lt;/span&gt;
&lt;span class="nc"&gt;LocalDate&lt;/span&gt; &lt;span class="n"&gt;d1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LocalDate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="nc"&gt;LocalDate&lt;/span&gt; &lt;span class="n"&gt;d2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LocalDate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

&lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;out&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;println&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d1&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;d2&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// true, value compare, no identity left to compare&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Boxed-primitive &lt;code&gt;==&lt;/code&gt; has always been a footgun, thanks to the autoboxing integer cache (&lt;code&gt;-128&lt;/code&gt; to &lt;code&gt;127&lt;/code&gt;) making small values compare equal by accident while larger ones do not. This is a different, sharper version of the same footgun. It is not that identity comparison is unreliable anymore. There is no identity left to be unreliable. Any code that used &lt;code&gt;==&lt;/code&gt; as an intentional identity check, a cache lookup, a pooling pattern, an object-identity-based &lt;code&gt;Set&lt;/code&gt;, silently gets different results the moment the underlying type becomes a value class. No compile error. No exception. Just a different answer at a line nobody thought to re-read.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second thing that breaks: &lt;code&gt;synchronized&lt;/code&gt;
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before: legal, LocalDate is an ordinary identity-bearing object&lt;/span&gt;
&lt;span class="nc"&gt;LocalDate&lt;/span&gt; &lt;span class="n"&gt;d1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LocalDate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;synchronized&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d1&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// After: LocalDate is a value class, this is no longer valid&lt;/span&gt;
&lt;span class="nc"&gt;LocalDate&lt;/span&gt; &lt;span class="n"&gt;d1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LocalDate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;synchronized&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d1&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// compile-time error, value classes cannot be used as monitors&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This one at least fails loudly, at compile time, which is the better failure mode of the two. But it means any code locking on an instance of a type that becomes a value class needs to be found and fixed before upgrading, and today there is no tooling that flags "this class might become a value class someday" ahead of time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest trade-off
&lt;/h2&gt;

&lt;p&gt;Preview-only, opt-in, nothing breaks in production code today. That is the reassuring half. The other half: the classes affected are not obscure corners of the JDK, they are &lt;code&gt;Integer&lt;/code&gt; and &lt;code&gt;LocalDate&lt;/code&gt;, used in essentially every nontrivial Java codebase. And the failure mode on &lt;code&gt;==&lt;/code&gt; specifically is silent. A compile-time break, like the &lt;code&gt;synchronized&lt;/code&gt; case, gets caught in CI. A silent behavior change in an &lt;code&gt;==&lt;/code&gt; comparison gets caught in production, if it gets caught at all.&lt;/p&gt;

&lt;p&gt;The safe posture right now, while this is still behind a flag, is to audit for two patterns before JEP 401 graduates out of preview: &lt;code&gt;==&lt;/code&gt; used intentionally for identity on boxed primitive types or &lt;code&gt;LocalDate&lt;/code&gt;, and &lt;code&gt;synchronized&lt;/code&gt; blocks or methods locking on instances of those same types. Neither pattern is common in well-written code, &lt;code&gt;.equals()&lt;/code&gt; should be doing the comparison work already in most cases, but "uncommon" is not the same as "absent," and this is the kind of bug that survives code review because it used to be correct.&lt;/p&gt;

&lt;p&gt;Found either pattern in your own codebase while reading this?&lt;/p&gt;

</description>
      <category>java</category>
      <category>jvm</category>
      <category>jdk28</category>
      <category>projectvalhalla</category>
    </item>
    <item>
      <title>The Shared Cache Tier Is Disappearing 🗄️</title>
      <dc:creator>Kyryl</dc:creator>
      <pubDate>Wed, 12 Aug 2026 15:42:35 +0000</pubDate>
      <link>https://dev.to/code_with_kyryl/the-shared-cache-tier-is-disappearing-3oio</link>
      <guid>https://dev.to/code_with_kyryl/the-shared-cache-tier-is-disappearing-3oio</guid>
      <description>&lt;p&gt;Canva just published how they rebuilt session revocation without a shared cache tier, and it is the third architecture writeup I have run into this month that lands on the exact same shape. Different companies, different problems, same answer: durable object storage as the source of truth, plus a locally rebuilt index, no shared cache in the hot path.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: revocation has to be fast and shared
&lt;/h2&gt;

&lt;p&gt;Session revocation is a specific kind of hard problem. When a token needs to die before its natural expiry, every gateway in the fleet needs to find out, fast, without every request round-tripping to a central store. The obvious answer for years has been a shared cache, Redis cluster or Memcached fleet, that every gateway queries or subscribes to.&lt;/p&gt;

&lt;p&gt;A shared cache tier solves the propagation problem, but it becomes its own liability. It is a single point of contention under load. It has its own failure modes: eviction storms, hot keys, painful cluster rebalances. And it needs its own on-call rotation, separate from the service that actually owns the data.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Canva built instead
&lt;/h2&gt;

&lt;p&gt;Canva stores revocation records as 16-byte binary entries in S3, sliced into 30-minute objects across a 12-hour revocation window. Instead of gateways querying a shared store on every request, each gateway pulls the recent object slices and rebuilds a local, in-memory sorted-array index from them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;S3 layout (conceptual):
  revocations/2026-08-11T00:00-00:30.bin
  revocations/2026-08-11T00:30-01:00.bin
  revocations/2026-08-11T01:00-01:30.bin
  ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Concurrency is handled with conditional GETs and PUTs, giving optimistic concurrency without a lock service. ZooKeeper leader election is used to reduce write conflicts further, but Canva is explicit that it is an optimization, not a correctness dependency. The system is correct without it, just slightly noisier under contention.&lt;/p&gt;

&lt;p&gt;The results, from their own numbers: cache memory down 87.5%. Workers sustaining 2,000+ revocations per second. The database footprint down to two read replicas. A million revocations fit in roughly 16MB.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern, not just the case study
&lt;/h2&gt;

&lt;p&gt;Strip away the specifics and the shape is: a workload with small records, a bounded time window, and tolerance for a few seconds of staleness, served by cheap durable object storage instead of a shared, stateful cache. Each worker rebuilds its own disposable index locally instead of depending on a shared service to hold the current state for everyone.&lt;/p&gt;

&lt;p&gt;This shape fits more than session revocation. Feature flags, entity caches, compacted-topic-style event state, anything that is read far more than it is written, has a small per-record footprint, and does not need every reader to agree on the exact same value at the exact same instant.&lt;/p&gt;

&lt;p&gt;Seeing three unrelated teams land here independently in the same month is a stronger signal than any one writeup. It suggests the shared cache tier's default status, "many workers need the same fast-changing data, reach for Redis," is no longer the obvious answer for a meaningful slice of these workloads.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest trade-off
&lt;/h2&gt;

&lt;p&gt;This pattern is not a free upgrade. Two real costs:&lt;/p&gt;

&lt;p&gt;It only works when the dataset has a natural staleness tolerance and a bounded window. Canva's 12-hour revocation window caps how much history a worker ever needs to rebuild from scratch. Without a bound like that, "rebuild the index from object storage" gets slower and the object count grows without limit.&lt;/p&gt;

&lt;p&gt;It trades a shared cache's single-source consistency for eventual consistency across workers. A shared Redis instance gives every reader the same answer the instant a write lands. Here, a revocation is only as fresh as the last object slice a given gateway has pulled. If your correctness requirements demand that every worker sees a write at the exact same millisecond, no propagation lag tolerated, this pattern is the wrong tool and a shared cache (or a shared database) is still the right one.&lt;/p&gt;

&lt;p&gt;Canva's own use of ZooKeeper leader election as an optional optimization, not a dependency, is a tell about how the trade-off was made: they accepted some write-conflict noise in exchange for not needing a coordination service to be up for the system to be correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this fits
&lt;/h2&gt;

&lt;p&gt;If you are running a workload that looks like "many stateless workers need to check a small, frequently-updated dataset, and a few seconds of staleness is fine," this is worth evaluating before reaching for another Redis cluster. The infrastructure is simpler to operate (no separate cache fleet to keep alive), the failure mode is more boring (a worker with a stale local index, not a cache stampede), and the cost profile at scale, per Canva's own numbers, is dramatically smaller.&lt;/p&gt;

&lt;p&gt;Have you replaced a shared cache tier with this shape? What broke first when you tried it, and did the staleness window end up being the real constraint?&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>aws</category>
      <category>backend</category>
      <category>systemdesign</category>
    </item>
    <item>
      <title>97% of Your AI Approval Clicks Were Reflexes 🤖</title>
      <dc:creator>Kyryl</dc:creator>
      <pubDate>Wed, 12 Aug 2026 15:42:15 +0000</pubDate>
      <link>https://dev.to/code_with_kyryl/97-of-your-ai-approval-clicks-were-reflexes-18lg</link>
      <guid>https://dev.to/code_with_kyryl/97-of-your-ai-approval-clicks-were-reflexes-18lg</guid>
      <description>&lt;p&gt;Anthropic ran a study with 1,053 testers before making a call most tools never make honestly about their own permission model: the approval prompt was not working, and here are the numbers to prove it.&lt;/p&gt;

&lt;p&gt;Auto mode, the classifier that decides which tool calls need a human, caught 89% of harmful actions in that study. Humans clicking through the old per-call approval prompt caught 13.6%. And 97% of those prompts got rubber-stamped without a second look, whether the action was harmless or not.&lt;/p&gt;

&lt;p&gt;Starting August 14, new Claude Code sessions on Pro, Max, and Team plans switch from "ask before every tool call" to "route through a classifier, only interrupt for irreversible, destructive, or outward-facing actions." Enterprise, API, and cloud-partner deployments (Bedrock, Vertex, Foundry) stay opt-in for now, with a default flip planned there within the month. Classifier overhead is not billed separately.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 97% number is the actual story
&lt;/h2&gt;

&lt;p&gt;A permission prompt only works as a safety mechanism if the human reading it is actually evaluating each one. At 97% rubber-stamped, that was not happening. People were clicking yes on reflex, the same way you click through a terms-of-service dialog. The prompt was theater. It gave the appearance of oversight without providing any, and the one time it mattered, the reflex fired the same as every other time.&lt;/p&gt;

&lt;p&gt;This matters beyond Claude Code specifically. Any tool that gates action behind a per-call human approval and expects that approval to be a meaningful check is making the same bet Claude Code just admitted it lost. If your CI pipeline, your deploy tooling, or your own internal agent framework has a "click to confirm" step that nobody actually reads anymore, this study is a preview of what an audit of your own approval logs would probably show.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "irreversible, destructive, outward-facing" likely means in practice
&lt;/h2&gt;

&lt;p&gt;Anthropic has not published the exact classifier boundary, but the categories map to what teams running agents in CI already learned to gate manually:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Irreversible&lt;/strong&gt;: force-pushes, dropped database tables, deleted branches, anything &lt;code&gt;git reset --hard&lt;/code&gt; adjacent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Destructive&lt;/strong&gt;: bulk file deletion, overwriting uncommitted work, &lt;code&gt;rm -rf&lt;/code&gt; on anything wider than a scratch directory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Outward-facing&lt;/strong&gt;: pushing code, opening or closing PRs, sending messages to Slack or email, posting to external services, anything visible to someone besides the person running the session.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you already run Claude Code with a pinned permission mode (auto-accept, plan mode, a custom allowlist), none of this changes for you. This only affects sessions that were relying on the default, which was most sessions belonging to people who never touched the setting.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest trade-off
&lt;/h2&gt;

&lt;p&gt;A classifier is not you. It was trained on what 1,053 testers, in aggregate, considered harmful. It was not trained on your specific codebase, your specific &lt;code&gt;generated/&lt;/code&gt; folder that looks disposable but is not, or the one script in your repo where a "routine" bulk delete is actually catastrophic.&lt;/p&gt;

&lt;p&gt;89% caught is an average across a study population, not a guarantee for any individual team's blast radius. A destructive action that looks routine in general (deleting files in a folder named &lt;code&gt;tmp&lt;/code&gt; or &lt;code&gt;build&lt;/code&gt;) may not trip the classifier even when, in your repo, that folder is load-bearing. Conversely, something narrow and specific to your setup that the classifier has never seen might get flagged when it is actually fine.&lt;/p&gt;

&lt;p&gt;If you run agents against infrastructure with unusual failure modes, the safe move is still to pin an explicit permission mode rather than trust the default classifier to have learned your repo's edge cases. The classifier reduces the average number of things you have to think about. It does not remove the need to think about the things that are specific to you.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually shifted
&lt;/h2&gt;

&lt;p&gt;This was framed as a UX improvement, fewer interruptions, faster flow. The more accurate framing is that Anthropic looked at its own approval funnel, found it was not catching anything a human was not already rubber-stamping, and moved the actual judgment call from a human reflex to a trained classifier. That is a better bet on the numbers given here. It is not the same as saying the judgment call is now being made well for every possible repo, it is being made by something with a measured track record instead of something with none.&lt;/p&gt;

&lt;p&gt;Teams already running Claude Code semi-autonomous in CI or across a fleet of agents were building workarounds for exactly this gap. This is Anthropic catching up to how those teams were already using the tool, not adding a new capability.&lt;/p&gt;

&lt;p&gt;Were you already running Claude Code with a pinned permission mode before this? What did you have to build around the old approve-every-call default?&lt;/p&gt;

</description>
      <category>ai</category>
      <category>devtools</category>
      <category>productivity</category>
      <category>automation</category>
    </item>
    <item>
      <title>🔥 The Real Latency Is the Human Handshake</title>
      <dc:creator>Kyryl</dc:creator>
      <pubDate>Tue, 11 Aug 2026 20:13:12 +0000</pubDate>
      <link>https://dev.to/code_with_kyryl/the-real-latency-is-the-human-handshake-387g</link>
      <guid>https://dev.to/code_with_kyryl/the-real-latency-is-the-human-handshake-387g</guid>
      <description>&lt;p&gt;Cross-team API changes are the real latency in microservices, and it is measured in weeks. Nobody puts that number on a dashboard because it does not show up in any trace, any p99, any APM tool. The network call is fast. The thing that is slow never touches the network at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mechanism
&lt;/h2&gt;

&lt;p&gt;Say you need one new field on an order object returned by another team's service. Maybe it is a &lt;code&gt;discountCode&lt;/code&gt;, maybe it is a &lt;code&gt;fulfillmentCenterId&lt;/code&gt;, does not matter. What matters is who owns the schema.&lt;/p&gt;

&lt;p&gt;In a monolith, that field lives in a shared model. You open a PR, a teammate reviews it, it merges by lunch. One commit, one deploy, done.&lt;/p&gt;

&lt;p&gt;Across a service boundary, the field lives in a service you do not control. The sequence looks like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;You message the owning team, or file a ticket.&lt;/li&gt;
&lt;li&gt;The ticket sits in their backlog until their next planning session.&lt;/li&gt;
&lt;li&gt;Someone on that team estimates it, prioritizes it against their own roadmap, and schedules it.&lt;/li&gt;
&lt;li&gt;They write the PR, review it, and ship it, on their timeline, not yours.&lt;/li&gt;
&lt;li&gt;You write your own PR to consume the new field, once it exists.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Two weeks if the owning team is fast and the field is trivial. A quarter if it competes with their own sprint commitments, which it usually does, because your one field is nobody's priority but yours.&lt;/p&gt;

&lt;h2&gt;
  
  
  It is not a network problem
&lt;/h2&gt;

&lt;p&gt;The instinct is to treat this as a technical latency problem, the same category as a slow query or a chatty API. It gets a technical response: better documentation, faster CI, an internal API gateway, more automation around service discovery.&lt;/p&gt;

&lt;p&gt;None of that touches the actual bottleneck. The request-response cycle between the two services takes milliseconds whether the field exists or not. The delay lives entirely in the gap between "we filed the ask" and "the owning team scheduled the work." That gap is a queue, and it is a queue that belongs to a team whose backlog you have no authority over.&lt;/p&gt;

&lt;p&gt;This is the part people miss: microservices did not introduce a network problem into your architecture. They introduced an organizational dependency graph, and every edge in that graph now has to be negotiated instead of just written. A monolith's "dependency" is a function call. A microservices "dependency" is a relationship between two teams' roadmaps.&lt;/p&gt;

&lt;h2&gt;
  
  
  A concrete version of the problem
&lt;/h2&gt;

&lt;p&gt;Picture a checkout service that needs a &lt;code&gt;loyaltyTier&lt;/code&gt; field from the customer service to compute a discount correctly. The checkout team does not own customer service. They file a request.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Ticket: Add `loyaltyTier` (enum: BRONZE, SILVER, GOLD) to GET /customers/{id}
Requested by: checkout-team
Priority: P2 (their P2, not yours)
Status: backlog
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The checkout team cannot ship their discount logic until this lands. They are not blocked by a database, a deploy pipeline, or a load balancer. They are blocked by another team's sprint board. If the customer team is mid-migration, or short-staffed, or simply has three P1s ahead of it, the checkout team waits. There is no retry, no timeout, no circuit breaker for an organizational queue.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest trade-off
&lt;/h2&gt;

&lt;p&gt;There are real fixes for this, and every one of them costs something up front. None of them are free lunches, and anyone pitching one as a strict win is skipping the cost column.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Embed a rep from the consuming team in the owning team's planning.&lt;/strong&gt; This buys real speed: the field gets prioritized because a human advocating for it is sitting in the room. It costs headcount. Someone's calendar now has a recurring meeting with a team they do not report to, and someone's manager has to sign off on that time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Go contract-first with a schema registry.&lt;/strong&gt; Define the contract before either side writes code, validate changes against it in CI, and both teams catch breaking changes before they ship instead of after. This buys predictability. It costs discipline: every team touching the registry has to actually write contracts before code, review schema diffs like they review code diffs, and resist the shortcut of just shipping a field because it is faster this one time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Build self-serve schema evolution for optional, additive fields.&lt;/strong&gt; Let consuming teams add new optional fields to a schema without a round trip through the owning team's backlog, gated by validation rather than a human approval queue. This cuts the wait to near zero for the common case. It costs governance: someone has to build the tooling, define what counts as safely additive versus breaking, and maintain the guardrails so "self-serve" does not turn into "anyone can silently change the contract."&lt;/p&gt;

&lt;p&gt;None of these fixes are cheap. All of them beat waiting a quarter for one field.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually changes
&lt;/h2&gt;

&lt;p&gt;Adding a field is not slow because computers are slow. It is slow because prioritization is a human process, and cross-team prioritization has no SLA. The teams that ship fast across service boundaries are not the ones with the fastest network. They are the ones who paid the coordination cost before they needed the field, not after.&lt;/p&gt;

&lt;p&gt;How long does a one-field ask actually take to land across a team boundary where you work, and which of these fixes, if any, does your org already run?&lt;/p&gt;

</description>
      <category>microservices</category>
      <category>architecture</category>
      <category>softwareengineering</category>
      <category>api</category>
    </item>
    <item>
      <title>🔥 Two Services, One Table, Zero Isolation</title>
      <dc:creator>Kyryl</dc:creator>
      <pubDate>Mon, 10 Aug 2026 21:39:57 +0000</pubDate>
      <link>https://dev.to/code_with_kyryl/two-services-one-table-zero-isolation-3i4i</link>
      <guid>https://dev.to/code_with_kyryl/two-services-one-table-zero-isolation-3i4i</guid>
      <description>&lt;p&gt;Two services writing to the same table is a distributed monolith wearing a microservice badge. You split the codebase, you kept the database, and the split you actually shipped is cosmetic. Every cost of the split is real. The isolation you were supposed to get for that cost is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision that does not feel like an architecture decision
&lt;/h2&gt;

&lt;p&gt;Nobody sits in a design review and votes to build a distributed monolith. It happens one convenience call at a time. Team A owns &lt;code&gt;orders&lt;/code&gt;, team B needs order data for its own service, and the fastest way to get it is to point B's code at the same Postgres instance and the same &lt;code&gt;orders&lt;/code&gt; table. No new endpoint to build, no contract to agree on, no deploy to coordinate. It ships this sprint instead of next quarter.&lt;/p&gt;

&lt;p&gt;That decision gets filed under data access. It is not a data-access decision. It is the architectural boundary, decided by default, by whoever wrote the first query. The table has one schema, and from that moment both services are load-bearing on that schema staying exactly the way it is. Nobody wrote that dependency down anywhere. It lives in two codebases that do not import each other and cannot see each other's queries.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the coupling looks like when it breaks
&lt;/h2&gt;

&lt;p&gt;Here is the incident, not hypothetically, the way it actually shows up.&lt;/p&gt;

&lt;p&gt;Service A owns the table by every reasonable definition, it was there first, its team migrated the schema originally. A needs to ship a feature. The feature needs a new required field, so someone adds:&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;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;fulfillment_channel&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;'standard'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A's migration tooling runs it, A's tests pass, because A's tests only ever insert through A's own code path, which now always sets &lt;code&gt;fulfillment_channel&lt;/code&gt;. A's deploy goes out clean. Nobody on A's team thinks about service B, because as far as A's team is concerned, B is not part of this change. A does not know B writes to this table at all, or if it does, nobody flagged this migration for review.&lt;/p&gt;

&lt;p&gt;Service B writes to &lt;code&gt;orders&lt;/code&gt; directly, from its own repository, using its own hand-rolled insert statement that predates the new column entirely. Two things can happen next, and both are bad.&lt;/p&gt;

&lt;p&gt;If the column had no default, B's inserts start failing immediately with a constraint violation. That is the good outcome, loud and fast, a page goes off, someone finds the cause within the hour.&lt;/p&gt;

&lt;p&gt;The worse outcome is what actually happened above: a default value. B's inserts keep succeeding. Every row B writes silently gets &lt;code&gt;fulfillment_channel = 'standard'&lt;/code&gt;, whether or not that is true. No error. No failed health check. No alert. The data is just wrong, quietly, for every order B touches, from the moment A's migration landed until someone downstream notices the fulfillment numbers do not add up. That could be hours. It is more often days, and by the time someone in the incident review asks "wait, since when has B been writing that channel," the wrong data is already in reports, already fed into whatever dashboard finance trusts.&lt;/p&gt;

&lt;p&gt;Nobody connects the two events, the migration and the corrupted writes, because nothing in either codebase points at the other. The only shared artifact is the table itself, and tables do not send review requests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why splitting the codebase did not split the coupling
&lt;/h2&gt;

&lt;p&gt;The instinct once you are burned by this is to blame the migration, add a review step, require anyone touching a shared table to ping the other team first. That is a process patch on an architecture problem, and it will hold until someone forgets, or a new hire does not know the informal rule exists, or the "other team" has been renamed twice since the rule was written down in a wiki nobody reads.&lt;/p&gt;

&lt;p&gt;The actual problem is that two services with independent deploys, independent on-call, independent codebases are still, underneath all of that, one system with one shared piece of mutable state. A network call between them would at least force a versioned contract, something with a schema of its own that changes deliberately, gets reviewed, gets a deprecation window. A shared table has no such contract. Its schema is the contract, enforced by nothing except discipline, and discipline is the thing that fails first under deadline pressure.&lt;/p&gt;

&lt;p&gt;You paid for the split. Two deploy pipelines, two on-call rotations, a network hop between the two halves whenever one calls the other for anything else. What you did not get, in exchange for that cost, is the one thing the split was supposed to buy: a boundary strong enough that one team's change cannot silently break the other. That is what "distributed monolith" means in practice. Every operational cost of microservices, none of the isolation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest trade-off
&lt;/h2&gt;

&lt;p&gt;There are two real fixes, and neither one is free.&lt;/p&gt;

&lt;p&gt;The first: pick one service as the actual owner of the table, and every other consumer goes through that owner's API. Whatever queries B was running directly against &lt;code&gt;orders&lt;/code&gt; become HTTP or gRPC calls to A. A's schema changes are now A's problem to manage behind a versioned interface, and B is protected from anything A does internally as long as the contract holds. The cost is real: you have to build that API surface, migrate every direct query B has, and accept the latency and availability coupling that comes with a synchronous call replacing a local join.&lt;/p&gt;

&lt;p&gt;The second: actually split the data. B gets its own table, its own copy of whatever fields it needs, kept in sync through an event stream, A publishes an &lt;code&gt;OrderUpdated&lt;/code&gt; event whenever the row changes, B consumes it and updates its own copy. Now B genuinely does not depend on A's schema, A can rename columns internally all day without touching B. What you buy instead is eventual consistency: B's copy is sometimes a few seconds or minutes behind, and now you own a second, denormalized dataset that can drift from the source if the event pipeline ever drops a message. Reconciliation becomes its own ongoing job.&lt;/p&gt;

&lt;p&gt;Neither of these is a migration you do over a sprint. Both are projects: mapping every access pattern the "other" service currently has against the shared table, deciding what actually needs to move, coordinating a cutover that does not lose writes in the transition. It is slower and more expensive than the shared table ever was, which is exactly why the shared table happened in the first place. It was the fast option. It is just not the option that gives you the isolation you were charging yourself for.&lt;/p&gt;

&lt;p&gt;How many "microservices" in your own systems still share write access to a table, and has anyone actually mapped what breaks if either side changes the schema?&lt;/p&gt;

</description>
      <category>microservices</category>
      <category>architecture</category>
      <category>softwareengineering</category>
      <category>database</category>
    </item>
    <item>
      <title>🧹 Kafka Is Deleting the Wrong Data: Retention vs Compaction</title>
      <dc:creator>Kyryl</dc:creator>
      <pubDate>Mon, 27 Jul 2026 19:22:06 +0000</pubDate>
      <link>https://dev.to/code_with_kyryl/kafka-is-deleting-the-wrong-data-retention-vs-compaction-3lld</link>
      <guid>https://dev.to/code_with_kyryl/kafka-is-deleting-the-wrong-data-retention-vs-compaction-3lld</guid>
      <description>&lt;p&gt;Kafka is a log, and logs grow. At some point the broker has to reclaim disk, and it has two completely different ways to do it. They are not variations on a theme. They delete on different rules, and confusing them is a quiet data-loss bug that looks like corruption weeks later.&lt;/p&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%2Fp785i7eq33mllx2x1h8l.gif" 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%2Fp785i7eq33mllx2x1h8l.gif" alt="Retention deletes old segments by age; compaction keeps the latest record per key" width="540" height="960"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Retention: delete by age
&lt;/h2&gt;

&lt;p&gt;Retention is a stopwatch. You set a window, and once data is older than that window it is deleted, segment by segment, regardless of what is inside.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;log.retention.hours&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;168      # delete anything older than 7 days&lt;/span&gt;
&lt;span class="py"&gt;log.retention.bytes&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;-1       # or cap by size per partition&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Kafka stores a partition as a sequence of &lt;strong&gt;segment&lt;/strong&gt; files. Retention works at segment granularity: when the newest record in a segment is older than the window (or the partition is over its byte cap), the whole segment is dropped. It never looks at record keys. It does not care whether a key still matters. Old is old.&lt;/p&gt;

&lt;p&gt;This is exactly right for a &lt;strong&gt;stream of events&lt;/strong&gt;. A click from last month, a sensor reading from an hour ago, a request log from yesterday. The value is in the recent flow; the old entries can leave.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compaction: keep the latest per key
&lt;/h2&gt;

&lt;p&gt;Compaction is a dedupe. Instead of asking "how old is this?", it asks "is there a newer record with the same key?" If yes, the older one can go.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;cleanup.policy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;compact&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With compaction, Kafka guarantees that for every key, the &lt;strong&gt;latest&lt;/strong&gt; record is retained. Older records for that same key are eventually removed by the log cleaner. A key that is written once and never updated survives forever, no matter how old it is. That is the crucial difference from retention.&lt;/p&gt;

&lt;p&gt;This is exactly right for &lt;strong&gt;state&lt;/strong&gt;. A changelog topic, a Kafka Streams KTable, a database outbox, a topic that materializes "current value per entity". The whole point is that a consumer can replay the topic from the beginning and rebuild the current state of every key.&lt;/p&gt;

&lt;p&gt;You can also combine them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;cleanup.policy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;compact,delete&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the latest value per key is kept, but truly ancient records can still age out. Useful for a changelog where very old, untouched keys are acceptable to drop.&lt;/p&gt;

&lt;h2&gt;
  
  
  The incident this prevents
&lt;/h2&gt;

&lt;p&gt;Here is the failure I have watched happen more than once.&lt;/p&gt;

&lt;p&gt;Someone models current entity state as a Kafka topic: key is the entity id, value is its latest state. A downstream service rebuilds a local view by consuming the topic from offset zero. Works perfectly in testing.&lt;/p&gt;

&lt;p&gt;The topic was left on the default &lt;code&gt;cleanup.policy=delete&lt;/code&gt; with a 7-day retention. Most entities update often, so nobody notices. Then a slow-moving entity goes untouched for eight days. Its only record ages out. Now when the downstream service rebuilds, that entity is simply &lt;strong&gt;missing&lt;/strong&gt;. No delete event, no error, no log line. It looks like data corruption, and you will spend a day chasing a bug that is really one config line.&lt;/p&gt;

&lt;p&gt;The fix is one property: that topic should have been &lt;code&gt;compact&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest trade-off
&lt;/h2&gt;

&lt;p&gt;Compaction is not free, and it is not a drop-in for retention.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The log cleaner spends CPU and I/O rewriting segments. On a high-churn topic that is real, ongoing work.&lt;/li&gt;
&lt;li&gt;Compaction only bounds size by &lt;strong&gt;number of distinct keys&lt;/strong&gt;, not by time. A topic with an unbounded key space (say, keying by request id) under &lt;code&gt;compact&lt;/code&gt; will grow without limit, because every key is unique and nothing is a duplicate. That is a disk-filling trap in the opposite direction.&lt;/li&gt;
&lt;li&gt;Deletes in a compacted topic need &lt;strong&gt;tombstones&lt;/strong&gt;: a record with the key and a null value. Miss that and a "deleted" key lives forever.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the rule is not "compaction is better". It is: retention for events, compaction for state, &lt;code&gt;compact,delete&lt;/code&gt; when state has a long tail you are willing to drop, and never &lt;code&gt;compact&lt;/code&gt; on an unbounded key space.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;Retention deletes by time. Compaction keeps the latest per key. They solve different problems, and the default (&lt;code&gt;delete&lt;/code&gt;) is the wrong choice for any topic that represents state.&lt;/p&gt;

&lt;p&gt;Go look at your most important topic. Is it &lt;code&gt;delete&lt;/code&gt; or &lt;code&gt;compact&lt;/code&gt;, and is that on purpose or just the default nobody changed?&lt;/p&gt;

</description>
      <category>kafka</category>
      <category>java</category>
      <category>dataengineering</category>
      <category>backend</category>
    </item>
    <item>
      <title>🗑️ Kafka Keeps Data Longer Than retention.ms</title>
      <dc:creator>Kyryl</dc:creator>
      <pubDate>Mon, 20 Jul 2026 18:52:46 +0000</pubDate>
      <link>https://dev.to/code_with_kyryl/kafka-keeps-data-longer-than-retentionms-16ak</link>
      <guid>https://dev.to/code_with_kyryl/kafka-keeps-data-longer-than-retentionms-16ak</guid>
      <description>&lt;p&gt;You set &lt;code&gt;retention.ms&lt;/code&gt; to one hour. Six hours later the data is still on disk. That is not a bug, and it is not your cleanup thread being slow.&lt;/p&gt;

&lt;p&gt;Kafka deletes data very differently from how most people picture it.&lt;/p&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%2F3na5txohfm7o8qoqhr6d.gif" 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%2F3na5txohfm7o8qoqhr6d.gif" alt="How Kafka retention works: it deletes whole closed segments, never the active one" width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Kafka deletes segments, not records
&lt;/h2&gt;

&lt;p&gt;A partition is not one big file. It is an ordered sequence of &lt;strong&gt;segments&lt;/strong&gt;, each a file on disk. Retention operates at the segment level, never at the record level.&lt;/p&gt;

&lt;p&gt;Kafka will never open a segment and rewrite it to drop a handful of expired records. That would be expensive and would break the append-only design the whole system is built on. Instead it waits until an entire segment is eligible, then deletes the whole file in one move.&lt;/p&gt;

&lt;h2&gt;
  
  
  When a segment is actually eligible
&lt;/h2&gt;

&lt;p&gt;A closed segment becomes eligible for deletion only when &lt;strong&gt;both&lt;/strong&gt; are true:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The segment is &lt;strong&gt;closed&lt;/strong&gt; (rolled). A segment rolls when it fills up (&lt;code&gt;segment.bytes&lt;/code&gt;, default 1 GB) or ages out (&lt;code&gt;segment.ms&lt;/code&gt;, default 7 days).&lt;/li&gt;
&lt;li&gt;Its &lt;strong&gt;newest&lt;/strong&gt; record is older than &lt;code&gt;retention.ms&lt;/code&gt; (or the partition is over &lt;code&gt;retention.bytes&lt;/code&gt;).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Note it is the &lt;em&gt;newest&lt;/em&gt; record in the segment that has to age out, not the oldest. One young record keeps the whole segment, and every older record in it, alive.&lt;/p&gt;

&lt;h2&gt;
  
  
  The active segment never dies
&lt;/h2&gt;

&lt;p&gt;Here is the part that surprises people, and the reason your data outlives its retention.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;active segment&lt;/strong&gt;, the one currently being written, is never eligible for deletion, no matter what. Retention only ever considers closed segments.&lt;/p&gt;

&lt;p&gt;So on a low-traffic topic, the active segment fills slowly and rolls rarely. The oldest record sitting in it can be hours or days past &lt;code&gt;retention.ms&lt;/code&gt;, just waiting for the segment to finally roll so it can even be &lt;em&gt;considered&lt;/em&gt; for deletion.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[ segment 0 ][ segment 1 ][ segment 2 ][ segment 3 (active) ]
   closed       closed       closed        being written
   deletable    deletable    deletable     NEVER deletable
   once past retention.ms                  regardless of age
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your retention is effectively &lt;code&gt;retention.ms&lt;/code&gt; &lt;strong&gt;plus&lt;/strong&gt; however long it takes the active segment to roll.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest trade-off
&lt;/h2&gt;

&lt;p&gt;The fix is not to lower &lt;code&gt;retention.ms&lt;/code&gt; further. That does nothing while the segment has not rolled. Lower &lt;strong&gt;&lt;code&gt;segment.ms&lt;/code&gt;&lt;/strong&gt; so segments roll more often and become deletable sooner.&lt;/p&gt;

&lt;p&gt;But smaller segments are not free: more files, more open file handles, more frequent rolls, and more index overhead. On a high-throughput topic the default 1 GB / 7 day segments are fine and you will never notice this. It only bites on &lt;strong&gt;low-traffic topics with a tight retention expectation&lt;/strong&gt;: compliance windows, PII deletion SLAs, "we only keep 24 hours" promises. Those are exactly the cases where the gap matters, so size the segment to the retention you actually need.&lt;/p&gt;

&lt;p&gt;This is separate from log compaction (&lt;code&gt;cleanup.policy=compact&lt;/code&gt;), which keeps the latest value per key instead of deleting by age. Same segment mechanics underneath, different eligibility rule.&lt;/p&gt;

&lt;p&gt;Have you ever had data outlive its configured retention on a quiet topic, and traced it back to a segment that just never rolled?&lt;/p&gt;

</description>
      <category>kafka</category>
      <category>dataengineering</category>
      <category>backend</category>
      <category>distributedsystems</category>
    </item>
    <item>
      <title>🔀 Your Kafka Group Freezes on Every Deploy</title>
      <dc:creator>Kyryl</dc:creator>
      <pubDate>Thu, 16 Jul 2026 19:09:08 +0000</pubDate>
      <link>https://dev.to/code_with_kyryl/your-kafka-group-freezes-on-every-deploy-45jd</link>
      <guid>https://dev.to/code_with_kyryl/your-kafka-group-freezes-on-every-deploy-45jd</guid>
      <description>&lt;p&gt;You have a Kafka consumer group with four consumers, humming along. Traffic climbs, so you add a fifth. For a few seconds, every consumer stops. Lag spikes. Then it recovers and nobody thinks about it again.&lt;/p&gt;

&lt;p&gt;That pause is not a bug. It is eager rebalancing, and most groups still run it by default.&lt;/p&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%2F3pt71ts944vz719um45z.gif" 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%2F3pt71ts944vz719um45z.gif" alt="Kafka rebalance strategies: what happens to partition assignment when a fifth consumer joins" width="600" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Rebalancing is not the enemy
&lt;/h2&gt;

&lt;p&gt;A consumer group rebalances whenever membership changes: a consumer joins, leaves, crashes, or the topic gains partitions. The group has to agree on who owns which partitions. That part is unavoidable.&lt;/p&gt;

&lt;p&gt;The cost is not the reassignment. It is &lt;em&gt;how&lt;/em&gt; the group gets there. That is controlled by &lt;code&gt;partition.assignment.strategy&lt;/code&gt;, and the choice you make there decides whether a deploy costs you a few milliseconds or a few seconds of frozen consumption.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four assignors
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;RangeAssignor&lt;/strong&gt; (the historical default) assigns each consumer a contiguous range of partitions, per topic.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;6 partitions, 3 consumers:
A -&amp;gt; P0 P1   B -&amp;gt; P2 P3   C -&amp;gt; P4 P5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Clean when the numbers divide. The moment they do not, the lower-id consumers get the extra partitions, and if you subscribe to several topics they pile up on the same consumers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;RoundRobinAssignor&lt;/strong&gt; deals every partition out one by one across all consumers.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;6 partitions, 4 consumers:
A -&amp;gt; P0 P4   B -&amp;gt; P1 P5   C -&amp;gt; P2   D -&amp;gt; P3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Even spread. But it has no memory. Every rebalance recomputes the whole assignment from scratch, so partitions jump around even when they did not need to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;StickyAssignor&lt;/strong&gt; keeps the spread even &lt;em&gt;and&lt;/em&gt; tries to preserve the previous assignment, so it moves as few partitions as possible on each rebalance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CooperativeStickyAssignor&lt;/strong&gt; does the same assignment as sticky, but over a different rebalance protocol. This is the one that matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Eager vs cooperative: the real difference
&lt;/h2&gt;

&lt;p&gt;Here is the part that trips people up. Sticky reduces how many partitions &lt;em&gt;move&lt;/em&gt;. It does not change what happens to the ones that &lt;em&gt;stay&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Range, round-robin, and plain sticky all use the &lt;strong&gt;eager&lt;/strong&gt; protocol. On every rebalance the group does a full stop-the-world:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Every consumer revokes &lt;strong&gt;all&lt;/strong&gt; of its partitions.&lt;/li&gt;
&lt;li&gt;Nobody consumes anything.&lt;/li&gt;
&lt;li&gt;The new assignment is computed.&lt;/li&gt;
&lt;li&gt;Consumers pick their partitions back up.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So even a partition that ends up on the exact same consumer still gets revoked and paused. Add one consumer to a group of ten and all of them stop, for every partition, until the dust settles.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cooperative-sticky&lt;/strong&gt; (KIP-429, Kafka 2.4+) rebalances incrementally:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Only the partitions that actually need to move are revoked.&lt;/li&gt;
&lt;li&gt;Every other partition keeps being consumed, right through the rebalance.&lt;/li&gt;
&lt;li&gt;The moved partitions get reassigned in a second, short round.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;One consumer joins, one or two partitions pause for a moment, and the rest of the group never notices. Stop-the-world becomes stop-one-partition.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;props&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;put&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
    &lt;span class="nc"&gt;ConsumerConfig&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;PARTITION_ASSIGNMENT_STRATEGY_CONFIG&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;CooperativeStickyAssignor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getName&lt;/span&gt;&lt;span class="o"&gt;()));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  You cannot just flip the config
&lt;/h2&gt;

&lt;p&gt;This is the trap. You cannot take a running group on the eager protocol and switch it to cooperative in a single deploy. A group with some members speaking eager and some speaking cooperative will not rebalance correctly.&lt;/p&gt;

&lt;p&gt;The supported path is a two-phase rolling upgrade:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Phase 1: deploy every instance with BOTH strategies listed.&lt;/span&gt;
&lt;span class="c1"&gt;// The group stays on the old protocol until all members support the new one.&lt;/span&gt;
&lt;span class="n"&gt;props&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;put&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
    &lt;span class="nc"&gt;ConsumerConfig&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;PARTITION_ASSIGNMENT_STRATEGY_CONFIG&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;CooperativeStickyAssignor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getName&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;
            &lt;span class="nc"&gt;RangeAssignor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getName&lt;/span&gt;&lt;span class="o"&gt;()));&lt;/span&gt;

&lt;span class="c1"&gt;// Phase 2: after every instance is running phase 1, deploy again&lt;/span&gt;
&lt;span class="c1"&gt;// with only the cooperative strategy. Now the group flips protocols.&lt;/span&gt;
&lt;span class="n"&gt;props&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;put&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
    &lt;span class="nc"&gt;ConsumerConfig&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;PARTITION_ASSIGNMENT_STRATEGY_CONFIG&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;CooperativeStickyAssignor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getName&lt;/span&gt;&lt;span class="o"&gt;()));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Skip phase 1 and you get a broken rebalance in production. Two deploys, in order, with the whole fleet on phase 1 before phase 2 starts.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest trade-off
&lt;/h2&gt;

&lt;p&gt;Cooperative-sticky is not free.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The migration is a two-step rolling upgrade you have to get right, not a one-line change.&lt;/li&gt;
&lt;li&gt;Incremental rebalancing can take &lt;strong&gt;more&lt;/strong&gt; rounds than a single eager rebalance, so the total rebalance can be longer even though nobody is fully stopped.&lt;/li&gt;
&lt;li&gt;Your &lt;code&gt;onPartitionsRevoked&lt;/code&gt; and &lt;code&gt;onPartitionsLost&lt;/code&gt; callbacks need to be correct, because partitions now come and go without a global reset to lean on.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a group that rebalances rarely and briefly, the eager pause may be cheap enough to ignore. For a group that scales, deploys, or loses instances often, the stop-the-world pause is a tax you pay on every event, and cooperative-sticky removes it.&lt;/p&gt;

&lt;p&gt;Which assignor does your consumer group run today, and have you ever actually measured how long a rolling deploy freezes it?&lt;/p&gt;

</description>
      <category>kafka</category>
      <category>java</category>
      <category>distributedsystems</category>
      <category>backend</category>
    </item>
  </channel>
</rss>
