<?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: Robson Kades</title>
    <description>The latest articles on DEV Community by Robson Kades (@robsonkades).</description>
    <link>https://dev.to/robsonkades</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%2F4128807%2F0f4c150d-f077-4c2e-9826-547d69055edf.jpg</url>
      <title>DEV Community: Robson Kades</title>
      <link>https://dev.to/robsonkades</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/robsonkades"/>
    <language>en</language>
    <item>
      <title>Efficient and Effective XML Processing in Java</title>
      <dc:creator>Robson Kades</dc:creator>
      <pubDate>Thu, 17 Sep 2026 00:11:32 +0000</pubDate>
      <link>https://dev.to/robsonkades/efficient-and-effective-xml-processing-in-java-4ono</link>
      <guid>https://dev.to/robsonkades/efficient-and-effective-xml-processing-in-java-4ono</guid>
      <description>&lt;p&gt;Extracting a value from an XML document costs, at its core, two operations: locating the span of bytes where the value lives and converting it to the type you want. Turning &lt;em&gt;219.80&lt;/em&gt; into a &lt;em&gt;BigDecimal&lt;/em&gt; is a scan to the element and a six-byte parse — nanoseconds, allocating next to nothing.&lt;/p&gt;

&lt;p&gt;Almost no Java code pays that price. It pays a different one, orders of magnitude larger: the whole document decoded from UTF-8 to UTF-16, every tag name materialized as a &lt;em&gt;String&lt;/em&gt;, every element promoted to an object — whether you need it or not. The classic APIs were designed to represent documents, and we use them to extract values. The difference goes unnoticed on one document; in a service handling millions a day — and XML is still the language of e-invoicing, financial messaging and all the legacy SOAP nobody is ever going to migrate — it is your entire CPU profile. That distance is not mandatory, and closing it is what this article is about.&lt;/p&gt;

&lt;h2&gt;
  
  
  The invisible cost of the “easy way”
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;DOM&lt;/strong&gt; materializes the entire document as an object tree — every element, attribute and text node becomes a heap instance. To read three fields out of a 7 KB document, you allocate the full representation of those 7 KB, navigate to the three values, and throw the tree away. Multiply by millions of documents and the garbage collector becomes the main character of your latency graph.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;JAXB&lt;/strong&gt; (and data binding in general) hides the tree behind annotated classes, which is great for ergonomics — but it still materializes everything, and layers reflection and conversions on top. There is a quiet maintenance cost too: a typical e-invoice schema has hundreds of fields; you generate&lt;br&gt;
classes for all of them to use half a dozen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;StAX&lt;/strong&gt; is the traditional answer for performance: streaming, one event at a time, no tree. The problem is that nobody likes the code it produces. A while &lt;em&gt;(reader.hasNext())&lt;/em&gt; loop with a hand-rolled state switch quickly grows into something hard to read and harder to change — and every element still becomes an allocated event, every tag name still becomes a String, whether you need it or not.&lt;/p&gt;
&lt;h2&gt;
  
  
  Goal-directed extraction: pay for what you read
&lt;/h2&gt;

&lt;p&gt;That observation — extraction, not parsing — is the premise of &lt;a href="https://github.com/robsonkades/fletch" rel="noopener noreferrer"&gt;Fletch&lt;/a&gt;, a library I wrote after facing exactly the scenario above while processing electronic invoices at volume. The idea: you declare what you want, and the engine makes a single pass over the document’s bytes materializing only that. No tree, no reflection, no generated classes, no dependencies.&lt;/p&gt;
&lt;h2&gt;
  
  
  Installation
&lt;/h2&gt;

&lt;p&gt;Maven&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;dependency&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;io.github.robsonkades&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;fletch&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;version&amp;gt;&lt;/span&gt;1.2.0&lt;span class="nt"&gt;&amp;lt;/version&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dependency&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Gradle&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gradle"&gt;&lt;code&gt;&lt;span class="n"&gt;implementation&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"io.github.robsonkades:fletch:1.2.0"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The primary API is a cursor that navigates elements. Given this document:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;order&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"1042"&lt;/span&gt; &lt;span class="na"&gt;urgent=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;customer&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;name&amp;gt;&lt;/span&gt;Ana Souza&lt;span class="nt"&gt;&amp;lt;/name&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;address&amp;gt;&amp;lt;city&amp;gt;&lt;/span&gt;Curitiba&lt;span class="nt"&gt;&amp;lt;/city&amp;gt;&amp;lt;zip&amp;gt;&lt;/span&gt;80000-000&lt;span class="nt"&gt;&amp;lt;/zip&amp;gt;&amp;lt;/address&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/customer&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;item&amp;gt;&amp;lt;sku&amp;gt;&lt;/span&gt;AB-1&lt;span class="nt"&gt;&amp;lt;/sku&amp;gt;&amp;lt;qty&amp;gt;&lt;/span&gt;2&lt;span class="nt"&gt;&amp;lt;/qty&amp;gt;&amp;lt;price&amp;gt;&lt;/span&gt;49.90&lt;span class="nt"&gt;&amp;lt;/price&amp;gt;&amp;lt;/item&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;item&amp;gt;&amp;lt;sku&amp;gt;&lt;/span&gt;CD-2&lt;span class="nt"&gt;&amp;lt;/sku&amp;gt;&amp;lt;qty&amp;gt;&lt;/span&gt;1&lt;span class="nt"&gt;&amp;lt;/qty&amp;gt;&amp;lt;price&amp;gt;&lt;/span&gt;120.00&lt;span class="nt"&gt;&amp;lt;/price&amp;gt;&amp;lt;/item&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;total&amp;gt;&lt;/span&gt;219.80&lt;span class="nt"&gt;&amp;lt;/total&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/order&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;extraction is a composition of functions, one per element shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="nf"&gt;Item&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;sku&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Integer&lt;/span&gt; &lt;span class="n"&gt;qty&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt; &lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{}&lt;/span&gt;
&lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="nf"&gt;Order&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;id&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;Item&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{}&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;XmlExtractor&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Item&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;ITEM&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;i&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;Item&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;i&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="s"&gt;"sku"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;i&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="s"&gt;"qty"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Integer&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;i&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="s"&gt;"price"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&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="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;XmlExtractor&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;ORDER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;o&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;Order&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;attribute&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"id"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Long&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;o&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;children&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"item"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;ITEM&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;o&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="s"&gt;"total"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&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;Order&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Xml&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;extract&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;xml&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;child&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"order"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;ORDER&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice what is &lt;strong&gt;not&lt;/strong&gt; there: no event loop, no manual state, no classes beyond the records you already wanted. And one detail that matters in daily use: the cursor is &lt;strong&gt;order-tolerant&lt;/strong&gt;. You read fields in the order that makes sense for your record; if the XML delivers them in a different order, the engine copes — reading in document order costs nothing, reading out of order costs a cheap re-scan of a byte range, not event materialization.&lt;/p&gt;

&lt;p&gt;For the &lt;strong&gt;I want 8 fields out of a 300-field document&lt;/strong&gt; case there is a second,&lt;br&gt;
declarative style that compiles the paths once into a table and is faster&lt;br&gt;
still:&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="nc"&gt;XmlMapping&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;ORDER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Xml&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;mapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;Draft:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;attr&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/order@id"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&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;d&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;asLong&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/order/item"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;ItemDraft:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&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;d&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;items&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toItem&lt;/span&gt;&lt;span class="o"&gt;()))&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"sku"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;   &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&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;i&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;sku&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;asString&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"qty"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;   &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&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;i&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;qty&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;asInt&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"price"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&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;i&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;price&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;asDecimal&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;endGroup&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/order/total"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&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;d&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;asDecimal&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="nl"&gt;Draft:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;toOrder&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

