<?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>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>
    <item>
      <title>🔄 Your Database Is Already an Event Stream</title>
      <dc:creator>Kyryl</dc:creator>
      <pubDate>Thu, 09 Jul 2026 20:35:01 +0000</pubDate>
      <link>https://dev.to/code_with_kyryl/your-database-is-already-an-event-stream-3cmn</link>
      <guid>https://dev.to/code_with_kyryl/your-database-is-already-an-event-stream-3cmn</guid>
      <description>&lt;p&gt;You have a service that owns the &lt;code&gt;orders&lt;/code&gt; table. Three other teams need to know when an order changes. So you do the obvious thing: after every write, you publish an event to Kafka.&lt;/p&gt;

&lt;p&gt;Then someone adds a new write path and forgets the publish. Now the order exists in the database but no event ever fired. The downstream read models drift. You find out three weeks later when finance asks why the numbers do not match.&lt;/p&gt;

&lt;p&gt;This is the dual-write problem, and you cannot discipline your way out of it. Every write path is a place someone forgets to emit the event.&lt;/p&gt;

&lt;p&gt;There is a cleaner option. Stop publishing events by hand. Let the database do 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%2Fvu23hks9q2q23gl59dcs.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%2Fvu23hks9q2q23gl59dcs.gif" alt="CDC pipeline: Postgres WAL to Debezium to Kafka to Spring Boot consumers, every row change becomes an event" width="720" height="720"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The database already knows what changed
&lt;/h2&gt;

&lt;p&gt;Change Data Capture (CDC) turns your database into an event source. Debezium reads the write-ahead log, the same log Postgres already uses for replication and crash recovery, and turns every committed row change into a Kafka event.&lt;/p&gt;

&lt;p&gt;Point a connector at a table. Every insert, update, and delete becomes a message on a topic. Downstream services build their own read models from that stream and never query your tables directly.&lt;/p&gt;

&lt;p&gt;The write already happened. The WAL already recorded it. There is nothing to remember and nothing to enforce in application code, because the event is a byproduct of the commit, not a second action you have to bolt onto every write path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring it up
&lt;/h2&gt;

&lt;p&gt;A Debezium Postgres connector is a bit of connector config, not application code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"orders-connector"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"config"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"connector.class"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"io.debezium.connector.postgresql.PostgresConnector"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"database.hostname"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"postgres"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"database.dbname"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"shop"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"plugin.name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pgoutput"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"table.include.list"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"public.orders"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"topic.prefix"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"shop"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Changes to &lt;code&gt;public.orders&lt;/code&gt; now land on the &lt;code&gt;shop.public.orders&lt;/code&gt; topic. Each message carries a &lt;code&gt;before&lt;/code&gt; and &lt;code&gt;after&lt;/code&gt; image of the row plus an &lt;code&gt;op&lt;/code&gt; field: &lt;code&gt;c&lt;/code&gt; for create, &lt;code&gt;u&lt;/code&gt; for update, &lt;code&gt;d&lt;/code&gt; for delete. A consumer decides what to do with each:&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;@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;"shop.public.orders"&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;onChange&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ChangeEvent&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="k"&gt;switch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;op&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"c"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"u"&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;readModel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;upsert&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;after&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"d"&lt;/span&gt;      &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;readModel&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;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;before&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;id&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;No coordination with the owning service. No shared library. The consumer subscribes and builds exactly the projection it needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The outbox pattern is CDC with intent
&lt;/h2&gt;

&lt;p&gt;Tailing the whole WAL means downstream consumers see your table exactly as it is. Sometimes that is fine. Sometimes you want to publish a deliberate event, not a raw row.&lt;/p&gt;

