<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en"><generator uri="https://jekyllrb.com/" version="4.3.4">Jekyll</generator><link href="https://blog.changcodes.com/feed/en.xml" rel="self" type="application/atom+xml" /><link href="https://blog.changcodes.com/" rel="alternate" type="text/html" hreflang="en" /><updated>2026-10-25T15:03:32+00:00</updated><id>https://blog.changcodes.com/feed/en.xml</id><title type="html">Chang Codes | En</title><subtitle>Engineering notes on workflows, data integrity and failure safety.</subtitle><entry xml:lang="en"><title type="html">Failure Safety in API Request Handling: Designing Defaults for Rate Limiting, Feature Switches, Authorization and Observability</title><link href="https://blog.changcodes.com/en/posts/api-failure-safety/" rel="alternate" type="text/html" title="Failure Safety in API Request Handling: Designing Defaults for Rate Limiting, Feature Switches, Authorization and Observability" /><published>2026-07-19T00:00:00+00:00</published><updated>2026-07-19T00:00:00+00:00</updated><id>https://blog.changcodes.com/en/posts/api-failure-safety</id><content type="html" xml:base="https://blog.changcodes.com/en/posts/api-failure-safety/"><![CDATA[<h2 id="1-problem-and-goals">1. Problem and Goals</h2>

<p>Our backend service carries several kinds of traffic at once:</p>

<ul>
  <li>User-facing self-service endpoints: submitting requests, checking status, viewing my records;</li>
  <li>Callbacks from external systems;</li>
  <li>Operations endpoints;</li>
  <li>A single-page application (SPA) frontend.</li>
</ul>

<p>Some user requests trigger external operations that are slow and expensive, such as creating Merge Requests and running pipelines.</p>

<p>In a service like this, the cross-cutting concerns themselves (rate limiting, feature switches, authorization, idempotency, metrics) can also go wrong: configuration can be missing or wrong, dependencies can misbehave, and instrumentation code can throw. Our goals are:</p>

<ul>
  <li>For every cross-cutting concern, explicitly define how it behaves when “a dependency is missing, the configuration is wrong, or a dependency fails”;</li>
  <li>Misconfiguration must not open features that should be closed, nor block traffic that should be allowed;</li>
  <li>Metrics collection never changes business outcomes;</li>
  <li>Error responses are useful to clients without leaking internal details.</li>
</ul>

<h2 id="2-constraints-and-considerations">2. Constraints and Considerations</h2>

<ul>
  <li><strong>The service runs as multiple instances</strong>, with no shared cache dedicated to rate limiting.</li>
  <li><strong>Configuration comes from a configuration center and supports hot reload.</strong> This means configuration may be missing, empty or misspelled, and can change at runtime.</li>
  <li><strong>The caller’s identity comes from a unified login SDK whose fields are not uniform.</strong> Sometimes it is an email-style account, sometimes an employee ID, and both need to resolve to the same identity.</li>
  <li><strong>Existing endpoints’ response shapes must not change.</strong> Some legacy endpoints return a <code class="language-plaintext highlighter-rouge">Map</code> directly, so cross-cutting logic cannot depend on a common return type.</li>
  <li><strong>Old pages linger after a frontend release.</strong> Already-open old pages request hashed static assets that were removed in the new version.</li>
  <li><strong>The team convention is that observability code reads but never writes.</strong> Instrumentation may only read values the business logic has already resolved, and must not in turn influence business logic.</li>
</ul>

<h2 id="3-design">3. Design</h2>

<h3 id="31-interceptor-responsibilities-and-order">3.1 Interceptor responsibilities and order</h3>

<p>Interceptors are registered in the following order, and Spring executes them in registration order:</p>

<ol>
  <li><strong>Rate limiting</strong>: applies only to handler methods annotated with <code class="language-plaintext highlighter-rouge">@RateLimit</code>.</li>
  <li><strong>Authorization for specific paths.</strong></li>
  <li><strong>API latency and status-code metrics.</strong></li>
  <li><strong>Business activity metrics</strong>: applies only to an explicitly listed path allowlist.</li>
</ol>

<p>Rate limiting comes first for two reasons. First, rejected requests should not consume any downstream processing. Second, business activity counts measure “how many business actions users completed”, and requests rejected with 429 should not be included; they are counted by a separate “rate-limited” metric.</p>

<p>Business activity metrics use a path allowlist rather than wildcards, so traffic unrelated to that business never enters this interceptor at all.</p>

<p>Implementing rate limiting and authorization in interceptors, which run before argument binding, has two benefits:</p>

<ul>
  <li>A malformed request body cannot trigger a 400 first, bypassing or pre-empting authorization;</li>
  <li>The interceptor writes the response itself, so no endpoint’s return type has to change.</li>
</ul>

<h3 id="32-annotation-driven-rate-limiting-that-runs-before-argument-binding">3.2 Annotation-driven rate limiting that runs before argument binding</h3>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@PostMapping</span><span class="o">(</span><span class="s">"/apply"</span><span class="o">)</span>
<span class="nd">@RateLimit</span><span class="o">(</span><span class="n">bucket</span> <span class="o">=</span> <span class="s">"apply"</span><span class="o">,</span> <span class="n">limitKey</span> <span class="o">=</span> <span class="s">"APPLY_RATE_LIMIT"</span><span class="o">,</span> <span class="n">defaultLimit</span> <span class="o">=</span> <span class="mi">10</span><span class="o">,</span>
           <span class="n">message</span> <span class="o">=</span> <span class="s">"Too many submissions, please try again later"</span><span class="o">)</span>
<span class="kd">public</span> <span class="nc">ResponseEntity</span><span class="o">&lt;?&gt;</span> <span class="n">apply</span><span class="o">(</span><span class="nd">@RequestBody</span> <span class="nc">Map</span><span class="o">&lt;</span><span class="nc">String</span><span class="o">,</span> <span class="nc">String</span><span class="o">&gt;</span> <span class="n">body</span><span class="o">)</span> <span class="o">{</span> <span class="o">...</span> <span class="o">}</span>
</code></pre></div></div>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">public</span> <span class="kt">boolean</span> <span class="nf">preHandle</span><span class="o">(</span><span class="nc">HttpServletRequest</span> <span class="n">req</span><span class="o">,</span> <span class="nc">HttpServletResponse</span> <span class="n">resp</span><span class="o">,</span> <span class="nc">Object</span> <span class="n">handler</span><span class="o">)</span> <span class="o">{</span>
    <span class="nc">RateLimit</span> <span class="n">rl</span> <span class="o">=</span> <span class="n">annotationOf</span><span class="o">(</span><span class="n">handler</span><span class="o">);</span>
    <span class="k">if</span> <span class="o">(</span><span class="n">rl</span> <span class="o">==</span> <span class="kc">null</span><span class="o">)</span> <span class="k">return</span> <span class="kc">true</span><span class="o">;</span>

    <span class="kt">int</span> <span class="n">limit</span> <span class="o">=</span> <span class="n">config</span><span class="o">.</span><span class="na">getInt</span><span class="o">(</span><span class="n">rl</span><span class="o">.</span><span class="na">limitKey</span><span class="o">(),</span> <span class="n">rl</span><span class="o">.</span><span class="na">defaultLimit</span><span class="o">());</span>   <span class="c1">// Read on every request, so hot reloads take effect immediately</span>
    <span class="k">if</span> <span class="o">(</span><span class="n">limit</span> <span class="o">&lt;=</span> <span class="mi">0</span><span class="o">)</span> <span class="k">return</span> <span class="kc">true</span><span class="o">;</span>                                    <span class="c1">// Cleared or non-positive: stop rate limiting, rather than blocking everything</span>

    <span class="nc">String</span> <span class="n">subject</span> <span class="o">=</span> <span class="n">resolveSubject</span><span class="o">(</span><span class="n">req</span><span class="o">);</span>
    <span class="k">if</span> <span class="o">(</span><span class="n">limiter</span><span class="o">.</span><span class="na">tryAcquire</span><span class="o">(</span><span class="n">rl</span><span class="o">.</span><span class="na">bucket</span><span class="o">()</span> <span class="o">+</span> <span class="s">":"</span> <span class="o">+</span> <span class="n">subject</span><span class="o">,</span> <span class="n">limit</span><span class="o">,</span> <span class="n">windowMillis</span><span class="o">()))</span> <span class="k">return</span> <span class="kc">true</span><span class="o">;</span>

    <span class="n">metrics</span><span class="o">.</span><span class="na">recordRateLimited</span><span class="o">(</span><span class="n">subject</span><span class="o">,</span> <span class="n">rl</span><span class="o">.</span><span class="na">bucket</span><span class="o">());</span>
    <span class="n">writeJson</span><span class="o">(</span><span class="n">resp</span><span class="o">,</span> <span class="mi">429</span><span class="o">,</span> <span class="nc">ApiResponse</span><span class="o">.</span><span class="na">error</span><span class="o">(</span><span class="s">"RATE_LIMITED"</span><span class="o">,</span> <span class="n">rl</span><span class="o">.</span><span class="na">message</span><span class="o">()));</span>
    <span class="k">return</span> <span class="kc">false</span><span class="o">;</span>
<span class="o">}</span>
</code></pre></div></div>

<p><strong>The rate-limit subject prefers the login identity.</strong> That way the same person switching browsers or opening several tabs consumes the same quota. Only when no identity can be read do we fall back to the client address, prefixed with <code class="language-plaintext highlighter-rouge">ip:</code> to distinguish it from identities. We fall back to the address rather than a shared bucket because any single caller could exhaust a shared bucket, affecting every caller whose identity cannot be determined.</p>

<p><strong>The limiter is a sliding-window log.</strong> Each bucket keeps a queue of timestamps, and “evict expired timestamps, check, append” happens inside <code class="language-plaintext highlighter-rouge">ConcurrentHashMap.compute</code>, so the whole operation is atomic per bucket. When a bucket becomes empty it returns <code class="language-plaintext highlighter-rouge">null</code>, and the key is removed:</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">buckets</span><span class="o">.</span><span class="na">compute</span><span class="o">(</span><span class="n">key</span><span class="o">,</span> <span class="o">(</span><span class="n">k</span><span class="o">,</span> <span class="n">q</span><span class="o">)</span> <span class="o">-&gt;</span> <span class="o">{</span>
    <span class="nc">Deque</span><span class="o">&lt;</span><span class="nc">Long</span><span class="o">&gt;</span> <span class="n">d</span> <span class="o">=</span> <span class="o">(</span><span class="n">q</span> <span class="o">==</span> <span class="kc">null</span><span class="o">)</span> <span class="o">?</span> <span class="k">new</span> <span class="nc">ArrayDeque</span><span class="o">&lt;&gt;()</span> <span class="o">:</span> <span class="n">q</span><span class="o">;</span>
    <span class="k">while</span> <span class="o">(!</span><span class="n">d</span><span class="o">.</span><span class="na">isEmpty</span><span class="o">()</span> <span class="o">&amp;&amp;</span> <span class="n">now</span> <span class="o">-</span> <span class="n">d</span><span class="o">.</span><span class="na">peekFirst</span><span class="o">()</span> <span class="o">&gt;=</span> <span class="n">windowMillis</span><span class="o">)</span> <span class="n">d</span><span class="o">.</span><span class="na">pollFirst</span><span class="o">();</span>
    <span class="k">if</span> <span class="o">(</span><span class="n">d</span><span class="o">.</span><span class="na">size</span><span class="o">()</span> <span class="o">&lt;</span> <span class="n">limit</span><span class="o">)</span> <span class="o">{</span> <span class="n">d</span><span class="o">.</span><span class="na">addLast</span><span class="o">(</span><span class="n">now</span><span class="o">);</span> <span class="n">allowed</span><span class="o">[</span><span class="mi">0</span><span class="o">]</span> <span class="o">=</span> <span class="kc">true</span><span class="o">;</span> <span class="o">}</span>
    <span class="k">return</span> <span class="n">d</span><span class="o">.</span><span class="na">isEmpty</span><span class="o">()</span> <span class="o">?</span> <span class="kc">null</span> <span class="o">:</span> <span class="n">d</span><span class="o">;</span>