&lt;span class="nc"&gt;Order&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Xml&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;extract&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;ORDER&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// thread-safe, reuse freely&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The mapping has one property the cursor doesn’t: over a UTF-8 InputStream it truly streams, through a 64 KB sliding window — you can process a batch larger than memory without thinking about it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it’s fast&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Nothing here is magic; it is the sum of small, consistent decisions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;One pass, one loop.&lt;/strong&gt; Tokenization, name matching and value decoding run fused, directly over the bytes. There are no intermediate event objects.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Names never become Strings.&lt;/strong&gt; Elements are compared by a hash computed with 8-byte word loads (SWAR — SIMD within a register), allocating nothing&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Subtrees you didn’t ask for are skipped&lt;/strong&gt; by balance counting at memchr speed, without ever reading the tag names.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Values are spans, not copies.&lt;/strong&gt; A number is converted straight from the document’s bytes into a long or BigDecimal; text only becomes a String if you ask for one.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Extraction stops when you’re done.&lt;/strong&gt; If the fields you need sit at the start of the document, the rest is never read.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Adding it up: the engine pays the essential cost of extraction — locating spans and converting them — and almost nothing else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Numbers&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;JMH benchmark from the repository, over a real 7 KB electronic invoice (a Brazilian NF-e), varying the number of line items: 1, 50 and 500. Environment: i7–13700K, Temurin/JDK 25. The comparison baseline is a hand-written StAX/Woodstox event loop, as lean as it gets — that is, the best case of the traditional approach, not the typical one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Throughput in ops/ms (higher is better)&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;1 item&lt;/th&gt;
&lt;th&gt;50 items&lt;/th&gt;
&lt;th&gt;500 items&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Fletch mapping&lt;/td&gt;
&lt;td&gt;283.7&lt;/td&gt;
&lt;td&gt;31.6&lt;/td&gt;
&lt;td&gt;3.45&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fletch cursor (document order)&lt;/td&gt;
&lt;td&gt;224.5&lt;/td&gt;
&lt;td&gt;29.9&lt;/td&gt;
&lt;td&gt;3.36&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fletch cursor (out of order)&lt;/td&gt;
&lt;td&gt;163.4&lt;/td&gt;
&lt;td&gt;18.6&lt;/td&gt;
&lt;td&gt;2.02&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Woodstox, hand-written event loop&lt;/td&gt;
&lt;td&gt;96.4&lt;/td&gt;
&lt;td&gt;12.4&lt;/td&gt;
&lt;td&gt;1.38&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On the small document the mapping delivers ~3× the throughput of the Woodstox loop; on the large one, ~2.5×. Allocation per document: about &lt;strong&gt;1.2 KB&lt;/strong&gt; for the mapping and &lt;strong&gt;6.5 KB&lt;/strong&gt; for the cursor, against tens of KB per document for a typical StAX pipeline — at volume, that is the difference between the GC showing up in your p99 or not. DOM and JAXB are not in the table because the repository doesn’t benchmark them; given the nature of those approaches (full materialization plus reflection), the gap only widens.&lt;/p&gt;

&lt;p&gt;None of this requires trust: &lt;strong&gt;mvn -P benchmarks package -DskipTests and java -jar target/benchmarks.jar&lt;/strong&gt; reproduce the table on your machine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use something else&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Honesty is also a form of efficiency. Fletch is an extraction tool, and there are things it deliberately does not do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Write or transform XML&lt;/strong&gt; — it is read-only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;XPath, XSLT, schema validation&lt;/strong&gt; — out of scope.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Truly namespace-aware processing&lt;/strong&gt; — elements match by raw tag name (a deliberate performance choice; it covers the common profile of integration documents with a default namespace, but if the same prefix means different things in different documents, you will miss it).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Manipulating the document as a data structure&lt;/strong&gt; — if you need the tree, use a tree library.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your case is &lt;strong&gt;I receive documents at volume and need typed values out of them&lt;/strong&gt; — which describes the overwhelming majority of systems still speaking XML — the takeaway is simple: the cost of the traditional way is not a law of physics. It is just an old default.&lt;/p&gt;

&lt;p&gt;Fletch is open source (Apache 2.0), zero dependencies, Java 17+: &lt;a href="//github.com/robsonkades/fletch"&gt;github.com/robsonkades/fletch&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>programming</category>
      <category>opensource</category>
      <category>architecture</category>
      <category>java</category>
    </item>
    <item>
      <title>An Outbox for 3 Million Events a Day on Azure SQL: the Engineering Behind It</title>
      <dc:creator>Robson Kades</dc:creator>
      <pubDate>Wed, 16 Sep 2026 23:50:00 +0000</pubDate>
      <link>https://dev.to/robsonkades/an-outbox-for-3-million-events-a-day-on-azure-sql-the-engineering-behind-it-478j</link>
      <guid>https://dev.to/robsonkades/an-outbox-for-3-million-events-a-day-on-azure-sql-the-engineering-behind-it-478j</guid>
      <description>&lt;p&gt;The Transactional Outbox pattern fits in a thirty-second diagram: write the event in the same transaction as the business data, and let a worker publish it later. Everyone draws that in the first meeting and goes home happy.&lt;/p&gt;

&lt;p&gt;What nobody draws is the second half: that table becomes the hottest structure in your database. It takes an insert, an update and a delete on the same row, several times a second, forever. It's read by a scheduler that never sleeps. And it grows until the day the sum of all its rows no longer fits in the server's memory — and from then on, every worker cycle goes to disk.&lt;/p&gt;

&lt;p&gt;This article is the record of performance work on a real outbox: &lt;strong&gt;~3 million events per day&lt;/strong&gt;, &lt;strong&gt;~45 million rows in steady state&lt;/strong&gt;, running on &lt;strong&gt;Azure SQL Database Business Critical&lt;/strong&gt;. The goal wasn't just "make it fast": it was to &lt;strong&gt;do the same work using fewer resources&lt;/strong&gt; — less IO, less CPU, less log, less memory. On Azure, resources are the invoice.&lt;/p&gt;

&lt;p&gt;One warning up front: the most expensive lesson in this work was about &lt;strong&gt;method&lt;/strong&gt;, not about SQL. It's in section 3, and it involves me rewriting the hot path and being &lt;strong&gt;50× wrong&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. The baseline design
&lt;/h2&gt;

&lt;p&gt;The worker does three things, in an order that matters:&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;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;OutboxJpaEntity&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;claimed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;transaction&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="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Window&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;OutboxJpaEntity&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;window&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;outboxRepo&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findFirst100ByEventTypeAndStatusOrderByIdAsc&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;currentEvent&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;PENDING&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;keyset&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="c1"&gt;// ... mark the batch as processed&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getContent&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="o"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// publish OUTSIDE the transaction&lt;/span&gt;
&lt;span class="n"&gt;publisher&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;claimed&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;Claim&lt;/strong&gt; — grab up to 100 PENDING events of a given type, locking them so no other instance takes them. &lt;strong&gt;Flip&lt;/strong&gt; — mark those events as processed. &lt;strong&gt;Publish&lt;/strong&gt; — send to Service Bus, &lt;strong&gt;outside&lt;/strong&gt; the transaction.&lt;/p&gt;

&lt;p&gt;The claim uses the classic SQL Server combo, expressed in JPA:&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;@QueryHints&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@QueryHint&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"jakarta.persistence.lock.timeout"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"-2"&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="c1"&gt;// SKIP_LOCKED&lt;/span&gt;
&lt;span class="nd"&gt;@Lock&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;LockModeType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;PESSIMISTIC_WRITE&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nc"&gt;Window&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;OutboxJpaEntity&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;findFirst100ByEventTypeAndStatusOrderByIdAsc&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;OutboxEventType&lt;/span&gt; &lt;span class="n"&gt;eventType&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;OutboxStatus&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;ScrollPosition&lt;/span&gt; &lt;span class="n"&gt;position&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Hibernate 7's dialect translates that into &lt;code&gt;WITH (UPDLOCK, ROWLOCK, READPAST)&lt;/code&gt;. &lt;code&gt;UPDLOCK&lt;/code&gt; reserves the rows; &lt;code&gt;ROWLOCK&lt;/code&gt; keeps granularity at the row level, away from escalation to page or table; &lt;code&gt;READPAST&lt;/code&gt; makes a concurrent instance &lt;strong&gt;skip&lt;/strong&gt; whatever is already locked instead of waiting on it. This is what lets you scale horizontally without a table lock, without a blocking &lt;code&gt;SELECT FOR UPDATE&lt;/code&gt;, and without inventing a distributed lock in Redis. Two instances running the same worker naturally pick up disjoint batches.&lt;/p&gt;

