<?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: Anand Rathnas</title>
    <description>The latest articles on DEV Community by Anand Rathnas (@anand_rathnas_d5b608cc3de).</description>
    <link>https://dev.to/anand_rathnas_d5b608cc3de</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%2F3671625%2F8642714b-af2d-4fc1-9097-c08fc07fdab5.png</url>
      <title>DEV Community: Anand Rathnas</title>
      <link>https://dev.to/anand_rathnas_d5b608cc3de</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/anand_rathnas_d5b608cc3de"/>
    <language>en</language>
    <item>
      <title>Grafana 11 Provisions Alerts Before Dashboards — and That Breaks folderUid</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Fri, 04 Sep 2026 05:49:41 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/grafana-11-provisions-alerts-before-dashboards-and-that-breaks-folderuid-2bln</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/grafana-11-provisions-alerts-before-dashboards-and-that-breaks-folderuid-2bln</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/grafana-provisioning-folder-uid-collision/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;We rolled a tidy little change into our Grafana provisioning: pin the dashboard folder's UID so deep links don't break across redeploys. Restarted the container. Watched it exit 1 with this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;failed to create folder for provisioned dashboards" folder=jo4 ... err="a folder with the same name already exists in the current location"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That error makes no sense. We hadn't created any folder called &lt;code&gt;jo4&lt;/code&gt; anywhere else. The provisioning files in &lt;code&gt;/etc/grafana/provisioning/dashboards/&lt;/code&gt; are the only place the name appears. So &lt;em&gt;who&lt;/em&gt; created the first one?&lt;/p&gt;

&lt;p&gt;The answer turned out to be: Grafana did. Before our dashboard provider ran. And it's not what the docs imply.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We Expected
&lt;/h2&gt;

&lt;p&gt;The Grafana provisioning docs are organized in roughly the order we'd expect a fresh install to come up:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Datasources (so dashboards have something to query)&lt;/li&gt;
&lt;li&gt;Dashboards (the things humans look at)&lt;/li&gt;
&lt;li&gt;Alerting (rules that fire against dashboards/queries)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Our &lt;code&gt;providers.yaml&lt;/code&gt; set the folder name &lt;em&gt;and&lt;/em&gt; the folder UID, on the reasonable theory that pinning the UID protects external deep links from rotating after a rebuild:&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;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
&lt;span class="na"&gt;providers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jo4&lt;/span&gt;
    &lt;span class="na"&gt;folder&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jo4&lt;/span&gt;
    &lt;span class="na"&gt;folderUid&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jo4-folder&lt;/span&gt;           &lt;span class="c1"&gt;# &amp;lt;-- our innocent addition&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;file&lt;/span&gt;
    &lt;span class="na"&gt;options&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;/etc/grafana/provisioning/dashboards/jo4&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If dashboards are provisioned before alerting, this is fine. The dashboard provider creates a folder named &lt;code&gt;jo4&lt;/code&gt; with UID &lt;code&gt;jo4-folder&lt;/code&gt;. Alerting provisioning runs later, references &lt;code&gt;folder: jo4&lt;/code&gt; by name, matches the existing one. Everyone goes home.&lt;/p&gt;

&lt;p&gt;That is not what Grafana 11.2.0 actually does.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Actually Happens
&lt;/h2&gt;

&lt;p&gt;We spun up a sandbox container, dropped in the exact same provisioning tree, and tailed the logs. Here's the relevant window, milliseconds and all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;06:26:57.106  msg="Initialising datasources"
06:26:57.204  msg="starting to provision alerting"
06:26:57.322  msg="finished to provision alerting"
06:26:57.344  msg="starting to provision dashboards"
06:26:57.346  level=error msg="failed to create folder for provisioned dashboards"
              folder=jo4 ... err="a folder with the same name already exists in the current location"
06:26:57.346  msg="Stopped background service" service=*provisioning.ProvisioningServiceImpl
              reason="failed to provision dashboards"
06:26:57.346  level=fatal msg="Server shutdown" reason="..."
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things to read off this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Alerting provisions before dashboards.&lt;/strong&gt; It's not even close — alerting starts 140ms before dashboards and finishes 22ms before dashboards even start. This is the opposite of how the docs are organized.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The alerting provisioner created the folder.&lt;/strong&gt; Two milliseconds into dashboard provisioning, the error is already firing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The whole provisioning service stops.&lt;/strong&gt; Not "skip this dashboard provider and continue." It stops everything. The Grafana process then exits 1 because provisioning is a required background service. The container restart-loops.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Why
&lt;/h2&gt;

&lt;p&gt;Once you accept the ordering, the rest falls out cleanly.&lt;/p&gt;

&lt;p&gt;The alerting provisioner takes a &lt;code&gt;folder&lt;/code&gt; field — a &lt;strong&gt;name&lt;/strong&gt;, not a UID. There is no &lt;code&gt;folderUid&lt;/code&gt; field accepted in alerting rule provisioning files. When alerting runs first and sees &lt;code&gt;folder: jo4&lt;/code&gt;, it needs that folder to exist. It doesn't. So Grafana creates it, with a UID it generates on the spot (something like &lt;code&gt;ffn7ba7vmp3i8a&lt;/code&gt; — looks random, is deterministic for this install).&lt;/p&gt;

&lt;p&gt;Now the dashboard provisioner runs. Our &lt;code&gt;providers.yaml&lt;/code&gt; says: "make sure there's a folder named &lt;code&gt;jo4&lt;/code&gt; with UID &lt;code&gt;jo4-folder&lt;/code&gt;." Grafana does the name lookup first, finds an existing folder named &lt;code&gt;jo4&lt;/code&gt; — but its UID is &lt;code&gt;ffn7ba7vmp3i8a&lt;/code&gt;, not &lt;code&gt;jo4-folder&lt;/code&gt;. The provisioner's reconciliation logic decides this is a different folder and tries to &lt;strong&gt;create&lt;/strong&gt; one with the pinned UID. The unique-name constraint on folders fires. Provisioning service stops. Container exits 1.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;folderUid&lt;/code&gt; in the dashboard provider is fundamentally incompatible with also having alerting rules in the same folder — at least under Grafana 11.2.0's current ordering — because alerting &lt;em&gt;will&lt;/em&gt; have created the folder first, with its own auto-generated UID, and your pinned UID can never win the race.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix
&lt;/h2&gt;

&lt;p&gt;One removed line. The dashboard provider keeps &lt;code&gt;folder: jo4&lt;/code&gt; (the human-readable name) and drops &lt;code&gt;folderUid&lt;/code&gt; entirely:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before:&lt;/strong&gt;&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;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
&lt;span class="na"&gt;providers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jo4&lt;/span&gt;
    &lt;span class="na"&gt;folder&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jo4&lt;/span&gt;
    &lt;span class="na"&gt;folderUid&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jo4-folder&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;file&lt;/span&gt;
    &lt;span class="na"&gt;disableDeletion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="na"&gt;updateIntervalSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt;
    &lt;span class="na"&gt;allowUiUpdates&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;options&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;/etc/grafana/provisioning/dashboards/jo4&lt;/span&gt;
      &lt;span class="na"&gt;foldersFromFilesStructure&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;After:&lt;/strong&gt;&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;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
&lt;span class="na"&gt;providers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jo4&lt;/span&gt;
    &lt;span class="na"&gt;folder&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jo4&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;file&lt;/span&gt;
    &lt;span class="na"&gt;disableDeletion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="na"&gt;updateIntervalSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt;
    &lt;span class="na"&gt;allowUiUpdates&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;options&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;/etc/grafana/provisioning/dashboards/jo4&lt;/span&gt;
      &lt;span class="na"&gt;foldersFromFilesStructure&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. With no &lt;code&gt;folderUid&lt;/code&gt;, the dashboard provider does a name-only lookup, finds the folder alerting already created, reuses it, and provisions all the dashboards into it. The container comes up clean.&lt;/p&gt;

&lt;p&gt;We also left a load-bearing comment in &lt;code&gt;providers.yaml&lt;/code&gt; so the next person who sees a dangling UID in deep links and thinks "I'll just pin this" gets the full story before they break the deploy:&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="c1"&gt;# IMPORTANT: do NOT set `folderUid`. Grafana 11.2.0 provisioning order is&lt;/span&gt;
&lt;span class="c1"&gt;# datasources → alerting → dashboards (verified empirically). Alerting&lt;/span&gt;
&lt;span class="c1"&gt;# provisioning auto-creates the folder by name (only field accepted) with a&lt;/span&gt;
&lt;span class="c1"&gt;# Grafana-generated UID. If we then pin a different folderUid here, Grafana&lt;/span&gt;
&lt;span class="c1"&gt;# tries to create a SECOND folder with the same name, errors, stops the&lt;/span&gt;
&lt;span class="c1"&gt;# provisioning service, and the container exits 1.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Verifying It Worked
&lt;/h2&gt;

&lt;p&gt;Re-running the sandbox after removing &lt;code&gt;folderUid&lt;/code&gt;, here's the timeline:&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;msg&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"starting to provision alerting"&lt;/span&gt;
&lt;span class="py"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"finished to provision alerting"&lt;/span&gt;
&lt;span class="py"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"starting to provision dashboards"&lt;/span&gt;
&lt;span class="py"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"finished to provision dashboards"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No error. Process stays up. Then we hit the Grafana API to see who owns the folder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; admin:admin http://localhost:3000/api/folders | jq &lt;span class="s1"&gt;'.[] | {title, uid}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output:&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;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"jo4"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"uid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ffn7ba7vmp3i8a"&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;And the dashboards inside that folder all report &lt;code&gt;folderUid: ffn7ba7vmp3i8a&lt;/code&gt;. So do the alerting rule groups. One folder, one UID, both provisioners pointing at it. Exactly what we wanted — we just don't get to choose the UID.&lt;/p&gt;