<span class="o">});</span>
</code></pre></div></div>

<p><strong>Trade-offs belong in the comments.</strong> Rate limiting runs before the idempotency check, so a duplicate submission that exceeds the quota gets a 429 rather than “duplicate submission”. We accept this and state it in the endpoint’s comments, so that future maintainers do not mistake it for a bug.</p>

<h3 id="33-configuration-defaults-choose-the-direction-by-the-consequence-of-misconfiguration">3.3 Configuration defaults: choose the direction by the consequence of misconfiguration</h3>

<p>Hot-reloaded configuration can go missing or be wrong at any time. We explicitly specify the fallback behavior for every configuration item, with this rule: <strong>look at the main consequence of misconfiguration, and default toward the less harmful outcome.</strong></p>

<table>
  <thead>
    <tr>
      <th>Configuration item</th>
      <th>Main consequence of misconfiguration</th>
      <th>Default behavior</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Rate-limit threshold</td>
      <td>Blocking legitimate traffic</td>
      <td>No rate limiting (a threshold ≤ 0 disables rate limiting)</td>
    </tr>
    <tr>
      <td>Access mode of a restricted resource</td>
      <td>The resource is opened unintentionally</td>
      <td>Unknown values are treated as “closed”</td>
    </tr>
    <tr>
      <td>Access time window of a restricted resource</td>
      <td>The resource is opened unintentionally</td>
      <td>Any missing or malformed field means “outside the window”</td>
    </tr>
    <tr>
      <td>Rule codes required for authorization</td>
      <td>Unauthorized access</td>
      <td>Missing means deny</td>
    </tr>
    <tr>
      <td>New-workflow switch</td>
      <td>Enabling an unverified path</td>
      <td>Use the old workflow</td>
    </tr>
    <tr>
      <td>Retry count, backoff time</td>
      <td>Negative values cause skipped calls or negative sleeps</td>
      <td>Clamped to a lower bound of 0</td>
    </tr>
  </tbody>
</table>

<p>The access mode is parsed as follows:</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">AccessMode</span> <span class="nf">resolve</span><span class="o">(</span><span class="nc">String</span> <span class="n">raw</span><span class="o">)</span> <span class="o">{</span>
    <span class="k">if</span> <span class="o">(</span><span class="n">raw</span> <span class="o">==</span> <span class="kc">null</span> <span class="o">||</span> <span class="n">raw</span><span class="o">.</span><span class="na">isBlank</span><span class="o">())</span> <span class="k">return</span> <span class="no">TEST_WINDOW</span><span class="o">;</span>        <span class="c1">// Not configured: restricted access (the default)</span>
    <span class="k">return</span> <span class="nf">switch</span> <span class="o">(</span><span class="n">raw</span><span class="o">.</span><span class="na">trim</span><span class="o">().</span><span class="na">toUpperCase</span><span class="o">(</span><span class="nc">Locale</span><span class="o">.</span><span class="na">ROOT</span><span class="o">))</span> <span class="o">{</span>
        <span class="k">case</span> <span class="s">"OPEN"</span>        <span class="o">-&gt;</span> <span class="no">OPEN</span><span class="o">;</span>
        <span class="k">case</span> <span class="s">"TEST_WINDOW"</span> <span class="o">-&gt;</span> <span class="no">TEST_WINDOW</span><span class="o">;</span>
        <span class="k">default</span>            <span class="o">-&gt;</span> <span class="no">CLOSED</span><span class="o">;</span>                            <span class="c1">// Typos or any other unknown value: closed</span>
    <span class="o">};</span>
<span class="o">}</span>
</code></pre></div></div>

<p>The access time window is parsed in a fixed time zone rather than the JVM’s default, so the same configuration maps to the same period of time on every host.</p>

<h3 id="34-authorization-fail-closed-and-reveal-nothing-on-denial">3.4 Authorization: fail closed, and reveal nothing on denial</h3>

<ul>
  <li><strong>Any exception from the permission service call is treated as “no permission”</strong>, and a missing rule code is likewise treated as “no permission”.</li>
  <li><strong>Multiple rule codes are checked in one round trip</strong>, for combined checks such as “administrators may also access”.</li>
  <li><strong>Distinguish 401 from 403.</strong> If no login identity can be read, or the login SDK throws, return 401; if the identity is valid but lacks permission, or the permission service fails, return 403.</li>
  <li><strong>The 403 response message is always “Access denied”</strong>, so a rejected caller cannot learn which permission is required.</li>
  <li><strong>The operator identity for the operations console is written by the server.</strong> The server first copies the parameters submitted by the client, then overwrites the reserved <code class="language-plaintext highlighter-rouge">__authenticatedOperator</code> parameter with the login identity. The order must not be reversed; otherwise a client could forge the operator by submitting a parameter with the same name. Audit logs record only parameters that have been masked and length-truncated.</li>
</ul>

<h3 id="35-error-responses-actionable-information-for-clients-details-stay-in-logs">3.5 Error responses: actionable information for clients, details stay in logs</h3>

<p>At the API layer we distinguish two kinds of errors:</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Client error: return an actionable hint; the details go only to the log</span>
<span class="nc">ResponseEntity</span><span class="o">&lt;?&gt;</span> <span class="n">clientFacingError</span><span class="o">(</span><span class="nc">String</span> <span class="n">code</span><span class="o">,</span> <span class="nc">String</span> <span class="n">userMessage</span><span class="o">,</span> <span class="nc">String</span> <span class="n">logDetail</span><span class="o">)</span> <span class="o">{</span>
    <span class="n">log</span><span class="o">.</span><span class="na">warn</span><span class="o">(</span><span class="s">"rejected code={} detail={}"</span><span class="o">,</span> <span class="n">code</span><span class="o">,</span> <span class="n">logDetail</span><span class="o">);</span>
    <span class="k">return</span> <span class="nc">ApiResponse</span><span class="o">.</span><span class="na">error</span><span class="o">(</span><span class="n">code</span><span class="o">,</span> <span class="n">userMessage</span><span class="o">);</span>
<span class="o">}</span>

<span class="c1">// Server error: the log records the cause and stack trace; the client receives only a generic message</span>
<span class="nc">ResponseEntity</span><span class="o">&lt;?&gt;</span> <span class="n">internalError</span><span class="o">(</span><span class="nc">String</span> <span class="n">code</span><span class="o">,</span> <span class="nc">String</span> <span class="n">logDetail</span><span class="o">,</span> <span class="nc">Throwable</span> <span class="n">cause</span><span class="o">)</span> <span class="o">{</span>
    <span class="n">log</span><span class="o">.</span><span class="na">error</span><span class="o">(</span><span class="s">"internal code={} detail={}"</span><span class="o">,</span> <span class="n">code</span><span class="o">,</span> <span class="n">logDetail</span><span class="o">,</span> <span class="n">cause</span><span class="o">);</span>
    <span class="k">return</span> <span class="nc">ApiResponse</span><span class="o">.</span><span class="na">error</span><span class="o">(</span><span class="n">code</span><span class="o">,</span> <span class="no">GENERIC_ERROR_MESSAGE</span><span class="o">);</span>
<span class="o">}</span>
</code></pre></div></div>

<p>Status codes are designed around “what the client should do next”, not “what happened inside the server”:</p>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">DUPLICATE_SUBMIT</code> is separate from <code class="language-plaintext highlighter-rouge">ACCEPTED</code>.</strong> Both have HTTP status 202, but when the frontend receives <code class="language-plaintext highlighter-rouge">DUPLICATE_SUBMIT</code> it keeps the form locked instead of treating it as a newly accepted request.</li>
  <li><strong>“Awaiting manual handling” is split into two hints depending on whether it can recover on its own.</strong> If the request stopped at a step that can be retried on the original MR, the hint is “you can keep waiting”; if it stopped at a deterministic external failure (such as a pipeline failure), the hint is “contact the maintainers; waiting will not help”. Which steps belong to the latter is defined centrally in a constant set.</li>
  <li><strong>Read-only preview endpoints return empty data on failure</strong>, so they never block the user from continuing to submit.</li>
</ul>

<h3 id="36-idempotency-and-long-running-operations">3.6 Idempotency and long-running operations</h3>

<p><strong>Idempotency.</strong> Write endpoints use the client-supplied <code class="language-plaintext highlighter-rouge">operationId</code> as the idempotency key, relying on a database unique constraint to “look up, insert, and re-read on conflict” (see Section 3.4 of the database post). Among concurrent duplicate requests, only one proceeds; the others return the winner’s result.</p>

<p><strong>Long-running operations.</strong> A long-running operation returns HTTP 202 after synchronous validation and then executes in a bounded thread pool. When the pool rejects a task, the failure state is written to the database (see Section 3.2 of the MR lifecycle post). The client can always see an explicit state by polling.</p>

<h3 id="37-observability-never-interferes-with-business-logic">3.7 Observability never interferes with business logic</h3>

<p>We set a few rules for metrics code and enforced each one in the implementation:</p>

<ul>
  <li><strong>The metrics interceptor’s <code class="language-plaintext highlighter-rouge">preHandle</code> always returns <code class="language-plaintext highlighter-rouge">true</code> and never writes a response.</strong></li>
  <li><strong>Metrics code only reads values that have already been resolved.</strong> For example, it reads request parameters with <code class="language-plaintext highlighter-rouge">request.getParameter</code> rather than reading the request body itself, which would consume the input stream and break later argument binding.</li>
  <li><strong>Metrics use their own identity resolution.</strong> When the identity is not trustworthy (for example, only a bare ID that might be an employee ID is available), the count is simply skipped rather than guessed, and the resolved value never flows back into business logic.</li>
  <li><strong>Counter writes are wrapped in <code class="language-plaintext highlighter-rouge">try/catch</code></strong>, and exceptions are only logged. Tag values are normalized and truncated to 128 characters; empty or invalid dimensions cause the emission to be skipped.</li>
  <li><strong>Choose when to count by semantics.</strong> “Request received” is counted when the request arrives; “successful access” is counted in <code class="language-plaintext highlighter-rouge">afterCompletion</code>, and only for 2xx responses.</li>
  <li><strong>Business outcome metrics are emitted only after the transaction commits</strong>, and only when the state actually flipped, avoiding over-counting due to rollbacks or concurrency (see Section 3.7 of the MR lifecycle post).</li>
  <li><strong>Metrics code sits outside the failure-handling logic.</strong> For example, the duration is recorded only after a batch hand-off completes, so even if the metrics code throws, it cannot trigger the batch’s failure settlement.</li>