&lt;p&gt;Pagination is &lt;strong&gt;keyset&lt;/strong&gt;, not offset:&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;final&lt;/span&gt; &lt;span class="nc"&gt;AtomicReference&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;KeysetScrollPosition&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;keyset&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;AtomicReference&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;gt;(&lt;/span&gt;&lt;span class="nc"&gt;ScrollPosition&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;keyset&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;span class="c1"&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;window&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;hasNext&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;keyset&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;KeysetScrollPosition&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;positionAt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;OFFSET 500000&lt;/code&gt;, the database reads and throws away half a million rows before handing you the page. With keyset, it seeks on &lt;code&gt;id &amp;gt; last_id&lt;/code&gt; and reads exactly 100. The difference isn't stylistic: it's the difference between constant cost and cost that grows with backlog depth. In a queue that occasionally piles up millions of rows, offset is a time bomb.&lt;/p&gt;

&lt;p&gt;And there's a cap on rounds per cycle:&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;while&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;isRunning&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;maybeMore&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="n"&gt;rounds&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;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;code&gt;MAX_ROUNDS = 20&lt;/code&gt; — at most 2,000 events per cycle. Without it, a large backlog would make the scheduler thread drain the entire queue in a single cycle, holding a pool connection and ignoring the shutdown signal for minutes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Publishing outside the transaction: the honest decision
&lt;/h3&gt;

&lt;p&gt;Publishing &lt;strong&gt;inside&lt;/strong&gt; the transaction is the classic mistake: your database transaction now lasts as long as a network round-trip to the broker, holding locks the whole time. Under load, that's what takes the system down.&lt;/p&gt;

&lt;p&gt;Publishing &lt;strong&gt;outside&lt;/strong&gt; means accepting &lt;strong&gt;at-least-once&lt;/strong&gt;: if the application dies between the claim commit and the publish, those events were marked processed and never went out. It's a small, known window, and the decision was documented rather than hidden:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Publish OUTSIDE the transaction: on failure, mark the batch as FAIL for&lt;/span&gt;
&lt;span class="c1"&gt;// later reprocessing (at-least-once, except in the crash window between&lt;/span&gt;
&lt;span class="c1"&gt;// the TX1 commit and markFailed).&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The alternative — two-phase commit between database and broker — costs more than the problem it solves. The consumer has to be idempotent anyway.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Before any code: understand the physics of the table
&lt;/h2&gt;

&lt;p&gt;No query optimization saves a design that doesn't fit in memory. So the first thing measured wasn't time — it was &lt;strong&gt;size&lt;/strong&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;payload&lt;/code&gt; was &lt;code&gt;NVARCHAR(2000)&lt;/code&gt;, stored &lt;strong&gt;in-row&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;With a ~600-character payload, the row landed around &lt;strong&gt;1.3 KB&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;45 million rows × 1.3 KB ≈ &lt;strong&gt;58 GB&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Engine memory on an 8-vCore Business Critical (&lt;code&gt;BC_Gen5_8&lt;/code&gt;) is &lt;strong&gt;41.5 GB&lt;/strong&gt; — and the buffer pool is smaller than that: plan cache, lock memory and the 6.28 GB carved out for In-Memory OLTP come out of the same budget.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The table did not fit in memory.&lt;/strong&gt; That number alone explains why the worker got slower over time regardless of indexing: past a certain point, reading the queue means reading disk.&lt;/p&gt;

&lt;p&gt;Second calculation, on transaction log: over its lifetime, each event produces &lt;strong&gt;an insert + the flip update + the purge delete&lt;/strong&gt;. Log volume lands around &lt;strong&gt;4× the payload per event&lt;/strong&gt;. On Azure that isn't a curiosity — there's &lt;em&gt;log rate governance&lt;/em&gt;, a per-service-objective ceiling on log MB/s. Blow past it and you get &lt;code&gt;LOG_RATE_GOVERNOR&lt;/code&gt; waits, and the whole application slows down with CPU to spare.&lt;/p&gt;

&lt;p&gt;Two conclusions from that arithmetic, and they drove everything afterwards:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Reducing bytes per row&lt;/strong&gt; is a compounding win: less buffer pool, less IO, less log, less backup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reducing rows touched per cycle&lt;/strong&gt; beats any amount of plan fine-tuning — and that is, first and foremost, an indexing problem.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  3. The most expensive lesson: estimated plans lie
&lt;/h2&gt;

&lt;p&gt;Here's the part that hurts.&lt;/p&gt;

&lt;p&gt;The original claim — a &lt;code&gt;SELECT&lt;/code&gt; with &lt;code&gt;UPDLOCK/READPAST&lt;/code&gt; followed by Hibernate's updates — struck me as naive. "Two statements where one would do." I rewrote it into the pattern every SQL Server outbox article recommends: a single &lt;code&gt;UPDATE ... FROM ... OUTPUT&lt;/code&gt; that locks, marks and returns the rows in one shot.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;estimated&lt;/strong&gt; plan for my version showed a cost of 0.06. It looked obvious.&lt;/p&gt;

&lt;p&gt;Then I measured for real, with &lt;code&gt;sys.dm_exec_query_stats&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Version&lt;/th&gt;
&lt;th&gt;Actual CPU per claim of 100&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;My &lt;code&gt;UPDATE ... OUTPUT&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;142.84 ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Original (SELECT + updates)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;~2.9 ms&lt;/strong&gt; (0.19 ms for the SELECT + 100 × 0.027 ms)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The original is &lt;strong&gt;~50× cheaper in actual CPU&lt;/strong&gt;. The optimizer estimated mine at 0.06 and it cost 142 ms — off by roughly 2000×.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why?&lt;/strong&gt; Two things stacked up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Halloween Protection.&lt;/strong&gt; The statement reads &lt;code&gt;status = 0&lt;/code&gt; and writes &lt;code&gt;status = 1&lt;/code&gt; in the &lt;em&gt;same&lt;/em&gt; index. SQL Server must guarantee an updated row isn't re-read and updated again — and its protection is an &lt;strong&gt;Eager Spool&lt;/strong&gt;: it materializes the entire set into tempdb before applying a single update. A plain &lt;code&gt;SELECT&lt;/code&gt; followed by primary-key updates has no such problem; nothing is being read and written at once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Concurrent &lt;code&gt;READPAST&lt;/code&gt; destroyed the row goal.&lt;/strong&gt; The optimizer plans &lt;code&gt;TOP 100&lt;/code&gt; assuming it will find 100 rows quickly. With other instances holding rows, &lt;code&gt;READPAST&lt;/code&gt; skips large stretches and the operator scans far more than estimated.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The "naive" version already had a clean covered read against the index, batched updates by PK, and — thanks to &lt;code&gt;@DynamicUpdate&lt;/code&gt; — an &lt;code&gt;UPDATE&lt;/code&gt; touching only the two columns that actually changed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The rule that stuck:&lt;/strong&gt; on this path, comparing &lt;strong&gt;estimated&lt;/strong&gt; plans is comparing fiction. Either you have &lt;code&gt;dm_exec_query_stats&lt;/code&gt; / &lt;code&gt;STATISTICS IO&lt;/code&gt; for both versions, or you don't know which one is faster. I reverted the claim to the original and it has stayed untouched since.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. The gotchas worth more than any refactor
&lt;/h2&gt;

&lt;p&gt;Before touching architecture, four configuration details paid off more than any rewrite. All of them invisible in the Java code.&lt;/p&gt;

&lt;h3&gt;
  
  
  4.1 Your driver turns off your filtered index
&lt;/h3&gt;

&lt;p&gt;The hot path index is filtered:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;NONCLUSTERED&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;IX_outbox_claim&lt;/span&gt;
    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;outbox_event&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;INCLUDE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;aggregate_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's tiny by construction — it holds only the PENDING backlog, which gets published within seconds. It's the perfect index for the claim.&lt;/p&gt;

&lt;p&gt;And it simply &lt;strong&gt;wasn't being used&lt;/strong&gt;. The claim turned into a Clustered Index Scan over millions of rows.&lt;/p&gt;

&lt;p&gt;The entire fix was one line of YAML:&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;hikari&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;connection-init-sql&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;SET ARITHABORT ON&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And here precision matters, because the easy explanation — the one I reached for first — is wrong.&lt;/p&gt;

&lt;p&gt;The easy explanation goes: "mssql-jdbc connects with &lt;code&gt;ARITHABORT OFF&lt;/code&gt;, and with &lt;code&gt;ARITHABORT OFF&lt;/code&gt; the optimizer refuses filtered indexes." The &lt;code&gt;CREATE INDEX&lt;/code&gt; docs do list &lt;code&gt;ARITHABORT ON&lt;/code&gt; among the SET options required for a filtered index, and state that with the wrong options "the query optimizer doesn't consider the index in the execution plan for any Transact-SQL statements."&lt;/p&gt;

&lt;p&gt;Except the &lt;code&gt;SET ARITHABORT&lt;/code&gt; docs say something that dismantles that: &lt;strong&gt;&lt;code&gt;ANSI_WARNINGS ON&lt;/code&gt; implicitly sets &lt;code&gt;ARITHABORT ON&lt;/code&gt;&lt;/strong&gt; at database compatibility level 90 or higher — and the JDBC driver connects with &lt;code&gt;ANSI_DEFAULTS ON&lt;/code&gt;, which includes &lt;code&gt;ANSI_WARNINGS ON&lt;/code&gt;. In the docs' own words: &lt;em&gt;"When &lt;code&gt;ANSI_WARNINGS&lt;/code&gt; is &lt;code&gt;ON&lt;/code&gt; (the default), the setting of &lt;code&gt;ARITHABORT&lt;/code&gt; has no functional effect."&lt;/em&gt; On a modern database, then, the filtered index was probably &lt;strong&gt;not&lt;/strong&gt; being refused by that rule.&lt;/p&gt;

&lt;p&gt;What the docs do state unambiguously is this, and it's enough to explain the symptom:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"The default &lt;code&gt;ARITHABORT&lt;/code&gt; setting for SQL Server Management Studio (SSMS) is &lt;code&gt;ON&lt;/code&gt;, while a client connection in an application defaults to &lt;code&gt;ARITHABORT OFF&lt;/code&gt;. Even if there's no functional difference as long as &lt;code&gt;ANSI_WARNINGS&lt;/code&gt; is &lt;code&gt;ON&lt;/code&gt;, the &lt;code&gt;ARITHABORT&lt;/code&gt; setting is still a &lt;strong&gt;cache key&lt;/strong&gt;. Therefore, SSMS and an application both using their respective defaults, have different cache entries, and might get different query plans (...) the same query might execute slower in the application than in SSMS."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Which is exactly the symptom observed: seek in SSMS, scan in the application, same query. Two plan cache entries, two compilations, two fates — and the application's drew the bad one.&lt;/p&gt;

&lt;p&gt;The practical lesson survives intact, and it's still the cheapest one here: &lt;strong&gt;match your application's &lt;code&gt;ARITHABORT&lt;/code&gt; to SSMS's&lt;/strong&gt;, or you're debugging a plan that isn't the one running in production. Just don't pin it on the wrong mechanism — as I did.&lt;/p&gt;

&lt;p&gt;To find out which case is yours, the evidence is in the plan cache:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- compare the plans of BOTH entries for the same query&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;ph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;query_plan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;plan_handle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;usecounts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;ph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;query_plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'(//StmtSimple/@StatementOptmLevel)[1]'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'varchar(50)'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;optm&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dm_exec_cached_plans&lt;/span&gt; &lt;span class="n"&gt;cp&lt;/span&gt;
&lt;span class="k"&gt;CROSS&lt;/span&gt; &lt;span class="n"&gt;APPLY&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dm_exec_query_plan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;plan_handle&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;ph&lt;/span&gt;
&lt;span class="k"&gt;CROSS&lt;/span&gt; &lt;span class="n"&gt;APPLY&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dm_exec_sql_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;plan_handle&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'%outbox_event%'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two entries for the same query text means it's the cache key. A single entry that still ignores the filtered index means it really is the SET options rule — and then it's worth checking the database's compatibility level.&lt;/p&gt;

&lt;h3&gt;
  
  
  4.2 A filtered index won't match a parameter
&lt;/h3&gt;

&lt;p&gt;The second trap on the same index: for the optimizer to prove the &lt;code&gt;WHERE status = 0&lt;/code&gt; filter covers the query, it needs to see &lt;strong&gt;the literal &lt;code&gt;0&lt;/code&gt;&lt;/strong&gt; in the predicate. When Hibernate parameterizes it (&lt;code&gt;status = @P2&lt;/code&gt;), the plan would have to be valid for any value of &lt;code&gt;@P2&lt;/code&gt; — including 1 and 2, which aren't in the index. Result: the filtered index is discarded.&lt;/p&gt;

&lt;p&gt;In other words: &lt;strong&gt;any query that needs this index has to carry the literal&lt;/strong&gt;, which in practice means a native query — not a Spring Data derived method. Wherever that shows up, the comment travels with it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- status = 0 is a LITERAL on purpose: parameterized, the optimizer won't match&lt;/span&gt;
&lt;span class="c1"&gt;-- the filtered index WHERE status = 0 (same trap as the claim).&lt;/span&gt;
&lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;dbo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;outbox_event&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;READPAST&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
           &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;event_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;event_type&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;status&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same goes for the flips: they use literals (&lt;code&gt;1&lt;/code&gt; for PROCESSED, &lt;code&gt;2&lt;/code&gt; for FAIL) in native queries, with the enum ordinal documented right next to them.&lt;/p&gt;

&lt;h3&gt;
  
  
  4.3 The NVARCHAR that was never Unicode
&lt;/h3&gt;

&lt;p&gt;The connection string already carried:&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;sendStringParametersAsUnicode&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's well-established good practice on SQL Server (without it, an &lt;code&gt;NVARCHAR&lt;/code&gt; parameter compared against a &lt;code&gt;VARCHAR&lt;/code&gt; column causes an implicit conversion and kills the seek). But it implies something: &lt;strong&gt;the driver was already sending payloads as &lt;code&gt;VARCHAR&lt;/code&gt;&lt;/strong&gt;. The Unicode promised by the &lt;code&gt;NVARCHAR(2000)&lt;/code&gt; column never made it through from the application side — we were paying 2 bytes per character for a guarantee that didn't exist.&lt;/p&gt;

&lt;p&gt;Migrating to &lt;code&gt;VARCHAR(2000)&lt;/code&gt; cuts roughly half the row bytes and half the log bytes. Given log ≈ 4× payload, that's a direct win against &lt;em&gt;log rate governance&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;With one non-negotiable caveat: &lt;code&gt;NVARCHAR → VARCHAR&lt;/code&gt; &lt;strong&gt;can corrupt data&lt;/strong&gt; silently (characters outside the collation's code page become &lt;code&gt;?&lt;/code&gt;). So the script carries a mandatory pre-check before the &lt;code&gt;ALTER&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;MAX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LEN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;max_len&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;CASE&lt;/span&gt; &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;CONVERT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;NVARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="k"&gt;CONVERT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
                &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;lossy_rows&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;dbo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;outbox_event&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It must return &lt;code&gt;lossy_rows = 0&lt;/code&gt;. In the test environment (16.5M rows): &lt;code&gt;max_len = 28&lt;/code&gt;, &lt;code&gt;lossy_rows = 0&lt;/code&gt;. Only then the &lt;code&gt;ALTER&lt;/code&gt; — which is &lt;em&gt;size-of-data&lt;/em&gt;, rewrites every row, and requires dropping and recreating any index containing the column.&lt;/p&gt;