&lt;p&gt;The outbox pattern is the same mechanism applied on purpose. Inside the same transaction as your business write, you insert a row into an &lt;code&gt;outbox&lt;/code&gt; table shaped like the event you want the world to see:&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;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'SHIPPED'&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;outbox&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;aggregate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'order'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'OrderShipped'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'{"orderId": 42}'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Debezium tails the &lt;code&gt;outbox&lt;/code&gt; table instead of &lt;code&gt;orders&lt;/code&gt;. The event is atomic with the business change because they share one transaction, and its shape is something you designed rather than whatever columns the table happens to have today.&lt;/p&gt;

&lt;p&gt;CDC is the mechanism either way. The outbox just decides what becomes an event and what stays internal.&lt;/p&gt;

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

&lt;p&gt;Here is the part nobody puts on the slide.&lt;/p&gt;

&lt;p&gt;Raw-table CDC couples your event stream to the table's physical shape. The moment a downstream consumer maps &lt;code&gt;shop.public.orders&lt;/code&gt;, your column names are a public contract, whether you meant them to be or not.&lt;/p&gt;

&lt;p&gt;That means column renames and other non-backwards-compatible schema changes are off the table for you. Rename &lt;code&gt;status&lt;/code&gt; to &lt;code&gt;order_status&lt;/code&gt; during an internal refactor and every downstream mapper breaks. Silently. There is no compile error, no failed deploy on your side. The consumer just starts reading &lt;code&gt;null&lt;/code&gt; because nobody told it the table changed.&lt;/p&gt;

&lt;p&gt;Tailing the raw WAL is convenient right up until the day your schema is no longer just yours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shape the output on purpose
&lt;/h2&gt;

&lt;p&gt;The fix is not avoiding CDC. It is refusing to treat the current table shape as an accident that leaks downstream.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Single Message Transforms (SMTs):&lt;/strong&gt; rename, drop, or restructure fields in the connector pipeline, so an internal column rename does not change the published event.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A CDC-facing view or outbox table:&lt;/strong&gt; publish from a surface you designed, and let the physical table underneath change freely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A schema registry:&lt;/strong&gt; enforce compatibility rules on the topic, so a breaking change fails loudly at publish time instead of silently downstream.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All three do the same thing: they put a deliberate boundary between your storage and your stream. The database can still emit every change for free. You just decide what that change looks like on the wire.&lt;/p&gt;

&lt;p&gt;CDC is not "replication with extra steps." It is a coupling decision. Either downstream services couple to your database's physical shape, or they couple to a contract you shaped on purpose. Pick the second one.&lt;/p&gt;

&lt;p&gt;Do you use Debezium to emit events? Do you tail the raw WAL or shape it through an outbox first, and has an internal refactor ever quietly broken a downstream consumer on you?&lt;/p&gt;

</description>
      <category>kafka</category>
      <category>debezium</category>
      <category>postgres</category>
      <category>eventdriven</category>
    </item>
    <item>
      <title>🔁 One Health Check Turned a 10-Minute Outage Into 40</title>
      <dc:creator>Kyryl</dc:creator>
      <pubDate>Mon, 06 Jul 2026 18:05:44 +0000</pubDate>
      <link>https://dev.to/code_with_kyryl/one-health-check-turned-a-10-minute-outage-into-40-5a9</link>
      <guid>https://dev.to/code_with_kyryl/one-health-check-turned-a-10-minute-outage-into-40-5a9</guid>
      <description>&lt;p&gt;The database went down for ten minutes. The restart storm it triggered lasted forty.&lt;/p&gt;

&lt;p&gt;Every pod's &lt;code&gt;/actuator/health&lt;/code&gt; turned red the moment the database became unreachable. Kubernetes read that as "the process is broken" and started killing pods, one after another, across the entire fleet. New pods booted, connected to nothing, failed the same check, and got killed again. The dependency was down for ten minutes. The self-inflicted damage ran four times longer.&lt;/p&gt;

&lt;p&gt;The trigger was one line of config that looked like a simplification.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup that looks clean
&lt;/h2&gt;