</ul>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">public</span> <span class="kt">void</span> <span class="nf">incrementUserCounter</span><span class="o">(</span><span class="nc">String</span> <span class="n">name</span><span class="o">,</span> <span class="nc">Map</span><span class="o">&lt;</span><span class="nc">String</span><span class="o">,</span> <span class="nc">String</span><span class="o">&gt;</span> <span class="n">tags</span><span class="o">)</span> <span class="o">{</span>
    <span class="k">try</span> <span class="o">{</span>
        <span class="nc">Map</span><span class="o">&lt;</span><span class="nc">String</span><span class="o">,</span> <span class="nc">String</span><span class="o">&gt;</span> <span class="n">safe</span> <span class="o">=</span> <span class="n">normalizeAndTruncate</span><span class="o">(</span><span class="n">tags</span><span class="o">,</span> <span class="mi">128</span><span class="o">);</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">safe</span> <span class="o">==</span> <span class="kc">null</span><span class="o">)</span> <span class="o">{</span> <span class="n">log</span><span class="o">.</span><span class="na">warn</span><span class="o">(</span><span class="s">"skip metric {}: invalid tags"</span><span class="o">,</span> <span class="n">name</span><span class="o">);</span> <span class="k">return</span><span class="o">;</span> <span class="o">}</span>
        <span class="n">registry</span><span class="o">.</span><span class="na">counter</span><span class="o">(</span><span class="n">name</span><span class="o">,</span> <span class="n">safe</span><span class="o">).</span><span class="na">inc</span><span class="o">();</span>
    <span class="o">}</span> <span class="k">catch</span> <span class="o">(</span><span class="nc">RuntimeException</span> <span class="n">e</span><span class="o">)</span> <span class="o">{</span>
        <span class="n">log</span><span class="o">.</span><span class="na">error</span><span class="o">(</span><span class="s">"metric {} failed; business request continues"</span><span class="o">,</span> <span class="n">name</span><span class="o">,</span> <span class="n">e</span><span class="o">);</span>   <span class="c1">// A metrics failure does not affect the business request</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<h3 id="38-static-asset-fallback-during-frontend-releases">3.8 Static asset fallback during frontend releases</h3>

<p>After an SPA release, already-open old pages request hashed assets that were removed in the new version. Without handling, these requests fall through to the global exception handler and return 500. We address this on both the server and the client:</p>

<p><strong>Server side:</strong></p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">index.html</code> is served with <code class="language-plaintext highlighter-rouge">Cache-Control: no-store</code>, so the latest asset manifest is always fetched;</li>
  <li>Hashed assets are served with <code class="language-plaintext highlighter-rouge">max-age=365d, public, immutable</code>;</li>
  <li>When an asset does not exist, the response depends on the request type: page navigation requests (<code class="language-plaintext highlighter-rouge">Accept: text/html</code> with no file extension in the path) get a fallback page, while static asset or API requests get a 404 JSON. Both responses set <code class="language-plaintext highlighter-rouge">no-store</code>.</li>
</ul>

<p><strong>Client side:</strong></p>

<ul>
  <li>Listen for script and stylesheet load errors in the capture phase; when a core asset fails to load, reload the page with a cache-busting parameter;</li>
  <li>Record the time of the last automatic reload in <code class="language-plaintext highlighter-rouge">sessionStorage</code>. Within 30 seconds, do not reload automatically again; show a “Refresh page” button instead, to avoid a reload loop.</li>
</ul>

<p>We also wrote smoke scripts for these response headers and status codes and run them in the pre-release environment.</p>

<h2 id="4-results">4. Results</h2>

<ul>
  <li><strong>The fallback behavior of every cross-cutting concern is explicitly defined</strong>, and documented in code comments and the configuration guide. When configuration is cleared or wrong, rate limiting degrades to no limiting, access switches degrade to closed, and authorization degrades to deny.</li>
  <li><strong>Rate limiting and authorization complete before argument binding</strong>, without changing the response shape of any existing endpoint.</li>
  <li><strong>Rate-limited requests do not pollute business metrics</strong>; they are counted by a separate metric.</li>
  <li><strong>Exceptions in metrics code do not affect business requests.</strong> Metrics code does not read the request body, does not write responses, and does not take part in business branching.</li>
  <li><strong>Error responses are distinguished by “next action”.</strong> Users can tell “duplicate submission”, “you can keep waiting” and “contact the team” apart, while internal details appear only in server logs.</li>
  <li><strong>During frontend releases, old pages recover on their own</strong>, and 500s caused by missing assets no longer occur.</li>
</ul>

<h2 id="5-known-limitations-and-next-steps">5. Known Limitations and Next Steps</h2>

<ul>
  <li><strong>Rate limiting is per instance.</strong> The effective global quota is roughly “per-instance threshold × number of instances”. In addition, when falling back to address-based limiting, we take the first hop of <code class="language-plaintext highlighter-rouge">X-Forwarded-For</code>, which clients can forge. We plan to use the hop appended by a trusted proxy instead, or to rate-limit uniformly at the gateway.</li>
  <li><strong>The identity resolution logic exists in three copies with subtle differences.</strong> One of them does not truncate email-style accounts, which can give the same person two tag values in metrics. We plan to consolidate them into one shared resolver.</li>
  <li><strong>The mapping from exceptions to HTTP status codes is inconsistent.</strong> Some business exceptions return 500; one authorization denial goes through a generic service exception and returns 500 instead of 403; and the fallback handler returns exception messages to the client verbatim. We plan to unify the error model so that the fallback handler returns only a generic message.</li>
  <li><strong>Some external SDK calls have no timeout and no circuit breaker.</strong> When an external dependency slows down, request threads are held for a long time. We plan to set explicit timeouts on all external calls and add isolation for critical dependencies.</li>
  <li><strong>Idempotency records only the <code class="language-plaintext highlighter-rouge">operationId</code> and does not replay the original response.</strong> If the client does not supply an <code class="language-plaintext highlighter-rouge">operationId</code>, the server-generated ID contains a timestamp, so retried requests cannot be deduplicated.</li>
  <li><strong>The API metrics interceptor itself has no <code class="language-plaintext highlighter-rouge">try/catch</code>.</strong> Whether it truly cannot affect business logic depends on the tracing and metrics libraries not throwing. In addition, some user endpoints are outside the coverage of the API metrics.</li>
</ul>]]></content><author><name></name></author><category term="en" /><summary type="html"><![CDATA[Defining how cross-cutting concerns such as rate limiting, feature switches, authorization and metrics behave when dependencies are missing, misconfigured or failing, so that misconfiguration degrades toward the less harmful outcome.]]></summary></entry><entry xml:lang="en"><title type="html">Letting MySQL Enforce Business Invariants: A Single Active Slot via a Generated Column, Guarded UPDATEs, and “Duplicate Key as the Answer”</title><link href="https://blog.changcodes.com/en/posts/mysql-business-invariants/" rel="alternate" type="text/html" title="Letting MySQL Enforce Business Invariants: A Single Active Slot via a Generated Column, Guarded UPDATEs, and “Duplicate Key as the Answer”" /><published>2026-05-17T00:00:00+00:00</published><updated>2026-05-17T00:00:00+00:00</updated><id>https://blog.changcodes.com/en/posts/mysql-business-invariants</id><content type="html" xml:base="https://blog.changcodes.com/en/posts/mysql-business-invariants/"><![CDATA[<h2 id="1-problem-and-goals">1. Problem and Goals</h2>

<p>Our system applies users’ change requests by opening Merge Requests (MRs) against a Git repository. If every request opened its own MR, many MRs would modify the same repository at the same time, force each other to rebase, and queue up on the pipelines. To avoid this we introduced <strong>batch aggregation</strong>: once a request is approved it enters a waiting set, and each round merges the waiting requests into a single MR that is processed as a unit.</p>

<p>This design requires the following invariants to hold at all times:</p>

<ul>
  <li><strong>I1 Single active batch</strong>: at any moment there is at most one active batch (running or blocked).</li>
  <li><strong>I2 No double claiming</strong>: a request is claimed by at most one batch.</li>
  <li><strong>I3 Non-blocking admission</strong>: while a batch is running, new requests can still enter the waiting set.</li>
  <li><strong>I4 Idempotent admission</strong>: if the same request is submitted repeatedly, it is admitted only once.</li>
  <li><strong>I5 One row per relationship</strong>: each “subject–target” relationship has exactly one row of current state, and re-applying reuses that row.</li>
  <li><strong>I6 Stale writes fail</strong>: state transitions are based on the expected current state; if the state has been changed elsewhere, the write must fail.</li>
</ul>

<p>The goal is to have the database enforce these invariants directly, rather than relying on “application code that happens to be correct”.</p>

<h2 id="2-constraints-and-considerations">2. Constraints and Considerations</h2>

<ul>
  <li><strong>The database is MySQL InnoDB.</strong> MySQL does not support partial unique indexes like PostgreSQL does (<code class="language-plaintext highlighter-rouge">CREATE UNIQUE INDEX ... WHERE status = 'RUNNING'</code>).</li>
  <li><strong>The service runs as multiple instances, and no new components are introduced.</strong> There is no Redis, ZooKeeper or similar distributed lock available, and we do not plan to introduce one for this.</li>
  <li><strong>Triggers overlap.</strong> The scheduling platform guarantees that a scheduled job runs on a single instance, but operators can manually trigger the same round, and a manual trigger may coincide with a scheduled one.</li>
  <li><strong>Batches are long-lived.</strong> A batch takes several minutes from creating the MR to merging it, and we cannot hold database locks for that long.</li>
  <li><strong>The process may crash at any step.</strong> The mutual-exclusion mechanism must not leak permanently because its holder crashed.</li>
</ul>

<h2 id="3-design">3. Design</h2>

<h3 id="31-a-generated-column-plus-a-unique-index-the-single-active-slot-i1">3.1 A generated column plus a unique index: the single active slot (I1)</h3>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">merge_batch</span> <span class="p">(</span>
  <span class="n">id</span>          <span class="nb">BIGINT</span>      <span class="k">NOT</span> <span class="k">NULL</span> <span class="n">AUTO_INCREMENT</span><span class="p">,</span>
  <span class="n">status</span>      <span class="nb">TINYINT</span>     <span class="k">NOT</span> <span class="k">NULL</span> <span class="k">DEFAULT</span> <span class="mi">1</span> <span class="k">COMMENT</span> <span class="s1">'1=RUNNING 2=SUCCEEDED 3=FAILED 4=BLOCKED'</span><span class="p">,</span>
  <span class="n">active_slot</span> <span class="nb">TINYINT</span> <span class="k">GENERATED</span> <span class="n">ALWAYS</span> <span class="k">AS</span>
                <span class="p">(</span><span class="k">CASE</span> <span class="k">WHEN</span> <span class="n">status</span> <span class="k">IN</span> <span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="mi">4</span><span class="p">)</span> <span class="k">THEN</span> <span class="mi">1</span> <span class="k">ELSE</span> <span class="k">NULL</span> <span class="k">END</span><span class="p">)</span> <span class="n">STORED</span><span class="p">,</span>
  <span class="n">mr_id</span>       <span class="nb">VARCHAR</span><span class="p">(</span><span class="mi">64</span><span class="p">)</span> <span class="k">NULL</span><span class="p">,</span>
  <span class="n">created_at</span>  <span class="nb">DATETIME</span>    <span class="k">NOT</span> <span class="k">NULL</span> <span class="k">DEFAULT</span> <span class="k">CURRENT_TIMESTAMP</span><span class="p">,</span>
  <span class="k">PRIMARY</span> <span class="k">KEY</span> <span class="p">(</span><span class="n">id</span><span class="p">),</span>
  <span class="k">UNIQUE</span> <span class="k">KEY</span> <span class="n">uk_active_slot</span> <span class="p">(</span><span class="n">active_slot</span><span class="p">),</span>
  <span class="k">KEY</span> <span class="n">idx_status_created</span> <span class="p">(</span><span class="n">status</span><span class="p">,</span> <span class="n">created_at</span><span class="p">,</span> <span class="n">id</span><span class="p">)</span>
<span class="p">)</span> <span class="n">ENGINE</span> <span class="o">=</span> <span class="n">InnoDB</span><span class="p">;</span>
</code></pre></div></div>

<p>This design relies on one semantic of MySQL unique indexes: <strong>a unique index allows multiple NULLs</strong>.</p>

<ul>
  <li>Active batches (RUNNING, BLOCKED) have <code class="language-plaintext highlighter-rouge">active_slot</code> = 1, and the unique index guarantees that at most one row in the whole table has the value 1.</li>
  <li>Terminal batches (SUCCEEDED, FAILED) have <code class="language-plaintext highlighter-rouge">active_slot</code> = NULL, so any number of them can coexist.</li>
  <li>The <code class="language-plaintext highlighter-rouge">UPDATE status</code> that moves a batch into a terminal state also turns <code class="language-plaintext highlighter-rouge">active_slot</code> into NULL, releasing the slot with it; no separate release step is needed.</li>
</ul>

<p>In effect this is equivalent to the partial unique index <code class="language-plaintext highlighter-rouge">UNIQUE (status) WHERE status IN (RUNNING, BLOCKED)</code>.</p>

<p>We also compared other mutual-exclusion approaches:</p>