&lt;h3&gt;
  
  
  4.4 Time zone in the &lt;code&gt;Instant&lt;/code&gt; bind
&lt;/h3&gt;

&lt;p&gt;An &lt;code&gt;Instant&lt;/code&gt; mapped to &lt;code&gt;datetime2&lt;/code&gt; is bound in the &lt;strong&gt;JVM's time zone&lt;/strong&gt;, not UTC. Which means the same code writes different values depending on which container it runs in — and a purge comparing &lt;code&gt;processed_at &amp;lt; @cutoff&lt;/code&gt; starts deleting the wrong window.&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="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[hibernate.jdbc.time_zone]"&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;UTC&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One line that trades an environmental dependency for a guarantee.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Indexes and page geometry
&lt;/h2&gt;

&lt;p&gt;With the driver fixed, the index started being used. At which point the subject becomes &lt;strong&gt;how pages behave under writes&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  FILLFACTOR: 85 was waste
&lt;/h3&gt;

&lt;p&gt;The original maintenance script rebuilt indexes with &lt;code&gt;FILLFACTOR = 85&lt;/code&gt; — leaving 15% of every page empty to absorb row growth and avoid page splits.&lt;/p&gt;

&lt;p&gt;Except here, the row &lt;strong&gt;doesn't grow&lt;/strong&gt;. RCSI and ADR are always on in Azure SQL Database — RCSI is the default on every new database, and ADR can't even be turned off. Row versioning adds a &lt;strong&gt;14-byte&lt;/strong&gt; version tag per row (a 6-byte transaction sequence number + an 8-byte row identifier), and the documentation is specific about when: on a database that &lt;strong&gt;already had&lt;/strong&gt; RCSI enabled, those 14 bytes go in &lt;strong&gt;at insert time&lt;/strong&gt;, not on first modification.&lt;/p&gt;