&lt;p&gt;Someone wired both the liveness and the readiness probe to the same endpoint.&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;livenessProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;httpGet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/actuator/health&lt;/span&gt;
    &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;
&lt;span class="na"&gt;readinessProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;httpGet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/actuator/health&lt;/span&gt;
    &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One endpoint, one thing to configure, one thing to reason about. It reads as clean. It is actually a category error, because those two probes are asking completely different questions and Spring's default &lt;code&gt;/actuator/health&lt;/code&gt; answers neither of them cleanly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two probes, two questions
&lt;/h2&gt;

&lt;p&gt;The distinction is the whole point, so it is worth stating flatly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Liveness&lt;/strong&gt; answers: should I kill this process and start a new one? The only honest reason to say yes is that the process is wedged in a way a restart will fix. A deadlock, an unrecoverable internal state, a JVM that is thrashing. Restarting helps only when the problem lives inside the process.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Readiness&lt;/strong&gt; answers: should I send this pod traffic right now? A pod can be perfectly alive and still be unable to serve, because something it depends on is unavailable. The fix there is not a restart. It is to stop routing requests until the dependency comes back.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;/actuator/health&lt;/code&gt; by default aggregates everything, including the &lt;code&gt;db&lt;/code&gt; health indicator, into one status. Point liveness at it and you have told Kubernetes to kill the process whenever the database is down. But a down database is not a process problem, and no number of restarts will bring it back.&lt;/p&gt;

&lt;h2&gt;
  
  
  The restart storm, step by step
&lt;/h2&gt;

&lt;p&gt;Here is the loop that ate thirty extra minutes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The database becomes unreachable.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;db&lt;/code&gt; health indicator goes &lt;code&gt;DOWN&lt;/code&gt;, so aggregated &lt;code&gt;/actuator/health&lt;/code&gt; returns &lt;code&gt;DOWN&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The liveness probe fails. Kubernetes kills the pod.&lt;/li&gt;
&lt;li&gt;A fresh pod boots, starts up, and immediately checks its health.&lt;/li&gt;
&lt;li&gt;The database is still down, so the new pod's health is &lt;code&gt;DOWN&lt;/code&gt; too.&lt;/li&gt;
&lt;li&gt;Liveness fails again. Kubernetes kills it again.&lt;/li&gt;
&lt;li&gt;Repeat, on every pod, for as long as the dependency stays down.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Now the failure is worse than the original outage. The pods are gone, so even reads that could have been served from cache are gone. Startup load hammers the recovering database the moment it comes back. And your dashboards are a wall of &lt;code&gt;CrashLoopBackOff&lt;/code&gt; that makes it look like the application itself is broken, which sends everyone debugging the wrong thing.&lt;/p&gt;

&lt;p&gt;What should have happened is boring by comparison. Readiness fails, Kubernetes pulls the pods out of the Service endpoints, traffic stops, the processes keep running. When the database returns, readiness goes green and traffic resumes. No restarts, no cold caches, no thundering herd.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix Actuator already ships
&lt;/h2&gt;

&lt;p&gt;You do not need a custom endpoint or a sidecar. Spring Boot Actuator has health groups built for exactly this split.&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;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="na"&gt;probes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
      &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;liveness&lt;/span&gt;&lt;span class="pi"&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;livenessState&lt;/span&gt;
        &lt;span class="na"&gt;readiness&lt;/span&gt;&lt;span class="pi"&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,db&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gives you two dedicated endpoints:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;/actuator/health/liveness&lt;/code&gt; includes only &lt;code&gt;livenessState&lt;/code&gt;, the process-internal signal. No database, no downstream calls. It answers "is this process wedged" and nothing else.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/actuator/health/readiness&lt;/code&gt; includes &lt;code&gt;readinessState&lt;/code&gt; plus the real dependency checks, &lt;code&gt;db&lt;/code&gt; here. It answers "can this pod serve traffic right now."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Point the probes at the right endpoints:&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;livenessProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;httpGet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/actuator/health/liveness&lt;/span&gt;
    &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;