<table>
  <thead>
    <tr>
      <th>Approach</th>
      <th>Main problem</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>A lock row plus <code class="language-plaintext highlighter-rouge">SELECT ... FOR UPDATE</code></td>
      <td>The lock is either held for the whole batch (a transaction lasting minutes), or a separate lease field has to be maintained</td>
    </tr>
    <tr>
      <td>Session-level <code class="language-plaintext highlighter-rouge">GET_LOCK()</code></td>
      <td>The lock is bound to a database connection, which does not fit well with connection reuse in a pool</td>
    </tr>
    <tr>
      <td>An external distributed lock such as Redis</td>
      <td>Requires a new component; the lease expiry is hard to align with the task’s actual duration</td>
    </tr>
    <tr>
      <td>A generated column plus a unique index</td>
      <td>The lock and the state are the same column: there is no lease, and “state is terminal but the lock was not released” cannot happen</td>
    </tr>
  </tbody>
</table>

<p>The last row is why we chose it: the mutual-exclusion state is the business state itself. If the holder crashes, the batch stays RUNNING and the slot remains occupied, but this is an explicit state that can be queried and handled, not an invisible leaked lock. A timeout sweep or the operations tool moves it to a terminal state, and the slot is released. The blocked state (BLOCKED) also occupies the slot, so subsequent batches stop until this batch has been dealt with.</p>

<h3 id="32-the-claim-transaction-non-locking-reads-and-guarded-updates-i2-i3">3.2 The claim transaction: non-locking reads and guarded UPDATEs (I2, I3)</h3>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">merge_batch_request</span> <span class="p">(</span>
  <span class="n">id</span>           <span class="nb">BIGINT</span>      <span class="k">NOT</span> <span class="k">NULL</span> <span class="n">AUTO_INCREMENT</span><span class="p">,</span>
  <span class="n">operation_id</span> <span class="nb">VARCHAR</span><span class="p">(</span><span class="mi">36</span><span class="p">)</span> <span class="k">NOT</span> <span class="k">NULL</span><span class="p">,</span>
  <span class="n">batch_id</span>     <span class="nb">BIGINT</span>      <span class="k">NULL</span> <span class="k">COMMENT</span> <span class="s1">'NULL means still waiting'</span><span class="p">,</span>
  <span class="n">created_at</span>   <span class="nb">DATETIME</span>    <span class="k">NOT</span> <span class="k">NULL</span> <span class="k">DEFAULT</span> <span class="k">CURRENT_TIMESTAMP</span><span class="p">,</span>
  <span class="k">PRIMARY</span> <span class="k">KEY</span> <span class="p">(</span><span class="n">id</span><span class="p">),</span>
  <span class="k">UNIQUE</span> <span class="k">KEY</span> <span class="n">uk_operation_id</span> <span class="p">(</span><span class="n">operation_id</span><span class="p">),</span>
  <span class="k">KEY</span> <span class="n">idx_batch_created</span> <span class="p">(</span><span class="n">batch_id</span><span class="p">,</span> <span class="n">created_at</span><span class="p">,</span> <span class="n">id</span><span class="p">)</span>
<span class="p">)</span> <span class="n">ENGINE</span> <span class="o">=</span> <span class="n">InnoDB</span><span class="p">;</span>
</code></pre></div></div>

<p>Each round’s claim is done in a single transaction:</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Transactional</span>
<span class="kd">public</span> <span class="nc">Claim</span> <span class="nf">claim</span><span class="o">(</span><span class="kt">int</span> <span class="n">maxRequests</span><span class="o">)</span> <span class="o">{</span>
    <span class="c1">// 1. Take the slot</span>
    <span class="kt">long</span> <span class="n">batchId</span><span class="o">;</span>
    <span class="k">try</span> <span class="o">{</span>
        <span class="n">batchId</span> <span class="o">=</span> <span class="n">insertReturningKey</span><span class="o">(</span><span class="s">"INSERT INTO merge_batch (status) VALUES (1)"</span><span class="o">);</span>
    <span class="o">}</span> <span class="k">catch</span> <span class="o">(</span><span class="nc">DuplicateKeyException</span> <span class="n">e</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">return</span> <span class="nc">Claim</span><span class="o">.</span><span class="na">slotOccupied</span><span class="o">();</span>   <span class="c1">// This transaction has not written anything yet; returning normally is equivalent to rolling back</span>
    <span class="o">}</span>

    <span class="c1">// 2. Read the waiting requests: a plain read, no FOR UPDATE</span>
    <span class="nc">List</span><span class="o">&lt;</span><span class="nc">Req</span><span class="o">&gt;</span> <span class="n">pending</span> <span class="o">=</span> <span class="n">query</span><span class="o">(</span>
        <span class="s">"SELECT id, operation_id FROM merge_batch_request "</span> <span class="o">+</span>
        <span class="s">"WHERE batch_id IS NULL AND created_at &lt; CURRENT_TIMESTAMP "</span> <span class="o">+</span>
        <span class="s">"ORDER BY created_at, id LIMIT ?"</span><span class="o">,</span> <span class="n">maxRequests</span><span class="o">);</span>
    <span class="k">if</span> <span class="o">(</span><span class="n">pending</span><span class="o">.</span><span class="na">isEmpty</span><span class="o">())</span> <span class="o">{</span>
        <span class="k">throw</span> <span class="k">new</span> <span class="nf">NothingToClaimException</span><span class="o">();</span>   <span class="c1">// Roll back, releasing the slot just taken</span>
    <span class="o">}</span>

    <span class="c1">// 3. Guarded claim: only claim rows that are still unclaimed, and the affected row count must match the number of rows read</span>
    <span class="kt">int</span> <span class="n">updated</span> <span class="o">=</span> <span class="n">update</span><span class="o">(</span>
        <span class="s">"UPDATE merge_batch_request SET batch_id = ? "</span> <span class="o">+</span>
        <span class="s">"WHERE batch_id IS NULL AND id IN ("</span> <span class="o">+</span> <span class="n">placeholders</span><span class="o">(</span><span class="n">pending</span><span class="o">)</span> <span class="o">+</span> <span class="s">")"</span><span class="o">,</span>
        <span class="n">batchId</span><span class="o">,</span> <span class="n">ids</span><span class="o">(</span><span class="n">pending</span><span class="o">));</span>
    <span class="k">if</span> <span class="o">(</span><span class="n">updated</span> <span class="o">!=</span> <span class="n">pending</span><span class="o">.</span><span class="na">size</span><span class="o">())</span> <span class="o">{</span>
        <span class="k">throw</span> <span class="k">new</span> <span class="nf">ClaimPreemptedException</span><span class="o">();</span>   <span class="c1">// Some row was changed elsewhere; roll back everything</span>
    <span class="o">}</span>

    <span class="c1">// 4. Write the append-only membership history</span>
    <span class="n">insertHistory</span><span class="o">(</span><span class="n">batchId</span><span class="o">,</span> <span class="n">pending</span><span class="o">);</span>
    <span class="k">return</span> <span class="nc">Claim</span><span class="o">.</span><span class="na">claimed</span><span class="o">(</span><span class="n">batchId</span><span class="o">,</span> <span class="n">pending</span><span class="o">);</span>
<span class="o">}</span>
</code></pre></div></div>

<p><strong>Why step 2 does not use <code class="language-plaintext highlighter-rouge">FOR UPDATE</code>.</strong> Under the REPEATABLE READ isolation level, a locking read with a range condition takes next-key locks on the scanned index range. Here the scanned range is the <code class="language-plaintext highlighter-rouge">batch_id IS NULL</code> range of <code class="language-plaintext highlighter-rouge">idx_batch_created</code>, which is exactly where newly admitted requests are inserted, so they would be blocked until the claim transaction ends, violating I3. Dropping the locking read does not affect correctness:</p>

<ul>
  <li>Other claimers are kept out by the slot in step 1;</li>
  <li>Even if an unexpected concurrent modification occurs, the <code class="language-plaintext highlighter-rouge">WHERE batch_id IS NULL</code> and the affected-row-count check in step 3 detect it and roll back the whole transaction.</li>
</ul>

<p><strong>Why an empty read throws an exception.</strong> If the transaction committed normally, it would leave behind a RUNNING batch with no members that still occupies the slot. Throwing makes the slot roll back together with the transaction.</p>

<p><strong>A lock-free pre-check outside the transaction.</strong> Before entering the claim transaction, we first run <code class="language-plaintext highlighter-rouge">SELECT ... WHERE batch_id IS NULL ... LIMIT 1</code>. When the waiting set is empty, the round ends immediately, avoiding the idle churn of “take the slot, then roll back”.</p>

<p><strong>Use the database clock throughout.</strong> The condition is written as <code class="language-plaintext highlighter-rouge">created_at &lt; CURRENT_TIMESTAMP</code>, and the timeout sweep as <code class="language-plaintext highlighter-rouge">created_at &lt; DATE_SUB(CURRENT_TIMESTAMP, INTERVAL ? SECOND)</code>. The write time and the comparison time come from the same clock, so clock skew between application servers does not affect the decision.</p>

<p><strong>One thing to verify in your implementation.</strong> Step 1 catches the unique-key conflict inside the transaction and returns normally. In MySQL, a failed statement rolls back only that statement, and the transaction itself remains usable. However, some data-access frameworks mark the transaction as rollback-only when they translate the exception, causing an error on commit. Before adopting this pattern, confirm how your framework behaves.</p>

<h3 id="33-state-as-version-guarded-state-transitions-i6">3.3 State as version: guarded state transitions (I6)</h3>

<p>Every state write includes the expected current state and checks the affected row count:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- Batch terminal transition: only a batch that is still RUNNING can transition</span>
<span class="k">UPDATE</span> <span class="n">merge_batch</span> <span class="k">SET</span> <span class="n">status</span> <span class="o">=</span> <span class="mi">2</span> <span class="k">WHERE</span> <span class="n">id</span> <span class="o">=</span> <span class="o">?</span> <span class="k">AND</span> <span class="n">status</span> <span class="o">=</span> <span class="mi">1</span><span class="p">;</span>   <span class="c1">-- Affected rows must be 1</span>

<span class="c1">-- Optimistic concurrency control for ordinary rows: the state just read serves as the version</span>
<span class="k">UPDATE</span> <span class="k">operation</span> <span class="k">SET</span> <span class="n">status</span> <span class="o">=</span> <span class="o">?</span><span class="p">,</span> <span class="p">...</span> <span class="k">WHERE</span> <span class="n">operation_id</span> <span class="o">=</span> <span class="o">?</span> <span class="k">AND</span> <span class="n">status</span> <span class="o">=</span> <span class="o">?</span><span class="p">;</span>
</code></pre></div></div>

<p>An affected row count of 0 means the state has been changed elsewhere, and the writer throws a “preempted” exception.</p>

<p>The <strong>position</strong> of such a guarded write inside a composite transaction also matters. The settlement transaction for a failed batch has several steps: mark the batch as FAILED, then delete or reset the records of each request in the batch. We put the batch’s guarded write first:</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">tx</span><span class="o">.</span><span class="na">execute</span><span class="o">(</span><span class="nc">List</span><span class="o">.</span><span class="na">of</span><span class="o">(</span>
    <span class="o">()</span> <span class="o">-&gt;</span> <span class="n">batchRepo</span><span class="o">.</span><span class="na">markFailed</span><span class="o">(</span><span class="n">batchId</span><span class="o">,</span> <span class="n">code</span><span class="o">,</span> <span class="n">msg</span><span class="o">),</span>  <span class="c1">// WHERE status = RUNNING; affected rows must be 1</span>
    <span class="o">()</span> <span class="o">-&gt;</span> <span class="n">resetOperations</span><span class="o">(</span><span class="n">batchId</span><span class="o">)</span>                   <span class="c1">// Subsequent steps</span>
<span class="o">));</span>
</code></pre></div></div>

<p>If the batch has already been settled by another path (for example, the timeout sweep and the running task arrive at the same time), the first step fails, the whole transaction rolls back, and the reset does not run a second time.</p>

<h3 id="34-treat-a-duplicate-key-as-an-answer-not-an-error-i4">3.4 Treat a duplicate key as an answer, not an error (I4)</h3>