&lt;p&gt;Which means: on Azure, the row is born with its version tag. The flip doesn't grow it by a single byte. That 15% of free space was reserved for growth that never happens.&lt;/p&gt;

&lt;p&gt;15% of empty space across 45 million rows ≈ &lt;strong&gt;9 GB of nothing&lt;/strong&gt; occupying buffer pool, being read in every scan, copied in every checkpoint and every backup. &lt;code&gt;FILLFACTOR = 98&lt;/code&gt; is plenty:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="n"&gt;REBUILD&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;FILLFACTOR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;98&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;OPTIMIZE_FOR_SEQUENTIAL_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ONLINE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  OPTIMIZE_FOR_SEQUENTIAL_KEY
&lt;/h3&gt;

&lt;p&gt;The key comes from an ascending sequence, so &lt;strong&gt;every insert lands on the same page&lt;/strong&gt; — the last one. Under burst, dozens of threads contend for that page's latch and form a &lt;code&gt;PAGELATCH_EX&lt;/code&gt; convoy: the bottleneck isn't IO or CPU, it's a queue waiting on an in-memory structure. &lt;code&gt;OPTIMIZE_FOR_SEQUENTIAL_KEY = ON&lt;/code&gt; exists precisely for this — it orders entry into that latch instead of letting the convoy form.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why the wide covering index was expensive on the flip
&lt;/h3&gt;

&lt;p&gt;There was a covering index with &lt;code&gt;status&lt;/code&gt; as the leading column:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;NONCLUSTERED&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;IX_Outbox_Status_Event_Type_Covering&lt;/span&gt;
    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;dbo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;outbox_event&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;INCLUDE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;aggregate_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;aggregate_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;processed_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;retry_count&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Great for reading. But when &lt;code&gt;status&lt;/code&gt; is a &lt;strong&gt;key column&lt;/strong&gt; and the flip changes &lt;code&gt;status&lt;/code&gt; from 0 to 1, the index entry changes position — and SQL Server implements that as a &lt;strong&gt;delete + insert&lt;/strong&gt; of the whole entry. The whole entry here includes the &lt;code&gt;payload&lt;/code&gt;. So: every published event rewrote ~1.3 KB of index, on top of the base table row.&lt;/p&gt;

&lt;p&gt;The filtered index serves the same access without that cost: because &lt;code&gt;status = 0&lt;/code&gt; lives in the &lt;code&gt;WHERE&lt;/code&gt; and not in the key, the flip &lt;strong&gt;removes&lt;/strong&gt; the entry instead of relocating it — and the index stays small, holding only the backlog.&lt;/p&gt;

&lt;h3&gt;
  
  
  Sequence IDs, not IDENTITY
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@GeneratedValue&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strategy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;GenerationType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;SEQUENCE&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;generator&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"outbox_event_seq"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@SequenceGenerator&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;"outbox_event_seq"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sequenceName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"outbox_event_seq"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;allocationSize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;SEQUENCE&lt;/span&gt; &lt;span class="n"&gt;outbox_event_seq&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;START&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="mi"&gt;10000001&lt;/span&gt; &lt;span class="k"&gt;INCREMENT&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt; &lt;span class="k"&gt;CACHE&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;IDENTITY&lt;/code&gt;, Hibernate &lt;strong&gt;cannot batch inserts&lt;/strong&gt; — it needs the generated id back for every row, which forces a round-trip per insert. With a sequence, &lt;code&gt;allocationSize = 50&lt;/code&gt; and the &lt;code&gt;pooled-lo&lt;/code&gt; optimizer, the application reserves 50 ids at once and inserts 50 rows in a single JDBC batch.&lt;/p&gt;

&lt;p&gt;The detail that breaks in production if you get it wrong: &lt;strong&gt;&lt;code&gt;INCREMENT BY&lt;/code&gt; in the database must match &lt;code&gt;allocationSize&lt;/code&gt; in Java&lt;/strong&gt;. Diverge, and they collide.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. The Java side: concurrency and clean shutdown
&lt;/h2&gt;

&lt;p&gt;The work here is almost entirely IO — the JVM spends its time waiting on the database. Measurement confirmed it: at 35–350 events/s, allocation sits in the &lt;strong&gt;hundreds of KB/s&lt;/strong&gt;. GC tuning had no win to offer. So the Java effort went elsewhere: &lt;strong&gt;don't hold threads, and don't lose batches on shutdown&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Virtual threads in the scheduler.&lt;/strong&gt; The worker blocks on IO the entire time — the canonical use case:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Bean&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"fastScheduler"&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;ThreadPoolTaskScheduler&lt;/span&gt; &lt;span class="nf"&gt;fastScheduler&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;ThreadPoolTaskScheduler&lt;/span&gt; &lt;span class="n"&gt;scheduler&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;ThreadPoolTaskScheduler&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;scheduler&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setVirtualThreads&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="c1"&gt;// virtual threads are daemon: without this wait the JVM exits mid-batch&lt;/span&gt;
    &lt;span class="n"&gt;scheduler&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setAwaitTerminationSeconds&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="n"&gt;scheduler&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setWaitForTasksToCompleteOnShutdown&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="c1"&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 comment points at the real trap: &lt;strong&gt;virtual threads are daemon threads&lt;/strong&gt;. Without &lt;code&gt;awaitTermination&lt;/code&gt;, the JVM exits without waiting for them and the in-flight batch dies with the process — at the worst possible moment, between claim and publish.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;SmartLifecycle&lt;/code&gt; to actually shut down:&lt;/strong&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="c1"&gt;// volatile: start() runs on the main thread, stop() on the&lt;/span&gt;