&lt;span class="na"&gt;readinessProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;httpGet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/actuator/health/readiness&lt;/span&gt;
    &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now a database outage trips readiness only. Traffic drains, the pods live, and recovery is automatic. Liveness stays green because the process is, in fact, fine.&lt;/p&gt;

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

&lt;p&gt;This is not a set-and-forget win, and pretending it is misses the way the bug comes back.&lt;/p&gt;

&lt;p&gt;The config takes five minutes and then nobody looks at it again. That is the problem. Over the next year the service grows: a Redis cache, a Kafka producer, a call to some downstream API. Each of those registers a health indicator, and each is a decision about which group it belongs in. If a new dependency lands in the readiness group, great. If it silently ends up aggregated into liveness, or if someone adds a custom indicator without thinking about probes at all, you have quietly rebuilt the original bug with a new trigger.&lt;/p&gt;

&lt;p&gt;The drift is invisible until the next outage. Nobody audits "which health indicators feed which probe" as part of adding a dependency, so the wrong wiring sits there dormant for months. The next time that specific dependency fails, the restart storm returns, and it looks brand new even though it is the same mistake.&lt;/p&gt;

&lt;p&gt;The mitigation is process, not code: when you add a dependency with a health indicator, decide its group in the same PR. Treat "which probe does this affect" as part of the definition of done for any new external call.&lt;/p&gt;




&lt;p&gt;Has your liveness probe ever quietly grown a dependency check nobody noticed, or has your fleet actually lived through a restart storm like this? Curious how other teams keep the liveness group honest as services accumulate dependencies.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>springboot</category>
      <category>observability</category>
      <category>java</category>
    </item>
    <item>
      <title>🔒 CREATE INDEX Is a Write Outage in Disguise</title>
      <dc:creator>Kyryl</dc:creator>
      <pubDate>Mon, 06 Jul 2026 17:55:29 +0000</pubDate>
      <link>https://dev.to/code_with_kyryl/create-index-is-a-write-outage-in-disguise-4abo</link>
      <guid>https://dev.to/code_with_kyryl/create-index-is-a-write-outage-in-disguise-4abo</guid>
      <description>&lt;p&gt;CREATE INDEX without CONCURRENTLY locks out writes for the entire build.&lt;/p&gt;

&lt;p&gt;On a large, live table that plain statement is not a migration. It is a multi-minute write outage for every endpoint touching that table. And the worst part is it sails through every test you have, because your test data is tiny and the build finishes before you can blink.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lock nobody reads about
&lt;/h2&gt;

&lt;p&gt;A plain &lt;code&gt;CREATE INDEX&lt;/code&gt; acquires a &lt;code&gt;SHARE&lt;/code&gt; lock on the table and holds it until the build completes.&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_orders_customer_id&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer_id&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;SHARE&lt;/code&gt; allows concurrent reads. It blocks anything that writes: &lt;code&gt;INSERT&lt;/code&gt;, &lt;code&gt;UPDATE&lt;/code&gt;, &lt;code&gt;DELETE&lt;/code&gt;. Every write against that table queues up behind the index build and waits. Not for a moment at the start. For the entire duration.&lt;/p&gt;

&lt;p&gt;On a small table that duration is milliseconds, so nothing queues and nobody notices. That is exactly why this bug survives code review and staging. The table you tested against had ten thousand rows. The table in production has two hundred million, and building the index on it takes four minutes. For those four minutes, every write to &lt;code&gt;orders&lt;/code&gt; is stalled.&lt;/p&gt;

&lt;p&gt;The application does not error. It hangs. Requests pile up, connection pools drain, timeouts cascade to services that never touched the database directly. From the outside it looks like a full outage, and the migration that caused it already "succeeded" in your CI.&lt;/p&gt;

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