&lt;p&gt;If you genuinely need a stable folder UID for deep links (we don't, but you might), the only path we've found that works is: let alerting auto-create it, read the resulting UID back via the API once after the first install, and codify it as an external constant. Don't try to pin it through &lt;code&gt;providers.yaml&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Documentation ordering is not runtime ordering.&lt;/strong&gt; The Grafana docs describe provisioning subsystems in the order you'd intuitively initialize them, but the actual startup sequence in 11.2.0 is datasources → alerting → dashboards. If something cross-references between subsystems, find out who runs first the empirical way — read the logs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A unique-name constraint plus two creators is always a race.&lt;/strong&gt; Whenever two independent provisioners can both produce the "same" named resource and only one of them lets you specify the UID, the one without UID control wins by going first. Your pinned UID has no path to victory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Provisioning service failure = container failure.&lt;/strong&gt; Grafana doesn't gracefully degrade when one provisioning file is bad. The whole service stops and the process exits. Treat your provisioning YAML with the same rigor as a database migration: it can take down the system.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;folderUid&lt;/code&gt; in a dashboard provider is a foot-gun the moment you also have alerting in the same folder.&lt;/strong&gt; Drop it. Let the folder be created by whoever runs first and look it up by name everywhere else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Leave the comment.&lt;/strong&gt; Future-you, or the next operator, will look at the removed &lt;code&gt;folderUid&lt;/code&gt; and wonder why. A five-line comment in the YAML is cheaper than another two-hour debugging session.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;Got bit by Grafana provisioning ordering?&lt;/strong&gt; What was your symptom? Drop it in the comments.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Building &lt;a href="https://jo4.io" rel="noopener noreferrer"&gt;jo4.io&lt;/a&gt; — a URL shortener with analytics for developers who ship.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>grafana</category>
      <category>monitoring</category>
      <category>debugging</category>
      <category>devops</category>
    </item>
    <item>
      <title>Your sed -i Edit Isn't Reaching the Container: The Bind-Mount Inode Trap</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Wed, 02 Sep 2026 05:35:45 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/your-sed-i-edit-isnt-reaching-the-container-the-bind-mount-inode-trap-4ip6</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/your-sed-i-edit-isnt-reaching-the-container-the-bind-mount-inode-trap-4ip6</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/docker-bind-mount-sed-i-inode-trap/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;We tweaked a scrape config in our Prometheus YAML, ran &lt;code&gt;sed -i&lt;/code&gt; on the host, confirmed the file on disk had the new content, and waited for Prometheus to pick it up. It didn't. An hour later we were staring at a &lt;code&gt;stat&lt;/code&gt; output that explained everything we'd ever misunderstood about bind mounts. If you've ever edited a file Docker mounted into a container and watched the container act like nothing happened, this post is for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Symptom
&lt;/h2&gt;

&lt;p&gt;Our Prometheus container has its config bind-mounted from the host:&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;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;prometheus&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prom/prometheus&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/opt/impress/prometheus.yml:/etc/prometheus/prometheus.yml&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We needed to add a scrape target. Easy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'s/scrape_interval: 15s/scrape_interval: 30s/'&lt;/span&gt; /opt/impress/prometheus.yml
docker &lt;span class="nb"&gt;exec &lt;/span&gt;prometheus &lt;span class="nb"&gt;kill&lt;/span&gt; &lt;span class="nt"&gt;-HUP&lt;/span&gt; 1   &lt;span class="c"&gt;# signal Prometheus to reload&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The host file looked correct. Prometheus's &lt;code&gt;/-/reload&lt;/code&gt; endpoint returned 200. But the runtime config in the UI still showed &lt;code&gt;15s&lt;/code&gt;. We re-ran the SIGHUP. Same. We checked Prometheus logs — it said it had reloaded the config. From its own file. Which apparently still said &lt;code&gt;15s&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That's the moment we stopped trusting our assumptions and asked the container what it was actually seeing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Diagnosis: &lt;code&gt;stat&lt;/code&gt; Tells the Truth
&lt;/h2&gt;

&lt;p&gt;We compared the two views of "the same file":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# On the host&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;md5sum&lt;/span&gt; /opt/impress/prometheus.yml
a3f9c1e8...  /opt/impress/prometheus.yml

&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;stat&lt;/span&gt; /opt/impress/prometheus.yml
  File: /opt/impress/prometheus.yml
  Size: 2847       Blocks: 8          IO Block: 4096   regular file
Device: fc01h/64513d    Inode: 1835421     Links: 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Inside the container&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;prometheus &lt;span class="nb"&gt;md5sum&lt;/span&gt; /etc/prometheus/prometheus.yml
7b2d4f06...  /etc/prometheus/prometheus.yml

&lt;span class="nv"&gt;$ &lt;/span&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;prometheus &lt;span class="nb"&gt;stat&lt;/span&gt; /etc/prometheus/prometheus.yml
  File: /etc/prometheus/prometheus.yml
  Size: 2791       Blocks: 8          IO Block: 4096   regular file
Device: fc01h/64513d    Inode: 1835098     Links: 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things jumped out:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Different MD5s.&lt;/strong&gt; The container is reading different bytes than the host file contains.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Links: 0&lt;/code&gt;&lt;/strong&gt; inside the container. Zero hard links means &lt;strong&gt;no directory entry points at this inode anywhere on the filesystem&lt;/strong&gt;. The file has been unlinked. The container is the only thing keeping the inode alive.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Different inode numbers (&lt;code&gt;1835421&lt;/code&gt; on the host, &lt;code&gt;1835098&lt;/code&gt; in the container) confirmed it: these aren't the same file anymore. They were, when the container started. They aren't now. The container is holding open a ghost — an inode that exists only because it still has an open file descriptor pointing at it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why sed -i Is the Culprit
&lt;/h2&gt;

&lt;p&gt;This is the part of &lt;code&gt;sed -i&lt;/code&gt; nobody reads the man page about. &lt;code&gt;sed -i&lt;/code&gt; does not edit in place. Despite the flag name, it does this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open the target file for reading.&lt;/li&gt;
&lt;li&gt;Open a temp file in the same directory (&lt;code&gt;sedXYZ&lt;/code&gt; or similar).&lt;/li&gt;
&lt;li&gt;Stream the transformed content to the temp file.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;rename(2)&lt;/code&gt; the temp file over the target name.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That &lt;code&gt;rename(2)&lt;/code&gt; is the killer. The original inode (the one with the old content) is no longer reachable by name, but it doesn't get freed because the container has it open. The directory entry &lt;code&gt;/opt/impress/prometheus.yml&lt;/code&gt; now points at a brand new inode with the new content. On the host that's the same path with new bytes — looks fine. Inside the container, nothing changed: the bind mount was set up at container start against the &lt;em&gt;original inode&lt;/em&gt;, and bind mounts of single files are bound to inodes, not directory entries.&lt;/p&gt;

&lt;p&gt;So the container keeps reading the ghost. Forever. Or until you restart it.&lt;/p&gt;

&lt;p&gt;This is also why &lt;code&gt;Links: 0&lt;/code&gt;. The original directory entry is gone (it now points to the new inode), so the old inode has zero references in the directory tree — but it has one open file descriptor inside the container, so the kernel keeps it alive.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix
&lt;/h2&gt;

&lt;p&gt;There are two correct ways out.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option A: edit in place for real
&lt;/h3&gt;

&lt;p&gt;You want to write into the &lt;em&gt;same&lt;/em&gt; inode, not replace it. &lt;code&gt;cat &amp;gt; file&lt;/code&gt; does exactly that — it opens the existing inode with &lt;code&gt;O_TRUNC&lt;/code&gt; and writes new content. The inode number doesn't change. The bind mount still points at it. The container sees the update immediately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Build new content somewhere, then truncate-and-write the SAME inode&lt;/span&gt;
&lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="s1"&gt;'s/scrape_interval: 15s/scrape_interval: 30s/'&lt;/span&gt; /opt/impress/prometheus.yml &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/prom.new
&lt;span class="nb"&gt;cat&lt;/span&gt; /tmp/prom.new &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /opt/impress/prometheus.yml
&lt;span class="nb"&gt;rm&lt;/span&gt; /tmp/prom.new
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Confirm with &lt;code&gt;stat&lt;/code&gt; that the inode number didn't change. If it did, you used a tool that does atomic-rename. Back up and try again.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option B: restart the container
&lt;/h3&gt;

&lt;p&gt;If the file change is paired with anything else (image bump, env-var change, a sibling config edit), just rebuild the container. This is what we did:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--force-recreate&lt;/span&gt; prometheus
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--force-recreate&lt;/code&gt; gives us a fully fresh container — new bind mounts against current inodes, plus any env-var or compose-file changes get picked up. Less surgical than &lt;code&gt;cat &amp;gt; file&lt;/code&gt;, but bulletproof when you're not sure what else drifted.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;docker compose restart prometheus&lt;/code&gt; also works for the inode problem alone, but it won't pick up env-var or compose-file changes — it restarts the existing container in place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tools That Have the Same Trap
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;sed -i&lt;/code&gt; is the famous one, but the trap is everywhere atomic-rename is the safe-write idiom:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;mv newfile oldfile&lt;/code&gt;&lt;/strong&gt; — literally &lt;code&gt;rename(2)&lt;/code&gt;. Same trap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;vim&lt;/code&gt; and &lt;code&gt;emacs&lt;/code&gt; by default&lt;/strong&gt; — both write to a backup/swap file and rename over the target, for crash safety. Configurable, but the defaults bite.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;perl -i -pe '...'&lt;/code&gt;&lt;/strong&gt; — same temp-file-and-rename dance as &lt;code&gt;sed -i&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;awk '...' file &amp;gt; tmp &amp;amp;&amp;amp; mv tmp file&lt;/code&gt;&lt;/strong&gt; — the explicit version of the same thing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Most "safe save" code in editors and language tooling&lt;/strong&gt; — &lt;code&gt;gofmt -w&lt;/code&gt;, &lt;code&gt;prettier --write&lt;/code&gt;, &lt;code&gt;black&lt;/code&gt;, you name it. They rename for atomicity, which is &lt;em&gt;correct&lt;/em&gt; for preventing half-written files on crash, and &lt;em&gt;wrong&lt;/em&gt; for bind-mounted single files.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Anything that promises atomic writes is doing this. Atomic writes and bind-mounted single files are fundamentally incompatible.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bulletproof Workflow
&lt;/h2&gt;

&lt;p&gt;After this incident we made two rules for editing bind-mounted config:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Prefer directory mounts over single-file mounts.&lt;/strong&gt; A bind mount of a directory tracks the directory entries inside it, not specific inodes. Atomic-rename inside that directory works the way you expect. The cost is exposing sibling files to the container.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When a single-file mount is unavoidable, edit with &lt;code&gt;cat &amp;gt; file&lt;/code&gt;.&lt;/strong&gt; Build the new content in a tempfile somewhere outside the mounted path, then truncate-and-write the target. Verify with &lt;code&gt;stat&lt;/code&gt; that the inode number is unchanged.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For our Prometheus case we kept the single-file mount (we don't want other files in &lt;code&gt;/opt/impress&lt;/code&gt; visible to Prometheus) and added a tiny wrapper script that does the cat-redirect dance. No more &lt;code&gt;sed -i&lt;/code&gt; on bind-mounted files. Ever.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;sed -i&lt;/code&gt; is a lie.&lt;/strong&gt; It's not in-place. It's "write a temp file and rename." That &lt;code&gt;rename(2)&lt;/code&gt; creates a new inode and orphans the old one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bind mounts of single files are inode-bound.&lt;/strong&gt; Whatever inode the directory entry pointed to at container-start time is the inode the container will hold forever. Replace that inode on the host and the container keeps the old one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Links: 0&lt;/code&gt; on a file inside a container is the smoking gun.&lt;/strong&gt; It means the container is the last reference to an inode that's been unlinked from the filesystem. You're looking at a ghost.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;md5sum&lt;/code&gt; on both sides of the mount is the fastest diagnostic.&lt;/strong&gt; If host and container disagree, you've hit some flavor of this trap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Atomic-rename is correct for safety, wrong for bind mounts.&lt;/strong&gt; Use &lt;code&gt;cat &amp;gt; file&lt;/code&gt; for in-place truncate-and-write, or mount the parent directory instead of the single file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When in doubt, &lt;code&gt;--force-recreate&lt;/code&gt; the container.&lt;/strong&gt; It's the bigger hammer but it's reliable and it picks up env-var changes too.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;Ever spent an hour chasing this?&lt;/strong&gt; What was your tell? Drop it in the comments.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Building &lt;a href="https://jo4.io" rel="noopener noreferrer"&gt;jo4.io&lt;/a&gt; — a URL shortener with analytics for developers who ship.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>linux</category>
      <category>debugging</category>
      <category>devops</category>
    </item>
    <item>
      <title>Why pgrep Can't Detect cloud-init's Apt Races on DigitalOcean</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Mon, 31 Aug 2026 06:51:34 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/why-pgrep-cant-detect-cloud-inits-apt-races-on-digitalocean-20fd</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/why-pgrep-cant-detect-cloud-inits-apt-races-on-digitalocean-20fd</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/cloud-init-vendor-script-apt-lock-race/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you've ever written a wait loop that polls &lt;code&gt;pgrep apt-get&lt;/code&gt; before running your own &lt;code&gt;apt-get&lt;/code&gt;, congratulations: you have a race condition. We had one too. On DigitalOcean's Ubuntu 24.04 droplets, our cold-boot setup script would intermittently die on the apt frontend lock — even though we'd "carefully" waited for apt to be idle. Here's why our defensive wait loop was lying to us, and the one line that actually fixed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Setup
&lt;/h2&gt;

&lt;p&gt;We run a small observability droplet — Prometheus + Grafana behind nginx, called &lt;code&gt;jo4-impress&lt;/code&gt;. It's bootstrapped from a GitHub Actions workflow that SCPs &lt;code&gt;impress/do-setup/&lt;/code&gt; to the freshly-provisioned droplet and runs &lt;code&gt;setup-impress.sh&lt;/code&gt;. The script installs Docker via the convenience script (&lt;code&gt;curl -fsSL https://get.docker.com | sh&lt;/code&gt;), then brings up the compose stack.&lt;/p&gt;

&lt;p&gt;Because we knew Ubuntu's &lt;code&gt;unattended-upgrades&lt;/code&gt; and the &lt;code&gt;apt-daily&lt;/code&gt; timers fire shortly after boot, we put a pgrep-based wait loop in front of the Docker install. The idea: poll until no &lt;code&gt;apt-get&lt;/code&gt; is running, &lt;em&gt;then&lt;/em&gt; run our own. Belt-and-braces. What could go wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;On a fresh droplet, the script intermittently failed with the classic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;E: Could not get lock /var/lib/dpkg/lock-frontend.
   It is held by process 2746 (apt-get)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The frustrating part: our wait loop reported "no apt-get running" two seconds before this error. We weren't ignoring it. We were &lt;em&gt;checking&lt;/em&gt;. And yet PID 2746 — an &lt;code&gt;apt-get&lt;/code&gt; we never saw — grabbed the lock the instant our &lt;code&gt;curl get.docker.com | sh&lt;/code&gt; invoked its own &lt;code&gt;apt-get update&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Here's the timeline pulled from &lt;code&gt;/var/log/apt/history.log&lt;/code&gt; on the droplet (workflow run 26424379616):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Time (UTC)&lt;/th&gt;
&lt;th&gt;Event&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;23:48:13&lt;/td&gt;
&lt;td&gt;Droplet boots&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;23:49:01–27&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;apt-get install ca-certificates gnupg curl …&lt;/code&gt; (spawned by &lt;code&gt;install-do-agent&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;23:49:35–38&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;apt-get install do-agent&lt;/code&gt; (still inside &lt;code&gt;install-do-agent&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;23:49:42&lt;/td&gt;
&lt;td&gt;Our pgrep wait loop concludes: "no apt-get running, proceeding"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;23:49:43&lt;/td&gt;
&lt;td&gt;`curl get.docker.com \&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;23:49:45&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Lock contention error.&lt;/strong&gt; Holder: PID 2746&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;23:49:48–49&lt;/td&gt;
&lt;td&gt;{% raw %}&lt;code&gt;apt-get install droplet-agent&lt;/code&gt; completes (this was PID 2746)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;PID 2746 was spawned by &lt;code&gt;install-dotty-agent&lt;/code&gt;, a DigitalOcean cloud-init vendor script. It started in the four-second gap between &lt;code&gt;install-do-agent&lt;/code&gt; finishing and our &lt;code&gt;curl | sh&lt;/code&gt; racing into &lt;code&gt;apt-get update&lt;/code&gt;. Our pgrep loop caught the quiet moment between two vendor scripts and concluded — correctly, in the instant it sampled — that apt was idle. By the time we acted on that conclusion, the next vendor script had already launched.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Two Sources of Contention
&lt;/h2&gt;

&lt;p&gt;This is where it gets interesting. On a DigitalOcean Ubuntu 24.04 droplet at first boot, there are &lt;strong&gt;two completely independent&lt;/strong&gt; things competing for the apt lock, and most defenses only address one of them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;(1) The systemd timer units.&lt;/strong&gt; These are the ones every "first-boot apt fix" tutorial talks about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;unattended-upgrades.service&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;apt-daily.service&lt;/code&gt;, &lt;code&gt;apt-daily.timer&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;apt-daily-upgrade.service&lt;/code&gt;, &lt;code&gt;apt-daily-upgrade.timer&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;They fire on a randomized schedule shortly after boot. &lt;code&gt;systemctl stop&lt;/code&gt; + &lt;code&gt;systemctl mask&lt;/code&gt; handles them. This is the well-known half of the problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;(2) cloud-init's &lt;code&gt;modules-final → config-scripts_vendor&lt;/code&gt; stage.&lt;/strong&gt; This is the half nobody warns you about. DigitalOcean ships two vendor scripts that cloud-init runs during this stage:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;install-do-agent&lt;/code&gt; — installs the DigitalOcean monitoring agent&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;install-dotty-agent&lt;/code&gt; — installs &lt;code&gt;droplet-agent&lt;/code&gt; (the web-console SSH bridge)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both shell out to &lt;code&gt;apt-get install …&lt;/code&gt; &lt;strong&gt;directly&lt;/strong&gt;. They are not systemd units. They don't appear in &lt;code&gt;systemctl list-units&lt;/code&gt;. Masking the apt timers does precisely nothing to them, because they were never running through those timers in the first place.&lt;/p&gt;

&lt;p&gt;On the run captured above, &lt;code&gt;install-do-agent&lt;/code&gt; ran 23:48:29–23:49:41 (71.8 seconds total) and &lt;code&gt;install-dotty-agent&lt;/code&gt; ran 23:49:41–23:49:53 (12.2 seconds). Between them: a short window where &lt;code&gt;pgrep apt-get&lt;/code&gt; returns empty — but cloud-init is very much still going to launch the next one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the pgrep Loop Failed
&lt;/h2&gt;

&lt;p&gt;The pgrep approach has a structural flaw, not a tuning flaw. Let's be explicit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# The old wait loop (approximately)&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;seq &lt;/span&gt;1 60&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; pgrep &lt;span class="nt"&gt;-x&lt;/span&gt; apt-get &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"apt is free, proceeding"&lt;/span&gt;
    &lt;span class="nb"&gt;break
  &lt;/span&gt;&lt;span class="k"&gt;fi
  &lt;/span&gt;&lt;span class="nb"&gt;sleep &lt;/span&gt;5
&lt;span class="k"&gt;done
&lt;/span&gt;apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; docker-ce  &lt;span class="c"&gt;# races whatever cloud-init launches next&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The check answers one question: &lt;em&gt;is there an apt-get process running **right now&lt;/em&gt;&lt;em&gt;?&lt;/em&gt; That's a point-in-time observation. It tells you nothing about whether something &lt;strong&gt;about to be launched&lt;/strong&gt; will run an &lt;code&gt;apt-get&lt;/code&gt; in the next few seconds.&lt;/p&gt;

&lt;p&gt;Cloud-init's vendor-scripts stage is a sequence. While it's executing the sequence, the gaps between individual &lt;code&gt;apt-get&lt;/code&gt; invocations are normal — script setup, package downloads, post-install hooks — and routinely large enough to make pgrep look clean. The wait loop is sampling a process state when it should be checking a higher-level lifecycle state: "is cloud-init done yet?"&lt;/p&gt;

&lt;p&gt;The systemd-timer half of the problem made this look like a tuning issue ("just sleep longer", "increase the poll count"). It wasn't. No amount of polling helps when the next contender hasn't been spawned yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix
&lt;/h2&gt;

&lt;p&gt;Cloud-init ships a command that answers the actual question:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cloud-init status &lt;span class="nt"&gt;--wait&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It blocks until cloud-init reaches its &lt;code&gt;done&lt;/code&gt; state — vendor scripts inclusive. Once it returns, every &lt;code&gt;install-*-agent&lt;/code&gt; invocation has completed and won't be spawning more apt-gets. We can install Docker without surprise contenders.&lt;/p&gt;

&lt;p&gt;The diff that fixed &lt;code&gt;setup-impress.sh&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;- # Wait until any boot-time apt is finished.
- for i in $(seq 1 60); do
-   if ! pgrep -x apt-get &amp;gt;/dev/null; then
-     break
-   fi
-   sleep 5
- done
&lt;/span&gt;&lt;span class="gi"&gt;+ # Defense-in-depth: silence the systemd timer units...
+ APT_UNITS=(
+   unattended-upgrades.service
+   apt-daily.service apt-daily.timer
+   apt-daily-upgrade.service apt-daily-upgrade.timer
+ )
+ systemctl stop "${APT_UNITS[@]}" 2&amp;gt;/dev/null || true
+ systemctl mask "${APT_UNITS[@]}" 2&amp;gt;/dev/null || true
+
+ # Load-bearing: block until cloud-init's vendor scripts finish.
+ echo "⏳ Waiting for cloud-init to finish (DO vendor scripts release apt)..."
+ cloud-init status --wait
+
&lt;/span&gt;  echo "📦 Installing Docker via convenience script..."
  curl -fsSL https://get.docker.com | sh
  systemctl enable --now docker
&lt;span class="gi"&gt;+
+ # Lift the masks once Docker is in.
+ systemctl unmask "${APT_UNITS[@]}" 2&amp;gt;/dev/null || true
+ systemctl enable --now apt-daily.timer apt-daily-upgrade.timer 2&amp;gt;/dev/null || true
+ systemctl enable --now unattended-upgrades.service 2&amp;gt;/dev/null || true
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things to notice. First: &lt;code&gt;cloud-init status --wait&lt;/code&gt; is doing the load-bearing work. It's the one line that closes the race. Second: we still mask the apt timers, but only as defense-in-depth against the scenario where cloud-init has finished but a timer fires inside the Docker-install window. We re-enable them at the end so the droplet's normal patching cadence resumes.&lt;/p&gt;

&lt;p&gt;The full context lives in &lt;a href="https://github.com/anandchakru/jo4/blob/main/impress/do-setup/setup-impress.sh" rel="noopener noreferrer"&gt;&lt;code&gt;impress/do-setup/setup-impress.sh&lt;/code&gt;&lt;/a&gt;, commented inline so the next person who reads it doesn't have to re-derive any of this.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;pgrep&lt;/code&gt; answers "is X running now?" — not "will X run in the next ten seconds?"&lt;/strong&gt; Any defensive wait built around pgrep has this blind spot. Use it for "wait for &lt;em&gt;this specific&lt;/em&gt; PID to exit", not "wait for &lt;em&gt;a class of process&lt;/em&gt; to be permanently quiet."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On Ubuntu 24.04 cloud images, there are two boot-time apt actors, not one.&lt;/strong&gt; Systemd timers and cloud-init vendor scripts run independently. Masking the timers and ignoring cloud-init buys you the silence right up until cloud-init speaks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;cloud-init status --wait&lt;/code&gt; is the right primitive.&lt;/strong&gt; It encodes the lifecycle question ("is cloud-init done?") that your script actually wants the answer to. No polling, no false positives, no off-by-a-few-seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Belt-and-braces is fine; one of them has to actually be load-bearing.&lt;/strong&gt; We kept the systemd masks because they cost us nothing and close a real (if narrower) window. But the masks alone never fixed the bug — &lt;code&gt;cloud-init status --wait&lt;/code&gt; did.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When an intermittent bug has a four-second window, the logs from one bad run are worth more than a week of staring at the code.&lt;/strong&gt; &lt;code&gt;/var/log/apt/history.log&lt;/code&gt; on the failing droplet handed us the answer in the form of PID 2746 and an exact second. The fix took ten minutes once we had it.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;Hit a similar cloud-init-vs-apt race?&lt;/strong&gt; Drop the timeline in the comments.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Building &lt;a href="https://jo4.io" rel="noopener noreferrer"&gt;jo4.io&lt;/a&gt; — a URL shortener with analytics for developers who ship.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>digitalocean</category>
      <category>devops</category>
      <category>debugging</category>
      <category>linux</category>
    </item>
    <item>
      <title>How to Plug Claude Routines Into Your SaaS With MCP</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Fri, 28 Aug 2026 12:37:56 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/how-to-plug-claude-routines-into-your-saas-with-mcp-1d0b</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/how-to-plug-claude-routines-into-your-saas-with-mcp-1d0b</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/mcp-server-claude-routines/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A while back a user asked, &lt;em&gt;"Can I run jo4 from a Claude routine?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I said &lt;em&gt;"sure, eventually"&lt;/em&gt;, which is what I always say when something sounds like a weekend and I know damn well it's a long weekend.&lt;/p&gt;

&lt;p&gt;It was a long weekend.&lt;/p&gt;

&lt;p&gt;But &lt;a href="https://jo4.io" rel="noopener noreferrer"&gt;jo4.io&lt;/a&gt; now speaks &lt;a href="https://modelcontextprotocol.io/" rel="noopener noreferrer"&gt;MCP&lt;/a&gt;. Claude routines (and any other MCP-aware client) can shorten links, list URLs, pull click stats, update titles, and delete links — all through the same Spring Boot API that powers the dashboard.&lt;/p&gt;

&lt;p&gt;Here's what shipped, why it's mostly an OAuth story, and how to wire your own client to it.&lt;/p&gt;




&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;Jo4 exposes an MCP server at &lt;code&gt;https://jo4-api.jo4.io/mcp&lt;/code&gt;. It's a remote, OAuth-protected MCP server — not a local stdio one. So:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Your client discovers the auth server at &lt;code&gt;/.well-known/oauth-authorization-server&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Your client registers itself dynamically (no human in the loop).&lt;/li&gt;
&lt;li&gt;Your client does an OAuth authorization-code + PKCE flow.&lt;/li&gt;
&lt;li&gt;Your client gets a bearer token and starts calling MCP tools like &lt;code&gt;jo4_shorten_url&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's the whole song. The rest of this post is the harmonies.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Thought I Was Building vs. What MCP Actually Required
&lt;/h2&gt;

&lt;p&gt;I had a mental model that went &lt;em&gt;"add a few tool annotations to my service classes, point Claude at the URL, ship a tweet."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I had not read the &lt;a href="https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization" rel="noopener noreferrer"&gt;MCP authorization spec&lt;/a&gt;. The MCP spec defers to a small constellation of OAuth RFCs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;RFC&lt;/th&gt;
&lt;th&gt;What it gives you&lt;/th&gt;
&lt;th&gt;Why MCP needs it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;6749&lt;/td&gt;
&lt;td&gt;OAuth 2.0 core (auth code, refresh)&lt;/td&gt;
&lt;td&gt;Standard token issuance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7591&lt;/td&gt;
&lt;td&gt;Dynamic Client Registration&lt;/td&gt;
&lt;td&gt;MCP clients have no human to fill out a portal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7636&lt;/td&gt;
&lt;td&gt;PKCE&lt;/td&gt;
&lt;td&gt;Public clients can't keep secrets&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8414&lt;/td&gt;
&lt;td&gt;Authorization Server Metadata&lt;/td&gt;
&lt;td&gt;Clients discover endpoints, not config them&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9728&lt;/td&gt;
&lt;td&gt;Protected Resource Metadata&lt;/td&gt;
&lt;td&gt;Resources point at their own auth server&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A "remote MCP server" is mostly &lt;em&gt;"a resource server with these five RFCs implemented correctly."&lt;/em&gt; The MCP-specific bits (tool registration, transport, JSON-RPC framing) take maybe 20% of the effort. OAuth eats the rest.&lt;/p&gt;

&lt;p&gt;If your backend already runs a hand-rolled OAuth server, congratulations — you're 80% of the way there. If it doesn't, you're about to learn a lot about RFC 7591.&lt;/p&gt;




&lt;h2&gt;
  
  
  Anatomy of the Shipped Code
&lt;/h2&gt;

&lt;p&gt;Roughly the new files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;io/jo4/jo4/mcp/
├── Jo4McpTools.java                    -- @McpTool methods (read + write)
├── Jo4McpTransportConfig.java          -- transport bean override
└── OAuthMcpContextExtractor.java       -- carries auth across reactor threads

io/jo4/jo4/controller/noauth/
├── OAuthDcrController.java             -- POST /oauth/register (RFC 7591)
└── OAuthMetadataController.java        -- GET /.well-known/* (RFC 8414, 9728)

io/jo4/jo4/security/
├── DcrRateLimiter.java                 -- per-IP DCR rate limiting
├── OAuthScopeEnforcementFilter.java    -- read vs. write scope gating
├── RedirectUriValidator.java           -- https/localhost only
└── ResourceIndicatorValidator.java     -- RFC 8707
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plus a couple of Liquibase changesets adding &lt;code&gt;audience&lt;/code&gt; to access tokens and DCR fields to clients. About 2,700 net lines, mostly tests. (You will write a &lt;em&gt;lot&lt;/em&gt; of tests for OAuth code. Please write a lot of tests for OAuth code.)&lt;/p&gt;




&lt;h2&gt;
  
  
  The Four RFCs That Bit Me
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. RFC 7591 — Dynamic Client Registration
&lt;/h3&gt;

&lt;p&gt;The thing I most wanted not to implement and absolutely had to.&lt;/p&gt;

&lt;p&gt;Picture an MCP client like Claude. It doesn't have a developer to log into your portal and click "Create Client". It needs to register itself, get a &lt;code&gt;client_id&lt;/code&gt;, and start an OAuth flow — all in a single uninterrupted dance.&lt;/p&gt;

&lt;p&gt;The endpoint is &lt;code&gt;POST /oauth/register&lt;/code&gt;. The body is the metadata of the client being created (redirect URIs, grant types, token auth method). The response is the same body plus a server-issued &lt;code&gt;client_id&lt;/code&gt; (and &lt;code&gt;client_secret&lt;/code&gt; if it's confidential).&lt;/p&gt;

&lt;p&gt;Two things tripped me up:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;token_endpoint_auth_method: "none"&lt;/code&gt; is required for public clients.&lt;/strong&gt; Native and SPA-style MCP clients can't keep a secret, so they register as public clients with &lt;code&gt;none&lt;/code&gt;. Your token endpoint must accept tokenless requests for these. PKCE is what protects them — without &lt;code&gt;code_challenge&lt;/code&gt;, the flow must reject.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Open registration means rate limiting.&lt;/strong&gt; Anyone on the internet can POST to &lt;code&gt;/oauth/register&lt;/code&gt;. I added a sliding-window per-IP limiter (&lt;code&gt;DcrRateLimiter&lt;/code&gt;) gated behind a config flag. Without it you have a delightful little DDoS surface on day one.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. RFC 8414 — Authorization Server Metadata
&lt;/h3&gt;

&lt;p&gt;A small controller serving JSON at &lt;code&gt;/.well-known/oauth-authorization-server&lt;/code&gt;:&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;"issuer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://jo4-api.jo4.io"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"authorization_endpoint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://jo4-api.jo4.io/oauth/authorize"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"token_endpoint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://jo4-api.jo4.io/oauth/token"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"registration_endpoint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://jo4-api.jo4.io/oauth/register"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"code_challenge_methods_supported"&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="s2"&gt;"S256"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scopes_supported"&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="s2"&gt;"read"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"write"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"grant_types_supported"&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="s2"&gt;"authorization_code"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"refresh_token"&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;&lt;code&gt;code_challenge_methods_supported: ["S256"]&lt;/code&gt; is non-negotiable for MCP — &lt;code&gt;plain&lt;/code&gt; PKCE is forbidden by the MCP authorization spec. List &lt;code&gt;S256&lt;/code&gt;. Only &lt;code&gt;S256&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. RFC 9728 — Protected Resource Metadata
&lt;/h3&gt;

&lt;p&gt;This one I almost skipped because it felt redundant. It's not.&lt;/p&gt;

&lt;p&gt;The MCP client has a resource URL (your &lt;code&gt;/mcp&lt;/code&gt; endpoint). Before doing anything, it asks the resource: &lt;em&gt;"who issues your tokens?"&lt;/em&gt; The answer comes from &lt;code&gt;/.well-known/oauth-protected-resource&lt;/code&gt;:&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;"resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://jo4-api.jo4.io"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"authorization_servers"&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="s2"&gt;"https://jo4-api.jo4.io"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scopes_supported"&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="s2"&gt;"read"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"write"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"bearer_methods_supported"&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="s2"&gt;"header"&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;In our case the resource and the AS are the same Spring app, so the URLs match. When &lt;code&gt;mcp.jo4.io&lt;/code&gt; becomes its own subdomain in front of the same backend, only the &lt;code&gt;resource&lt;/code&gt; field changes. That's the whole point of having a separate document.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. RFC 8707 — Resource Indicators
&lt;/h3&gt;

&lt;p&gt;Small but sharp. Tokens issued for the MCP resource carry an &lt;code&gt;aud&lt;/code&gt; (audience) claim pinning them to that resource. A token your dashboard issued for browser API calls cannot be replayed at &lt;code&gt;/mcp&lt;/code&gt;, and vice versa. A &lt;code&gt;ResourceIndicatorValidator&lt;/code&gt; enforces it on both authorize and token endpoints.&lt;/p&gt;

&lt;p&gt;If you skip this, a token issued for one purpose works for any purpose. That's fine until the day it isn't.&lt;/p&gt;




&lt;h2&gt;
  
  
  The MCP-Specific Bit (Finally)
&lt;/h2&gt;

&lt;p&gt;Once OAuth is sorted, the MCP layer is small. We use &lt;a href="https://docs.spring.io/spring-ai/reference/api/mcp/mcp-overview.html" rel="noopener noreferrer"&gt;Spring AI&lt;/a&gt;'s MCP server starter. The tools look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@McpTool&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"jo4_shorten_url"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Create a new shortened jo4 link..."&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;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;shortenUrl&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;McpSyncServerExchange&lt;/span&gt; &lt;span class="n"&gt;exchange&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nd"&gt;@McpToolParam&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Destination URL"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&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;longUrl&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nd"&gt;@McpToolParam&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Optional title"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nd"&gt;@McpToolParam&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Optional custom slug"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;customSlug&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="o"&gt;...)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;OAuthTokenAuthentication&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requireWriteScope&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exchange&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;UrlEntity&lt;/span&gt; &lt;span class="n"&gt;entity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;UrlEntity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;longUrl&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;longUrl&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;shortUrl&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customSlug&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;urlSummary&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;urlService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;createUrl&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entity&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getUser&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;
                                            &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getUser&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getTenantId&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;Six tools shipped: &lt;code&gt;jo4_get_url&lt;/code&gt;, &lt;code&gt;jo4_list_my_urls&lt;/code&gt;, &lt;code&gt;jo4_shorten_url&lt;/code&gt;, &lt;code&gt;jo4_update_url&lt;/code&gt;, &lt;code&gt;jo4_delete_url&lt;/code&gt;, &lt;code&gt;jo4_get_stats&lt;/code&gt;. They delegate to the same &lt;code&gt;UrlService&lt;/code&gt; the REST controllers use. Tenant isolation is automatic because the OAuth token already carries the user.&lt;/p&gt;

&lt;h3&gt;
  
  
  The thread-local trap
&lt;/h3&gt;

&lt;p&gt;The non-obvious bit: the Spring AI MCP transport hands tool execution off to a Reactor &lt;code&gt;boundedElastic&lt;/code&gt; thread. Spring Security's &lt;code&gt;SecurityContextHolder&lt;/code&gt; is a thread-local. By the time your &lt;code&gt;@McpTool&lt;/code&gt; method runs, the security context is empty.&lt;/p&gt;

&lt;p&gt;The fix is a small &lt;code&gt;McpTransportContextExtractor&lt;/code&gt; that snapshots the auth on the servlet thread and stuffs it into MCP's transport context (which &lt;em&gt;does&lt;/em&gt; propagate):&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;@Override&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;McpTransportContext&lt;/span&gt; &lt;span class="nf"&gt;extract&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ServerRequest&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;Authentication&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SecurityContextHolder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getContext&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getAuthentication&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auth&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nc"&gt;OAuthTokenAuthentication&lt;/span&gt; &lt;span class="n"&gt;oauthAuth&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;McpTransportContext&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;KEY_OAUTH_AUTH&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;oauthAuth&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;McpTransportContext&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;EMPTY&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;Then in tool methods: &lt;code&gt;exchange.transportContext().get(KEY_OAUTH_AUTH)&lt;/code&gt;. Took an evening to track down the first time a tool returned &lt;code&gt;null&lt;/code&gt; for &lt;code&gt;userId&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  How To Connect Your Own Client (The Tutorial Bit)
&lt;/h2&gt;

&lt;p&gt;Say you're writing your own MCP client (or you want to test the flow with &lt;code&gt;curl&lt;/code&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1 — Discover
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl https://jo4-api.jo4.io/.well-known/oauth-protected-resource
curl https://jo4-api.jo4.io/.well-known/oauth-authorization-server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first tells you which AS issues tokens for the resource. The second tells you that AS's endpoints. A real client chains them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2 — Register dynamically
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://jo4-api.jo4.io/oauth/register &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "client_name": "my-mcp-client",
    "redirect_uris": ["http://localhost:8765/callback"],
    "grant_types": ["authorization_code", "refresh_token"],
    "response_types": ["code"],
    "token_endpoint_auth_method": "none",
    "application_type": "native",
    "scope": "read write"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You get back a &lt;code&gt;client_id&lt;/code&gt;. Keep it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;localhost&lt;/code&gt; redirects are accepted (per the MCP spec's loopback exception). Anything else must be &lt;code&gt;https://&lt;/code&gt;. No &lt;code&gt;http://&lt;/code&gt; to non-loopback hosts.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3 — PKCE authorization
&lt;/h3&gt;

&lt;p&gt;Build a verifier and challenge:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;VERIFIER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;openssl rand &lt;span class="nt"&gt;-base64&lt;/span&gt; 32 | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'=+/'&lt;/span&gt; | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="nt"&gt;-43&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;CHALLENGE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$VERIFIER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | openssl dgst &lt;span class="nt"&gt;-sha256&lt;/span&gt; &lt;span class="nt"&gt;-binary&lt;/span&gt; | &lt;span class="se"&gt;\&lt;/span&gt;
            &lt;span class="nb"&gt;base64&lt;/span&gt; | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'=+/'&lt;/span&gt; | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="s1"&gt;'/+'&lt;/span&gt; &lt;span class="s1"&gt;'_-'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open the user's browser at:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;https://jo4-api.jo4.io/oauth/authorize
  ?response_type=code
  &amp;amp;client_id=YOUR_CLIENT_ID
  &amp;amp;redirect_uri=http://localhost:8765/callback
  &amp;amp;scope=read%20write
  &amp;amp;code_challenge=$CHALLENGE
  &amp;amp;code_challenge_method=S256
  &amp;amp;resource=https://jo4-api.jo4.io
  &amp;amp;state=xyz
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The user logs in. Your callback receives &lt;code&gt;?code=...&lt;/code&gt;. Exchange it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://jo4-api.jo4.io/oauth/token &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/x-www-form-urlencoded'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"grant_type=authorization_code"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"code=THE_CODE"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"redirect_uri=http://localhost:8765/callback"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"client_id=YOUR_CLIENT_ID"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"code_verifier=&lt;/span&gt;&lt;span class="nv"&gt;$VERIFIER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"resource=https://jo4-api.jo4.io"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You get an &lt;code&gt;access_token&lt;/code&gt; (plus &lt;code&gt;refresh_token&lt;/code&gt; if you asked for offline access).&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4 — Call the MCP server
&lt;/h3&gt;

&lt;p&gt;The MCP endpoint is &lt;code&gt;https://jo4-api.jo4.io/mcp&lt;/code&gt;. Streamable HTTP transport. Standard JSON-RPC 2.0. Use whichever MCP SDK matches your language; from a real MCP client this is one config line:&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;"mcpServers"&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;"jo4"&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;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://jo4-api.jo4.io/mcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"transport"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"streamable-http"&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;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;Most clients with built-in OAuth support will run the discovery + DCR + PKCE dance for you the first time the server is added. You stop seeing OAuth and start seeing tools.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5 — Try a tool
&lt;/h3&gt;

&lt;p&gt;From a Claude routine, after the OAuth handshake, this just works:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"Shorten &lt;a href="https://example.com/very-long-marketing-page" rel="noopener noreferrer"&gt;https://example.com/very-long-marketing-page&lt;/a&gt; and call it spring-launch."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Behind the scenes the client invokes &lt;code&gt;jo4_shorten_url&lt;/code&gt; with &lt;code&gt;longUrl&lt;/code&gt; and &lt;code&gt;customSlug&lt;/code&gt;. Jo4 returns the new short URL. The routine continues on its merry way.&lt;/p&gt;

&lt;p&gt;For analytics:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"How many clicks did &lt;code&gt;spring-launch&lt;/code&gt; get last week, broken down by country?"&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's &lt;code&gt;jo4_get_stats&lt;/code&gt; with &lt;code&gt;slug=spring-launch&lt;/code&gt; and a 7-day window. Routine reads the breakdown, summarizes, done.&lt;/p&gt;




&lt;h2&gt;
  
  
  Things I'd Tell My Past Self
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Read the MCP authorization spec end-to-end before writing a line of code.&lt;/strong&gt; It cross-references five RFCs and you need all of them. Skim, then re-read.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Treat DCR as a public endpoint from minute one.&lt;/strong&gt; Rate limiting and validation aren't day-two concerns. Anyone can &lt;code&gt;POST /oauth/register&lt;/code&gt; against your prod server the moment it's live.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Audience-bind your tokens.&lt;/strong&gt; Tokens minted for one resource leaking into another resource is exactly the class of bug RFC 8707 exists to prevent. Just enforce it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test the IDOR path.&lt;/strong&gt; When tools accept slugs by string, &lt;em&gt;every&lt;/em&gt; tool needs an explicit "does this user own this slug" check that returns NOT_FOUND (not FORBIDDEN — never reveal that the slug exists for someone else). I caught this in code review on &lt;code&gt;jo4_get_stats&lt;/code&gt;. You don't want to catch it later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch out for thread-locals on async transport.&lt;/strong&gt; If you read auth from &lt;code&gt;SecurityContextHolder&lt;/code&gt; inside a tool method, you'll get &lt;code&gt;null&lt;/code&gt; and won't know why. Use the transport context.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;Right now we ship six tools — read and write for the URL primitive plus stats. Next on the list: bio-page tools (&lt;code&gt;jo4_create_bio_page&lt;/code&gt;, &lt;code&gt;jo4_add_bio_link&lt;/code&gt;) and webhook-management tools so a routine can subscribe itself to click events.&lt;/p&gt;

&lt;p&gt;The OAuth scaffolding is done. New tools are now a one-method-and-a-test affair.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Building an MCP server for your own SaaS?&lt;/strong&gt; Drop the question that's stuck — happy to compare notes on whichever RFC is currently ruining your evening.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Building &lt;a href="https://jo4.io" rel="noopener noreferrer"&gt;jo4.io&lt;/a&gt; — a URL shortener with analytics, now reachable from your favorite MCP-aware AI.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mcp</category>
      <category>oauth</category>
      <category>springboot</category>
    </item>
    <item>
      <title>From Polling to SSE in RTK Query Without Breaking Reconnect Logic</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Wed, 26 Aug 2026 02:27:22 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/from-polling-to-sse-in-rtk-query-without-breaking-reconnect-logic-4g5a</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/from-polling-to-sse-in-rtk-query-without-breaking-reconnect-logic-4g5a</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/react-polling-to-sse-rtk-query/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;We had a React notification bell that polled &lt;code&gt;/notifications/unread-count&lt;/code&gt; every 30 seconds. It worked. It also sent thousands of pointless requests when the answer was "still zero, still zero, still zero." We replaced it with Server-Sent Events.&lt;/p&gt;

&lt;p&gt;The pattern looks small from the outside — open a stream, listen for events, update state. The reality has more sharp edges than I expected. Here's the implementation that survived production.&lt;/p&gt;

&lt;h2&gt;
  
  
  The TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Native browser &lt;code&gt;EventSource&lt;/code&gt; can't send custom headers, so it can't carry an &lt;code&gt;Authorization&lt;/code&gt; Bearer token. Use &lt;code&gt;@microsoft/fetch-event-source&lt;/code&gt; instead.&lt;/li&gt;
&lt;li&gt;The bell still does a one-shot bootstrap fetch via RTK Query so first paint isn't blank. SSE drives subsequent updates.&lt;/li&gt;
&lt;li&gt;SSE updates are pushed into the &lt;em&gt;same&lt;/em&gt; RTK Query cache the bootstrap fetch wrote into, so every reader of &lt;code&gt;useGetUnreadCountQuery&lt;/code&gt; updates without prop drilling.&lt;/li&gt;
&lt;li&gt;Reconnect with exponential backoff. Force logout on 401. Survive tab backgrounding.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Component
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SSE_ENABLED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;VITE_USE_SSE_NOTIFICATIONS&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;POLL_INTERVAL_FALLBACK_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;NotificationBell&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dispatch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useAppDispatch&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="c1"&gt;// Bootstrap: one-shot for first paint. SSE drives subsequent updates when on,&lt;/span&gt;
  &lt;span class="c1"&gt;// RTK Query polls when off — easy kill switch via env var.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unreadData&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useGetUnreadCountQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;SSE_ENABLED&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;pollingInterval&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;POLL_INTERVAL_FALLBACK_MS&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;streamRef&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useRef&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;close&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;SSE_ENABLED&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;apiBase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;VITE_API_BASE&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;streamRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;subscribeToNotifications&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;apiBase&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// Patch RTK Query's cache so every reader of useGetUnreadCountQuery&lt;/span&gt;
      &lt;span class="c1"&gt;// updates, not just this component.&lt;/span&gt;
      &lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nx"&gt;notificationApi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;util&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;updateQueryData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;getUnreadCount&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;draft&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;draft&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
              &lt;span class="nx"&gt;draft&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
          &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;streamRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="nx"&gt;streamRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;unreadCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;unreadData&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// ... render bell ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few choices to call out:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Feature flag with a polling fallback.&lt;/strong&gt; &lt;code&gt;SSE_ENABLED&lt;/code&gt; is an env var. When it's off, the same component falls back to RTK Query's &lt;code&gt;pollingInterval&lt;/code&gt;. This is the kill switch you'll thank yourself for the first time SSE behaves badly behind a customer's corporate proxy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;updateQueryData&lt;/code&gt;, not local state.&lt;/strong&gt; The bootstrap fetch and the SSE updates write into the same RTK Query cache key. Any other component that calls &lt;code&gt;useGetUnreadCountQuery&lt;/code&gt; — a sidebar, a modal — re-renders automatically when the SSE stream pushes a new count. Local component state would have meant prop drilling or a parallel state tree.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;streamRef&lt;/code&gt; for the close handle.&lt;/strong&gt; The cleanup callback closes the stream on unmount. The ref pattern is necessary because the stream object is created inside &lt;code&gt;useEffect&lt;/code&gt; but referenced from the cleanup closure.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Subscribe Function
&lt;/h2&gt;

&lt;p&gt;This is the gnarly part. &lt;code&gt;EventSource&lt;/code&gt; is the native browser API, but it's a non-starter:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"EventSource cannot send custom headers."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's a hard limit — you can't pass an &lt;code&gt;Authorization: Bearer &amp;lt;jwt&amp;gt;&lt;/code&gt; header. There's a workaround (cookie auth + &lt;code&gt;withCredentials&lt;/code&gt;) but we're not using cookies for our API. So: &lt;code&gt;@microsoft/fetch-event-source&lt;/code&gt;, which is a &lt;code&gt;fetch&lt;/code&gt;-based polyfill that supports headers and exposes lifecycle hooks.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;fetchEventSource&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;EventSourceMessage&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@microsoft/fetch-event-source&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getAccessToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;forceLogout&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../../utils/authTokenManager&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;RECONNECT_INITIAL_DELAY_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;RECONNECT_MAX_DELAY_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;RECONNECT_BACKOFF_FACTOR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;FatalAuthError&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Auth failed for SSE stream&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;FatalAuthError&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;subscribeToNotifications&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;apiBase&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;onUnreadCount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;close&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AbortController&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;closed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;reconnectDelay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;RECONNECT_INITIAL_DELAY_MS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;apiBase&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/api/v1/protected/notifications/stream`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handleMessage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;EventSourceMessage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unread-count&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;number&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;onUnreadCount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="c1"&gt;// Successful event = healthy connection; reset backoff.&lt;/span&gt;
        &lt;span class="nx"&gt;reconnectDelay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;RECONNECT_INITIAL_DELAY_MS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[notificationStream] malformed event payload&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;connect&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;closed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getAccessToken&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// Auth state hasn't settled yet; retry after delay.&lt;/span&gt;
      &lt;span class="nf"&gt;scheduleReconnect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetchEventSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;Accept&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text/event-stream&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="c1"&gt;// Critical: don't reload on tab visibility changes (default behavior).&lt;/span&gt;
        &lt;span class="na"&gt;openWhenHidden&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;onopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;FatalAuthError&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
          &lt;span class="p"&gt;}&lt;/span&gt;
          &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`SSE handshake failed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
          &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="na"&gt;onmessage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;handleMessage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nf"&gt;onclose&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="c1"&gt;// Server closed the stream (e.g., 30-min lifetime cap). Reconnect.&lt;/span&gt;
          &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;closed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;scheduleReconnect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="nf"&gt;onerror&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;FatalAuthError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nx"&gt;closed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="nf"&gt;forceLogout&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// stop fetchEventSource's retry loop&lt;/span&gt;
          &lt;span class="p"&gt;}&lt;/span&gt;
          &lt;span class="c1"&gt;// Network blip — bail out and let our catch handle reconnect.&lt;/span&gt;
          &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;FatalAuthError&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;closed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[notificationStream] disconnected, reconnecting&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nf"&gt;scheduleReconnect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;scheduleReconnect&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;closed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;reconnectDelay&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;reconnectDelay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reconnectDelay&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;RECONNECT_BACKOFF_FACTOR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;RECONNECT_MAX_DELAY_MS&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;closed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Sharp Edges
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;openWhenHidden: true&lt;/code&gt;.&lt;/strong&gt; The default behavior of &lt;code&gt;fetch-event-source&lt;/code&gt; is to &lt;em&gt;close the stream when the tab is backgrounded&lt;/em&gt; and reopen on focus. This sounds reasonable until you realize that's exactly when you most want the connection to stay alive — so the unread count is up to date when the user comes back. Set it to true.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;401/403 = hard stop, not retry.&lt;/strong&gt; A network blip is recoverable. An auth failure is not — the token is bad, retrying every second won't fix it. We throw a tagged &lt;code&gt;FatalAuthError&lt;/code&gt;, force-logout, and break out of the retry loop. Without this, a stale token caused our SSE client to hammer the server with retries forever.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reset backoff on successful event.&lt;/strong&gt; Without this, a connection that hiccups, reconnects, runs fine for an hour, then hiccups again would start the second reconnect at the &lt;em&gt;previous&lt;/em&gt; attempt's max delay. Resetting backoff on every successful event means each disconnect starts fresh.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Server &lt;code&gt;onclose&lt;/code&gt; is normal.&lt;/strong&gt; Our backend caps stream lifetime at 30 minutes (so JWT expiry is handled by reconnect). The client should treat &lt;code&gt;onclose&lt;/code&gt; as routine: schedule reconnect, log nothing scary. Don't surface it as an error.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;AbortController&lt;/code&gt; for cleanup.&lt;/strong&gt; &lt;code&gt;fetchEventSource&lt;/code&gt; doesn't expose a "stop" method; the only way to terminate it is via the &lt;code&gt;signal&lt;/code&gt;. Wire up &lt;code&gt;controller.abort()&lt;/code&gt; in the close handler.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cache Patching, Not Local State
&lt;/h2&gt;

&lt;p&gt;The single most useful pattern in this whole thing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;notificationApi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;util&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;updateQueryData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;getUnreadCount&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;draft&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;draft&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;draft&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;updateQueryData&lt;/code&gt; is RTK Query's escape hatch for updating cached data outside of normal query/mutation flow. It mutates the cache &lt;em&gt;as if&lt;/em&gt; the query had refetched and gotten the new value. Every component subscribed to &lt;code&gt;useGetUnreadCountQuery&lt;/code&gt; re-renders. No new endpoint, no new slice, no prop drilling.&lt;/p&gt;

&lt;p&gt;This is the same primitive RTK Query exposes for optimistic updates. Repurposing it for "we got a server-pushed update" is uncontroversial and pays off the moment a second component (a sidebar count, a modal indicator) needs the same data.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We Didn't Do
&lt;/h2&gt;

&lt;p&gt;A few patterns we considered and skipped:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;WebSockets.&lt;/strong&gt; Overkill for one-way server-to-client updates. SSE is simpler, has built-in reconnect semantics in browsers, and works through every HTTP intermediary that supports &lt;code&gt;Transfer-Encoding: chunked&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Long polling.&lt;/strong&gt; Same byte cost as SSE in steady state, more complicated state machine, no native reconnect helper.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mobile SSE.&lt;/strong&gt; Mobile apps use FCM/APNs push for real-time, plus an on-resume REST call for the unread count when the app foregrounds. JS runtimes on mobile suspend when backgrounded, which makes long-lived SSE unworkable.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;EventSource&lt;/code&gt; can't send headers.&lt;/strong&gt; If you do bearer-token auth, you need a &lt;code&gt;fetch&lt;/code&gt;-based SSE client (we use &lt;code&gt;@microsoft/fetch-event-source&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;openWhenHidden: true&lt;/code&gt; is almost always what you want.&lt;/strong&gt; The default of "close on tab hide" is exactly backwards for an idle long-lived stream.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat 401/403 differently from network errors.&lt;/strong&gt; Auth failures are unrecoverable from the client's perspective — force logout, stop retrying. Network blips are recoverable — exponential backoff.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reset backoff on successful events,&lt;/strong&gt; not just on successful reconnects. A long-running stream that hiccups should re-enter retry at the initial delay, not the previous attempt's max delay.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Server-side stream lifetime caps are a feature, not a bug.&lt;/strong&gt; They give you a clean reconnect boundary that handles mid-stream JWT expiry without any explicit re-validation logic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RTK Query's &lt;code&gt;updateQueryData&lt;/code&gt; is the right place to put server-pushed updates.&lt;/strong&gt; Cache patches propagate to every subscriber. Local component state forces prop drilling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Always make new transports feature-flagged with a polling fallback.&lt;/strong&gt; First time SSE behaves badly behind a customer's corporate proxy, you'll flip the flag and forget about it.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;Have you migrated polling to SSE in production?&lt;/strong&gt; What's the gotcha that bit you? Drop it in the comments.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Building &lt;a href="https://jo4.io" rel="noopener noreferrer"&gt;jo4.io&lt;/a&gt; — a URL shortener whose real-time updates work the way the user expects, even when the network does not.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>react</category>
      <category>typescript</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Server-Sent Events Across Multiple Pods: Redis Pub/Sub + Spring WebFlux</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Mon, 24 Aug 2026 02:25:05 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/server-sent-events-across-multiple-pods-redis-pubsub-spring-webflux-g0o</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/server-sent-events-across-multiple-pods-redis-pubsub-spring-webflux-g0o</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/sse-redis-pubsub-fanout-spring-webflux/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;We replaced our notification-bell polling with Server-Sent Events. On a single pod, this is easy — Spring WebFlux + a &lt;code&gt;Flux&lt;/code&gt; of events, done in 30 lines. On multiple pods, it gets interesting fast: a write that happens on pod A needs to reach a subscriber on pod B, and naively, it doesn't.&lt;/p&gt;

&lt;p&gt;Here's the full pattern: how the SSE endpoint is shaped, how Redis pub/sub fans events across pods, and the subtle ordering and lifecycle gotchas we hit along the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Endpoint
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;/notifications/stream&lt;/code&gt; SSE endpoint that emits the current unread count on subscribe, then live updates whenever the count changes:&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="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"/stream"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;produces&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MediaType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;TEXT_EVENT_STREAM_VALUE&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Flux&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ServerSentEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;NotificationStreamEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nf"&gt;streamNotifications&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;authContext&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;requireScope&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"read"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;userId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;authContext&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getCurrentUser&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

    &lt;span class="nc"&gt;ServerSentEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;NotificationStreamEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;initial&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ServerSentEvent&lt;/span&gt;
        &lt;span class="o"&gt;.&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;NotificationStreamEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"unread-count"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;NotificationStreamEvent&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;unreadCount&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;notificationService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getUnreadCount&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;)))&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

    &lt;span class="nc"&gt;Flux&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ServerSentEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;NotificationStreamEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
        &lt;span class="n"&gt;notificationEventBus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;subscribe&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;map&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;ServerSentEvent&lt;/span&gt;&lt;span class="o"&gt;.&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;NotificationStreamEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="o"&gt;()).&lt;/span&gt;&lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;

    &lt;span class="nc"&gt;Flux&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ServerSentEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;NotificationStreamEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;heartbeat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
        &lt;span class="nc"&gt;Flux&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofSeconds&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;map&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;ServerSentEvent&lt;/span&gt;&lt;span class="o"&gt;.&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;NotificationStreamEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;comment&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ping"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;

    &lt;span class="nc"&gt;Flux&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ServerSentEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;NotificationStreamEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Flux&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;concat&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="nc"&gt;Flux&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;just&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;initial&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
            &lt;span class="nc"&gt;Flux&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;merge&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;heartbeat&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;take&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofMinutes&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;

    &lt;span class="nc"&gt;HttpHeaders&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;apiHeaders&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;set&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpHeaders&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;CACHE_CONTROL&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"no-cache"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;set&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"X-Accel-Buffering"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"no"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&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;A few things worth highlighting:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Initial emit on subscribe.&lt;/strong&gt; The first &lt;code&gt;ServerSentEvent&lt;/code&gt; carries the current count synchronously. Clients get the answer immediately and don't need a separate REST round-trip for first paint.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Heartbeat every 30 seconds.&lt;/strong&gt; SSE looks idle to TCP intermediaries. Comment events (&lt;code&gt;:ping&lt;/code&gt;) keep the connection alive through any proxy/CDN idle timeout.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;X-Accel-Buffering: no&lt;/code&gt;.&lt;/strong&gt; Reverse proxies (nginx and friends) buffer responses by default. SSE is point-the-other-way — buffering means events sit in the proxy until the buffer fills, defeating the entire stream. This header is a hint to skip buffering for this response.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;take(Duration.ofMinutes(30))&lt;/code&gt;.&lt;/strong&gt; Server-initiated close. The stream lives for 30 minutes, then the server hangs up. The client reconnects with a fresh JWT. This is how we handle mid-stream token expiry without inventing our own re-validation logic — close-and-reconnect is &lt;em&gt;the&lt;/em&gt; mechanism.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Per-User Sink
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;notificationEventBus.subscribe(userId)&lt;/code&gt; call lazily creates a per-user &lt;code&gt;Sinks.Many&amp;lt;NotificationStreamEvent&amp;gt;&lt;/code&gt; and returns its &lt;code&gt;Flux&lt;/code&gt;. New subscribers latch onto the same sink; the sink is removed when the last subscriber unsubscribes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Flux&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;NotificationStreamEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;userId&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;Flux&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;defer&lt;/span&gt;&lt;span class="o"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Sinks&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Many&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;NotificationStreamEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;sink&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sinks&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;computeIfAbsent&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Sinks&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;many&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;multicast&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;directBestEffort&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;sink&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;asFlux&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;}).&lt;/span&gt;&lt;span class="na"&gt;doFinally&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;signal&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;cleanupIfEmpty&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;cleanupIfEmpty&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;sinks&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;computeIfPresent&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sink&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;sink&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;currentSubscriberCount&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;sink&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;Two non-obvious choices:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;Flux.defer&lt;/code&gt;&lt;/strong&gt; instead of &lt;code&gt;Flux.create&lt;/code&gt; or eager construction. Without &lt;code&gt;defer&lt;/code&gt;, this race fires:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;subscribe(userId)&lt;/code&gt; returns a &lt;code&gt;Flux&lt;/code&gt; referencing a sink.&lt;/li&gt;
&lt;li&gt;Before the downstream attaches, the &lt;em&gt;previous&lt;/em&gt; subscriber for that user unsubscribes.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cleanupIfEmpty&lt;/code&gt; removes the sink from the map.&lt;/li&gt;
&lt;li&gt;A publish arrives, looks up the sink in the map, finds nothing, drops the event.&lt;/li&gt;
&lt;li&gt;The downstream finally attaches — to the now-orphaned sink.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;code&gt;Flux.defer&lt;/code&gt; rebuilds the lookup at the moment downstream attaches, which makes the &lt;code&gt;computeIfAbsent&lt;/code&gt;/attach pair effectively atomic from the publisher's perspective.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;directBestEffort&lt;/code&gt;&lt;/strong&gt; backpressure. A slow subscriber drops events rather than terminating the stream. For unread-count specifically, this is correct: the latest count &lt;em&gt;replaces&lt;/em&gt; any dropped one, so dropping is harmless.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Cross-Pod Problem
&lt;/h2&gt;

&lt;p&gt;So far so good on one pod. The break comes the moment you scale horizontally.&lt;/p&gt;

&lt;p&gt;Pod A holds the SSE connection for User 42. A write on pod B increments User 42's unread count and publishes to pod B's local sink map. Pod A's sink map has no idea anything happened. The user sees nothing change.&lt;/p&gt;

&lt;p&gt;The fix is Redis pub/sub. Every pod subscribes to a Redis pattern; every write fans out to that pattern; every pod with a local subscriber for the affected user emits to its local sink.&lt;br&gt;
&lt;/p&gt;

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

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="no"&gt;CHANNEL_PREFIX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"jo4:notify:user:"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="no"&gt;CHANNEL_PATTERN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;CHANNEL_PREFIX&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"*"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;ConcurrentHashMap&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Long&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Sinks&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Many&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;NotificationStreamEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;sinks&lt;/span&gt;
        &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ConcurrentHashMap&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;gt;();&lt;/span&gt;

    &lt;span class="nd"&gt;@Override&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;NotificationStreamEvent&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="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;channel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;CHANNEL_PREFIX&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;objectMapper&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;writeValueAsString&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="n"&gt;redisTemplate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;convertAndSend&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;warn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Redis publish failed for userId={}; falling back to local emit"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="n"&gt;emitLocally&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userId&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="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Override&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;onMessage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Message&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;byte&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="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;channel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getChannel&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="nc"&gt;StandardCharsets&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;UTF_8&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;startsWith&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;CHANNEL_PREFIX&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
            &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;userId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;parseLong&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;substring&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;CHANNEL_PREFIX&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="o"&gt;()));&lt;/span&gt;
            &lt;span class="nc"&gt;NotificationStreamEvent&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;objectMapper&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;readValue&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getBody&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="nc"&gt;NotificationStreamEvent&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="n"&gt;emitLocally&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userId&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="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="c1"&gt;// Never throw from MessageListener — would tear down the listener container.&lt;/span&gt;
            &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Failed to handle pub/sub on channel={}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getChannel&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trick:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Publishes go to Redis only.&lt;/strong&gt; They don't touch the local sinks directly. Even pod A's own writes route through Redis, which means every replica's local sinks get updated through one consistent path (no "but we wrote it locally" branch to maintain).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Subscribes happen on every pod&lt;/strong&gt;, via &lt;code&gt;MessageListener&lt;/code&gt;. The pattern subscription is wired in a &lt;code&gt;@Configuration&lt;/code&gt; class (&lt;code&gt;MessageListenerContainer.addMessageListener(bus, new PatternTopic("jo4:notify:user:*"))&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Routing is by channel suffix.&lt;/strong&gt; Channel &lt;code&gt;jo4:notify:user:42&lt;/code&gt; carries events for user 42; every pod that has a local sink for 42 emits, every pod that doesn't ignores the message. No global broadcast to every subscriber.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Fallback to Local
&lt;/h2&gt;

&lt;p&gt;Notice this branch:&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="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;warn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Redis publish failed for userId={}; falling back to local emit"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;emitLocally&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userId&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="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Redis pub/sub is fire-and-forget and the connection can die. If the publish fails, we still emit to the local sink so users on the &lt;em&gt;current&lt;/em&gt; pod don't miss the event. Users on other pods miss it during the outage, but that's strictly better than everyone missing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Transactional-Listener Bridge
&lt;/h2&gt;

&lt;p&gt;How do events actually get into &lt;code&gt;bus.publish(...)&lt;/code&gt;? Not from the controller — the controller is a passive subscriber. They come from the rest of the application, via Spring's transactional event listener:&lt;br&gt;
&lt;/p&gt;

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

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;NotificationService&lt;/span&gt; &lt;span class="n"&gt;notificationService&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;UnreadCountCacheService&lt;/span&gt; &lt;span class="n"&gt;cacheService&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;NotificationEventBus&lt;/span&gt; &lt;span class="n"&gt;eventBus&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@TransactionalEventListener&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;phase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TransactionPhase&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;AFTER_COMMIT&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;onUnreadCountChanged&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;UnreadCountChangedEvent&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="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;userId&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;userId&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;notificationService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getUnreadCountFresh&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="n"&gt;cacheService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;evict&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="n"&gt;eventBus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;publish&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;NotificationStreamEvent&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;unreadCount&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="c1"&gt;// Listener must not throw — would mark the just-committed transaction&lt;/span&gt;
            &lt;span class="c1"&gt;// as failed in some configurations and disrupt unrelated handlers.&lt;/span&gt;
            &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Failed to publish unread-count change for userId={}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things this is doing right:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;AFTER_COMMIT&lt;/code&gt;, not &lt;code&gt;BEFORE_COMMIT&lt;/code&gt;.&lt;/strong&gt; The SSE event must not fire until the DB write commits. Otherwise the client could see a count that's about to be rolled back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fresh DB read, not the cached value.&lt;/strong&gt; After a write, the cache may briefly hold the pre-write count. Reading fresh from the DB is the only safe authority for what we're publishing. Then we evict the cache (rather than write-through it) so the next reader hits the DB and re-populates.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Catch-all that never throws.&lt;/strong&gt; A &lt;code&gt;@TransactionalEventListener&lt;/code&gt; that throws from &lt;code&gt;AFTER_COMMIT&lt;/code&gt; can mark the just-committed transaction as failed in some configurations. Listeners must be defensive.&lt;/p&gt;

&lt;p&gt;The application code that mutates unread counts publishes a &lt;code&gt;UnreadCountChangedEvent&lt;/code&gt; and moves on. It doesn't know about Redis, doesn't know about SSE, doesn't know about cache eviction. All of that is downstream of the event.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A single-pod SSE stream is simple.&lt;/strong&gt; A multi-pod SSE stream with shared subscribers needs a fan-out plane. Redis pub/sub is the cheapest one that works.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Always route publishes through the same path,&lt;/strong&gt; even local ones. "Publish locally + publish to Redis" creates double-delivery possibilities that "publish to Redis only, subscribe from everywhere" avoids by construction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backpressure is a product decision.&lt;/strong&gt; For unread counts, dropping is correct because newer values supersede older ones. For chat messages, dropping is incorrect. Pick &lt;code&gt;directBestEffort&lt;/code&gt;, &lt;code&gt;buffer&lt;/code&gt;, or &lt;code&gt;failFast&lt;/code&gt; per use case.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Flux.defer&lt;/code&gt; for any sink that may be created/destroyed by lifecycle.&lt;/strong&gt; Eager Flux construction races against unsubscribe-then-subscribe sequences in subtle ways.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Server-initiated close is the right way to handle JWT expiry on long-lived streams.&lt;/strong&gt; Hang up after a fixed lifetime; the client reconnects with a fresh token. No mid-stream auth gymnastics.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;MessageListener&lt;/code&gt; callbacks must never throw.&lt;/strong&gt; A thrown exception tears down the listener container and your app stops receiving events with no obvious symptom.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;AFTER_COMMIT&lt;/code&gt; is the only safe phase for "broadcast that the data changed" listeners.&lt;/strong&gt; Anything earlier risks broadcasting a value that's about to roll back.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;How did you scale your real-time stream beyond one process?&lt;/strong&gt; Pub/sub, sticky sessions, websocket cluster? Drop your pattern in the comments.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Building &lt;a href="https://jo4.io" rel="noopener noreferrer"&gt;jo4.io&lt;/a&gt; — a URL shortener with real-time updates that work the same on one pod or twenty.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>springboot</category>
      <category>redis</category>
      <category>java</category>
      <category>webdev</category>
    </item>
    <item>
      <title>We Were Hitting Auth0 /userinfo on Every Request. Here's the Fix.</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Fri, 21 Aug 2026 02:25:31 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/we-were-hitting-auth0-userinfo-on-every-request-heres-the-fix-4blc</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/we-were-hitting-auth0-userinfo-on-every-request-heres-the-fix-4blc</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/auth0-post-login-action-userinfo-cleanup/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you're using Auth0 as an OIDC provider for a Spring backend, here's a question worth asking right now: &lt;strong&gt;what fields does your backend assume are in the access token, but aren't actually there by default?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For us, the answer was &lt;code&gt;email&lt;/code&gt;. And the default behavior, when our user-sync filter found no email claim, was to fall back to a synchronous HTTP call to Auth0's &lt;code&gt;/userinfo&lt;/code&gt; endpoint. Per request. With caching, but not enough caching.&lt;/p&gt;

&lt;p&gt;Once we noticed, the fix took an afternoon. Here's the whole story.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Default Auth0 Access Token
&lt;/h2&gt;

&lt;p&gt;Auth0 access tokens are minimal by design. Out of the box, an access token's claims look something like:&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;"iss"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://your-tenant.auth0.com/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sub"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"auth0|abc123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"aud"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://your-api/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"iat"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1716950000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"exp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1716953600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scope"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"openid profile email"&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;Notice what's &lt;em&gt;not&lt;/em&gt; there: &lt;code&gt;email&lt;/code&gt;, &lt;code&gt;name&lt;/code&gt;, &lt;code&gt;picture&lt;/code&gt;, &lt;code&gt;email_verified&lt;/code&gt;. Those live in the &lt;em&gt;ID token&lt;/em&gt; by default, or come back from a separate &lt;code&gt;/userinfo&lt;/code&gt; call.&lt;/p&gt;

&lt;p&gt;Most tutorials encourage you to use the access token for authorization (verify signature, check scopes) and the ID token for user identity. Fine for a SPA. Awkward for a Spring backend that only sees a single bearer token from &lt;code&gt;Authorization: Bearer &amp;lt;jwt&amp;gt;&lt;/code&gt; and doesn't know which one it is.&lt;/p&gt;

&lt;p&gt;We took a tempting shortcut: assume the access token has &lt;code&gt;email&lt;/code&gt; (because some tenants are configured to inline it), and if it doesn't, fall back to &lt;code&gt;/userinfo&lt;/code&gt; to grab the rest of the profile.&lt;/p&gt;

&lt;h2&gt;
  
  
  What That Looked Like
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getClaimAsString&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"email"&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;firstName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getClaimAsString&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"given_name"&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;lastName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getClaimAsString&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"family_name"&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;picture&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getClaimAsString&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"picture"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;StringUtils&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isBlank&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;warn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Email claim missing from JWT, falling back to /userinfo"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;Auth0UserInfo&lt;/span&gt; &lt;span class="n"&gt;userInfo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fetchUserInfoFromAuth0&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getTokenValue&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;userInfo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;StringUtils&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isBlank&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;firstName&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="n"&gt;firstName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;userInfo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;givenName&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;StringUtils&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isBlank&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lastName&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="n"&gt;lastName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;userInfo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;familyName&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;StringUtils&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isBlank&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;picture&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="n"&gt;picture&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;userInfo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;picture&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fallback was wrapped in a Redis cache plus a sync lock, so on a hot path most requests hit cache and skipped the HTTP call. But:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Cache misses on cold start, after deploys, after Redis restarts, after cache evictions.&lt;/li&gt;
&lt;li&gt;New users always missed the cache by definition.&lt;/li&gt;
&lt;li&gt;The mobile app's startup fan-out (3-5 parallel requests) thundered against the lock, and at least one of them did the actual fetch.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When we measured: a single user could trigger several &lt;code&gt;/userinfo&lt;/code&gt; calls per session. Multiplied across the user base and the rate of token rotations, that's a non-trivial dependency on Auth0 staying fast and available — &lt;em&gt;for every API request to our own service&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Right Fix Is on the Auth0 Side
&lt;/h2&gt;

&lt;p&gt;The clean answer is to never need &lt;code&gt;/userinfo&lt;/code&gt; at request time. Put the claims you need &lt;em&gt;into the access token itself&lt;/em&gt;, at issue time. Auth0 supports this via &lt;strong&gt;Post-Login Actions&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A Post-Login Action runs once per login, on Auth0's side, and can mutate the access token before issuance. The Action that fixed our problem looks like this (Auth0 Action JavaScript):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;exports&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onExecutePostLogin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;namespace&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://jo4.io/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;accessToken&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setCustomClaim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;namespace&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;email`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;accessToken&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setCustomClaim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;namespace&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;email_verified`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email_verified&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;accessToken&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setCustomClaim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;namespace&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;given_name`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;given_name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;accessToken&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setCustomClaim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;namespace&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;family_name`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;family_name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;accessToken&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setCustomClaim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;namespace&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;picture`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;picture&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two important details about that namespace prefix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Auth0 strips non-namespaced custom claims.&lt;/strong&gt; If you &lt;code&gt;setCustomClaim('email', ...)&lt;/code&gt;, Auth0 silently drops it because it conflicts with the OIDC reserved name. Custom claims must be namespaced URIs. We used &lt;code&gt;https://jo4.io/&lt;/code&gt;, but it's a &lt;em&gt;symbolic&lt;/em&gt; URI — there's no requirement Auth0 actually fetch from it. It just needs to look like a URI.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Match the namespace exactly on the backend.&lt;/strong&gt; We made the namespace configurable so the Action and the filter can't drift:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Value&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"${app.auth0.email-claim-key:https://jo4.io/email}"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;emailClaimKey&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="nd"&gt;@Value&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"${app.auth0.given-name-claim-key:https://jo4.io/given_name}"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;givenNameClaimKey&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="nd"&gt;@Value&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"${app.auth0.picture-claim-key:https://jo4.io/picture}"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;pictureClaimKey&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And read from those keys directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getClaimAsString&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;emailClaimKey&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;firstName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getClaimAsString&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;givenNameClaimKey&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;lastName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getClaimAsString&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;familyNameClaimKey&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;picture&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getClaimAsString&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pictureClaimKey&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No fallback. No &lt;code&gt;/userinfo&lt;/code&gt; call. The claims either arrive in the access token or they don't, and if they don't, there's a real configuration bug to fix — not a symptom to paper over.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Graceful Degradation
&lt;/h2&gt;

&lt;p&gt;That said, "no fallback" is too brittle for a deploy where the Action ships before, after, or in the middle of the backend rollout. We added one bounded escape hatch: if the email claim is missing &lt;strong&gt;and&lt;/strong&gt; the user already exists in our DB, log a loud warning and proceed with the existing record:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;StringUtils&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isBlank&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Optional&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;UserEntity&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;userService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findByExternalAuthId&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auth0Id&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isPresent&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;warn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Email claim '{}' missing from JWT for auth0Id={}; "&lt;/span&gt;
            &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"using existing DB record. Verify Auth0 Post-Login Action "&lt;/span&gt;
            &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"sets this custom claim."&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;emailClaimKey&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;auth0Id&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;userSyncCacheService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;markUserSynced&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auth0Id&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getEmail&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Email claim '{}' missing from JWT and user not found "&lt;/span&gt;
        &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"by auth0Id={}. Auth0 Post-Login Action must set this custom claim."&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;emailClaimKey&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;auth0Id&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;IllegalStateException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;"Email claim missing from JWT — Auth0 Post-Login Action misconfigured"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the Action gets disabled accidentally, existing users keep working (with a noisy warning that you'll see the next time you check your logs). New signups fail fast and loud. The error message names the exact misconfiguration. Future-you will thank present-you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Migration Window
&lt;/h2&gt;

&lt;p&gt;We also moved &lt;code&gt;email_verified&lt;/code&gt; from the standard claim name into the namespaced one in a separate change. The migration has a wrinkle: tokens issued before the migration still have the old claim, and they don't expire instantly.&lt;/p&gt;

&lt;p&gt;So during the window we accepted both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;Boolean&lt;/span&gt; &lt;span class="n"&gt;emailVerified&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getClaimAsBoolean&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"https://jo4.io/email_verified"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;emailVerified&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;emailVerified&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Tmp fallback for previously issued tokens.&lt;/span&gt;
    &lt;span class="c1"&gt;// Remove this if-block after [token-rotation cutoff date].&lt;/span&gt;
    &lt;span class="n"&gt;emailVerified&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getClaimAsBoolean&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"email_verified"&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;We dated the cleanup in the comment. After the cutoff (when all pre-migration tokens had expired), we deleted the fallback in a separate commit. One commit to migrate, another to clean up — never the same commit, because the cleanup needs to happen on a clock not on a deploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bonus: Lock Release
&lt;/h2&gt;

&lt;p&gt;A subtler bug we found while in there. The original sync code acquired a Redis lock around the userinfo fetch, but on the success path the lock was never explicitly released — it just expired via TTL after 30 seconds. That meant concurrent requests for that user during the next 30 seconds would all skip the sync entirely.&lt;/p&gt;

&lt;p&gt;The fix is the obvious one: explicit release in &lt;code&gt;finally&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;userSyncCacheService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;tryAcquireSyncLock&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auth0Id&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;userService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findByExternalAuthId&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auth0Id&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;orElse&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;performSync&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;auth0Id&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;userSyncCacheService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;releaseSyncLock&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auth0Id&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The release is best-effort — if Redis is briefly unavailable, the TTL still cleans up. But on the happy path, the lock evaporates the instant we're done, instead of holding everyone else off for half a minute.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Auth0 access tokens have minimal claims by default.&lt;/strong&gt; Don't assume &lt;code&gt;email&lt;/code&gt; or &lt;code&gt;name&lt;/code&gt; are in there — read the actual JWT and find out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't paper over missing claims with a synchronous external call.&lt;/strong&gt; The cache-the-callout approach scales until it doesn't, and the failure mode is "everything is slow" not "auth is broken," which is harder to diagnose.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Push enrichment to the issuer.&lt;/strong&gt; Auth0 Post-Login Actions add custom claims at issue time, once per login, with no per-request cost.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom claims must be URI-namespaced.&lt;/strong&gt; Plain names like &lt;code&gt;email&lt;/code&gt; will be silently stripped — Auth0 reserves those for OIDC. Use a URI prefix you control.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Configure the namespace, don't hardcode it.&lt;/strong&gt; Drift between the Action and the backend is a high-impact misconfiguration; making it a single env var prevents it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Always release distributed locks in &lt;code&gt;finally&lt;/code&gt;.&lt;/strong&gt; Relying on TTL cleanup means your happy path forces unnecessary contention on every other caller for the duration of the TTL.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;Has your auth integration ever hidden a per-request external call you weren't aware of?&lt;/strong&gt; Drop the story in the comments — these are always educational.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Building &lt;a href="https://jo4.io" rel="noopener noreferrer"&gt;jo4.io&lt;/a&gt; — a URL shortener whose backend doesn't ask Auth0 the same question on every API call.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>auth0</category>
      <category>java</category>
      <category>performance</category>
      <category>security</category>
    </item>
    <item>
      <title>DigitalOcean Postgres Has a Firewall Trap. We Walked Right Into It.</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Wed, 19 Aug 2026 02:20:59 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/digitalocean-postgres-has-a-firewall-trap-we-walked-right-into-it-3inf</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/digitalocean-postgres-has-a-firewall-trap-we-walked-right-into-it-3inf</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/digitalocean-postgres-firewall-enforcement-trap/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;We added a database backup pipeline. The pipeline temporarily allowlists the runner IP, runs &lt;code&gt;pg_dump&lt;/code&gt;, then revokes the IP. Standard pattern, used by basically every CI-driven DB job.&lt;/p&gt;

&lt;p&gt;The first run did all of those things correctly. It also took down production.&lt;/p&gt;

&lt;p&gt;Here's what nobody tells you about DigitalOcean managed Postgres.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Setup
&lt;/h2&gt;

&lt;p&gt;Our managed Postgres had no trusted-source rules at all. By default this means "open to the world" — or rather, open to anything that has the connection string and credentials. The droplet running our app talked to it from a public IP, no allowlist required.&lt;/p&gt;

&lt;p&gt;The new pipeline added one rule:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;doctl databases firewalls add &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DB_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--rule&lt;/span&gt; &lt;span class="s2"&gt;"ip_addr:&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;RUNNER_IP&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The runner IP. Just the runner. We expected: "OK, the runner can also reach the DB now."&lt;/p&gt;

&lt;p&gt;What actually happened: &lt;strong&gt;DigitalOcean flipped the database into enforcement mode the moment a single rule was added.&lt;/strong&gt; From "anyone with credentials" to "only IPs on the allowlist." Our droplet wasn't on the allowlist — it had never needed to be. So the application instantly lost its database connection.&lt;/p&gt;

&lt;p&gt;The backup pipeline was running. The dump succeeded. The app was 500-ing for every request the entire time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Quiet Bit
&lt;/h2&gt;

&lt;p&gt;The semantics are documented somewhere — eventually we found a docs page that mentions trusted sources are enforced "when configured." That phrasing is doing a lot of work. "Configured" means "any rule exists." Adding the first rule is the moment your enforcement model changes from "off" to "on."&lt;/p&gt;

&lt;p&gt;There is no warning in the dashboard. No prompt. No "are you sure?" You add a rule, the rule appears in the list, and your previously-public-but-credentialed database becomes private to that one rule. Everything else that wasn't on the list — including the production app — is now blocked.&lt;/p&gt;

&lt;p&gt;If you remove all rules, the DB returns to open mode. So you can dig out of the hole. But during the window, all your traffic is dropped at the network layer, not at the auth layer. From the app's perspective, the database has vanished.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix
&lt;/h2&gt;

&lt;p&gt;Two parts: an immediate ops fix, and a permanent guard.&lt;/p&gt;

&lt;h3&gt;
  
  
  Immediate ops fix
&lt;/h3&gt;

&lt;p&gt;Add the production droplet's IP to the allowlist &lt;em&gt;before&lt;/em&gt; the pipeline runs again. Once your droplet is on the list, the pipeline adding another runner IP is fine — it's just one more entry in an existing allowlist, no enforcement-mode flip.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;DROPLET_IP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;doctl compute droplet list &lt;span class="nt"&gt;--format&lt;/span&gt; Name,PublicIPv4 &lt;span class="nt"&gt;--no-header&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nv"&gt;n&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PROJECT_NAME&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-server"&lt;/span&gt; &lt;span class="s1"&gt;'$1==n {print $2}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
doctl databases firewalls append &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DB_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--rule&lt;/span&gt; &lt;span class="s2"&gt;"ip_addr:&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;DROPLET_IP&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;append&lt;/code&gt;, not &lt;code&gt;add&lt;/code&gt; — same effect for a single rule, but &lt;code&gt;append&lt;/code&gt; makes it explicit you're not setting the entire firewall.&lt;/p&gt;

&lt;h3&gt;
  
  
  Permanent guard
&lt;/h3&gt;

&lt;p&gt;The real fix is preflight in every workflow that touches the firewall. Refuse to run if the production droplet isn't already on the list:&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="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Verify droplet IP is in DB trusted-sources (preflight)&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;# Adding ANY trusted-source rule to a previously-empty list flips DO Postgres&lt;/span&gt;
    &lt;span class="s"&gt;# into enforcement mode. If the droplet IP isn't already on the list, the prod&lt;/span&gt;
    &lt;span class="s"&gt;# app loses DB access the moment we open the runner IP. Fail loudly here so we&lt;/span&gt;
    &lt;span class="s"&gt;# never strand prod again — operator must add the droplet IP permanently.&lt;/span&gt;

    &lt;span class="s"&gt;DB_ID="${{ steps.database.outputs.db_id }}"&lt;/span&gt;
    &lt;span class="s"&gt;DROPLET_NAME="${PROJECT_NAME}-server"&lt;/span&gt;

    &lt;span class="s"&gt;DROPLET_IP=$(doctl compute droplet list --format Name,PublicIPv4 --no-header \&lt;/span&gt;
      &lt;span class="s"&gt;| awk -v n="$DROPLET_NAME" '$1==n {print $2}')&lt;/span&gt;

    &lt;span class="s"&gt;if [ -z "$DROPLET_IP" ]; then&lt;/span&gt;
      &lt;span class="s"&gt;echo "❌ Could not resolve public IP for droplet '$DROPLET_NAME'"&lt;/span&gt;
      &lt;span class="s"&gt;exit 1&lt;/span&gt;
    &lt;span class="s"&gt;fi&lt;/span&gt;

    &lt;span class="s"&gt;if ! doctl databases firewalls list "$DB_ID" \&lt;/span&gt;
        &lt;span class="s"&gt;| awk 'NR&amp;gt;1 &amp;amp;&amp;amp; $3=="ip_addr" {print $4}' \&lt;/span&gt;
        &lt;span class="s"&gt;| grep -qxF "$DROPLET_IP"; then&lt;/span&gt;
      &lt;span class="s"&gt;echo "❌ Droplet IP $DROPLET_IP is NOT in DB trusted-sources for $DB_ID"&lt;/span&gt;
      &lt;span class="s"&gt;echo ""&lt;/span&gt;
      &lt;span class="s"&gt;echo "Add it once (operator) before re-running this workflow:"&lt;/span&gt;
      &lt;span class="s"&gt;echo "  doctl databases firewalls append $DB_ID --rule \"ip_addr:$DROPLET_IP\""&lt;/span&gt;
      &lt;span class="s"&gt;echo ""&lt;/span&gt;
      &lt;span class="s"&gt;echo "Refusing to proceed — opening the runner IP without the droplet rule"&lt;/span&gt;
      &lt;span class="s"&gt;echo "would lock prod out of the database."&lt;/span&gt;
      &lt;span class="s"&gt;exit 1&lt;/span&gt;
    &lt;span class="s"&gt;fi&lt;/span&gt;

    &lt;span class="s"&gt;echo "✅ Droplet IP confirmed in trusted-sources"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a hard guard, not a hint. Without the droplet rule, the workflow refuses to run. The operator has to add the rule once, manually, with full knowledge of what they're doing. After that, every subsequent workflow run sails through the preflight.&lt;/p&gt;

&lt;p&gt;We added the same preflight to every other workflow that touches the DB firewall — backup, seed, restore. One pattern, replicated everywhere we mutate trusted-sources.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Footnote on &lt;code&gt;doctl&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;While we were in there, another &lt;code&gt;doctl&lt;/code&gt; quirk: &lt;code&gt;doctl databases firewalls list&lt;/code&gt; doesn't support &lt;code&gt;--no-header&lt;/code&gt; or &lt;code&gt;--format&lt;/code&gt;. Both flags exist for &lt;em&gt;other&lt;/em&gt; &lt;code&gt;doctl&lt;/code&gt; subcommands, but not this one. We were parsing column 3 expecting &lt;code&gt;(UUID, Type, Value)&lt;/code&gt; and getting back &lt;code&gt;(UUID, ClusterUUID, Type, Value)&lt;/code&gt; because the un-suppressible header line tipped our &lt;code&gt;awk&lt;/code&gt; indexing off by one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Wrong (silently parses nothing):&lt;/span&gt;
doctl databases firewalls list &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DB_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--no-header&lt;/span&gt; &lt;span class="nt"&gt;--format&lt;/span&gt; UUID,Type,Value &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nv"&gt;ip&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RUNNER_IP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s1"&gt;'$2=="ip_addr" &amp;amp;&amp;amp; $3==ip {print $1}'&lt;/span&gt;

&lt;span class="c"&gt;# Right:&lt;/span&gt;
doctl databases firewalls list &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DB_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nv"&gt;ip&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RUNNER_IP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s1"&gt;'NR&amp;gt;1 &amp;amp;&amp;amp; $3=="ip_addr" &amp;amp;&amp;amp; $4==ip {print $1}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Skip the header with &lt;code&gt;NR&amp;gt;1&lt;/code&gt;, accept that columns are positional, and remember that &lt;code&gt;doctl&lt;/code&gt; flag support is per-subcommand. There's no global contract.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Empty allowlist is not the same as no enforcement.&lt;/strong&gt; A managed-DB firewall with zero rules behaves as "off." A firewall with one rule behaves as "deny by default." Adding the first rule is a state change, not an addition.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test firewall changes against a non-prod database first&lt;/strong&gt; if your platform doesn't have explicit "enforcement: on" / "enforcement: off" toggles. The blast radius of the first rule is the entire system.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hard preflights beat warnings.&lt;/strong&gt; A workflow comment that says "be sure the droplet IP is on the allowlist" is wishful thinking. A preflight that exits non-zero unless the droplet IP is on the allowlist is a control.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;doctl&lt;/code&gt; flag support is per-subcommand.&lt;/strong&gt; Don't assume &lt;code&gt;--format&lt;/code&gt; works just because the next subcommand over supports it. Read the actual &lt;code&gt;--help&lt;/code&gt; for the exact subcommand you're calling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When ops touches network ACLs, fence both ends.&lt;/strong&gt; Open the rule, run the work, close the rule, &lt;em&gt;and&lt;/em&gt; preflight that the persistent rules required by prod are present before you do any of that.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;Have you been bitten by an "off-by-default" cloud setting that turned on the moment you configured anything?&lt;/strong&gt; Drop the story in the comments.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Building &lt;a href="https://jo4.io" rel="noopener noreferrer"&gt;jo4.io&lt;/a&gt; — a URL shortener whose ops scripts now treat firewall rules like loaded weapons.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>digitalocean</category>
      <category>postgres</category>
      <category>devops</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Why Your JPA Counter Column Keeps Resetting to Zero</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Mon, 17 Aug 2026 02:22:16 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/why-your-jpa-counter-column-keeps-resetting-to-zero-2inh</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/why-your-jpa-counter-column-keeps-resetting-to-zero-2inh</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/jpa-modifying-query-version-counter-bug/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I'll cut to the chase: if you have a counter column, a &lt;code&gt;@Modifying @Query&lt;/code&gt; that increments it, &lt;strong&gt;and&lt;/strong&gt; a &lt;code&gt;@Version&lt;/code&gt; field on the same entity, you almost certainly have a silent data-loss bug.&lt;/p&gt;

&lt;p&gt;We had this exact bug. It cost us about two weeks of "huh, that referrer's click count looks low" before we figured out what was happening. Here's what we found and how we fixed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Setup
&lt;/h2&gt;

&lt;p&gt;We track referrer attribution. Every time someone clicks a referral short link, we increment a denormalized &lt;code&gt;total_clicks&lt;/code&gt; counter on the referrer row. Doing this with a load-modify-save round-trip would be slow under fan-out, so we use a &lt;code&gt;@Modifying @Query&lt;/code&gt; that increments at the SQL level:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Modifying&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;clearAutomatically&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@Query&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"UPDATE ReferrerEntity r "&lt;/span&gt;
     &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"SET r.totalClicks = COALESCE(r.totalClicks, 0) + 1, "&lt;/span&gt;
     &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"    r.modifiedTime = :now "&lt;/span&gt;
     &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"WHERE r.id = :referrerId AND r.deleted = false"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;incrementClickCount&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@Param&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"referrerId"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;referrerId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                        &lt;span class="nd"&gt;@Param&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"now"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Looks fine. Atomic at the DB level. Counts always go up. Until you also have this on the entity:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Version&lt;/span&gt;
&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="nd"&gt;@Column&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"total_clicks"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@Builder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Default&lt;/span&gt;
&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;totalClicks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0L&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And somewhere else in the code path, an unrelated service does this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;ReferrerEntity&lt;/span&gt; &lt;span class="n"&gt;referrer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;referrerRepository&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;id&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;orElseThrow&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;referrer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setPayoutEmail&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;newEmail&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;referrerRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;referrer&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// &amp;lt;-- here be dragons&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;Watch the timeline:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;Thread A (click handler)&lt;/th&gt;
&lt;th&gt;Thread B (settings update)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;findById(id)&lt;/code&gt; → loads referrer with &lt;code&gt;totalClicks=42, version=7&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;incrementClickCount(id)&lt;/code&gt; → SQL &lt;code&gt;UPDATE&lt;/code&gt;, row now &lt;code&gt;totalClicks=43, version=7&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;incrementClickCount(id)&lt;/code&gt; → SQL &lt;code&gt;UPDATE&lt;/code&gt;, row now &lt;code&gt;totalClicks=44, version=7&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;setPayoutEmail(...)&lt;/code&gt;; &lt;code&gt;save()&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;What does step 4 do? It writes back the entity Hibernate has in memory: &lt;code&gt;totalClicks=42, version=7, payoutEmail=newEmail&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;@Version&lt;/code&gt; check passes — version &lt;em&gt;is&lt;/em&gt; still 7, because our &lt;code&gt;@Modifying&lt;/code&gt; query never bumped it. So Hibernate happily issues:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;referrer&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;total_clicks&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="n"&gt;payout_email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'...'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8&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="o"&gt;?&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two clicks, gone. Silently. The version mechanism that's supposed to prevent exactly this lost-update did &lt;em&gt;not&lt;/em&gt; prevent it, because the writes that bumped the counter never bumped the version.&lt;/p&gt;

&lt;p&gt;The bug compounds under load. The hotter the counter, the more clicks get clobbered. Slow weeks the counter looks fine; viral campaigns it's wildly under-counted. Fun debugging.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Two-Part Fix
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Part 1: Mark counter columns &lt;code&gt;updatable = false&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The fundamental issue is that Hibernate's &lt;code&gt;save()&lt;/code&gt; writes &lt;em&gt;every&lt;/em&gt; column it tracks, including ones it has no business writing. A counter that's only ever incremented by &lt;code&gt;@Modifying @Query&lt;/code&gt; is one of those columns. Tell Hibernate to leave it alone:&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;@Column&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"total_clicks"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;updatable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@Builder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Default&lt;/span&gt;
&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;totalClicks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0L&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="nd"&gt;@Column&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"total_conversions"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;updatable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@Builder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Default&lt;/span&gt;
&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;totalConversions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0L&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;updatable = false&lt;/code&gt; tells Hibernate: this column is read-only from the entity's perspective. Don't include it in &lt;code&gt;UPDATE&lt;/code&gt; statements. The only path that writes to it is the explicit SQL update. &lt;code&gt;save()&lt;/code&gt; calls now leave the counter alone, no matter what value the in-memory entity has.&lt;/p&gt;

&lt;p&gt;This is the rule we now apply across the codebase. Anywhere a column is mutated &lt;em&gt;exclusively&lt;/em&gt; via &lt;code&gt;@Modifying @Query&lt;/code&gt; — counters, denormalized stats, queue depths — that column gets &lt;code&gt;updatable = false&lt;/code&gt;. Without exception.&lt;/p&gt;

&lt;h3&gt;
  
  
  Part 2: Bump the version inside the &lt;code&gt;@Modifying @Query&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;updatable = false&lt;/code&gt; plugs the lost-update hole, but a different hole opens: now &lt;code&gt;save()&lt;/code&gt; doesn't know the row changed, so optimistic-concurrency races against &lt;em&gt;other&lt;/em&gt; fields silently overwrite each other.&lt;/p&gt;

&lt;p&gt;Concretely: thread A bumps the counter via &lt;code&gt;@Modifying&lt;/code&gt;. Thread B loads the entity (with old &lt;code&gt;payoutEmail&lt;/code&gt;), updates it, saves. Without a version bump, B's save succeeds — but B was operating on a row that A had already mutated. If you care about A's mutation being a "real" change (and you should, for audit purposes), B should have hit &lt;code&gt;OptimisticLockException&lt;/code&gt; and retried.&lt;/p&gt;

&lt;p&gt;The fix is one extra clause in the SET:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Modifying&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;clearAutomatically&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@Query&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"UPDATE ReferrerEntity r "&lt;/span&gt;
     &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"SET r.totalClicks = COALESCE(r.totalClicks, 0) + 1, "&lt;/span&gt;
     &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"    r.modifiedTime = :now, "&lt;/span&gt;
     &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"    r.version = COALESCE(r.version, 0) + 1 "&lt;/span&gt;  &lt;span class="c1"&gt;// &amp;lt;-- this line&lt;/span&gt;
     &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"WHERE r.id = :referrerId AND r.deleted = false"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;incrementClickCount&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@Param&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"referrerId"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;referrerId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                        &lt;span class="nd"&gt;@Param&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"now"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Apply this to &lt;strong&gt;every&lt;/strong&gt; &lt;code&gt;@Modifying @Query&lt;/code&gt; UPDATE on every entity that has a &lt;code&gt;@Version&lt;/code&gt; field. We did the audit and bumped versions in around fifteen queries across &lt;code&gt;ReferrerRepository&lt;/code&gt;, &lt;code&gt;TeamRepository&lt;/code&gt;, &lt;code&gt;UrlVariantRepository&lt;/code&gt;, and others. Easy fix, big footprint.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Test That Would Have Caught It
&lt;/h2&gt;

&lt;p&gt;We added a guard test that walks repository methods reflectively, finds every &lt;code&gt;@Modifying @Query&lt;/code&gt; UPDATE, checks the JPQL string for &lt;code&gt;version =&lt;/code&gt; in the SET clause, and fails if any are missing:&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;@Test&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;everyModifyingQueryBumpsVersionWhenEntityHasVersion&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Method&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;queries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;findAllModifyingQueriesOn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ReferrerRepository&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="k"&gt;for&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Method&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;queries&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Query&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getAnnotation&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Query&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="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;jpql&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;isUpdate&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jpql&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entityHasVersion&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;targetEntity&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;)))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;assertTrue&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;jpql&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;replaceAll&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"\\s+"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;" "&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;contains&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"version = COALESCE("&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
                &lt;span class="n"&gt;m&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="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;" is missing version bump"&lt;/span&gt;
            &lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the kind of test that will save you the next time someone adds a &lt;code&gt;@Modifying @Query&lt;/code&gt; six months from now and forgets the version bump.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;save()&lt;/code&gt; writes every column it tracks, including ones you wish it wouldn't.&lt;/strong&gt; If a column is only mutated by direct SQL, mark it &lt;code&gt;updatable = false&lt;/code&gt; to keep &lt;code&gt;save()&lt;/code&gt; from silently overwriting it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;@Version&lt;/code&gt; only protects what the JPA layer knows changed.&lt;/strong&gt; A &lt;code&gt;@Modifying @Query&lt;/code&gt; that doesn't bump the version is invisible to the optimistic-lock check. Subsequent &lt;code&gt;save()&lt;/code&gt; calls will overwrite &lt;em&gt;and&lt;/em&gt; the version check will pass.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add &lt;code&gt;r.version = COALESCE(r.version, 0) + 1&lt;/code&gt; to every &lt;code&gt;@Modifying @Query&lt;/code&gt; UPDATE&lt;/strong&gt; on a versioned entity. No exceptions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reflection-based guard tests are cheap insurance.&lt;/strong&gt; A single test that walks repository methods catches the next regression before it merges. Worth the 100 lines.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Counter columns are a DSL of their own.&lt;/strong&gt; They have one valid mutation pattern (atomic SQL increment) and one valid read pattern (load and trust). Anything else is a bug waiting for traffic.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;Have you been bitten by JPA's "helpful" save behavior?&lt;/strong&gt; What was the symptom that led you to it? Drop the war story in the comments.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Building &lt;a href="https://jo4.io" rel="noopener noreferrer"&gt;jo4.io&lt;/a&gt; — a URL shortener whose click counters add up the same way every time.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>java</category>
      <category>springboot</category>
      <category>jpa</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Backups That You Don't Restore Aren't Backups: A Weekly Restore Drill</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Fri, 14 Aug 2026 03:26:42 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/backups-that-you-dont-restore-arent-backups-a-weekly-restore-drill-4eld</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/backups-that-you-dont-restore-arent-backups-a-weekly-restore-drill-4eld</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/database-backups-restore-test-do-spaces/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Most teams have backups. Half of those teams have never restored one. The other half discovered the hard way that their dumps were corrupt, incomplete, or pointed at a database schema that no longer exists.&lt;/p&gt;

&lt;p&gt;Backups you don't actively restore aren't backups — they're warm fuzzy feelings. Here's how we built a backup pipeline whose first-class citizen is &lt;strong&gt;the weekly restore&lt;/strong&gt;, not the nightly dump.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Pipeline at 30,000 Feet
&lt;/h2&gt;

&lt;p&gt;Three GitHub Actions workflows:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Nightly backup&lt;/strong&gt; — &lt;code&gt;pg_dump&lt;/code&gt; from the production DB, gzip, upload to DigitalOcean Spaces. Runs at 03:00 UTC daily.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restore test&lt;/strong&gt; — pulls the latest backup, restores it into a Docker &lt;code&gt;postgres:16&lt;/code&gt; container in the runner, asserts table count and row counts. Runs Sunday 04:00 UTC.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Manual restore&lt;/strong&gt; — parameterized one-click pipeline for the actual recovery flow. Same code path the restore test exercises, minus the assertions, plus the parameter for "which backup."&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The clever bit isn't (1). The clever bit is (2) hard-failing if (1) silently broke.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Backup Workflow
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;DigitalOcean-460-Backup&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;3&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*'&lt;/span&gt;   &lt;span class="c1"&gt;# 03:00 UTC daily&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

&lt;span class="na"&gt;concurrency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jo4-db-backup&lt;/span&gt;
  &lt;span class="na"&gt;cancel-in-progress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;   &lt;span class="c1"&gt;# Don't cancel a running backup&lt;/span&gt;

&lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;DO_REGION&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nyc3&lt;/span&gt;
  &lt;span class="na"&gt;PROJECT_NAME&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jo4&lt;/span&gt;
  &lt;span class="na"&gt;SCHEMA_NAME&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;alertstage&lt;/span&gt;
  &lt;span class="na"&gt;BACKUP_PREFIX&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;db-daily-backup&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two settings worth highlighting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;concurrency.cancel-in-progress: false&lt;/code&gt;&lt;/strong&gt; — if &lt;code&gt;workflow_dispatch&lt;/code&gt; fires while the cron is running, queue the new run, don't cancel the old one. Cancelling a half-uploaded dump leaves a corrupt object in the bucket and a confusing log trail.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pin the &lt;code&gt;pg_dump&lt;/code&gt; major version.&lt;/strong&gt; PostgreSQL is forgiving about minor versions, but a &lt;code&gt;pg_dump&lt;/code&gt; from a different major to the server is a bug factory:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Install PostgreSQL client and verify version&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;sudo apt-get install -y -qq postgresql-client&lt;/span&gt;
    &lt;span class="s"&gt;PG_DUMP_MAJOR=$(pg_dump --version | awk '{print $3}' | cut -d. -f1)&lt;/span&gt;
    &lt;span class="s"&gt;if [ "$PG_DUMP_MAJOR" != "16" ]; then&lt;/span&gt;
      &lt;span class="s"&gt;echo "❌ pg_dump must be 16 to match server, got: $(pg_dump --version)"&lt;/span&gt;
      &lt;span class="s"&gt;exit 1&lt;/span&gt;
    &lt;span class="s"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Firewall Dance
&lt;/h2&gt;

&lt;p&gt;Our managed Postgres is locked behind a trusted-source IP allowlist. The runner IP rotates every job. So every backup adds the runner's IP, runs the dump, and removes the IP — even if the dump fails:&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="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Open DB trusted-source for runner IP&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;db_firewall&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;RUNNER_IP=$(curl -s https://api.ipify.org)&lt;/span&gt;
    &lt;span class="s"&gt;DB_ID="${{ steps.database.outputs.db_id }}"&lt;/span&gt;
    &lt;span class="s"&gt;doctl databases firewalls add "$DB_ID" --rule "ip_addr:${RUNNER_IP}"&lt;/span&gt;
    &lt;span class="s"&gt;echo "runner_ip=$RUNNER_IP" &amp;gt;&amp;gt; $GITHUB_OUTPUT&lt;/span&gt;
    &lt;span class="s"&gt;echo "db_id=$DB_ID"          &amp;gt;&amp;gt; $GITHUB_OUTPUT&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Close DB trusted-source (revoke runner IP)&lt;/span&gt;
  &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always()&lt;/span&gt;   &lt;span class="c1"&gt;# critical — runs even if the dump step failed&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;UUIDS=$(doctl databases firewalls list "$DB_ID" \&lt;/span&gt;
      &lt;span class="s"&gt;| awk -v ip="$RUNNER_IP" 'NR&amp;gt;1 &amp;amp;&amp;amp; $3=="ip_addr" &amp;amp;&amp;amp; $4==ip {print $1}')&lt;/span&gt;
    &lt;span class="s"&gt;echo "$UUIDS" | while read -r uuid; do&lt;/span&gt;
      &lt;span class="s"&gt;[ -z "$uuid" ] &amp;amp;&amp;amp; continue&lt;/span&gt;
      &lt;span class="s"&gt;doctl databases firewalls remove "$DB_ID" "$uuid" || true&lt;/span&gt;
    &lt;span class="s"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;if: always()&lt;/code&gt; is non-negotiable here. If the dump fails and we &lt;em&gt;don't&lt;/em&gt; clean up, we leak ephemeral runner IPs into the trusted-sources list. After a few months you're looking at hundreds of stale entries.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Dump Itself
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Run pg_dump&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;TS=$(date -u +%Y%m%d-%H%M%S)&lt;/span&gt;
    &lt;span class="s"&gt;DUMP_FILE="/tmp/jo4db-${TS}.sql"&lt;/span&gt;

    &lt;span class="s"&gt;PGPASSWORD="${{ steps.database.outputs.db_password }}" \&lt;/span&gt;
    &lt;span class="s"&gt;PGSSLMODE=require \&lt;/span&gt;
    &lt;span class="s"&gt;pg_dump \&lt;/span&gt;
      &lt;span class="s"&gt;-h "${DB_HOST}" -p "${DB_PORT}" -U "${DB_USER}" -d "${DB_DATABASE}" \&lt;/span&gt;
      &lt;span class="s"&gt;--schema=${SCHEMA_NAME} \&lt;/span&gt;
      &lt;span class="s"&gt;--no-owner \&lt;/span&gt;
      &lt;span class="s"&gt;--no-privileges \&lt;/span&gt;
      &lt;span class="s"&gt;--quote-all-identifiers \&lt;/span&gt;
      &lt;span class="s"&gt;-f "${DUMP_FILE}"&lt;/span&gt;

    &lt;span class="s"&gt;gzip -9 "${DUMP_FILE}"&lt;/span&gt;

    &lt;span class="s"&gt;SIZE_BYTES=$(stat -c%s "${DUMP_FILE}.gz")&lt;/span&gt;
    &lt;span class="s"&gt;if [ "$SIZE_BYTES" -lt 1024 ]; then&lt;/span&gt;
      &lt;span class="s"&gt;echo "❌ Dump suspiciously small ($SIZE_BYTES bytes) — aborting upload"&lt;/span&gt;
      &lt;span class="s"&gt;exit 1&lt;/span&gt;
    &lt;span class="s"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;--no-owner --no-privileges&lt;/code&gt;&lt;/strong&gt; — the dump is restorable on any Postgres 16+ instance, not just the one with our exact role names. Important for the restore test, which uses a fresh container.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;--quote-all-identifiers&lt;/code&gt;&lt;/strong&gt; — defends against case-sensitivity surprises if the restore target uses different default search paths.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The 1KB sanity floor&lt;/strong&gt; — even an empty schema produces more than 1KB of dump (CREATE TABLE statements, comments, etc.). If the dump is smaller, something went wrong, abort before you upload garbage that overwrites yesterday's good backup.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Lifecycle Rule (Idempotently)
&lt;/h2&gt;

&lt;p&gt;We expire backups after 30 days via a bucket lifecycle rule. The catch: &lt;code&gt;s3cmd setlifecycle&lt;/code&gt; &lt;em&gt;replaces&lt;/em&gt; the entire bucket policy. We don't want to clobber other rules:&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="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Ensure 30-day lifecycle rule on backup prefix&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;BUCKET="${PROJECT_NAME}-assets"&lt;/span&gt;
    &lt;span class="s"&gt;RULE_ID="expire-daily-backups"&lt;/span&gt;

    &lt;span class="s"&gt;EXISTING=$(s3cmd getlifecycle "s3://${BUCKET}" 2&amp;gt;&amp;amp;1 || true)&lt;/span&gt;

    &lt;span class="s"&gt;if echo "$EXISTING" | grep -qF "${RULE_ID}"; then&lt;/span&gt;
      &lt;span class="s"&gt;echo "✅ Lifecycle rule already in place — skipping"&lt;/span&gt;
    &lt;span class="s"&gt;elif echo "$EXISTING" | grep -q "&amp;lt;Rule&amp;gt;"; then&lt;/span&gt;
      &lt;span class="s"&gt;echo "❌ Bucket has other rules but not '${RULE_ID}'."&lt;/span&gt;
      &lt;span class="s"&gt;echo "   Refusing to overwrite. Merge manually."&lt;/span&gt;
      &lt;span class="s"&gt;exit 1&lt;/span&gt;
    &lt;span class="s"&gt;else&lt;/span&gt;
      &lt;span class="s"&gt;echo "&amp;lt;LifecycleConfiguration&amp;gt;&amp;lt;Rule&amp;gt;&amp;lt;ID&amp;gt;${RULE_ID}&amp;lt;/ID&amp;gt;..." &amp;gt; /tmp/lifecycle.xml&lt;/span&gt;
      &lt;span class="s"&gt;s3cmd setlifecycle /tmp/lifecycle.xml "s3://${BUCKET}"&lt;/span&gt;
    &lt;span class="s"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three branches: ours is there (skip), foreign rules but not ours (abort, don't overwrite), nothing at all (apply ours). This is the only way to safely re-run lifecycle setup automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Restore Test — The Whole Point
&lt;/h2&gt;

&lt;p&gt;Here's where most teams stop. Don't.&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;DigitalOcean-470-Restore-Test&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;4&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;0'&lt;/span&gt;   &lt;span class="c1"&gt;# Sunday 04:00 UTC&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;restore-test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;postgres&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:16&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;testadmin&lt;/span&gt;
          &lt;span class="na"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;testpw_local_only&lt;/span&gt;
          &lt;span class="na"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jo4test&lt;/span&gt;
        &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;5432:5432'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Sandbox banner &amp;amp; hard guard&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;# Hard guard #1 — target host MUST be localhost&lt;/span&gt;
          &lt;span class="s"&gt;if [ "${TEST_PG_HOST}" != "localhost" ] \&lt;/span&gt;
             &lt;span class="s"&gt;&amp;amp;&amp;amp; [ "${TEST_PG_HOST}" != "127.0.0.1" ]; then&lt;/span&gt;
            &lt;span class="s"&gt;echo "❌ Restore target is not localhost: ${TEST_PG_HOST}"&lt;/span&gt;
            &lt;span class="s"&gt;exit 1&lt;/span&gt;
          &lt;span class="s"&gt;fi&lt;/span&gt;

          &lt;span class="s"&gt;# Hard guard #2 — must not look like a managed service&lt;/span&gt;
          &lt;span class="s"&gt;if echo "${TEST_PG_HOST}" \&lt;/span&gt;
             &lt;span class="s"&gt;| grep -qE "(ondigitalocean|amazonaws|gcp|azure)"; then&lt;/span&gt;
            &lt;span class="s"&gt;echo "❌ Target appears to be cloud-managed: ${TEST_PG_HOST}"&lt;/span&gt;
            &lt;span class="s"&gt;exit 1&lt;/span&gt;
          &lt;span class="s"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The hard guards are paranoia, on purpose. The restore-test workflow shares code paths with the manual restore workflow. One env var typo — &lt;code&gt;TEST_PG_HOST=jo4-db-do-user-...ondigitalocean.com&lt;/code&gt; — and you've just restored last week's backup over today's production database. The two &lt;code&gt;grep -qE&lt;/code&gt; checks make that mistake un-shippable.&lt;/p&gt;

&lt;p&gt;The rest of the restore test:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Download the most recent backup from Spaces.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hard-fail if the most recent backup is older than 2 days.&lt;/strong&gt; This is the silent-failure detector — if the nightly cron stops working, the next Sunday's restore test fails loudly within a week.&lt;/li&gt;
&lt;li&gt;Pipe the gzipped SQL into &lt;code&gt;psql&lt;/code&gt; against the localhost container.&lt;/li&gt;
&lt;li&gt;Assert: minimum table count (catches half-applied schemas), positive row counts on key tables (catches empty dumps that passed the 1KB sanity floor).&lt;/li&gt;
&lt;li&gt;Record end-to-end restore duration. This is your &lt;em&gt;empirical&lt;/em&gt; RTO, not a number you guessed.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What This Buys You
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;nightly backup&lt;/strong&gt; that fails noisily if the dump is anomalously small or the upload doesn't complete.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;30-day rolling window&lt;/strong&gt; maintained by the bucket itself, not a cleanup script you'll forget about.&lt;/li&gt;
&lt;li&gt;An &lt;strong&gt;automated weekly proof&lt;/strong&gt; that backups are restorable, restored against a real Postgres of the same major version, with assertions on what came out.&lt;/li&gt;
&lt;li&gt;An &lt;strong&gt;empirical RTO&lt;/strong&gt; — every Sunday measures it, every quarter you can compare measurements against your commitment.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;manual restore workflow&lt;/strong&gt; that's the same code path the test exercises — your worst day is not the day you debug the restore script.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Untested backups have a 50% failure rate.&lt;/strong&gt; Industry data, our internal data, your data — same number. Test.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Failing-loud beats failing-silent every time.&lt;/strong&gt; A workflow that hard-fails on stale backups, on small dumps, on cross-major &lt;code&gt;pg_dump&lt;/code&gt; versions, on non-localhost restore targets — that workflow tells you when something's broken before you need it to work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pin the &lt;code&gt;pg_dump&lt;/code&gt; major version.&lt;/strong&gt; Cross-major dumps will &lt;em&gt;seem&lt;/em&gt; to work and will be missing things you depended on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Always use &lt;code&gt;if: always()&lt;/code&gt; for cleanup steps that touch shared state&lt;/strong&gt; (firewall rules, lock files, lifecycle policies). The cleanup needs to run on failure, otherwise failure leaks state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Concurrency cancellation is the wrong default for backups.&lt;/strong&gt; Queue them, don't cancel them. Cancelled &lt;code&gt;pg_dump&lt;/code&gt; mid-upload is the kind of bug that masquerades as a corrupt restore six weeks later.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;When did you last restore a backup?&lt;/strong&gt; What broke when you tried? Drop it in the comments — the post-mortems are always educational.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Building &lt;a href="https://jo4.io" rel="noopener noreferrer"&gt;jo4.io&lt;/a&gt; — a URL shortener with a documented RPO, RTO, and a weekly proof we can hit them.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>devops</category>
      <category>githubactions</category>
      <category>database</category>
    </item>
    <item>
      <title>How We Reset Monthly Quotas Without Race Conditions or Cron Drift</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Wed, 12 Aug 2026 03:26:05 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/how-we-reset-monthly-quotas-without-race-conditions-or-cron-drift-35j6</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/how-we-reset-monthly-quotas-without-race-conditions-or-cron-drift-35j6</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/monthly-quota-reset-spring-scheduled/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you're billing monthly via Stripe or RevenueCat, it's tempting to reset usage quotas inside your webhook handler. The customer pays → invoice fires → you zero out their &lt;code&gt;urlCount&lt;/code&gt;. Clean. One source of truth.&lt;/p&gt;

&lt;p&gt;It works for &lt;em&gt;some&lt;/em&gt; customers. Two customer types it doesn't work for, and they're both yours:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Yearly subscribers.&lt;/strong&gt; Their payment event fires once a year. Without a separate reset, they get the &lt;em&gt;monthly&lt;/em&gt; cap (e.g., 500 URLs/month on Pro) and then exhaust it forever, because nothing zeroes the counter for the next 11 months.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FREE users.&lt;/strong&gt; They never trigger a payment event. Their &lt;code&gt;urlCount&lt;/code&gt; only moves on URL create/delete, so it grows linearly and never resets.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We needed a quota reset that's &lt;em&gt;decoupled&lt;/em&gt; from billing cadence. Here's the Spring scheduler + Postgres advisory lock job we built.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Shape of the Problem
&lt;/h2&gt;

&lt;p&gt;Two things must be true:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The job runs once per calendar month, regardless of how many app instances are running.&lt;/li&gt;
&lt;li&gt;Two instances waking up at the same moment must not both run it. (We're not idempotent — &lt;code&gt;urlCount = 0&lt;/code&gt; is, but the audit log isn't, and we want exactly one log line per reset.)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The naive solution is a global database flag, but you race on that too. The clean solution in Postgres is &lt;code&gt;pg_try_advisory_xact_lock&lt;/code&gt; — an instance-level lock keyed on an integer that auto-releases at transaction end.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Scheduler
&lt;/h2&gt;



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

    &lt;span class="c1"&gt;// Per-job lock ID. Distinct from other schedulers using advisory locks.&lt;/span&gt;
    &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="no"&gt;LOCK_ID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1234567891L&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;UserRepository&lt;/span&gt; &lt;span class="n"&gt;userRepository&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;TeamRepository&lt;/span&gt; &lt;span class="n"&gt;teamRepository&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;EntityManager&lt;/span&gt; &lt;span class="n"&gt;entityManager&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;TransactionTemplate&lt;/span&gt; &lt;span class="n"&gt;transactionTemplate&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="cm"&gt;/**
     * Reset quotas at 00:05 UTC on the 1st of each month.
     * Staggered 5 minutes off midnight to dodge the top-of-hour load spike.
     */&lt;/span&gt;
    &lt;span class="nd"&gt;@Scheduled&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cron&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"0 5 0 1 * *"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;zone&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"UTC"&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;resetMonthlyQuotas&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;transactionTemplate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;execute&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="nc"&gt;Boolean&lt;/span&gt; &lt;span class="n"&gt;acquired&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="n"&gt;entityManager&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;createNativeQuery&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"SELECT pg_try_advisory_xact_lock(:lockId)"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setParameter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"lockId"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;LOCK_ID&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getSingleResult&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(!&lt;/span&gt;&lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;TRUE&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;equals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;acquired&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;debug&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Monthly reset skipped — another instance has the lock"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;

            &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;info&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Acquired lock, running monthly quota reset..."&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;currentTimeMillis&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
            &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;userRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;resetAllUrlCounts&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;teams&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;teamRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;resetAllUrlCounts&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;info&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Reset complete: users={}, teams={}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;teams&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="o"&gt;});&lt;/span&gt;
    &lt;span class="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;A few choices worth calling out:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cron &lt;code&gt;0 5 0 1 * *&lt;/code&gt;.&lt;/strong&gt; Five minutes past midnight UTC on the first of the month. The 5-minute offset matters more than it looks — every other top-of-hour cron job in your infrastructure is also waking up at &lt;code&gt;0 0 0 1 * *&lt;/code&gt;. Stagger.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;pg_try_advisory_xact_lock&lt;/code&gt;&lt;/strong&gt; (not &lt;code&gt;pg_advisory_xact_lock&lt;/code&gt;). The &lt;code&gt;try_&lt;/code&gt; variant returns &lt;code&gt;false&lt;/code&gt; instead of blocking. If another instance has the lock, we return immediately and log a debug line. No piled-up workers waiting to do nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Transaction-scoped lock&lt;/strong&gt; (&lt;code&gt;xact_lock&lt;/code&gt;, not &lt;code&gt;lock&lt;/code&gt;). The lock auto-releases when the transaction commits or rolls back. We never have to remember to release it. If the JVM crashes mid-job, the connection drops, the transaction aborts, and the lock evaporates with it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;@EnableScheduling&lt;/code&gt; on the component.&lt;/strong&gt; Spring needs this annotation somewhere in the application context. Putting it on the scheduler class itself makes the dependency local — drop the class, drop the annotation, no orphan config.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Repository UPDATE
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Modifying&lt;/span&gt;
&lt;span class="nd"&gt;@Query&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"UPDATE UserEntity u SET u.urlCount = 0, u.modifiedTime = :now "&lt;/span&gt;
     &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"WHERE u.deleted = false"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;resetAllUrlCounts&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@Param&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"now"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Single SQL &lt;code&gt;UPDATE&lt;/code&gt; across the whole table, inside the same transaction as the advisory lock. If you're tempted to fetch every user and &lt;code&gt;save()&lt;/code&gt; each one — don't. That's &lt;code&gt;O(n)&lt;/code&gt; round-trips and &lt;code&gt;O(n)&lt;/code&gt; Hibernate dirty-checking. The bulk &lt;code&gt;UPDATE&lt;/code&gt; is &lt;code&gt;O(1)&lt;/code&gt; round-trips.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Admin Override
&lt;/h2&gt;

&lt;p&gt;Production rule of thumb: if the system can do it on a schedule, an admin will eventually need to do it for one customer right now. We added a single-user reset on the admin panel:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Transactional&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;UserEntity&lt;/span&gt; &lt;span class="nf"&gt;resetUrlCount&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;UserEntity&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;userRepository&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;userId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;filter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;TRUE&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;equals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getDeleted&lt;/span&gt;&lt;span class="o"&gt;()))&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;orElseThrow&lt;/span&gt;&lt;span class="o"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AppException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ErrorCode&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;USER_NOT_FOUND&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;

    &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;previous&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getUrlCount&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getUrlCount&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;userRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;resetUrlCountForUser&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userId&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;currentTimeMillis&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;info&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Admin reset urlCount for user {}: {} -&amp;gt; 0"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;previous&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;userRepository&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;userId&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;orElse&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&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;Two details:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Filter on &lt;code&gt;deleted = false&lt;/code&gt;&lt;/strong&gt; before resetting. Don't unblock soft-deleted accounts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Log the previous value.&lt;/strong&gt; "Reset urlCount" with no context tells you nothing in an audit. "Reset urlCount for user 42: 487 → 0" tells you exactly what happened.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Bonus: The Sticky Override
&lt;/h2&gt;

&lt;p&gt;Some customers need a permanent quota bump that survives plan changes, billing webhooks, and the monthly reset. We added a nullable &lt;code&gt;urlLimitOverride&lt;/code&gt; column on the user entity:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;getEffectiveUrlLimit&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Admin override always wins.&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;urlLimitOverride&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;urlLimitOverride&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// Defend against missed billing webhooks.&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;hasActiveSubscription&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;subscriptionTier&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;FREE&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="no"&gt;FREE_TIER_URL_LIMIT&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;urlLimit&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;Three rules of precedence:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Admin override wins. Settable only via an admin-only endpoint, never mapped from any user-facing DTO.&lt;/li&gt;
&lt;li&gt;Lapsed subscription on a paid tier falls back to FREE limits. This is the safety net for missed webhooks.&lt;/li&gt;
&lt;li&gt;Otherwise, the stored &lt;code&gt;urlLimit&lt;/code&gt; (kept in sync with the plan config by the billing webhooks).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The override is &lt;em&gt;sticky on purpose&lt;/em&gt; — billing webhooks don't touch it, the monthly reset doesn't touch it, the daily plan-config sync doesn't touch it. An admin sets it, and an admin clears it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfalls We Hit (So You Don't)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Don't put the reset inside billing webhooks.&lt;/strong&gt; Yearly subscribers and FREE users will resent you for it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't use an unbounded &lt;code&gt;pg_advisory_lock&lt;/code&gt;&lt;/strong&gt; without a try variant. Two instances will both wake up; one blocks for the other; the second one runs the job again immediately after the first commits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't forget the audit log row.&lt;/strong&gt; When a customer asks "why did my quota suddenly drop on the 1st?", the answer should be one query away.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't bundle the override into the user-facing profile DTO.&lt;/strong&gt; Defense in depth — the only path to the column is the admin endpoint with &lt;code&gt;requireAdmin()&lt;/code&gt; upstream.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't skip the idempotency test.&lt;/strong&gt; Run the job twice in the same minute. The second run should be a no-op (the lock holder rejected it) or set &lt;code&gt;urlCount&lt;/code&gt; from 0 to 0 (still a no-op). Both are fine. Anything else is a bug.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;How do you decouple usage resets from billing cadence?&lt;/strong&gt; What's your scheduler setup look like? Drop it in the comments.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Building &lt;a href="https://jo4.io" rel="noopener noreferrer"&gt;jo4.io&lt;/a&gt; — a URL shortener where the quota math is the easy part of the platform.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>springboot</category>
      <category>java</category>
      <category>postgres</category>
      <category>saas</category>
    </item>
    <item>
      <title>Groq Returned Empty Content. The Bug Was Hiding in Reasoning Tokens.</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Mon, 10 Aug 2026 03:14:18 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/groq-returned-empty-content-the-bug-was-hiding-in-reasoning-tokens-4229</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/groq-returned-empty-content-the-bug-was-hiding-in-reasoning-tokens-4229</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/groq-empty-content-reasoning-tokens/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;We use Groq's &lt;code&gt;gpt-oss-safeguard&lt;/code&gt; model to classify pages behind freshly created short links. Most pages take a few hundred tokens to score. Some don't. And the ones that don't were silently failing — for weeks — until we noticed the symptom: a small but consistent stream of links stuck in "preview pending" forever.&lt;/p&gt;

&lt;p&gt;Here's what we found.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;The classifier wraps a single Groq chat completion. Send page text, get back a JSON verdict (&lt;code&gt;safe&lt;/code&gt;, &lt;code&gt;unsafe&lt;/code&gt;, with category codes). For 95% of links, this works in well under a second.&lt;/p&gt;

&lt;p&gt;For the other 5%, we'd see this in logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;WARN  Empty content in Groq response
WARN  Classification failed for shortUrl=xyz123 — preview stays enabled
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Empty content. Not a network error, not a rate limit, not malformed JSON. The API returned 200, the &lt;code&gt;choices&lt;/code&gt; array had one entry, and &lt;code&gt;choices[0].message.content&lt;/code&gt; was &lt;code&gt;""&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;What did those pages have in common? They weren't obvious spam. They weren't obvious safe. They were &lt;em&gt;ambiguous&lt;/em&gt; — a wellness blog that mentioned medication dosages, a forum thread about firearms law, a satire site quoting violent rhetoric. The kind of content where a human reviewer would also pause.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Wrong First Guess
&lt;/h2&gt;

&lt;p&gt;Our first instinct: the model is rate-limited or degraded for hard inputs. We added retries. The empty-content rate didn't budge.&lt;/p&gt;

&lt;p&gt;Second guess: we're hitting &lt;code&gt;max_tokens&lt;/code&gt;. We had set it to 200. Maybe ambiguous pages produce longer verdicts. We bumped it to 400. Empty content rate didn't budge.&lt;/p&gt;

&lt;p&gt;The clue we kept missing was sitting in the response body itself, in a field we weren't parsing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Root Cause
&lt;/h2&gt;

&lt;p&gt;Groq's response includes a &lt;code&gt;usage&lt;/code&gt; block, and &lt;code&gt;usage.completion_tokens_details.reasoning_tokens&lt;/code&gt; was the smoking gun:&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;"choices"&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;"message"&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;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&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;"finish_reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"length"&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;"usage"&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;"completion_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"completion_tokens_details"&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;"reasoning_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;200&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;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;&lt;code&gt;gpt-oss-safeguard&lt;/code&gt; is a reasoning model. Before emitting a single character of content, it spends completion tokens on internal chain-of-thought. Easy pages spend a few dozen reasoning tokens, then emit a 50-token verdict. Ambiguous pages spend &lt;em&gt;several hundred&lt;/em&gt; reasoning tokens — and on those, our 200-token budget was being exhausted &lt;strong&gt;inside the reasoning phase&lt;/strong&gt;, leaving zero tokens for content.&lt;/p&gt;

&lt;p&gt;The API obediently returned the response. &lt;code&gt;choices[0].message.content&lt;/code&gt; was &lt;code&gt;""&lt;/code&gt; because there was nothing left in the budget to write into it. &lt;code&gt;finish_reason&lt;/code&gt; was &lt;code&gt;length&lt;/code&gt;, not &lt;code&gt;stop&lt;/code&gt; — the model didn't decide it was done, the token budget cut it off mid-thought.&lt;/p&gt;

&lt;p&gt;We were paying for full inference and getting empty strings.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix
&lt;/h2&gt;

&lt;p&gt;Three changes:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Switch from &lt;code&gt;max_tokens&lt;/code&gt; to &lt;code&gt;max_completion_tokens&lt;/code&gt;&lt;/strong&gt; — &lt;code&gt;max_tokens&lt;/code&gt; is deprecated for reasoning models. Use the correct parameter name so the API enforces the limit you mean.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Raise the budget with headroom.&lt;/strong&gt; We profiled real ambiguous pages: worst case was ~550 reasoning tokens. We set the budget to 1024 — covers worst case plus content with margin to spare.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="no"&gt;MAX_COMPLETION_TOKENS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1024&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;requestBody&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;objectMapper&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;writeValueAsString&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"model"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;modelName&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"messages"&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;Map&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"role"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"system"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"content"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;SAFETY_POLICY&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
        &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"role"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"user"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"content"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;),&lt;/span&gt;
    &lt;span class="s"&gt;"max_completion_tokens"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;MAX_COMPLETION_TOKENS&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"temperature"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;