&lt;span class="c1"&gt;// SpringApplicationShutdownHook, and the executeJob() loop reads the flag&lt;/span&gt;
&lt;span class="c1"&gt;// on the fastScheduler threads.&lt;/span&gt;
&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;volatile&lt;/span&gt; &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="n"&gt;isRunning&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three different threads touch that flag, and it's &lt;code&gt;volatile&lt;/code&gt; that guarantees the loop actually &lt;strong&gt;sees&lt;/strong&gt; the stop signal. The loop checks it on every round (&lt;code&gt;while (isRunning() &amp;amp;&amp;amp; ...)&lt;/code&gt;), so a shutdown interrupts between batches — never in the middle of one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Small pool, short timeout:&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;maximum-pool-size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;20&lt;/span&gt;
&lt;span class="na"&gt;connection-timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;250&lt;/span&gt;
&lt;span class="na"&gt;minimum-idle&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A large pool is a popular trap: more connections mean more context for the database to manage and more internal contention, not more throughput. And &lt;code&gt;connection-timeout: 250&lt;/code&gt; is a deliberate &lt;strong&gt;fail-fast&lt;/strong&gt; choice — if there's no connection within 250 ms the system is saturated, and queuing threads to wait just moves the queue inside the application.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Well-aligned batching:&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="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[hibernate.jdbc.batch_size]"&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="m"&gt;50&lt;/span&gt;
&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[hibernate.jdbc.fetch_size]"&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="m"&gt;50&lt;/span&gt;
&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[hibernate.id.optimizer.pooled.preferred]"&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pooled-lo&lt;/span&gt;
&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[hibernate.connection.provider_disables_autocommit]"&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[hibernate.order_updates]"&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="na"&gt;open-in-view&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;code&gt;provider_disables_autocommit: true&lt;/code&gt; stops Hibernate from opening the database transaction too early — the pooled connection already arrives with autocommit off, and without this flag Hibernate spends a redundant round-trip per transaction. &lt;code&gt;open-in-view: false&lt;/code&gt; because keeping a persistence session open while the response renders is holding a connection for free.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The publisher respects the broker's limit.&lt;/strong&gt; A small detail that prevents a production error: on the Service Bus &lt;strong&gt;Standard tier&lt;/strong&gt; the ceiling is 256 KB, both per message and per batch — and it isn't configurable. (On Premium the batch goes to 1 MB, and a single message can reach 100 MB over AMQP; if you change tier, this constant changes.) The publisher packs up to that ceiling and fails fast, with a clear message, on any single event that can't fit:&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;size&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;MAX_BATCH_BYTES&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;IllegalArgumentException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="s"&gt;"Message "&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;getId&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;" exceeds the batch limit of "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="no"&gt;MAX_BATCH_BYTES&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;" bytes"&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;h2&gt;
  
  
  7. How to measure (because without this, none of the above is true)
&lt;/h2&gt;

&lt;p&gt;The tooling that backed every decision in this article:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Actual CPU per query&lt;/strong&gt; — the source of truth, the one that proved I was 50× wrong:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;TOP&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;qs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total_worker_time&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;qs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;execution_count&lt;/span&gt;&lt;span class="p"&gt;)&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="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;Avg_CPU_ms&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;qs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;execution_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;qp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;query_plan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dm_exec_query_stats&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;qs&lt;/span&gt;
&lt;span class="k"&gt;CROSS&lt;/span&gt; &lt;span class="n"&gt;APPLY&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dm_exec_sql_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;qs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sql_handle&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt;
&lt;span class="k"&gt;CROSS&lt;/span&gt; &lt;span class="n"&gt;APPLY&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dm_exec_query_plan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;qs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;plan_handle&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;qp&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'%outbox%'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;qs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;execution_count&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Indexes nobody uses&lt;/strong&gt; — every unused index is pure write cost on a table that does nothing but write:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;OBJECT_NAME&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;object_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;TableName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;IndexName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_seeks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_scans&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_lookups&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_updates&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dm_db_index_usage_stats&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;
&lt;span class="k"&gt;INNER&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;indexes&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;object_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;object_id&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;index_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;index_id&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_seeks&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_scans&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_lookups&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Fragmentation and real size&lt;/strong&gt; via &lt;code&gt;sys.dm_db_index_physical_stats&lt;/code&gt; — that's where the 9 GB wasted by FILLFACTOR 85 came from.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wait evidence&lt;/strong&gt; — &lt;code&gt;sys.dm_db_wait_stats&lt;/code&gt;, &lt;code&gt;sys.dm_db_resource_stats&lt;/code&gt; and Query Store, all &lt;em&gt;database-scoped&lt;/em&gt; on Azure SQL. That's where &lt;code&gt;LOG_RATE_GOVERNOR&lt;/code&gt; and &lt;code&gt;PAGELATCH_EX&lt;/code&gt; show up by name.&lt;/p&gt;




&lt;h2&gt;
  
  
  8. What stuck
&lt;/h2&gt;

&lt;p&gt;In order of real impact — and note how little of it is "better code":&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;SET ARITHABORT ON&lt;/code&gt;&lt;/strong&gt; in the pool init. One line of YAML: it aligns the application with SSMS and kills the parallel plan cache entry where the bad plan lived.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;NVARCHAR&lt;/code&gt; → &lt;code&gt;VARCHAR&lt;/code&gt;.&lt;/strong&gt; Half the row and log bytes, for a Unicode guarantee the driver was no longer delivering. With a mandatory data-loss pre-check.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FILLFACTOR 98 + &lt;code&gt;OPTIMIZE_FOR_SEQUENTIAL_KEY&lt;/code&gt;.&lt;/strong&gt; ~9 GB of emptiness reclaimed and the last-page latch convoy tamed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A filtered index, with the literal in the query.&lt;/strong&gt; An index holding only the PENDING backlog, and queries written so the optimizer can actually use it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keyset instead of offset&lt;/strong&gt; in the claim's pagination: constant cost instead of cost that grows with backlog depth.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And the lesson that outranks all of them: &lt;strong&gt;I rewrote the hot path before measuring, and I was 50× wrong.&lt;/strong&gt; The estimated plan said 0.06; reality charged 142 ms. The "obvious" pattern the articles recommend — a single-statement &lt;code&gt;UPDATE ... OUTPUT&lt;/code&gt; — was the worse of the two in this specific context, because of a Halloween Protection the diagram never shows.&lt;/p&gt;

&lt;p&gt;Comparing estimated plans is comparing fiction. &lt;code&gt;dm_exec_query_stats&lt;/code&gt;, &lt;code&gt;STATISTICS IO&lt;/code&gt;, or we know nothing.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Stack: Java 25, Spring Boot 4.1, Hibernate 7, mssql-jdbc, Azure SQL Database Business Critical.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>programming</category>
      <category>opensource</category>
      <category>java</category>
      <category>architecture</category>
    </item>
    <item>
      <title>UUIDv7: The Idea Behind a High-Throughput Java Generator</title>
      <dc:creator>Robson Kades</dc:creator>
      <pubDate>Wed, 16 Sep 2026 23:44:42 +0000</pubDate>
      <link>https://dev.to/robsonkades/uuidv7-the-idea-behind-a-high-throughput-java-generator-48gm</link>
      <guid>https://dev.to/robsonkades/uuidv7-the-idea-behind-a-high-throughput-java-generator-48gm</guid>
      <description>&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/robsonkades/uuidv7" rel="noopener noreferrer"&gt;uuidv7&lt;/a&gt;&lt;/strong&gt; is a Java library for generating UUID version 7 identifiers, with time ordering, local monotonic sequences, and batch generation support. It runs on Java 17 or later, depends only on the JDK, and offers output as &lt;code&gt;UUID&lt;/code&gt; objects, strings, and binary arrays.&lt;/p&gt;

&lt;p&gt;The problem appears in applications that assign an identifier to every event, message, or record. Reading the clock, obtaining randomness, and creating an object are small operations repeated throughout the data flow. Centralizing the sequence would also make concurrent producers compete for the same state.&lt;/p&gt;