&lt;p&gt;&lt;code&gt;CREATE INDEX CONCURRENTLY&lt;/code&gt; was built for exactly this.&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;CONCURRENTLY&lt;/span&gt; &lt;span class="n"&gt;idx_orders_customer_id&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It does not take the blocking &lt;code&gt;SHARE&lt;/code&gt; lock. It builds the index in the background, scanning the table while writes keep flowing the whole time. Reads and writes both continue. This is the default you want for any table that is already taking production traffic.&lt;/p&gt;

&lt;h2&gt;
  
  
  It is not free
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;CONCURRENTLY&lt;/code&gt; trades a fast, clean build for a slower, riskier one. Three costs, all real:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is slower.&lt;/strong&gt; To build without blocking writes, Postgres does two full passes over the table instead of one. One pass to build, a second to catch rows that changed during the first. On a huge table that roughly doubles the build time. You are trading wall-clock time for availability, which is almost always the right trade, but it is a trade.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It cannot run inside a transaction block.&lt;/strong&gt; This is the one that breaks migration tooling. Most migration frameworks wrap each migration in a transaction by default, and &lt;code&gt;CREATE INDEX CONCURRENTLY&lt;/code&gt; will error out if it finds itself inside one. You cannot bundle it with other DDL in the same atomic step. It has to run on its own, outside a transaction, which means you lose the "all or nothing" guarantee for that migration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It can fail into an INVALID index.&lt;/strong&gt; This is the sharp edge. If a &lt;code&gt;CONCURRENTLY&lt;/code&gt; build fails partway, a deadlock, a cancelled session, a statement timeout, Postgres leaves a half-built index behind and marks it &lt;code&gt;INVALID&lt;/code&gt;. It does not roll back. It does not clean up. That index is not used by the planner, but it is still updated on every write and still consumes disk. It just sits there, dead weight, until a human notices.&lt;/p&gt;

&lt;p&gt;You find them by asking the catalog directly:&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;SELECT&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relname&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;index_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relname&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;table_name&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_index&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;pg_class&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;indexrelid&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;pg_class&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;indrelid&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;indisvalid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anything that comes back has to be dropped and rebuilt:&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;DROP&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;CONCURRENTLY&lt;/span&gt; &lt;span class="n"&gt;idx_orders_customer_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- then re-run the CREATE INDEX CONCURRENTLY&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you run migrations through automation, a failed &lt;code&gt;CONCURRENTLY&lt;/code&gt; will not retry cleanly either, because the invalid index now occupies the name you are trying to create. Your retry fails with "relation already exists" until someone drops the corpse.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one real exception
&lt;/h2&gt;

&lt;p&gt;"Always use CONCURRENTLY in production" is a good rule with exactly one honest exception: a brand new, empty table you just created in the same migration.&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;audit_log&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;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_audit_log_created_at&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;audit_log&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;-- fine&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing is writing to &lt;code&gt;audit_log&lt;/code&gt; yet. There are no concurrent writes to block, so the &lt;code&gt;SHARE&lt;/code&gt; lock costs nothing, and the plain build is faster and runs cleanly inside the same transaction as the &lt;code&gt;CREATE TABLE&lt;/code&gt;. Reaching for &lt;code&gt;CONCURRENTLY&lt;/code&gt; here would be cargo-culting the rule past the point where it applies.&lt;/p&gt;

&lt;p&gt;The tell is simple: if the table already has live traffic, use &lt;code&gt;CONCURRENTLY&lt;/code&gt;. If you are indexing something that does not exist yet, do not bother.&lt;/p&gt;

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

&lt;p&gt;&lt;code&gt;CONCURRENTLY&lt;/code&gt; is not a free upgrade you flip on and forget. You trade a guaranteed-fast, guaranteed-clean, transaction-safe build for a slower one that runs outside your transaction and can fail into a mess you have to notice and clean up yourself.&lt;/p&gt;