&lt;span class="o"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. Parse &lt;code&gt;usage&lt;/code&gt; and alert when reasoning tokens approach the budget.&lt;/strong&gt; This is the part that actually prevents the next regression:&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="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reasoningTokens&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;reasoningTokens&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;MAX_COMPLETION_TOKENS&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;warn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Classifier reasoning tokens near budget: {}/{} — "&lt;/span&gt;
        &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"consider raising max_completion_tokens"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;reasoningTokens&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;MAX_COMPLETION_TOKENS&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;When reasoning crosses 80% of the budget, we log a warning. The next ambiguous page in that distribution is the one that will trip &lt;code&gt;finish_reason=length&lt;/code&gt; and return empty content. We'd rather raise the budget &lt;em&gt;before&lt;/em&gt; users see stuck previews, not after.&lt;/p&gt;

&lt;p&gt;We also added the diagnostic to the empty-content branch:&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="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rawContent&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;rawContent&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isBlank&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;finishReason&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;firstChoice&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getFinishReason&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;warn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Empty content (finish_reason={} completion_tokens={} "&lt;/span&gt;
        &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"reasoning_tokens={})"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;finishReason&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;completionTokens&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reasoningTokens&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;ClassificationResult&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;"Empty classifier output (finish_reason="&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;finishReason&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;")"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So if it ever happens again, the next person debugging it has the answer in the first log line, not after a week of squinting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;For reasoning models, &lt;code&gt;max_tokens&lt;/code&gt; is a budget the model spends thinking &lt;em&gt;and&lt;/em&gt; speaking.&lt;/strong&gt; If the budget runs out mid-thought, you get a 200 response with empty content. There is no exception, no error code in the body — just a &lt;code&gt;""&lt;/code&gt; and a &lt;code&gt;finish_reason: length&lt;/code&gt; that you have to parse to see.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Profile against the &lt;em&gt;hardest&lt;/em&gt; inputs, not the average.&lt;/strong&gt; Our 200-token budget worked fine on test fixtures because our fixtures were obvious. Ambiguity is what blows the budget.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;finish_reason&lt;/code&gt; is the field that tells you the truth.&lt;/strong&gt; &lt;code&gt;stop&lt;/code&gt; = model is done. &lt;code&gt;length&lt;/code&gt; = the model wanted to keep going and you didn't let it. Treat them as completely different outcomes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;completion_tokens_details.reasoning_tokens&lt;/code&gt; is the leading indicator.&lt;/strong&gt; Don't wait for empty content to alert. Watch reasoning-token usage as a percentage of the budget, and alert &lt;em&gt;before&lt;/em&gt; you cross the cliff.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use the deprecated-API warnings.&lt;/strong&gt; &lt;code&gt;max_tokens&lt;/code&gt; was the wrong field name for reasoning models. The API silently honored it anyway, which made the bug subtler. The right field name is &lt;code&gt;max_completion_tokens&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;Have you been bitten by an LLM that "succeeded" with no output?&lt;/strong&gt; What was your tell? Drop it in the comments.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Building &lt;a href="https://jo4.io" rel="noopener noreferrer"&gt;jo4.io&lt;/a&gt; — a URL shortener with AI-backed content scanning that fails loudly, not silently.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>java</category>
      <category>debugging</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