&lt;p&gt;The solution's central idea is to &lt;strong&gt;keep the sequence close to its producer and share work that can be performed once per batch&lt;/strong&gt;. The library combines local states, a bounded set of states for virtual threads, and range reservation before writing. This organization explains both its APIs and its performance results.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. A Sequence That Keeps Increasing
&lt;/h2&gt;

&lt;p&gt;As defined in RFC 9562, Section 5.7, UUIDv7 places a 48-bit Unix timestamp, in milliseconds, in the most significant part of the identifier. Besides the version and variant fields, it has 74 bits in &lt;code&gt;rand_a&lt;/code&gt; and &lt;code&gt;rand_b&lt;/code&gt;, referred to here as the payload. These fields can combine random values and counters to keep the sequence increasing.&lt;/p&gt;

&lt;p&gt;In this generator, the fast sequence can be represented as &lt;code&gt;U = (T, A, B)&lt;/code&gt;: logical timestamp, 12-bit field, and 62-bit counter. Since version and variant are constant, comparison considers &lt;code&gt;T&lt;/code&gt; first, then &lt;code&gt;A&lt;/code&gt;, and finally &lt;code&gt;B&lt;/code&gt;, treating the values as unsigned integers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If the observed time is greater than &lt;code&gt;T&lt;/code&gt;, the generator adopts it: &lt;code&gt;T' &amp;gt; T&lt;/code&gt;. The new UUID is greater than its predecessor regardless of the initial values of &lt;code&gt;A&lt;/code&gt; and &lt;code&gt;B&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;If the observed time is less than or equal to &lt;code&gt;T&lt;/code&gt; and the counter still has room, &lt;code&gt;T' = T&lt;/code&gt;, &lt;code&gt;A' = A&lt;/code&gt;, and &lt;code&gt;B' = B + 1&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;If a counter already at its limit must be incremented, the generator advances &lt;code&gt;T&lt;/code&gt; and generates new initial values for &lt;code&gt;A&lt;/code&gt; and &lt;code&gt;B&lt;/code&gt;. If the timestamp is also at its limit, it throws an exception.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These transitions preserve &lt;code&gt;U' &amp;gt; U&lt;/code&gt; for UUIDs generated from the same state, provided that one thread owns it or access is synchronized. The timestamp can remain ahead of the observed clock during rollback; it represents the time used by the generator.&lt;/p&gt;

&lt;p&gt;Monotonicity is guaranteed &lt;strong&gt;per generator state&lt;/strong&gt;. This lets producers advance independently, without a global sequencer. Uniqueness across states and processes remains probabilistic; the UUID timestamp does not establish causality between events.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. State for Each Execution Model
&lt;/h2&gt;

&lt;p&gt;The static API selects state according to the execution model:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;State ownership&lt;/th&gt;
&lt;th&gt;Cost and usage condition&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Platform thread&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ThreadLocal&lt;/code&gt; reused by the thread&lt;/td&gt;
&lt;td&gt;No shared lock for fast-sequence updates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;UUIDv7Generator&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Instance controlled by the application&lt;/td&gt;
&lt;td&gt;Requires confinement or external synchronization of all accesses&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Virtual thread (Java 21+)&lt;/td&gt;
&lt;td&gt;Shared state in a &lt;em&gt;stripe&lt;/em&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ReentrantLock&lt;/code&gt; serializes access to that stripe&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The fast path uses a shared atomic sequence when creating states. Afterward, each state advances locally. Coordination is associated with initialization rather than every UUID emitted by a platform thread.&lt;/p&gt;

&lt;p&gt;For virtual threads, the component maintains separate sets of fast and secure stripes. Each set has the smallest power of two greater than or equal to &lt;code&gt;4 × availableProcessors()&lt;/code&gt;: with 24 processors reported to the JVM, that is 128 stripes. Selection mixes the virtual thread's identifier, preserving its association even when it changes carrier threads.&lt;/p&gt;

&lt;p&gt;This organization reuses states and &lt;code&gt;SecureRandom&lt;/code&gt; instances across short-lived tasks. Threads assigned to the same stripe share generator state and can compete for its lock.&lt;/p&gt;

&lt;p&gt;For applications that already organize work in event loops or partition workers, &lt;code&gt;UUIDv7Generator&lt;/code&gt; provides an explicit instance. The application controls its lifecycle and maintains the same sequence across single-value calls and batches.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Generating a Batch Starts with a Reservation
&lt;/h2&gt;

&lt;p&gt;Single-value generation assembles two &lt;code&gt;long&lt;/code&gt; values directly and returns a &lt;code&gt;UUID&lt;/code&gt;. For a nonempty batch, the generator reads the clock once, reserves a sequence, and writes into the caller's array.&lt;/p&gt;

&lt;p&gt;The reservation method uses &lt;code&gt;lastUnixTsMs&lt;/code&gt; for the logical timestamp, &lt;code&gt;randB&lt;/code&gt; for the counter, and &lt;code&gt;RAND_B_MASK&lt;/code&gt; for its 62-bit limit:&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="kt"&gt;boolean&lt;/span&gt; &lt;span class="nf"&gt;tryReserve&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;count&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;unixTsMs&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;unixTsMs&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;lastUnixTsMs&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="no"&gt;RAND_B_MASK&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;randB&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&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;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;advance&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;unixTsMs&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;randB&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="mi"&gt;1L&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;true&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 internal method receives &lt;code&gt;count &amp;gt; 0&lt;/code&gt;. &lt;code&gt;advance()&lt;/code&gt; establishes the first value; the addition moves state to the last reserved value. If the counter was at &lt;code&gt;100&lt;/code&gt;, reserving four values at the same timestamp assigns &lt;code&gt;[101, 104]&lt;/code&gt; and leaves state at &lt;code&gt;104&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The counter's initial value has its two upper bits cleared: &lt;code&gt;B₀ ≤ 2^60 − 1&lt;/code&gt;. Since &lt;code&gt;Bmax = 2^62 − 1&lt;/code&gt;, at least &lt;code&gt;3 × 2^60&lt;/code&gt; positions remain available. This accommodates any internal reservation with a positive &lt;code&gt;int&lt;/code&gt; size immediately after counter initialization.&lt;/p&gt;

&lt;p&gt;In stripes, the lock protects reservation and capture of the initial two halves. Normal buffer writing happens after releasing it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;A: reserve [101, 104] under lock → release → write its buffer
B: reserve [105, 108] under lock → release → write its buffer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Updating state under the lock definitively assigns the interval. B can finish before A, but the ranges are disjoint. The next reservation can advance while the previous batch is still being written.&lt;/p&gt;

&lt;p&gt;State updates cost &lt;code&gt;O(1)&lt;/code&gt; per batch on the normal path; writing remains &lt;code&gt;O(n)&lt;/code&gt;. A conceptual model is &lt;code&gt;C_UUID(n) ≈ C_fixed/n + C_write&lt;/code&gt;, where fixed cost includes the clock, state selection, reservation, and applicable synchronization. The benchmark does not measure these components individually.&lt;/p&gt;

&lt;p&gt;If the observed time does not exceed &lt;code&gt;T&lt;/code&gt; and the counter does not have room for the batch, the method returns &lt;code&gt;false&lt;/code&gt; without changing state. Filling then generates one UUID at a time; for virtual threads, the stripe lock remains held until completion. If the timestamp can no longer advance either, the call can fail after writing part of the batch; values already used are not reused.&lt;/p&gt;

&lt;p&gt;The destination belongs to the caller: &lt;code&gt;long[]&lt;/code&gt; uses two elements per UUID and &lt;code&gt;byte[]&lt;/code&gt; uses 16 bytes in big-endian order. Reusing these arrays avoids per-identifier objects. The application controls buffer consumption and publication to other threads.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Randomness According to Purpose
&lt;/h2&gt;