&lt;p&gt;For any table with real traffic, that trade is worth it every time. A slower build you have to babysit beats a multi-minute outage you cannot. But "worth it" is not "free," and pretending the invalid-index failure mode does not exist is how teams get surprised at 2am.&lt;/p&gt;




&lt;p&gt;Has anyone actually gotten burned by an invalid index left behind after a failed &lt;code&gt;CONCURRENTLY&lt;/code&gt; build? How did you find it, and do you check &lt;code&gt;pg_index&lt;/code&gt; as part of your migration process or only after something breaks?&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>sql</category>
      <category>migrations</category>
    </item>
    <item>
      <title>📈 One Path Variable Can Bankrupt Your Prometheus</title>
      <dc:creator>Kyryl</dc:creator>
      <pubDate>Mon, 06 Jul 2026 17:30:00 +0000</pubDate>
      <link>https://dev.to/code_with_kyryl/one-path-variable-can-bankrupt-your-prometheus-5877</link>
      <guid>https://dev.to/code_with_kyryl/one-path-variable-can-bankrupt-your-prometheus-5877</guid>
      <description>&lt;p&gt;A path variable in your metrics tag will quietly bankrupt your Prometheus backend.&lt;/p&gt;

&lt;p&gt;It passes code review. It compiles. It works, for a while. Then one day the dashboards start timing out, and it takes the team days to trace the slowdown back to a single line of instrumentation that looked completely normal.&lt;/p&gt;

&lt;h2&gt;
  
  
  The line that looks fine
&lt;/h2&gt;

&lt;p&gt;Here is the kind of code that ships this problem. A &lt;code&gt;Timer&lt;/code&gt; around an order lookup, tagged with the &lt;code&gt;orderId&lt;/code&gt; so you can slice latency per request.&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/orders/{orderId}"&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;Order&lt;/span&gt; &lt;span class="nf"&gt;getOrder&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@PathVariable&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;orderId&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;Timer&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;"order.lookup"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;tag&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"orderId"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// &amp;lt;-- the problem&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="na"&gt;record&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;orderService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findById&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;orderId&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;Nothing about this fails a review. It reads as "measure how long an order lookup takes, and let me break it down by order." Reasonable intent. The metric even works when you test it locally with three orders.&lt;/p&gt;

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

&lt;p&gt;Prometheus is a time series database. The identity of a time series is its metric name plus the full set of label key-value pairs. Change one label value and you do not add a point to an existing series. You create a brand new series.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;order.lookup{orderId="1001"}&lt;/code&gt; and &lt;code&gt;order.lookup{orderId="1002"}&lt;/code&gt; are two completely separate series, each with its own storage, its own index entry, its own memory footprint.&lt;/p&gt;

&lt;p&gt;Now run that in production. Every distinct &lt;code&gt;orderId&lt;/code&gt; that flows through the endpoint mints a new series. A million orders means a million series from this one metric. Add a &lt;code&gt;userId&lt;/code&gt; tag somewhere else and the counts multiply. This is cardinality explosion, and &lt;code&gt;/actuator/prometheus&lt;/code&gt; will happily expose all of it.&lt;/p&gt;

&lt;p&gt;The failure is gradual, which is what makes it nasty:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Storage grows far faster than your request volume would suggest.&lt;/li&gt;
&lt;li&gt;The Prometheus head block balloons, memory pressure climbs, scrape durations creep up.&lt;/li&gt;
&lt;li&gt;Queries that touch the metric get slow, then time out.&lt;/li&gt;
&lt;li&gt;Someone files "Prometheus is slow" or "the orders dashboard times out."&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Notice what is missing from that chain: nobody says "the &lt;code&gt;orderId&lt;/code&gt; tag is the problem." The symptom is three steps removed from the cause. I have watched a team spend the better part of a week bisecting scrape configs and bumping memory limits before someone finally ran a cardinality check and found one metric responsible for millions of series.&lt;/p&gt;