<p>A unique-constraint conflict has a clear business meaning in each scenario, and we handle each one accordingly:</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Admission: a duplicate means "already admitted"; return false, no error</span>
<span class="kd">public</span> <span class="kt">boolean</span> <span class="nf">admit</span><span class="o">(</span><span class="nc">String</span> <span class="n">opId</span><span class="o">)</span> <span class="o">{</span>
    <span class="k">try</span> <span class="o">{</span>
        <span class="n">insert</span><span class="o">(</span><span class="s">"INSERT INTO merge_batch_request (operation_id) VALUES (?)"</span><span class="o">,</span> <span class="n">opId</span><span class="o">);</span>
        <span class="k">return</span> <span class="kc">true</span><span class="o">;</span>
    <span class="o">}</span> <span class="k">catch</span> <span class="o">(</span><span class="nc">DuplicateKeyException</span> <span class="n">e</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">return</span> <span class="kc">false</span><span class="o">;</span>
    <span class="o">}</span>
<span class="o">}</span>

<span class="c1">// Audit record: a duplicate means "already recorded"; do not overwrite the existing row (it may already be terminal)</span>
<span class="kd">public</span> <span class="kt">void</span> <span class="nf">insertAudit</span><span class="o">(</span><span class="nc">Audit</span> <span class="n">a</span><span class="o">)</span> <span class="o">{</span>
    <span class="k">try</span> <span class="o">{</span> <span class="n">insert</span><span class="o">(</span><span class="n">a</span><span class="o">);</span> <span class="o">}</span> <span class="k">catch</span> <span class="o">(</span><span class="nc">DuplicateKeyException</span> <span class="n">e</span><span class="o">)</span> <span class="o">{</span> <span class="cm">/* Expected deduplication result */</span> <span class="o">}</span>
<span class="o">}</span>

<span class="c1">// Request idempotency: look up first; insert if not found; an insert conflict means a concurrent request inserted first, so re-read and return the winner's result</span>
<span class="kd">public</span> <span class="nc">Result</span> <span class="nf">checkOrInsert</span><span class="o">(</span><span class="nc">String</span> <span class="n">requestId</span><span class="o">,</span> <span class="nc">String</span> <span class="n">opId</span><span class="o">)</span> <span class="o">{</span>
    <span class="nc">Record</span> <span class="n">r</span> <span class="o">=</span> <span class="n">findById</span><span class="o">(</span><span class="n">requestId</span><span class="o">);</span>
    <span class="k">if</span> <span class="o">(</span><span class="n">r</span> <span class="o">!=</span> <span class="kc">null</span><span class="o">)</span> <span class="k">return</span> <span class="nc">Result</span><span class="o">.</span><span class="na">duplicate</span><span class="o">(</span><span class="n">r</span><span class="o">.</span><span class="na">operationId</span><span class="o">());</span>
    <span class="k">try</span> <span class="o">{</span>
        <span class="n">insert</span><span class="o">(</span><span class="k">new</span> <span class="nc">Record</span><span class="o">(</span><span class="n">requestId</span><span class="o">,</span> <span class="n">opId</span><span class="o">));</span>
        <span class="k">return</span> <span class="nc">Result</span><span class="o">.</span><span class="na">fresh</span><span class="o">();</span>
    <span class="o">}</span> <span class="k">catch</span> <span class="o">(</span><span class="nc">DuplicateKeyException</span> <span class="n">e</span><span class="o">)</span> <span class="o">{</span>
        <span class="nc">Record</span> <span class="n">winner</span> <span class="o">=</span> <span class="n">findById</span><span class="o">(</span><span class="n">requestId</span><span class="o">);</span>
        <span class="k">return</span> <span class="n">winner</span> <span class="o">!=</span> <span class="kc">null</span> <span class="o">?</span> <span class="nc">Result</span><span class="o">.</span><span class="na">duplicate</span><span class="o">(</span><span class="n">winner</span><span class="o">.</span><span class="na">operationId</span><span class="o">())</span>
                              <span class="o">:</span> <span class="nc">Result</span><span class="o">.</span><span class="na">retryLater</span><span class="o">();</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p>None of these three patterns takes an explicit lock; their correctness under concurrency comes from the unique constraint itself.</p>

<h3 id="35-one-row-per-relationship-in-place-reset-and-refusing-to-overwrite-i5">3.5 One row per relationship: in-place reset and refusing to overwrite (I5)</h3>

<p>The <code class="language-plaintext highlighter-rouge">binding</code> table has a unique key on <code class="language-plaintext highlighter-rouge">(subject_id, target_key)</code>, so each relationship has exactly one row. But every request generates a new <code class="language-plaintext highlighter-rouge">operation_id</code>, so the “one row per operation” write model has to be reconciled with the “one row per relationship” unique key:</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">Binding</span> <span class="n">existing</span> <span class="o">=</span> <span class="n">findBySubjectAndTarget</span><span class="o">(</span><span class="n">subject</span><span class="o">,</span> <span class="n">target</span><span class="o">);</span>
<span class="k">if</span> <span class="o">(</span><span class="n">existing</span> <span class="o">==</span> <span class="kc">null</span><span class="o">)</span> <span class="o">{</span>
    <span class="n">insert</span><span class="o">(</span><span class="n">newRow</span><span class="o">);</span>                                     <span class="c1">// First application</span>
<span class="o">}</span> <span class="k">else</span> <span class="k">if</span> <span class="o">(</span><span class="no">REAPPLIABLE</span><span class="o">.</span><span class="na">contains</span><span class="o">(</span><span class="n">existing</span><span class="o">.</span><span class="na">status</span><span class="o">()))</span> <span class="o">{</span>
    <span class="kt">int</span> <span class="n">n</span> <span class="o">=</span> <span class="n">update</span><span class="o">(</span>
        <span class="s">"UPDATE binding SET status = ?, operation_id = ?, expires_at = ? "</span> <span class="o">+</span>
        <span class="s">"WHERE subject_id = ? AND target_key = ? AND status = ?"</span><span class="o">,</span>   <span class="c1">// Pin the exact old state that was read</span>
        <span class="n">newRow</span><span class="o">.</span><span class="na">status</span><span class="o">(),</span> <span class="n">newRow</span><span class="o">.</span><span class="na">operationId</span><span class="o">(),</span> <span class="n">newRow</span><span class="o">.</span><span class="na">expiresAt</span><span class="o">(),</span>
        <span class="n">subject</span><span class="o">,</span> <span class="n">target</span><span class="o">,</span> <span class="n">existing</span><span class="o">.</span><span class="na">status</span><span class="o">());</span>
    <span class="k">if</span> <span class="o">(</span><span class="n">n</span> <span class="o">==</span> <span class="mi">0</span><span class="o">)</span> <span class="k">throw</span> <span class="k">new</span> <span class="nc">PreemptedException</span><span class="o">();</span>         <span class="c1">// A concurrent re-application or state change</span>
<span class="o">}</span> <span class="k">else</span> <span class="o">{</span>
    <span class="k">throw</span> <span class="k">new</span> <span class="nf">ConstraintViolationException</span><span class="o">();</span>           <span class="c1">// Refuse to overwrite an active row or one awaiting recovery</span>
<span class="o">}</span>
</code></pre></div></div>

<ul>
  <li><strong>Only re-appliable terminal states such as “rejected” and “closed” may be reset in place.</strong> The reset replaces <code class="language-plaintext highlighter-rouge">operation_id</code>, which is exactly how stale callbacks are recognized in the previous post.</li>
  <li><strong>Every other state refuses to be overwritten.</strong> If a failed row still awaiting manual recovery were overwritten, its <code class="language-plaintext highlighter-rouge">operation_id</code> would be replaced, and the recovery flow attached to the old <code class="language-plaintext highlighter-rouge">operation_id</code> would no longer find its target.</li>
  <li><strong>The WHERE clause pins the exact old state that was read</strong>, rather than “any re-appliable state”, so that a change between the read and the write is still detected when several states are re-appliable.</li>
  <li>The history of every application is stored in a separate history table; the main table holds only the current state.</li>
</ul>

<h3 id="36-cross-table-transitions-a-fixed-lock-order-and-a-result-only-when-the-state-actually-flips">3.6 Cross-table transitions: a fixed lock order, and a result only when the state actually flips</h3>

<p>Transitions that modify both the <code class="language-plaintext highlighter-rouge">operation</code> and <code class="language-plaintext highlighter-rouge">binding</code> tables always lock <code class="language-plaintext highlighter-rouge">operation</code> first and <code class="language-plaintext highlighter-rouge">binding</code> second. All paths use the same lock order, which avoids deadlocks. The advancing method returns a non-null result only when this call actually changed the state, and callers use that to decide whether to produce side effects (emitting metrics, sending notifications). As a result, of two concurrent paths, only one produces side effects (see Section 3.7 of the previous post for details).</p>

<h3 id="37-settling-a-failed-batch-close-first-then-re-read-and-let-merged-win">3.7 Settling a failed batch: close first, then re-read, and let “merged” win</h3>

<p>When a batch fails, the MR may already exist, and it may happen to merge successfully just after the failure was decided. The settlement flow is: if the MR is still open, close it first, then re-read the MR’s final state.</p>

<ul>
  <li><strong>If it reads merged</strong>: treat it as a success, align all members to “merged”, and mark the batch SUCCEEDED. A merged MR can no longer be closed, so when “merge” and “close” race, the merge result is never lost.</li>
  <li><strong>If it reads closed, or there is no MR</strong>: mark the batch FAILED and, in the same transaction, delete each member’s processing record; the append-only membership history and audit records are kept.</li>
</ul>

<h2 id="4-results">4. Results</h2>

<ul>
  <li><strong>All six invariants are enforced by database constraints or guarded writes.</strong> Concurrent claims, duplicate admissions, duplicate requests and stale state writes are all rejected or recognized at the database level, independent of call timing.</li>
  <li><strong>No new components were introduced.</strong> The single-active mutual exclusion has no lock table, lease or external lock service; the mutual-exclusion state and the business state are the same column, so they can never disagree.</li>
  <li><strong>Admission is not blocked by batches.</strong> The claim transaction takes no locks on the waiting range, so new requests are written as usual while a batch is running.</li>
  <li><strong>State is explicit after a crash.</strong> A crashed batch stays in a queryable RUNNING state and is settled by the timeout sweep or the operations tool; there are no invisible leaked locks.</li>
  <li><strong>Scheduled and manual triggers can safely overlap.</strong> Both run the same code; when they overlap, the later one gets “slot occupied”, with no extra coordination needed.</li>
</ul>

<h2 id="5-known-limitations-and-next-steps">5. Known Limitations and Next Steps</h2>

<ul>
  <li><strong>State-as-version has an ABA problem.</strong> If a row leaves a state and returns to it between the read and the write, the guard condition still passes. The table reserves a <code class="language-plaintext highlighter-rouge">version</code> column that is not yet in use. For states that may round-trip, we plan to switch to a monotonically increasing version number.</li>
  <li><strong>There is only one global slot.</strong> The current design does not support parallelism by dimension. If per-partition parallelism is needed, the generated column can be changed to <code class="language-plaintext highlighter-rouge">CASE WHEN status IN (1, 4) THEN partition_key END</code>, and the unique index becomes “at most one active batch per partition”.</li>
  <li><strong>The batch path has row-by-row access.</strong> The hand-off phase looks up the relationship row once per request (N+1); membership history is inserted row by row; and <code class="language-plaintext highlighter-rouge">IN</code> lists can reach over a thousand placeholders without being chunked. We plan to switch to batch queries and batch inserts, and to chunk the <code class="language-plaintext highlighter-rouge">IN</code> lists.</li>
  <li><strong>Some queries lack suitable indexes.</strong> The reconciliation query filters by <code class="language-plaintext highlighter-rouge">status</code> plus a time range, but only a single-column time index exists; fetching the largest MR number uses <code class="language-plaintext highlighter-rouge">ORDER BY CAST(mr_id AS UNSIGNED)</code>, and sorting on an expression cannot use an index. We plan to add a composite <code class="language-plaintext highlighter-rouge">(status, updated_at)</code> index and store the MR number in a numeric column.</li>
  <li><strong>Expiry of idempotency records is not enforced.</strong> The records have an expiry field, but there is no cleanup job and no corresponding index.</li>
  <li><strong>Time precision is one second.</strong> <code class="language-plaintext highlighter-rouge">created_at</code> has second precision and the claim condition is <code class="language-plaintext highlighter-rouge">created_at &lt; CURRENT_TIMESTAMP</code>, so requests admitted within the current second wait until the next round. This is a deliberate trade-off, but it needs to be documented.</li>
</ul>]]></content><author><name></name></author><category term="en" /><summary type="html"><![CDATA[Without distributed locks, using a generated column with a unique index, guarded UPDATEs and unique constraints so that MySQL itself enforces the six business invariants of batch aggregation.]]></summary></entry><entry xml:lang="en"><title type="html">Driving Long-Running Workflows with Merge Requests: State Machines, Idempotency and Reconciliation in Practice</title><link href="https://blog.changcodes.com/en/posts/mr-driven-long-workflow/" rel="alternate" type="text/html" title="Driving Long-Running Workflows with Merge Requests: State Machines, Idempotency and Reconciliation in Practice" /><published>2026-03-15T00:00:00+00:00</published><updated>2026-03-15T00:00:00+00:00</updated><id>https://blog.changcodes.com/en/posts/mr-driven-long-workflow</id><content type="html" xml:base="https://blog.changcodes.com/en/posts/mr-driven-long-workflow/"><![CDATA[<h2 id="1-problem-and-goals">1. Problem and Goals</h2>

