<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>K6 on Özkan Pakdil Software Engineer</title>
    <link>https://ozkanpakdil.github.io/tags/k6/</link>
    <description>Recent content in K6 on Özkan Pakdil Software Engineer</description>
    <image>
      <title>Özkan Pakdil Software Engineer</title>
      <url>https://ozkanpakdil.github.io/images/papermod-cover.png</url>
      <link>https://ozkanpakdil.github.io/images/papermod-cover.png</link>
    </image>
    <generator>Hugo -- 0.148.2</generator>
    <language>en</language>
    <copyright>Özkan Pakdil</copyright>
    <lastBuildDate>Mon, 21 Sep 2026 00:00:00 +0000</lastBuildDate>
    <atom:link href="https://ozkanpakdil.github.io/tags/k6/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>From Spring Boot to Rust: Rewriting a Live Marketplace</title>
      <link>https://ozkanpakdil.github.io/posts/my_collections/2026/2026-09-21-dotnet-java-rust/</link>
      <pubDate>Mon, 21 Sep 2026 00:00:00 +0000</pubDate>
      <guid>https://ozkanpakdil.github.io/posts/my_collections/2026/2026-09-21-dotnet-java-rust/</guid>
      <description>How mpazari.com moved from a JVM/GraalVM Spring Boot stack to warp &#43; minijinja &#43; sqlx: parity goals, the test harness, the deploy pipeline, and k6 numbers for all three runtimes.</description>
      <content:encoded><![CDATA[<h1 id="from-spring-boot-to-rust-rewriting-a-live-marketplace">From Spring Boot to Rust: Rewriting a Live Marketplace</h1>
<p>mpazari.com spent years on a legacy ASP.NET stack — the <code>.aspx</code> URLs still in the wild prove
it. A recent Spring Boot rewrite replaced that engine, first as a JVM jar, then as a GraalVM
native image to tame memory usage. The question that started this project: <em>if we rewrote the
whole application in Rust, what would we gain, what would we break, and how do we prove we
broke nothing?</em> This post covers the rewrite, the parity hunting, the server-side
build/deploy pipeline, and the load test comparing all three runtimes on the same production
box.</p>
<h2 id="1-why-and-what-exactly">1. Why, and what exactly</h2>
<p>The goal was not a rewrite &ldquo;someday&rdquo;. It was: <strong>same database, same URLs, same behavior —
different engine.</strong> Every page had to behave identically to the <code>aspx</code> version:</p>
<table>
  <thead>
      <tr>
          <th>Aspect</th>
          <th>Java (old)</th>
          <th>Rust (new)</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Framework</td>
          <td>Spring Boot / MVC + Thymeleaf</td>
          <td>warp 0.3 + hand-rolled filter chain</td>
      </tr>
      <tr>
          <td>Templates</td>
          <td>Thymeleaf (<code>th:*</code> attributes)</td>
          <td>minijinja 2 (Jinja syntax)</td>
      </tr>
      <tr>
          <td>DB access</td>
          <td>Spring JDBC / NamedParameterJdbcTemplate</td>
          <td>sqlx 0.8, plain SQL, same PostgreSQL schema</td>
      </tr>
      <tr>
          <td>Sessions</td>
          <td>Spring Session</td>
          <td>stateless HMAC-SHA256 signed JSON cookie</td>
      </tr>
      <tr>
          <td>Deploy artifact</td>
          <td>GraalVM native image (112 MB) or fat jar</td>
          <td>single 21 MB binary</td>
      </tr>
  </tbody>
</table>
<h2 id="2-the-load-test-same-box-three-runtimes">2. The load test: same box, three runtimes</h2>
<p>Method:</p>
<ul>
<li>Same machine (Hetzner, Ubuntu 20.04), same PostgreSQL data, same Apache-less path — k6
maps <code>www.mpazari.com</code> straight to the app port (<code>9100</code>) via <code>/etc/hosts</code> override, so the
measurements are app-only, no proxy noise.</li>
<li>k6 scenario: ramp to 50 VUs in 1m, hold 50 VUs for 5m, ramp down in 1m; each iteration
fetches the home page and sleeps 1s.</li>
<li>Thresholds: <code>p(95) &lt; 200ms</code>, <code>p(99) &lt; 500ms</code>.</li>
</ul>
<table>
  <thead>
      <tr>
          <th>Runtime</th>
          <th>avg</th>
          <th>p(95)</th>
          <th>p(99)</th>
          <th>max</th>
          <th>reqs</th>
          <th>failed</th>
          <th>RSS @ 50 VU</th>
          <th>Artifact</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>GraalVM native</td>
          <td>158.58ms</td>
          <td>172.57ms</td>
          <td>178.64ms</td>
          <td>374.39ms</td>
          <td>15,570</td>
          <td>0%</td>
          <td>134–154 MB</td>
          <td>112 MB binary</td>
      </tr>
      <tr>
          <td>Spring Boot jar</td>
          <td>156.97ms</td>
          <td>171.86ms</td>
          <td>177.16ms</td>
          <td>287.17ms</td>
          <td>15,590</td>
          <td>0%</td>
          <td>477–949 MB</td>
          <td>32 MB jar</td>
      </tr>
      <tr>
          <td><strong>Rust (warp + sqlx)</strong></td>
          <td><strong>160.89ms</strong></td>
          <td><strong>179.45ms</strong></td>
          <td><strong>191.91ms</strong></td>
          <td><strong>351.72ms</strong></td>
          <td><strong>15,545</strong></td>
          <td><strong>0%</strong></td>
          <td><strong>20–40 MB</strong></td>
          <td><strong>21 MB binary</strong></td>
      </tr>
  </tbody>
</table>
<p>All three runtimes pass both thresholds on the same day, on the same box, against the same
database. Rust lands within ~7ms of the JVM&rsquo;s p(95) — and serves the load from a 21 MB
binary topping out at 40 MB RSS, while the JVM pays its heap for the same traffic.</p>
<h3 id="21-the-bug-the-load-test-caught-a-lazy-regex-that-compiled-per-request">2.1 The bug the load test caught: a <code>Lazy</code> regex that compiled per request</h3>
<p>The first Rust run was a collapse: avg 757ms, p(95) 952ms, 10k reqs vs 15.5k for Java —
and <code>top</code> showed the Rust process eating <strong>623% CPU</strong> for 28 req/s. Nothing about the
workload explained it, so we profiled the live process (<code>perf record</code> on the server, then
the same repro locally with macOS <code>sample</code>).</p>
<p>The call graph said it all:</p>
<pre tabindex="0"><code>regex_automata::dfa::determinize ... 596 samples
  once_cell::Lazy::get_or_init
    regex::Regex::new
      mpazari::util::taxonomy_slug
        mpazari::web::home::home
</code></pre><p><code>taxonomy_slug</code> declared its regex <strong>inside the function</strong>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-rust" data-lang="rust"><span style="display:flex;"><span><span style="color:#66d9ef">pub</span> <span style="color:#66d9ef">fn</span> <span style="color:#a6e22e">taxonomy_slug</span>(s: <span style="color:#66d9ef">&amp;</span><span style="color:#66d9ef">str</span>) -&gt; String {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">let</span> x <span style="color:#f92672">=</span> transliterate(s).to_lowercase();
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">let</span> re: <span style="color:#a6e22e">Lazy</span><span style="color:#f92672">&lt;</span>Regex<span style="color:#f92672">&gt;</span> <span style="color:#f92672">=</span> Lazy::new(<span style="color:#f92672">||</span> Regex::new(<span style="color:#e6db74">&#34;[^a-z0-9]+&#34;</span>).unwrap()); <span style="color:#75715e">// ← per call!
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>    <span style="color:#f92672">..</span>.
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>A <code>let re: Lazy&lt;Regex&gt;</code> inside a function is not a cache — it is a fresh <code>Lazy</code> per call,
so every invocation re-compiled the regex, lazily building its full DFA. The home page
renders ~183 brand slugs + 82 cities + 40 categories plus 39 listing cards — <strong>hundreds of
regex compilations per request</strong>. It looked like a <code>Lazy</code> (static-shaped code), but it was
a <code>let</code>, so it behaved like <code>Regex::new</code> on the hot path.</p>
<p>Moving the regex to a module-level <code>static RE_NON_ALNUM: Lazy&lt;Regex&gt;</code>:</p>
<table>
  <thead>
      <tr>
          <th></th>
          <th>before</th>
          <th>after</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>home page (single request, prod)</td>
          <td>170–198ms</td>
          <td>30–40ms</td>
      </tr>
      <tr>
          <td>k6 50 VUs</td>
          <td>avg 757ms, p(95) 952ms</td>
          <td>avg 161ms, p(95) 179ms</td>
      </tr>
      <tr>
          <td>app CPU under load</td>
          <td>623%</td>
          <td>18%</td>
      </tr>
      <tr>
          <td>regex frames in sample</td>
          <td>~12% of samples</td>
          <td>0</td>
      </tr>
  </tbody>
</table>
<p>The lesson generalizes: <strong>in Rust, a <code>Lazy</code> is only a cache if it outlives the call.</strong>
<code>let re = Lazy::new(...)</code> reads like <code>static RE: Lazy&lt;Regex&gt;</code> and compiles silently —
unit tests pass, pages render correctly, and only a production load test with a CPU
counter exposes it.</p>
<p>The second fix was Java-parity caching, found by reading <code>GlobalModelAdvice</code>: Spring caches
brands/cities/categories/counts in memory for 1h and the two footer COUNT(*)s for 10m —
the Rust side re-queried <code>brand_hub</code>/<code>city_hub</code>/counts on every request. Porting the same
TTLs (<code>TaxonomyCache</code> + a 10-minute <code>CountsCache</code>) brought per-request SQL to ~5 queries,
matching the Java home handler.</p>
<h3 id="22-memory-the-number-the-whole-rewrite-was-chasing">2.2 Memory: the number the whole rewrite was chasing</h3>
<p>The RSS column in the main table comes from the same production box: swap the runtime,
restart, run a separate 2.5-minute 50-VU k6 while sampling the process RSS every 15s.
Idle values, for completeness:</p>
<table>
  <thead>
      <tr>
          <th>Runtime</th>
          <th>RSS idle</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>GraalVM native image</td>
          <td>~4 MB¹</td>
      </tr>
      <tr>
          <td>Spring Boot jar</td>
          <td>~477 MB</td>
      </tr>
      <tr>
          <td><strong>Rust (warp + sqlx)</strong></td>
          <td><strong>19 MB</strong></td>
      </tr>
  </tbody>
</table>
<p>The JVM pays for its configured heap: <code>run-prod.sh</code> starts it with <code>-Xms1g</code>, so RSS begins
near half a gigabyte and climbs toward ~1 GB as pages are touched under load. The native
image is impressively compact at boot (~4 MB of resident pages before requests pull code
and heap in) but settles around 140 MB serving traffic. Rust idles at 19 MB and tops out at
40 MB — roughly <strong>3.5× less than the native image and 24× less than the JVM</strong>, while
serving the same 50 VUs within a few ms of both.</p>
<h2 id="6-what-we-learned">6. What we learned</h2>
<ul>
<li><strong>Parity testing is the whole project.</strong> A rewrite is not a port; it is a proof. The test
harness (31 acceptance + 150 e2e) is the actual deliverable — the Rust code is almost a
byproduct of writing it.</li>
<li><strong>Silent emptiness is the enemy.</strong> A missing context key renders an empty list, not an
error. Every &ldquo;empty page&rdquo; report (<code>/markalar</code>, <code>/sehirler</code>, <code>/arama</code>, favorites, the edit
form) was a handler-template key mismatch, never a database problem.</li>
<li><strong>Legacy URLs are a product feature.</strong> A decade of <code>.aspx</code> links, query-string shapes and
redirect quirks live in Java&rsquo;s <code>LegacyRedirectController</code>. Rust reimplemented the full map
— and Playwright tests every row of it.</li>
<li><strong>One binary named the same as the old one makes deploy a copy.</strong> Zero ceremony: cp,
restart, health sweep, rollback if unhealthy.</li>
</ul>
]]></content:encoded>
    </item>
  </channel>
</rss>