&lt;p&gt;The fast mode uses a non-cryptographic pseudorandom generator to set the initial values of &lt;code&gt;A&lt;/code&gt; and &lt;code&gt;B&lt;/code&gt; when the logical timestamp advances. Subsequent calls increment the counter. UUIDs generated from the same state at the same timestamp therefore have related values because they share the same starting point.&lt;/p&gt;

&lt;p&gt;For the APIs backed by &lt;code&gt;SecureRandom&lt;/code&gt;, each state maintains a 512-byte buffer. Both methods consume values from that same buffer:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;API&lt;/th&gt;
&lt;th&gt;Policy&lt;/th&gt;
&lt;th&gt;Buffer consumption&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;secureMonotonicUUID()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;SecureRandom&lt;/code&gt; supplies initial values and increments; each increment ranges from 1 to 1,024&lt;/td&gt;
&lt;td&gt;2 bytes per increment that fits in the counter; 16 to set new initial values&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;secureUnorderedUUID()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Fresh random values in all 74 payload bits for each UUID, with no ordering guarantee&lt;/td&gt;
&lt;td&gt;16 bytes per UUID&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;secureRandomUUID()&lt;/code&gt; is an alias of &lt;code&gt;secureMonotonicUUID()&lt;/code&gt; and shares its sequence. When an increment requires advancing the timestamp and restarting the counter, the buffer supplies bytes for both operations.&lt;/p&gt;

&lt;p&gt;The two APIs serve different needs: keeping the sequence increasing or generating the entire payload again. In the monotonic API backed by &lt;code&gt;SecureRandom&lt;/code&gt;, the next UUID from the same state, at the same timestamp and without restarting the counter, has at most 1,024 possible values. This sequence is therefore not intended for secret tokens.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The Result of Batch Generation
&lt;/h2&gt;

&lt;p&gt;The &lt;strong&gt;September 5, 2026&lt;/strong&gt; measurements used JMH 1.37, Temurin OpenJDK 25.0.4.1+1-LTS, Windows 11 Pro, and an Intel Core i7-13700K. The protocol used two forks, five warmup and five measurement iterations of one second each, a fixed 1 GiB heap, and &lt;code&gt;-prof gc&lt;/code&gt;. The cached clock was disabled.&lt;/p&gt;

&lt;p&gt;Single-thread results:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Output&lt;/th&gt;
&lt;th&gt;Million UUIDs/s&lt;/th&gt;
&lt;th&gt;Allocation, B/UUID&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;randomUUID()&lt;/code&gt; / object&lt;/td&gt;
&lt;td&gt;260.1 ± 3.9&lt;/td&gt;
&lt;td&gt;32.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;fill(long[])&lt;/code&gt; / batch of 256&lt;/td&gt;
&lt;td&gt;4,449.3 ± 29.8&lt;/td&gt;
&lt;td&gt;≈ 0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;fill(byte[])&lt;/code&gt; / batch of 256&lt;/td&gt;
&lt;td&gt;4,030.3 ± 33.4&lt;/td&gt;
&lt;td&gt;≈ 0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;secureMonotonicUUID()&lt;/code&gt; / object&lt;/td&gt;
&lt;td&gt;118.4 ± 6.0&lt;/td&gt;
&lt;td&gt;32.78&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;secureUnorderedUUID()&lt;/code&gt; / object&lt;/td&gt;
&lt;td&gt;27.3 ± 0.4&lt;/td&gt;
&lt;td&gt;38.25&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;±&lt;/code&gt; values are the half-widths of the 99.9% confidence intervals reported by JMH. Near-zero allocation refers to generation after warmup into previously allocated, reused arrays.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;long[]&lt;/code&gt; batch achieved approximately &lt;strong&gt;17.1 times&lt;/strong&gt; the per-identifier throughput of the single-value API. This result combines sharing costs across the batch with changing the output format: the batch writes into a reused array containing 4 KiB of data, while the single-value API delivers objects to JMH. The 32 B/UUID measured for that API implies about 8.32 GB/s of allocation at that rate.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;@OperationsPerInvocation(256)&lt;/code&gt;, JMH counts 256 identifiers per call. The &lt;strong&gt;0.225 ns/UUID&lt;/strong&gt; derived from throughput is an amortized cost: the batch cost divided by its 256 values. Per batch, the value is approximately 57.5 ns. Single-call latency requires a separate measurement.&lt;/p&gt;

&lt;p&gt;With eight platform threads, the fast API reached an aggregate &lt;strong&gt;1.018 billion ± 31.5 million UUIDs/s&lt;/strong&gt;: approximately 3.91 times single-thread throughput. Throughput increased, with sublinear scaling on this machine.&lt;/p&gt;

&lt;p&gt;In another experiment, each invocation submitted 64 virtual-thread tasks, with one thousand UUIDs per task, and waited for their completion. Generation into &lt;code&gt;byte[]&lt;/code&gt; reached 1.414 million ± 0.095 million tasks/s, equivalent to about 1.414 billion UUIDs/s. This measurement includes submission, scheduling, value consumption, waiting, and a new array per task; its unit is the complete task.&lt;/p&gt;

&lt;p&gt;The numbers describe these environments and consumption patterns. Batch generation shows its advantage when the consumer uses the binary output; object conversion, persistence, and networking add costs absent from the array-filling measurement.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Installation with Maven
&lt;/h2&gt;

&lt;p&gt;With Maven and JDK 17 or later, run the following command from the uuidv7 source directory to compile the component, run its tests, and install version &lt;code&gt;1.3.0&lt;/code&gt; into the local Maven repository:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mvn &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="s2"&gt;"-Dgpg.skip=true"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;-Dgpg.skip=true&lt;/code&gt; option disables GPG artifact signing for this local installation.&lt;/p&gt;

&lt;p&gt;Then add the dependency inside &lt;code&gt;&amp;lt;dependencies&amp;gt;&lt;/code&gt; in the application's &lt;code&gt;pom.xml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;dependency&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;io.github.robsonkades&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;uuidv7&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;version&amp;gt;&lt;/span&gt;1.3.0&lt;span class="nt"&gt;&amp;lt;/version&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dependency&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  7. From the Component to the Data Flow
&lt;/h2&gt;

&lt;p&gt;The API supports starting with single-value generation and using batches where processing already groups records:&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="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;io.github.robsonkades.uuidv7.UUIDv7&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;io.github.robsonkades.uuidv7.UUIDv7Generator&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;class&lt;/span&gt; &lt;span class="nc"&gt;UUIDv7Example&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="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;UUIDv7&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;randomUUID&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;batch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="o"&gt;];&lt;/span&gt;
        &lt;span class="nc"&gt;UUIDv7&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fill&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="c1"&gt;// Consume all 256 identifiers before reusing the buffer.&lt;/span&gt;

        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;generator&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;UUIDv7Generator&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="c1"&gt;// Use from one thread.&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;nextId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;generator&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;next&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 service can use &lt;code&gt;randomUUID()&lt;/code&gt; when creating an entity. A pipeline already producing binary blocks can fill its buffer with &lt;code&gt;fill()&lt;/code&gt;. A worker with confined state can keep its own &lt;code&gt;UUIDv7Generator&lt;/code&gt; instance. All three paths express the same state ownership idea, with representations suited to each consumer.&lt;/p&gt;

&lt;p&gt;Given the same initial state and the same clock reading for every element, reservation produces the same sequence as generating one UUID at a time. The tests compare both batch formats with the single-value sequence across 45 combinations of counter, clock, and size. They also check the next output and exercise eight threads sharing a stripe near rollover.&lt;/p&gt;

&lt;p&gt;The core idea of uuidv7 is to organize generation around the producer and the destination of the data. Local states allow independent sequences; stripes reuse resources across virtual threads; reservations divide fixed cost across many identifiers. The batch API makes this combination accessible without requiring an intermediate object for every generated value.&lt;/p&gt;

</description>
      <category>java</category>
      <category>programming</category>
      <category>performance</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