<p>We maintain a self-service change system built on “configuration as code”. After a user submits a change request, the system has to complete the following steps in order:</p>

<ol>
  <li>Submit an approval to an external approval system and wait for the result callback;</li>
  <li>Using a bot account, create a branch in the Git repository, commit the files, and open a Merge Request (MR);</li>
  <li>Wait for the MR’s check pipeline to pass;</li>
  <li>Approve and merge the MR as the approval bot, rebasing and retrying if the merge fails;</li>
  <li>Wait for the deployment pipeline to roll out the configuration and report back via callback that it is “in effect”.</li>
</ol>

<p>A single change spans three external systems (the approval system, the Git hosting platform GitLab, and CI) and takes anywhere from a few minutes to a few hours. Handling it as an ordinary synchronous request runs into several problems: an HTTP connection cannot wait that long; a failure at any step can leave the request stuck in an intermediate state nobody knows about; callbacks from external systems may be duplicated, late, or out of order; and a process restart loses whatever step was executing.</p>

<p>We set four goals for this workflow:</p>

<ul>
  <li><strong>Observable</strong>: at any moment, a request is in an explainable state, and you can tell which step it stopped at.</li>
  <li><strong>Failures are visible</strong>: a failure must end up as an explicit failure state, never as a request that is “processing” forever.</li>
  <li><strong>Robust to events</strong>: duplicate events, late events, and events belonging to an old request must not change the state of the current request.</li>
  <li><strong>Evolvable</strong>: changing the order of steps or adapting to upstream API changes must not interrupt in-flight requests.</li>
</ul>

<h2 id="2-constraints-and-considerations">2. Constraints and Considerations</h2>

<p><strong>External calls are outside the local transaction.</strong> After creating the branch, commit and MR on GitLab, the local database write can still fail; conversely, once local state is written, the remote call can still fail. There is no atomicity between the two sides to rely on.</p>

<p><strong>Many event sources, with weak delivery semantics.</strong> State changes are driven by user submissions, approval callbacks, the system’s own progression, deployment pipeline callbacks, scheduled reconciliation, and manual recovery by operators. External callbacks are delivered “at least once”; they may arrive late, or even before our own state write lands.</p>

<p><strong>Upstream responses are ambiguous.</strong> GitLab returns HTTP 401 when the same account approves twice, which is the same status code as “this account is not allowed to approve”. The response alone cannot distinguish the two.</p>

<p><strong>Deployment topology.</strong> The service runs as multiple instances. We did not want to introduce new components such as a workflow engine or distributed locks for this one workflow; the only shared coordination point we have is the relational database.</p>

<p><strong>The workflow must evolve.</strong> The step order needed to change from “create the MR, then approve” to “approve, then create the MR”, with approvals already in flight at switchover time. The upstream platform also planned to add a required structured field to its approval API, with a compatibility period.</p>

<h2 id="3-design">3. Design</h2>

<h3 id="31-two-layers-of-state-step-state-and-external-state">3.1 Two layers of state: step state and external state</h3>

<p>We record state at two granularities in two tables:</p>

<ul>
  <li>The <code class="language-plaintext highlighter-rouge">operation</code> table records <strong>step state</strong>, down to details such as “pipeline timed out”, “rebase conflict” or “bot approval failed”. Status codes are allocated in ranges by phase (creation from 10001, handling from 10101, closing from 10201), so metrics can be aggregated by prefix.</li>
  <li>The <code class="language-plaintext highlighter-rouge">binding</code> table records <strong>external state</strong>, expressing only the phases users care about: approval pending, MR being created, merging, merged, in effect, failed, rejected. The table has a unique key on <code class="language-plaintext highlighter-rouge">(subject_id, target_key)</code>, so each “subject–target” relationship has exactly one row.</li>
</ul>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">enum</span> <span class="nc">StepStatus</span> <span class="o">{</span>            <span class="c1">// Fine-grained: for troubleshooting and metrics</span>
    <span class="no">CREATE_STARTED</span><span class="o">(</span><span class="mi">10001</span><span class="o">),</span> <span class="no">CREATE_SUCCEEDED</span><span class="o">(</span><span class="mi">10002</span><span class="o">),</span> <span class="no">CREATE_PIPELINE_FAILED</span><span class="o">(</span><span class="mi">10004</span><span class="o">),</span>
    <span class="no">HANDLE_STARTED</span><span class="o">(</span><span class="mi">10101</span><span class="o">),</span> <span class="no">HANDLE_APPROVED</span><span class="o">(</span><span class="mi">10102</span><span class="o">),</span> <span class="no">HANDLE_REBASE_CONFLICT</span><span class="o">(</span><span class="mi">10105</span><span class="o">),</span>
    <span class="no">HANDLE_PIPELINE_PENDING</span><span class="o">(</span><span class="mi">10501</span><span class="o">),</span> <span class="no">CALLED_BACK</span><span class="o">(</span><span class="mi">10301</span><span class="o">),</span> <span class="cm">/* ... */</span><span class="o">;</span>
<span class="o">}</span>

<span class="kd">enum</span> <span class="nc">BindingStatus</span> <span class="o">{</span>         <span class="c1">// Coarse-grained: for display and "can the user re-apply"</span>
    <span class="no">APPROVAL_SENDING</span><span class="o">,</span> <span class="no">APPROVAL_SENT</span><span class="o">,</span> <span class="no">MR_CREATING</span><span class="o">,</span> <span class="no">MR_CREATED</span><span class="o">,</span> <span class="no">MERGING</span><span class="o">,</span> <span class="no">MERGED</span><span class="o">,</span> <span class="no">VALIDATED</span><span class="o">,</span>
    <span class="no">CREATE_FAILED</span><span class="o">,</span> <span class="no">HANDLE_FAILED</span><span class="o">,</span> <span class="no">REJECTED</span><span class="o">,</span> <span class="no">CLOSED</span><span class="o">;</span>

    <span class="cm">/** Single source of truth: both the data layer and the API layer use it to decide whether re-applying is allowed. */</span>
    <span class="kd">static</span> <span class="kd">final</span> <span class="nc">Set</span><span class="o">&lt;</span><span class="nc">BindingStatus</span><span class="o">&gt;</span> <span class="no">REAPPLIABLE</span> <span class="o">=</span> <span class="nc">Set</span><span class="o">.</span><span class="na">of</span><span class="o">(</span><span class="no">REJECTED</span><span class="o">,</span> <span class="no">CLOSED</span><span class="o">);</span>
<span class="o">}</span>
</code></pre></div></div>

<p>We split state into two layers because the two audiences need different things. Troubleshooting needs to know exactly which step a request stopped at; users, and the “can I re-apply” decision, only care about the broad phase. With a single status field, every refinement of the internal steps would ripple into the frontend display and the re-apply rules.</p>

<p>Every state transition writes both tables in the same local transaction:</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">void</span> <span class="nf">transition</span><span class="o">(</span><span class="nc">OperationBuilder</span> <span class="n">op</span><span class="o">,</span> <span class="nc">StepStatus</span> <span class="n">step</span><span class="o">,</span> <span class="nc">BindingStatus</span> <span class="n">status</span><span class="o">)</span> <span class="o">{</span>
    <span class="n">tx</span><span class="o">.</span><span class="na">execute</span><span class="o">(</span><span class="nc">List</span><span class="o">.</span><span class="na">of</span><span class="o">(</span>
        <span class="o">()</span> <span class="o">-&gt;</span> <span class="n">operationRepo</span><span class="o">.</span><span class="na">update</span><span class="o">(</span><span class="n">op</span><span class="o">.</span><span class="na">status</span><span class="o">(</span><span class="n">step</span><span class="o">)),</span>          <span class="c1">// Optimistic concurrency check against the state just read</span>
        <span class="o">()</span> <span class="o">-&gt;</span> <span class="n">bindingRepo</span><span class="o">.</span><span class="na">updateStatus</span><span class="o">(</span><span class="n">op</span><span class="o">.</span><span class="na">id</span><span class="o">(),</span> <span class="n">status</span><span class="o">)</span>
    <span class="o">));</span>
<span class="o">}</span>
</code></pre></div></div>

<p>The diagram below shows the main path of the external state (failure branches omitted):</p>

<pre><code class="language-mermaid">stateDiagram-v2
    [*] --&gt; APPROVAL_SENT: Request submitted
    APPROVAL_SENT --&gt; REJECTED: Approval rejected or withdrawn
    APPROVAL_SENT --&gt; MR_CREATING: Approval granted
    MR_CREATING --&gt; MR_CREATED: Check pipeline passed
    MR_CREATING --&gt; CREATE_FAILED
    MR_CREATED --&gt; MERGING
    MERGING --&gt; MERGED: Merge succeeded
    MERGING --&gt; HANDLE_FAILED
    MERGED --&gt; VALIDATED: Deploy callback or scheduled reconciliation
    REJECTED --&gt; [*]
    VALIDATED --&gt; [*]
</code></pre>

<h3 id="32-asynchronous-entry-point-every-failure-is-persisted">3.2 Asynchronous entry point; every failure is persisted</h3>