&lt;p&gt;You can catch it directly once you suspect it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# top metrics by series count
topk(10, count by (__name__)({__name__=~".+"}))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But you have to suspect it first, and the whole point is that nothing pointed you there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is not a new tool
&lt;/h2&gt;

&lt;p&gt;The instinct is to reach for a sampling library or a relabeling rule to drop the bad label. You do not need either. Spring already hands you the right value.&lt;/p&gt;

&lt;p&gt;For every request, Spring resolves the matched route pattern, the same mechanism its built-in HTTP server metrics (&lt;code&gt;http.server.requests&lt;/code&gt;) use to keep their &lt;code&gt;uri&lt;/code&gt; tag bounded. The best-matching pattern is available on the request as an attribute.&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/orders/{orderId}"&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;Order&lt;/span&gt; &lt;span class="nf"&gt;getOrder&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@PathVariable&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;HttpServletRequest&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;)&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;pattern&lt;/span&gt; &lt;span class="o"&gt;=&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;)&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getAttribute&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="nc"&gt;HandlerMapping&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;BEST_MATCHING_PATTERN_ATTRIBUTE&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// "/api/orders/{orderId}"&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Timer&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;"order.lookup"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;tag&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"route"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pattern&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;     &lt;span class="c1"&gt;// bounded to the number of routes&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="na"&gt;record&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;orderService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findById&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;orderId&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;Now the tag value is &lt;code&gt;/api/orders/{orderId}&lt;/code&gt;, the template, not the resolved id. Every request through this endpoint lands on the same series. Cardinality for this metric is bounded to the number of routes you have, which is a small, fixed number that does not grow with traffic.&lt;/p&gt;

&lt;p&gt;If all you wanted was per-endpoint latency, you may not need a custom timer at all. &lt;code&gt;http.server.requests&lt;/code&gt; already gives you templated URI, method, and status out of the box. Reach for a custom metric only when you need a dimension the built-in one does not expose, and when you do, tag it with something bounded.&lt;/p&gt;

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

&lt;p&gt;Templating the URI collapses per-entity granularity in the metric itself. That is a real loss, not a footnote.&lt;/p&gt;

&lt;p&gt;Once the tag is &lt;code&gt;/api/orders/{orderId}&lt;/code&gt;, the metric can no longer answer "how slow was the lookup for order 1002 specifically." It can only tell you about the route as a whole: p50, p99, error rate across all orders.&lt;/p&gt;

&lt;p&gt;If you genuinely need to investigate one entity, that is now a logs or traces question, not a metrics question. Attach the &lt;code&gt;orderId&lt;/code&gt; to a span or a structured log field, where high cardinality is expected and the backend is built for it. Metrics are for bounded, aggregatable dimensions. Traces and logs are for the long tail of individual cases.&lt;/p&gt;

&lt;p&gt;That split is the right one. Metrics answer "is the system healthy and how is this route trending." Traces answer "what happened to this one request." Putting a unique id in a metric tag is asking metrics to do the job of tracing, and the bill for that mistake is paid by your storage backend, quietly, until it is not quiet anymore.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule
&lt;/h2&gt;

&lt;p&gt;Any tag value that is not drawn from a small, known set does not belong on a metric. User ids, order ids, request ids, email addresses, raw paths: all of them are cardinality bombs. Route patterns, status codes, HTTP methods, enum-like states: all fine.&lt;/p&gt;

&lt;p&gt;The tell is simple. Before you add a tag, ask how many distinct values it can take over the life of the service. If the answer scales with your traffic, you have found the bug before it finds you.&lt;/p&gt;




&lt;p&gt;Has your Prometheus setup ever fallen over from a label nobody flagged, and how long did it take to trace it back to the metric? Curious how other teams caught it, and what guardrails you put in place afterward.&lt;/p&gt;

</description>
      <category>springboot</category>
      <category>java</category>
      <category>observability</category>
      <category>prometheus</category>
    </item>
  </channel>
</rss>