<p>The submit endpoint performs only synchronous validation (parameters, idempotency, whether a request is already in progress), then hands the long-running workflow to a bounded thread pool and immediately returns HTTP 202 with an <code class="language-plaintext highlighter-rouge">operationId</code>. The client polls by <code class="language-plaintext highlighter-rouge">operationId</code>.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">private</span> <span class="kt">void</span> <span class="nf">fireAsync</span><span class="o">(</span><span class="nc">String</span> <span class="n">opId</span><span class="o">,</span> <span class="nc">Task</span> <span class="n">task</span><span class="o">,</span> <span class="nc">StepStatus</span> <span class="n">failStep</span><span class="o">,</span> <span class="nc">BindingStatus</span> <span class="n">failStatus</span><span class="o">)</span> <span class="o">{</span>
    <span class="k">try</span> <span class="o">{</span>
        <span class="n">executor</span><span class="o">.</span><span class="na">execute</span><span class="o">(()</span> <span class="o">-&gt;</span> <span class="o">{</span>
            <span class="k">try</span> <span class="o">{</span>
                <span class="n">task</span><span class="o">.</span><span class="na">run</span><span class="o">();</span>
            <span class="o">}</span> <span class="k">catch</span> <span class="o">(</span><span class="nc">Exception</span> <span class="n">e</span><span class="o">)</span> <span class="o">{</span>
                <span class="n">log</span><span class="o">.</span><span class="na">error</span><span class="o">(</span><span class="s">"async step failed"</span><span class="o">,</span> <span class="n">e</span><span class="o">);</span>        <span class="c1">// The failure state has already been written by the aspect; see below</span>
            <span class="o">}</span> <span class="k">catch</span> <span class="o">(</span><span class="nc">Throwable</span> <span class="n">t</span><span class="o">)</span> <span class="o">{</span>
                <span class="n">log</span><span class="o">.</span><span class="na">error</span><span class="o">(</span><span class="s">"[CRITICAL] JVM-level error"</span><span class="o">,</span> <span class="n">t</span><span class="o">);</span> <span class="c1">// The JVM may be in a bad state; log only, do not write to the DB</span>
            <span class="o">}</span>
        <span class="o">});</span>
    <span class="o">}</span> <span class="k">catch</span> <span class="o">(</span><span class="nc">RejectedExecutionException</span> <span class="n">e</span><span class="o">)</span> <span class="o">{</span>
        <span class="c1">// Pool is full or shutting down: persist the failure so pollers see "failed" rather than "processing"</span>
        <span class="n">tx</span><span class="o">.</span><span class="na">execute</span><span class="o">(</span><span class="nc">List</span><span class="o">.</span><span class="na">of</span><span class="o">(</span>
            <span class="o">()</span> <span class="o">-&gt;</span> <span class="n">operationRepo</span><span class="o">.</span><span class="na">updateStatus</span><span class="o">(</span><span class="n">opId</span><span class="o">,</span> <span class="n">failStep</span><span class="o">),</span>
            <span class="o">()</span> <span class="o">-&gt;</span> <span class="n">bindingRepo</span><span class="o">.</span><span class="na">updateStatus</span><span class="o">(</span><span class="n">opId</span><span class="o">,</span> <span class="n">failStatus</span><span class="o">)));</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p>The pool’s core size equals its maximum size, the queue is bounded, and the rejection policy is <code class="language-plaintext highlighter-rouge">AbortPolicy</code>. We did not choose the two other common options, for these reasons:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">CallerRunsPolicy</code> would make a web thread run a task lasting several minutes, propagating overload to every endpoint.</li>
  <li>An unbounded queue hides overload: tasks may wait in the queue for a long time while users keep seeing “processing”.</li>
</ul>

<p><code class="language-plaintext highlighter-rouge">AbortPolicy</code> combined with “persist on rejection” turns overload into a failure state that is visible to users and can be counted.</p>

<p>Each step method declares its own failure state through an annotation, and an aspect provides a uniform safety net:</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@WorkflowStep</span><span class="o">(</span><span class="n">onErrorStep</span> <span class="o">=</span> <span class="nc">StepStatus</span><span class="o">.</span><span class="na">HANDLE_MERGE_FAILED</span><span class="o">,</span>
              <span class="n">onErrorStatus</span> <span class="o">=</span> <span class="nc">BindingStatus</span><span class="o">.</span><span class="na">HANDLE_FAILED</span><span class="o">)</span>
<span class="kd">public</span> <span class="nc">Result</span> <span class="nf">handleMergeRequest</span><span class="o">(</span><span class="nc">String</span> <span class="n">opId</span><span class="o">,</span> <span class="kt">long</span> <span class="n">mrIid</span><span class="o">)</span> <span class="o">{</span> <span class="o">...</span> <span class="o">}</span>
</code></pre></div></div>

<p>Failures recognized inside the method (such as pipeline failure or rebase conflict) are written by business code with a more specific state; unexpected exceptions are written by the aspect with the state declared in the annotation. Together they guarantee that every exit path leaves a terminal state behind.</p>

<h3 id="33-callback-guard-accept-only-events-for-the-current-owner-that-is-currently-waiting">3.3 Callback guard: accept only events for the “current owner” that is “currently waiting”</h3>

<p>Approval callbacks are handled in two layers. The first layer deduplicates by the request ID carried in the callback itself, blocking duplicate deliveries. The second layer decides whether the callback is still valid:</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">Binding</span> <span class="n">row</span> <span class="o">=</span> <span class="n">bindingRepo</span><span class="o">.</span><span class="na">findByOperationId</span><span class="o">(</span><span class="n">callback</span><span class="o">.</span><span class="na">operationId</span><span class="o">());</span>
<span class="k">if</span> <span class="o">(</span><span class="n">row</span> <span class="o">==</span> <span class="kc">null</span> <span class="o">||</span> <span class="o">!</span><span class="no">AWAITING_APPROVAL</span><span class="o">.</span><span class="na">contains</span><span class="o">(</span><span class="n">row</span><span class="o">.</span><span class="na">status</span><span class="o">()))</span> <span class="o">{</span>
    <span class="k">return</span> <span class="nf">accepted</span><span class="o">(</span><span class="s">"IGNORED_STALE"</span><span class="o">);</span>   <span class="c1">// Return 2xx so the upstream does not treat it as a failure and keep retrying</span>
<span class="o">}</span>
</code></pre></div></div>

<p>There are two design points here.</p>

<ul>
  <li><strong>Ownership is decided by operationId.</strong> When a user re-applies, the <code class="language-plaintext highlighter-rouge">binding</code> row is reset in place and given a new <code class="language-plaintext highlighter-rouge">operationId</code> (see the database post). A late callback for the old request therefore finds no row and is ignored, instead of pushing the new request into the wrong state.</li>
  <li><strong>The waiting set includes “sending”.</strong> <code class="language-plaintext highlighter-rouge">AWAITING_APPROVAL = {APPROVAL_SENDING, APPROVAL_SENT}</code>. We write SENDING before calling the approval API and SENT after the call succeeds. The approval system’s callback can arrive before SENT is written; if the waiting set contained only SENT, such callbacks would be misjudged as stale.</li>
</ul>

<h3 id="34-idempotency-based-on-remote-state">3.4 Idempotency based on remote state</h3>

<p>Because local and remote state are not atomic, every step that modifies remote state first reads the remote state before acting:</p>

<ul>
  <li>Before handling an MR, if the MR is already merged, we finish directly as “merged” without approving again. Otherwise the re-approval would be rejected by GitLab, the recorded result would be “approval failed”, and troubleshooting would be steered toward a permissions problem.</li>
  <li>Before closing an MR, if it is already closed or merged, we skip the close call.</li>
</ul>

<p>The bot approval step needs extra handling for the 401 ambiguity:</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">void</span> <span class="nf">approveAsReviewer</span><span class="o">(</span><span class="nc">Api</span> <span class="n">reviewer</span><span class="o">,</span> <span class="kt">long</span> <span class="n">mrIid</span><span class="o">)</span> <span class="kd">throws</span> <span class="nc">ApiException</span><span class="o">,</span> <span class="nc">InterruptedException</span> <span class="o">{</span>
    <span class="kt">int</span> <span class="n">maxRetries</span> <span class="o">=</span> <span class="nc">Math</span><span class="o">.</span><span class="na">max</span><span class="o">(</span><span class="mi">0</span><span class="o">,</span> <span class="n">config</span><span class="o">.</span><span class="na">getInt</span><span class="o">(</span><span class="s">"approval.maxRetries"</span><span class="o">,</span> <span class="mi">3</span><span class="o">));</span> <span class="c1">// Negative values are treated as 0</span>
    <span class="nc">ApiException</span> <span class="n">last</span> <span class="o">=</span> <span class="kc">null</span><span class="o">;</span>
    <span class="k">for</span> <span class="o">(</span><span class="kt">int</span> <span class="n">attempt</span> <span class="o">=</span> <span class="mi">1</span><span class="o">;</span> <span class="n">attempt</span> <span class="o">&lt;=</span> <span class="n">maxRetries</span> <span class="o">+</span> <span class="mi">1</span><span class="o">;</span> <span class="n">attempt</span><span class="o">++)</span> <span class="o">{</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">hasApprovalFrom</span><span class="o">(</span><span class="n">reviewer</span><span class="o">,</span> <span class="n">mrIid</span><span class="o">))</span> <span class="o">{</span>
            <span class="k">return</span><span class="o">;</span>                                   <span class="c1">// Already approved: skip the call</span>
        <span class="o">}</span>
        <span class="k">try</span> <span class="o">{</span>
            <span class="n">approver</span><span class="o">.</span><span class="na">approve</span><span class="o">(</span><span class="n">reviewer</span><span class="o">,</span> <span class="n">mrIid</span><span class="o">);</span>
            <span class="k">return</span><span class="o">;</span>
        <span class="o">}</span> <span class="k">catch</span> <span class="o">(</span><span class="nc">ApiException</span> <span class="n">e</span><span class="o">)</span> <span class="o">{</span>
            <span class="n">last</span> <span class="o">=</span> <span class="n">e</span><span class="o">;</span>
            <span class="k">if</span> <span class="o">(</span><span class="n">attempt</span> <span class="o">&gt;</span> <span class="n">maxRetries</span><span class="o">)</span> <span class="k">break</span><span class="o">;</span>
            <span class="nc">Thread</span><span class="o">.</span><span class="na">sleep</span><span class="o">(</span><span class="n">backoffBaseMs</span> <span class="o">&lt;&lt;</span> <span class="o">(</span><span class="n">attempt</span> <span class="o">-</span> <span class="mi">1</span><span class="o">));</span> <span class="c1">// Exponential backoff</span>
        <span class="o">}</span>
    <span class="o">}</span>
    <span class="c1">// Both duplicate approval and unauthorized approval return 401. Read once more to check whether the approval actually exists.</span>
    <span class="k">if</span> <span class="o">(</span><span class="n">hasApprovalFrom</span><span class="o">(</span><span class="n">reviewer</span><span class="o">,</span> <span class="n">mrIid</span><span class="o">))</span> <span class="o">{</span>
        <span class="k">return</span><span class="o">;</span>
    <span class="o">}</span>
    <span class="k">throw</span> <span class="n">last</span><span class="o">;</span>
<span class="o">}</span>
</code></pre></div></div>

<ul>
  <li><strong>Read the approver list before every attempt.</strong> If the previous approval request reached GitLab but the response was lost, the next attempt sees that the approval already exists instead of receiving another 401.</li>
  <li><strong>Read once more after the final failure.</strong> After the last attempt there is no next attempt to correct things, and this read covers that case.</li>
  <li><strong>The read is fail-open.</strong> If the approver list cannot be read, we treat it as “not yet approved” and go ahead with the approval. This preserves the behavior from before the check was introduced, so the check itself does not become a new source of failure.</li>
  <li><strong>The two approval accounts are retried separately.</strong> The two approvals are not one atomic operation; retrying them as a pair would re-submit the approval for the first account.</li>
</ul>

<p>These checks make “re-run from where it stopped” safe, so the operations recovery tool can simply re-trigger a given step.</p>

<h3 id="35-waiting-on-external-state-tiered-polling-distinguishing-timeout-from-failure">3.5 Waiting on external state: tiered polling, distinguishing “timeout” from “failure”</h3>

<p>Both the rebase result and the pipeline status have to be polled. We abstracted polling into a generic component: short intervals for the first N attempts, longer intervals afterward, and a timeout once the limit is reached. A poll has three possible outcomes: passed, failed, timed out.</p>

<ul>
  <li><strong>Pipeline failure</strong> is deterministic: retrying with the same MR will fail again, so the request goes into a failure state.</li>
  <li><strong>Pipeline timeout</strong> means the result is still unknown. The external state stays at “merging”, the step state is recorded as “pipeline pending”, and a human confirms the outcome.</li>
</ul>

<p>The API layer uses this to show users different hints: a deterministic failure says “contact the maintainers”, while an unknown outcome says “you can keep waiting” (see the API post for details).</p>

<h3 id="36-changing-the-step-order-online-without-draining">3.6 Changing the step order online, without draining</h3>

<p>When we changed the workflow from “create the MR, then approve” to “approve, then create the MR”, we did not schedule downtime or a drain window. Instead, both orders coexisted for a period of time:</p>

<ol>
  <li><strong>The new workflow is controlled by a configuration switch</strong>, off by default, and can be switched back to the old workflow at any time.</li>
  <li>
    <p><strong>Callbacks identify the workflow by payload shape.</strong> In the old workflow, the MR already exists when the approval is submitted, and its number is put into an extension field of the approval form; in the new workflow, there is no MR yet when the approval is submitted. Callback handling decides accordingly:</p>

    <div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">boolean</span> <span class="n">legacyFlow</span> <span class="o">=</span> <span class="nc">StringUtils</span><span class="o">.</span><span class="na">isNotEmpty</span><span class="o">(</span><span class="n">callback</span><span class="o">.</span><span class="na">ext</span><span class="o">().</span><span class="na">get</span><span class="o">(</span><span class="s">"mrId"</span><span class="o">));</span>
<span class="k">if</span> <span class="o">(</span><span class="n">approved</span><span class="o">)</span> <span class="o">{</span>
    <span class="k">if</span> <span class="o">(</span><span class="n">legacyFlow</span><span class="o">)</span> <span class="n">workflow</span><span class="o">.</span><span class="na">fireHandle</span><span class="o">(</span><span class="n">opId</span><span class="o">,</span> <span class="n">mrId</span><span class="o">);</span>      <span class="c1">// MR already exists: approve and merge</span>
    <span class="k">else</span>            <span class="n">workflow</span><span class="o">.</span><span class="na">fireCreateAfterApproval</span><span class="o">(</span><span class="n">opId</span><span class="o">);</span> <span class="c1">// Create the MR first</span>
<span class="o">}</span>
</code></pre></div>    </div>

    <p>Approvals sent before the switch was flipped but called back afterward are still handled by the old workflow.</p>
  </li>
  <li>
    <p><strong>The create step queries the database to decide between “insert” and “advance”</strong>, rather than relying on a flag passed by the caller:</p>

    <div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="o">(</span><span class="n">operationRepo</span><span class="o">.</span><span class="na">exists</span><span class="o">(</span><span class="n">opId</span><span class="o">))</span> <span class="o">{</span>
    <span class="n">transition</span><span class="o">(</span><span class="n">op</span><span class="o">,</span> <span class="no">CREATE_STARTED</span><span class="o">,</span> <span class="no">MR_CREATING</span><span class="o">);</span>   <span class="c1">// New workflow: the row was created when the approval was submitted</span>
<span class="o">}</span> <span class="k">else</span> <span class="o">{</span>
    <span class="n">insertOperationAndBinding</span><span class="o">(</span><span class="n">op</span><span class="o">);</span>                 <span class="c1">// Old workflow: first-time creation</span>
<span class="o">}</span>
</code></pre></div>    </div>

    <p>This makes the same step valid for both orders, and keeps it idempotent when operators re-trigger it.</p>
  </li>
</ol>

<h3 id="37-scheduled-reconciliation-for-lost-callbacks">3.7 Scheduled reconciliation for lost callbacks</h3>

<p>Callbacks from the deployment pipeline can be lost, leaving the corresponding requests stuck at “merged”. A scheduled job converges such records.</p>

<p><strong>Sliding time window.</strong> Each run scans only “merged” records whose last-modified time falls within <code class="language-plaintext highlighter-rouge">(now - maxAge, now - minAge)</code>. Records newer than <code class="language-plaintext highlighter-rouge">minAge</code> may simply have their callback still on the way, so they are skipped; records older than <code class="language-plaintext highlighter-rouge">maxAge</code> fall out of the window, which bounds the work per run.</p>

<p><strong>A watermark instead of per-row lookups.</strong> The callback table records only MR numbers, with no business fields. The reconciliation job runs just two queries: one <code class="language-plaintext highlighter-rouge">IN</code> query that fetches <code class="language-plaintext highlighter-rouge">operationId → mrIid</code> in bulk, and one that fetches the largest MR number in the callback table. Then it does one comparison per row:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>mrIid &lt;= maxCallbackIid  → advance to "in effect"
mrIid &gt;  maxCallbackIid  → stay "merged", wait for the next run
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">mrIid == max</code> means this MR’s own callback has arrived; <code class="language-plaintext highlighter-rouge">mrIid &lt; max</code> means an MR with a larger number has already been called back, and since MR numbers increase monotonically within a project, we infer that the callback for this earlier MR was lost. This inference holds on the condition that the deployment pipeline runs in merge order, and a later run never skips changes merged before it. The <code class="language-plaintext highlighter-rouge">minAge</code> window prevents “late” from being mistaken for “lost”.</p>

<p><strong>Takes effect exactly once when racing with the normal callback path.</strong> Both the callback handler and the reconciliation job call the same state-advancing method:</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Transactional</span>
<span class="kd">public</span> <span class="nc">String</span> <span class="nf">markValidated</span><span class="o">(</span><span class="nc">String</span> <span class="n">opId</span><span class="o">)</span> <span class="o">{</span>
    <span class="nc">Operation</span> <span class="n">op</span> <span class="o">=</span> <span class="n">operationRepo</span><span class="o">.</span><span class="na">selectForUpdate</span><span class="o">(</span><span class="n">opId</span><span class="o">);</span>  <span class="c1">// Fixed lock order: operation first</span>
    <span class="nc">Binding</span>   <span class="n">b</span>  <span class="o">=</span> <span class="n">bindingRepo</span><span class="o">.</span><span class="na">selectForUpdate</span><span class="o">(</span><span class="n">opId</span><span class="o">);</span>    <span class="c1">// then binding</span>
    <span class="k">if</span> <span class="o">(</span><span class="n">b</span> <span class="o">==</span> <span class="kc">null</span> <span class="o">||</span> <span class="o">(</span><span class="n">b</span><span class="o">.</span><span class="na">status</span><span class="o">()</span> <span class="o">!=</span> <span class="no">MERGED</span> <span class="o">&amp;&amp;</span> <span class="n">b</span><span class="o">.</span><span class="na">status</span><span class="o">()</span> <span class="o">!=</span> <span class="no">VALIDATED</span><span class="o">))</span> <span class="o">{</span>
        <span class="k">return</span> <span class="kc">null</span><span class="o">;</span>                                     <span class="c1">// Preconditions not met: idempotent no-op</span>
    <span class="o">}</span>
    <span class="k">if</span> <span class="o">(</span><span class="n">op</span><span class="o">.</span><span class="na">status</span><span class="o">()</span> <span class="o">!=</span> <span class="no">CALLED_BACK</span><span class="o">)</span> <span class="o">{</span>
        <span class="n">operationRepo</span><span class="o">.</span><span class="na">updateStatus</span><span class="o">(</span><span class="n">opId</span><span class="o">,</span> <span class="no">CALLED_BACK</span><span class="o">);</span>   <span class="c1">// Repair a step state that may be lagging</span>
    <span class="o">}</span>
    <span class="k">if</span> <span class="o">(</span><span class="n">b</span><span class="o">.</span><span class="na">status</span><span class="o">()</span> <span class="o">==</span> <span class="no">MERGED</span><span class="o">)</span> <span class="o">{</span>
        <span class="n">requireOneRow</span><span class="o">(</span><span class="n">bindingRepo</span><span class="o">.</span><span class="na">update</span><span class="o">(</span>
            <span class="s">"SET status = VALIDATED WHERE operation_id = ? AND status = MERGED"</span><span class="o">,</span> <span class="n">opId</span><span class="o">));</span>
        <span class="k">return</span> <span class="n">b</span><span class="o">.</span><span class="na">subjectId</span><span class="o">();</span>                            <span class="c1">// Non-null only when the state actually flipped</span>
    <span class="o">}</span>
    <span class="k">return</span> <span class="kc">null</span><span class="o">;</span>
<span class="o">}</span>
</code></pre></div></div>

<p>When the two paths race, row locks ensure only one of them completes the flip; the other reads VALIDATED and returns <code class="language-plaintext highlighter-rouge">null</code>. Callers count a “validated successfully” metric only when the return value is non-null, and emit it only after the outer transaction commits, so a rollback cannot leave an over-counted metric and concurrency cannot cause double counting.</p>

<h3 id="38-adapting-to-upstream-api-changes">3.8 Adapting to upstream API changes</h3>

<p>The upstream platform planned to add a required field to its approval API: a list of check items that must each be acknowledged. The GitLab client library we use cannot express this field. Our approach:</p>

<ul>
  <li><strong>Funnel the approval action into a dedicated component.</strong> It has two implementations: the existing one using the client library, and a new one that sends the HTTP request directly with the check items. A configuration switch is read on every call to choose between them, so switching requires no restart.</li>
  <li><strong>Do not hard-code the check items.</strong> The list is read from the upstream configuration API and cached per project.</li>
  <li><strong>Self-heal using 422 responses.</strong> When a project’s check-item configuration changes, a request built from the stale cache receives a 422, and the 422 response body contains the list currently in effect. The component rebuilds the request from that list and retries once, saving an extra read round trip.</li>
  <li><strong>Keep exception semantics unchanged.</strong> Both implementations report failures using the client library’s original exception type, carrying the real HTTP status code. The caller’s retry loop, 401 ambiguity handling and logging all stay unchanged.</li>
</ul>

<h3 id="39-why-we-did-not-choose-a-workflow-engine">3.9 Why we did not choose a workflow engine</h3>

<p>A mature workflow engine provides durable execution, automatic retries and crash recovery, which are exactly the parts this design implements by hand. We did not adopt one because the workflow has a limited number of steps; the only shared infrastructure the team already has is the relational database; and the genuinely hard parts (ambiguous external responses, deciding when a callback is stale, idempotency based on remote state) are business semantics that an engine cannot solve for us. The cost is that we have to fill in some durable-execution capabilities ourselves; see Section 5.</p>

<h2 id="4-results">4. Results</h2>

<ul>
  <li><strong>State is queryable.</strong> Every request can be looked up by <code class="language-plaintext highlighter-rouge">operationId</code> to see its phase and the exact step it stopped at; troubleshooting no longer requires reconstructing the process from logs.</li>
  <li><strong>Failures are visible.</strong> Thread pool overload, step exceptions and pipeline failures all end up as explicit failure states, never as “processing” forever.</li>
  <li><strong>Robust to events.</strong> Duplicate deliveries, late callbacks and callbacks belonging to an old request do not change the current request’s state.</li>
  <li><strong>Safe re-entry.</strong> When operators re-trigger a step from where it stopped, already-merged requests finish directly and already-approved accounts skip approval, without creating new failures.</li>
  <li><strong>The workflow can evolve.</strong> Changing the step order is done through configuration, and in-flight approvals did not need to be drained during the release; upstream API changes are switched by a flag, with no changes for callers and no restart needed.</li>
  <li><strong>Lost callbacks converge automatically.</strong> Scheduled reconciliation advances stuck records to a terminal state and guarantees the success metric is counted exactly once.</li>
</ul>

<h2 id="5-known-limitations-and-next-steps">5. Known Limitations and Next Steps</h2>

<ul>
  <li><strong>Valid transitions are not defined centrally.</strong> The optimistic concurrency check compares against “the state just read”, which prevents lost updates but not illegal from→to transitions. Validity is currently scattered across entry guards, the re-appliable set and the preconditions of the advancing methods. We plan to introduce a central transition table that every write is validated against.</li>
  <li><strong>Execution state is not durable.</strong> The long-running workflow runs in an in-process thread pool, and waiting is implemented with thread sleeps. If the process crashes mid-step, the record stays “in progress”. The batch path already has a timeout sweep; the single-request path currently relies on the operations tool for recovery. We plan to add timeout sweeps for every “in progress” state, or to make waiting scheduler-driven.</li>
  <li><strong>Reconciliation relies on inference.</strong> The watermark approach depends on monotonic MR numbers and the deployment pipeline’s execution semantics, and since the callback table has no business dimension, the comparison is global. If callbacks could carry a business identifier, we could switch to exact matching.</li>
  <li><strong>The workflow code lives in one large class.</strong> This raises the cost of auditing the state machine as a whole; we plan to split it by phase.</li>
</ul>]]></content><author><name></name></author><category term="en" /><summary type="html"><![CDATA[How a workflow spanning an approval system, GitLab and CI stays observable, surfaces every failure, tolerates duplicate and late events, and evolves without interrupting in-flight requests.]]></summary></entry></feed>