<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Distributed-Systems on sysarcher/@ich_code</title><link>https://sysarcher.github.io/categories/distributed-systems/</link><description>Recent content in Distributed-Systems on sysarcher/@ich_code</description><generator>Hugo</generator><language>en-us</language><lastBuildDate>Tue, 27 Oct 2026 10:00:00 +0200</lastBuildDate><atom:link href="https://sysarcher.github.io/categories/distributed-systems/index.xml" rel="self" type="application/rss+xml"/><item><title>What's past is prologue</title><link>https://sysarcher.github.io/posts/whats-past-is-prologue/</link><pubDate>Tue, 27 Oct 2026 10:00:00 +0200</pubDate><guid>https://sysarcher.github.io/posts/whats-past-is-prologue/</guid><description>&lt;p&gt;&lt;em&gt;Retiring snapshot-per-write for an event-fed read model&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: Shakespeare succinctly describes what will follow, but our history storage mechanism was worse than O(N) — we were writing a whole copy of everything on every change. We moved to using events and an event-driven architecture. This is very much appreciated by our DB. Event-driven systems are an aggregation of the past — what&amp;rsquo;s past, is prologue! (&lt;em&gt;The Tempest&lt;/em&gt;, Act II)&lt;/p&gt;
&lt;p&gt;I discuss this topic very briefly here as well (2018 video): &lt;a href="https://youtu.be/-VJ6FtTS8zg"&gt;https://youtu.be/-VJ6FtTS8zg&lt;/a&gt;&lt;/p&gt;</description><content:encoded><![CDATA[<p><em>Retiring snapshot-per-write for an event-fed read model</em></p>
<p><strong>TL;DR</strong>: Shakespeare succinctly describes what will follow, but our history storage mechanism was worse than O(N) — we were writing a whole copy of everything on every change. We moved to using events and an event-driven architecture. This is very much appreciated by our DB. Event-driven systems are an aggregation of the past — what&rsquo;s past, is prologue! (<em>The Tempest</em>, Act II)</p>
<p>I discuss this topic very briefly here as well (2018 video): <a href="https://youtu.be/-VJ6FtTS8zg">https://youtu.be/-VJ6FtTS8zg</a></p>
<hr>
<p>Blended Labs is growing. More schools, more terms, more users.</p>
<p>Growth changes the shape of the problem. A design that behaves acceptably at a few thousand snapshots does not behave acceptably at a few hundred thousand, and the failure mode is rarely an outage — it is a cost curve that bends upward with accumulated history. Writes get slower as the history they append to gets larger; reads get slower as the number of historical copies they must diff grows; storage grows with every edit, not with every entity.</p>
<p>This is the story of removing that curve from the marksheet subsystem.</p>
<hr>
<h2 id="the-naive-implementation">The naive implementation</h2>
<p>The write model was — and still is — a flat table of submissions: one row per <code>(period, course, class, column, student)</code> with a value and audit fields. That part is fine.</p>
<p>Alongside every write, the system also stored a <strong>snapshot of the entire class marksheet</strong>.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>POST-WRITE SNAPSHOT
</span></span><span style="display:flex;"><span>┌──────────────────────────────────────────────────────────────┐
</span></span><span style="display:flex;"><span>│ snapshot                                                     │
</span></span><span style="display:flex;"><span>│   periodId, courseId, classId                                │
</span></span><span style="display:flex;"><span>│   action        marksheetUpdate | periodUpdate | recalc | …  │
</span></span><span style="display:flex;"><span>│   createdBy, createdAt                                       │
</span></span><span style="display:flex;"><span>│   cells[]       every cell in the class                      │
</span></span><span style="display:flex;"><span>│   categoryMeta[] per-category column metadata + links        │
</span></span><span style="display:flex;"><span>│                                                              │
</span></span><span style="display:flex;"><span>│   unique (periodId, courseId, classId, createdAt DESC)       │
</span></span><span style="display:flex;"><span>└──────────────────────────────────────────────────────────────┘
</span></span></code></pre></div><p>One teacher changing one mark produced one new document containing the whole class. A recalculation of a term produced one snapshot per course, per pass.</p>
<p>The design was not stupid. It bought three things cheaply:</p>
<ol>
<li><strong>Point-in-time reads.</strong> &ldquo;Show the marksheet as it was when this was locked&rdquo; was a single fetch, not a reconstruction.</li>
<li><strong>Decoupling.</strong> Snapshot reads did not depend on the write schema evolving.</li>
<li><strong>Reproducibility.</strong> A generated report could cite the snapshot it was built from.</li>
</ol>
<p>It worked while the numbers were small. And it was probably fast to implement (though I would contest the &ldquo;fast&rdquo; part).</p>
<h3 id="where-it-breaks">Where it breaks</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>write amplification (Big-O notation below)
</span></span><span style="display:flex;"><span>  one cell change  →  O(class size) bytes written
</span></span><span style="display:flex;"><span>  one recalc       →  O(courses × class size)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>storage growth
</span></span><span style="display:flex;"><span>  proportional to writes × class size, not to the number of marks
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>read amplification
</span></span><span style="display:flex;"><span>  history = load N snapshots, diff them cell by cell
</span></span><span style="display:flex;"><span>  worst bucket: 862 snapshots, ~43 MB, &gt;2,600 queries
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>dual truth
</span></span><span style="display:flex;"><span>  a locked period had live rows AND a copy
</span></span><span style="display:flex;"><span>  the locked read path read the copy, not the source
</span></span></code></pre></div><p><em>*period-locking is an internal concept we use for preserving data fidelity</em> — unnecessary for what follows.</p>
<h2 id="the-measured-cost">The measured cost</h2>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>We decided to redact the numbers, but it was in the tens of GBs (not the low tens either)
</span></span></code></pre></div><p>At the current write rate, the design projected roughly 5x additional snapshot storage per year, none of which was required to answer either of the two product questions. And this calculation is just based on user growth. Ideally, from a product perspective, we should encourage more people to use more of our features — but this was gonna kill us and our pockets.</p>
<hr>
<h2 id="constraints">Constraints</h2>
<p>The migration had to be invisible at the edges:</p>
<table>
	<thead>
			<tr>
					<th>Constraint</th>
					<th>Meaning</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Same UI</td>
					<td>no component changes</td>
			</tr>
			<tr>
					<td>Same GraphQL</td>
					<td>byte-identical schema file <strong>!!</strong></td>
			</tr>
			<tr>
					<td>Same document bytes</td>
					<td>report checksums unchanged</td>
			</tr>
			<tr>
					<td>Same visible data</td>
					<td>with opaque ids and transient recalculation state excepted</td>
			</tr>
	</tbody>
</table>
<hr>
<h2 id="target-model">Target model</h2>
<p>Remove the copy. Keep the write table. Add an append-only log and a projection. Make locked reads read the source.</p>
<p><img alt="Target model" loading="lazy" src="/posts/whats-past-is-prologue/target-model.png"></p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>BEFORE
</span></span><span style="display:flex;"><span>┌──────────────┐
</span></span><span style="display:flex;"><span>│  write path  │──► submissions
</span></span><span style="display:flex;"><span>│              │──► full class copy
</span></span><span style="display:flex;"><span>└──────────────┘
</span></span><span style="display:flex;"><span>      │
</span></span><span style="display:flex;"><span>      ├── grid / reports  ◄── submissions
</span></span><span style="display:flex;"><span>      ├── history         ◄── load N copies + 3N queries
</span></span><span style="display:flex;"><span>      └── locked reads    ◄── load copy
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>AFTER
</span></span><span style="display:flex;"><span>┌──────────────┐
</span></span><span style="display:flex;"><span>│  write path  │──► submissions ──► grid / reports / locked reads
</span></span><span style="display:flex;"><span>│              │──► grade-entry event
</span></span><span style="display:flex;"><span>└──────────────┘
</span></span><span style="display:flex;"><span>                        │
</span></span><span style="display:flex;"><span>                        ▼
</span></span><span style="display:flex;"><span>                 ┌──────────────┐
</span></span><span style="display:flex;"><span>                 │  event log   │
</span></span><span style="display:flex;"><span>                 └──────────────┘
</span></span><span style="display:flex;"><span>                        │
</span></span><span style="display:flex;"><span>                        ▼
</span></span><span style="display:flex;"><span>                 ┌──────────────┐
</span></span><span style="display:flex;"><span>                 │  projection  │──► history drawer
</span></span><span style="display:flex;"><span>                 └──────────────┘
</span></span></code></pre></div><h2 id="what-event-driven-means-here">What &ldquo;event-driven&rdquo; means here</h2>
<p>The term is used loosely. In this system it means three concrete things:</p>
<ol>
<li><strong>State changes are recorded as immutable facts.</strong> A write appends an event describing what changed, carrying a monotonically increasing <code>sequence</code>. Facts are never edited in place; corrections arrive as new events.</li>
<li><strong>Read models are derived, not shared.</strong> A consumer reads the log in sequence order and maintains a projection shaped for one question. It keeps its own cursor, so one slow projection does not hold back another, and a new projection is added by replaying from sequence 0.</li>
<li><strong>Delivery is at-least-once, so effects must be idempotent.</strong> A consumer can see the same event twice after a crash or a leader handover. The projection carries a unique key per event, so the second application is a no-op.</li>
</ol>
<p>Three things this pattern does <em>not</em> require — and that we deliberately did not build:</p>
<table>
	<thead>
			<tr>
					<th>Commonly assumed</th>
					<th>What this system does</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Event-sourcing the whole domain</td>
					<td>The write table is still the source of truth; events are emitted alongside it</td>
			</tr>
			<tr>
					<td>A message broker</td>
					<td>A log in the same database plus an in-process dispatcher; no broker</td>
			</tr>
			<tr>
					<td>CQRS across the board</td>
					<td>One projection, for one screen</td>
			</tr>
	</tbody>
</table>
<p>The event is appended <strong>inside the same transaction</strong> as the row it describes, so there is no dual-write window. The dispatcher&rsquo;s poller owns delivery after commit; an inline wake-up is only a latency hint and is allowed to fail without affecting the write.</p>
<p>The price is eventual consistency: a projection may lag the log. That is acceptable only where the screen has a defined staleness budget. Here, the grid, report generation and locked marksheet paths all read current state and never lag; the history drawer may lag by one consumer batch.</p>
<hr>
<h2 id="invariants-first">Invariants first</h2>
<p>Before any code, we wrote down what &ldquo;correct&rdquo; meant. Everything after that was a design search constrained by five sentences.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>1  every change is observed exactly once                (no gaps)
</span></span><span style="display:flex;"><span>2  observing it twice has no additional effect          (no duplicates)
</span></span><span style="display:flex;"><span>3  changes are observed in the order they happened      (no reordering)
</span></span><span style="display:flex;"><span>4  the derived view can be thrown away and rebuilt      (no lock-in)
</span></span><span style="display:flex;"><span>5  no screen changes what it shows                      (no visible delta)
</span></span></code></pre></div><h3 id="seams">Seams</h3>
<p>In <em>Working Effectively with Legacy Code</em>, Michael Feathers describes seams; that is what we picked.</p>
<p>The tempting move was to event-source the domain: make events the write model, derive everything from them, and let the submissions table become a projection too. That would have been more elegant and much more expensive — every write path, every migration, every report input, and the entire blast radius of the domain.
We chose a narrower seam:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>keep:      the write table, the domain model, the API surface
</span></span><span style="display:flex;"><span>add:       event emission beside the write
</span></span><span style="display:flex;"><span>add:       one projection, for one screen
</span></span><span style="display:flex;"><span>remove:    the snapshot write
</span></span><span style="display:flex;"><span>change:    locked reads from copy → source
</span></span></code></pre></div><h3 id="staleness">Staleness</h3>
<p>Event-driven systems trade consistency for availability, but &ldquo;eventually consistent&rdquo; is not a design — it is a question. The question is: which screens are allowed to be behind, and by how much?</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>grid / marksheet        current-state read        never lags
</span></span><span style="display:flex;"><span>report generation       current-state read        never lags
</span></span><span style="display:flex;"><span>locked marksheet        current-state read        never lags
</span></span><span style="display:flex;"><span>history view            projection                may lag one batch
</span></span><span style="display:flex;"><span>analytics               projection                may lag one batch
</span></span></code></pre></div><p>Putting the history view on the projection and everything else on current state was the decision that made the migration safe. If the projection is wrong, one screen is wrong. If we had moved the grid onto it, a consumer bug would have been a grading incident.</p>
<h3 id="we-treated-the-projection-as-infrastructure">We treated the projection as infrastructure</h3>
<p>A projection is not a cache. A cache can be dropped and recomputed on demand; a projection is a read path with its own cursor, its own failure modes, and its own recovery procedure. We designed it that way from the start:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>- idempotent writes, keyed per source event
</span></span><span style="display:flex;"><span>- a cursor that advances only after the write succeeds
</span></span><span style="display:flex;"><span>- a documented rebuild path: truncate, reset cursor, replay
</span></span></code></pre></div><p>That last one matters more than it sounds. The ability to rebuild from the log is what makes the projection a derived artifact rather than a second source of truth.</p>
<hr>
<h2 id="execution">Execution</h2>
<p>The migration was sequenced so that no phase required rolling back the previous one.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>PHASE 1   emit events beside the write
</span></span><span style="display:flex;"><span>          snapshot still written, projection still empty
</span></span><span style="display:flex;"><span>          gate: events exist, payloads are complete
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>PHASE 2   backfill history before the cutoff
</span></span><span style="display:flex;"><span>          gate: parity against the old copies
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>PHASE 3   replay events after the cutoff
</span></span><span style="display:flex;"><span>          gate: cursor reaches the head, no failures
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>PHASE 4   switch the history read to the projection
</span></span><span style="display:flex;"><span>          gate: view output matches the diff-based history
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>PHASE 5   switch locked reads to current state
</span></span><span style="display:flex;"><span>          gate: locked marksheet identical to the last copy
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>PHASE 6   stop writing snapshots
</span></span><span style="display:flex;"><span>          gate: no new documents; old ones frozen, not dropped
</span></span></code></pre></div><p>The snapshot was never deleted. Freezing it is cheap; deleting it removes the rebuild source and the ability to answer historical questions if the projection is ever suspect.</p>
<h3 id="the-backfill-had-to-assume-interruption">The backfill had to assume interruption</h3>
<p>Two eras meet at a cutoff: before it, copies are the only record; after it, the event log is.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>  2024 ────────────────────────── cutoff ───────────────────── today
</span></span><span style="display:flex;"><span>  │◄────── copies only ─────────►│◄────── event log ─────────►│
</span></span><span style="display:flex;"><span>                │                            │
</span></span><span style="display:flex;"><span>                └──────── backfill ──────────┘
</span></span><span style="display:flex;"><span>                     one pass, restartable
</span></span></code></pre></div><p>We assumed the job would be interrupted — by a deploy, a crash, an operator, or a deadline. So every emitted row carried a deterministic identity derived from the copy, the student and the column. Re-running the job produces the same identities; the storage rejects the ones that already exist. The job is safe to run twice, and safe to stop halfway.</p>
<p>We also knew the traversal order had to be cheap. Walking the copies in the order the history needs them let the database serve the scan from an index instead of sorting twenty gigabytes in memory. That was the difference between a backfill and an outage.</p>
<h3 id="cutover-without-a-flag-day">Cutover without a flag day</h3>
<p>There was no moment where we turned the old system off and the new one on. The phases overlapped.</p>
<p>Events were emitted for weeks before anything read the projection. The backfill ran while the old reads were still live. The replay followed the backfill, and the cursor caught up while the old code served the same screens. Reads switched one surface at a time, each behind a parity check.</p>
<p>The only irreversible step was the last one. Once snapshot writes stopped, rolling back the image would have meant old code reading a copy that no longer advanced. We treated that as a one-way door and wrote it down before deploying.</p>
<h3 id="the-token">The token</h3>
<p>Locked terms had one more problem: an old report had to be recognisable as stale.</p>
<p>The old system identified a locked term by the copy it was generated against. That identity had to survive the switch, or every existing report would look outdated on day one and the first regeneration would produce a wave of spurious warnings. So we gave each locked term a generation counter, seeded it at one for every term already locked, and made the new identity a pair of the term and its generation. Old reports kept a token that no longer matched the new format, but nothing compared it for correctness — only for equality when deciding whether to warn. The wave never happened.</p>
<hr>
<h2 id="the-night-it-ran">The night it ran</h2>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>18:05   backfill starts in the first pod of the new rollout
</span></span><span style="display:flex;"><span>19:14   the last snapshot is written
</span></span><span style="display:flex;"><span>19:50   the backfill and the generation stamp both commit
</span></span><span style="display:flex;"><span>20:51   the replay reaches the head
</span></span></code></pre></div><p>It was a nerve-racking time, at least for me. I sat there, watching migrations apply and the cursor move.</p>
<hr>
<h2 id="outcome">Outcome</h2>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>history view       1 indexed range read (was: up to 862 copy loads)
</span></span><span style="display:flex;"><span>recalculation      no copy write per course per pass
</span></span><span style="display:flex;"><span>locked reads       one query, same data as the copy
</span></span><span style="display:flex;"><span>copy growth        stopped (~41 GB/year avoided)
</span></span><span style="display:flex;"><span>API surface        unchanged
</span></span><span style="display:flex;"><span>report checksums   unchanged
</span></span></code></pre></div><p>The generalisable point is not &ldquo;event sourcing&rdquo;. It is narrower:</p>
<blockquote>
<p>A projection is a read model with a correctness story of its own: a monotonic cursor, an idempotent write key, a rebuild path, and a defined staleness budget. Treat it as production infrastructure, not as a derived table.</p>
</blockquote>
<p>The snapshot was never wrong. It was a write-path materialisation of a read-path question, and we paid for it on both sides. (Thanks to AI for this convoluted sentence)</p>
<hr>
<h2 id="references">References</h2>
<ul>
<li>Martin Fowler, <a href="https://martinfowler.com/eaaDev/EventSourcing.html">Event Sourcing</a>, 2005.</li>
<li>Martin Fowler, <a href="https://martinfowler.com/articles/201701-event-driven.html">What do you mean by &ldquo;Event-Driven&rdquo;?</a>, 2017.</li>
<li>Chris Richardson, <a href="https://microservices.io/patterns/data/event-driven-architecture.html">Event-driven architecture pattern</a>.</li>
<li>Chris Richardson, <a href="https://microservices.io/patterns/data/transactional-outbox.html">Transactional outbox pattern</a>.</li>
<li>Microsoft Azure Architecture Center, <a href="https://learn.microsoft.com/en-us/azure/architecture/patterns/event-sourcing">Event Sourcing pattern</a>.</li>
<li>Martin Kleppmann, <em>Designing Data-Intensive Applications</em>, Ch. 11 (&ldquo;Stream Processing&rdquo;), O&rsquo;Reilly, 2017. <a href="http://dataintensive.net">dataintensive.net</a></li>
<li>Pat Helland, <a href="https://dl.acm.org/detail.cfm?id=2884038">Immutability Changes Everything</a>, ACM Queue 13(9), 2015.</li>
<li>Vaughn Vernon, <em>Implementing Domain-Driven Design</em>, Ch. 8 (&ldquo;Domain Events&rdquo;), Addison-Wesley, 2013.</li>
<li>Greg Young, <a href="https://leanpub.com/esversioning">Versioning in an Event Sourced System</a>, Leanpub, 2017.</li>
</ul>
<p>ME: <a href="https://youtu.be/-VJ6FtTS8zg">https://youtu.be/-VJ6FtTS8zg</a></p>
]]></content:encoded></item><item><title>Examination at Scale: Queues, Databases, and ACKs</title><link>https://sysarcher.github.io/posts/examination-at-scale/</link><pubDate>Thu, 15 Oct 2026 10:00:00 +0200</pubDate><guid>https://sysarcher.github.io/posts/examination-at-scale/</guid><description>&lt;p&gt;When Sara is taking her exam, the last thing she has on her mind is whether clicking &amp;ldquo;submit&amp;rdquo; will actually work.&lt;/p&gt;
&lt;p&gt;The exam ends: in the last two minutes of the window, most of the class hits submit at the same time. It&amp;rsquo;s not only the class — these are central exams, everyone in the country is submitting at the same time.&lt;/p&gt;
&lt;p&gt;When the system shows &amp;ldquo;Submitted&amp;rdquo;, this is a promise to Sara that her exam is now safely stored with it and she doesn&amp;rsquo;t need to worry.&lt;/p&gt;</description><content:encoded><![CDATA[<p>When Sara is taking her exam, the last thing she has on her mind is whether clicking &ldquo;submit&rdquo; will actually work.</p>
<p>The exam ends: in the last two minutes of the window, most of the class hits submit at the same time. It&rsquo;s not only the class — these are central exams, everyone in the country is submitting at the same time.</p>
<p>When the system shows &ldquo;Submitted&rdquo;, this is a promise to Sara that her exam is now safely stored with it and she doesn&rsquo;t need to worry.</p>
<h2 id="acknowledgement">Acknowledgement</h2>
<p>The acknowledgement is a promise. How is it implemented? What is the formal and informal literature available for this? These are questions a systems designer or architect has to answer. Jim Gray&rsquo;s <a href="https://www.microsoft.com/en-us/research/wp-content/uploads/2016/02/tr-95-56.pdf">&ldquo;Queues are Databases&rdquo;</a> is a foundational paper. He points out that queues and databases are not different species — a queue <em>is</em> a database albeit with specialized semantics (enqueue, transactional dequeue, visibility, ACK).</p>
<p><strong>What does an ACK mean in your system?</strong> What guarantees does your system provide around storage of the user&rsquo;s data?</p>
<p><strong>The dual-write problem</strong> is the big question any distributed system has to answer, and its canonical solution is commonly called a transaction. As Chris Richardson explains it: a database update and a broker publish must be atomic; 2PC is not viable, so you persist the message with the domain write and let a relay publish it.</p>
<ul>
<li><a href="https://microservices.io/patterns/data/transactional-outbox.html">Transactional outbox</a> (for the atomicity)</li>
<li><a href="https://microservices.io/patterns/data/polling-publisher.html">Polling Publisher</a> + <a href="https://microservices.io/patterns/data/transaction-log-tailing.html">Transaction Log Tailing</a> (relay implementations)</li>
</ul>
<p>An ACK must not outrun durability: persist before you externalize. This is write-ahead logging, formalized in <strong>Mohan et al., &ldquo;ARIES&rdquo; (ACM TODS 1992)</strong>. The older, more general formulation is in Hohpe &amp; Woolf, <em>Enterprise Integration Patterns</em> (2003), which covers <strong>Guaranteed Delivery</strong>, <strong>Message Store</strong>, <strong>Transactional Client</strong>, and <strong>Idempotent Receiver</strong>.</p>
<p>Two Pat Helland papers frame the distributed-systems theory:</p>
<ul>
<li>&ldquo;Life Beyond Distributed Transactions: An Apostate&rsquo;s Opinion&rdquo; (CIDR 2007): don&rsquo;t span resources in distributed transactions; use messages plus idempotence.</li>
<li>&ldquo;Idempotence Is Not a Medical Condition&rdquo; (ACM Queue 2012): exactly-once is not achievable as delivery but is achieved as effect.</li>
</ul>
<p>For us, this means that the submission document and its result event commit in <strong>one database transaction</strong>, and the ACK is that commit. No window of uncertainty exists in this case. In the modern path below, this promise splits in two: the gate&rsquo;s &ldquo;Submitted&rdquo; is the durable acceptance write (the queue document), and the worker&rsquo;s finalize transaction — submission document plus result event, atomically — is the ACK for the result itself.</p>
<p>AWS have documented this same pattern for their Lambda service: <em>Lambda makes sure that your function is run, even in the face of server failures, so it needs a durable queue in which to store your request. With a durable queue, your request can be re-driven if your function fails the first time.</em> (<a href="https://builder.aws.com/content/3EuRcgkTP1MI0c7zM8W6HL3WIqA/avoiding-insurmountable-queue-backlogs">AWS Builders&rsquo; Library</a>)</p>
<h2 id="legacy-architecture">Legacy Architecture</h2>
<p>This is what the legacy architecture looks like (high level):</p>
<p><img alt="The legacy architecture — a submit path wired directly to every side effect, one shared read model, and one shared Redis for cache and queue" loading="lazy" src="/posts/examination-at-scale/old-architecture.svg"></p>
<p>Everything is inline. At scale, the budget is very tight: we want to save the document coming in as quickly as possible so we can save the next one. Any slowdown downstream ripples up, as can be seen in the picture.</p>
<p>Side effects had no single delivery story and were spread all over the place: sometimes they are direct calls, sometimes enqueued with a poller implemented to drain the outbox. Each context has its own retry semantics, as well as its own failure handling. And its own idea of an event — <strong>there is no shared ledger, no canonical envelope, and no single place to look to answer whether a side effect happened and how many times.</strong></p>
<p>Reads were coupled similarly: the de-facto shared read model is a snapshot collection whose documents fully embedded their submissions — one of the largest collections in the database, written by several contexts and read through one another&rsquo;s services. No single context owned it and it made it expensive to both change and read.</p>
<p>At scale, this fails: the deadline burst arrives at a request path wired everywhere. The transaction fails if any downstream fails. We have also had cases of partial successes because there&rsquo;s no overarching manager (e.g. a durable execution engine).</p>
<h2 id="modern-architecture">Modern Architecture</h2>
<p><img alt="The submission path — the gate accepts into a durable queue document; workers claim it, verify identity and targeting, grade, and commit the finalize transaction" loading="lazy" src="/posts/examination-at-scale/submission-path.svg"></p>
<h3 id="write-path">Write Path</h3>
<p>Although fairly complex, I have tried to summarize it in the following table. The idea is to get the submission asap into a ledger — one that we can later build upon.</p>
<p>All the expensive work is done by workers. A worker would claim a pending queue document and process it. Details omitted for brevity.</p>
<table>
	<thead>
			<tr>
					<th>#</th>
					<th>Operation</th>
					<th>Cost</th>
					<th>Stage</th>
					<th>Where / Why</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>1, 2</td>
					<td>Sign-in check; per-user rate limit</td>
					<td>Negligible; 1 cache op</td>
					<td>Gate</td>
					<td>Guards against runaway clients</td>
			</tr>
			<tr>
					<td>3</td>
					<td>Assessment <code>_id</code> read</td>
					<td>1 indexed read</td>
					<td>Gate</td>
					<td>Deadline and format</td>
			</tr>
			<tr>
					<td>4, 5</td>
					<td>Deadline check; payload shape check</td>
					<td>CPU</td>
					<td>Gate</td>
					<td>Reject bad input early</td>
			</tr>
			<tr>
					<td>6, 7, 8</td>
					<td><code>attemptKey</code> detection; compare payloads</td>
					<td>2 indexed reads + CPU</td>
					<td>Gate</td>
					<td>Duplicates return same status</td>
			</tr>
			<tr>
					<td>9</td>
					<td>Attempt policy pre-check</td>
					<td>Up to 3 reads</td>
					<td>Gate</td>
					<td>Immediate rejection</td>
			</tr>
			<tr>
					<td>10</td>
					<td><code>w:majority</code> · <code>attemptKey</code> · <code>enqueuedAt</code> — submission durably saved</td>
					<td>1 write</td>
					<td>Gate</td>
					<td>Durability barrier</td>
			</tr>
			<tr>
					<td>11</td>
					<td><code>queue.add({ submissionId })</code> — dispatch job</td>
					<td>1 cache op</td>
					<td>Gate, best-effort</td>
					<td>Sweeper covers failure</td>
			</tr>
			<tr>
					<td>12, 13</td>
					<td>Receive job and claim it atomically</td>
					<td>1 write</td>
					<td>Worker</td>
					<td>Single processor; redeliveries become no-ops</td>
			</tr>
			<tr>
					<td>14-19</td>
					<td>Verify the student, targeting, course and question data; re-check attempt policy</td>
					<td>A few reads</td>
					<td>Worker</td>
					<td>Permanent failures surface via status</td>
			</tr>
			<tr>
					<td>20-23</td>
					<td>Grade, then commit submission and result event in one transaction</td>
					<td>CPU, 3+ writes</td>
					<td>Worker</td>
					<td>The transaction is the ACK for the result</td>
			</tr>
			<tr>
					<td>24-28</td>
					<td>Side effects: gradebook recalc, consumer drain, proctored finish, draft delete, cache cleanup</td>
					<td>Variable</td>
					<td>Worker</td>
					<td>Best-effort; never block a submit on a third party</td>
			</tr>
			<tr>
					<td>29-31</td>
					<td>Notify and recover: push confirmation, record permanent failures, sweep stuck jobs</td>
					<td>Cheap, bounded</td>
					<td>Worker</td>
					<td>Recovers lost jobs</td>
			</tr>
	</tbody>
</table>
<p><strong>Gate total:</strong> ~6 indexed reads + 1 majority write + 1 best-effort Redis op — no transaction, no external calls, no scans, no Person read.</p>
<p>Downstream of the finalize transaction, the result flows through the event layer:</p>
<p><img alt="The downstream event layer — the finalize transaction appends to the durable log; an at-least-once dispatcher delivers via an inline drain, a three-second poller, and a loss-tolerant Redis nudge; idempotent consumers own the read models" loading="lazy" src="/posts/examination-at-scale/event-layer.svg"></p>
<h3 id="infrastructure">Infrastructure</h3>
<p>The queue is built on Redis. The configuration of Redis is another blog post, but the instance of Redis we&rsquo;re using doesn&rsquo;t support Append Only File (AOF), which can lead to data loss. It is tolerated here because we&rsquo;re persisting first and foremost on the primary data store. Is a queue a good source of truth? We try to answer that next…</p>
<h2 id="is-a-queue-a-good-source-of-truth">Is a queue a good source of truth?</h2>
<p>As established earlier, a queue is a specialized database. So the question is: what is the durability guarantee that this queue makes?</p>
<table>
	<thead>
			<tr>
					<th>Medium</th>
					<th>Can it be the acceptance point?</th>
					<th>Literature</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Kafka (replicated log, <code>acks=all</code> + <code>min.insync.replicas</code>)</td>
					<td><strong>Yes</strong> — log-centric architecture treats the log as truth</td>
					<td>Jay Kreps, &ldquo;The Log&rdquo; (2013); Confluent, &ldquo;Exactly-once Semantics&rdquo; (Narkhede, 2017)</td>
			</tr>
			<tr>
					<td>SQS (managed, durable, at-least-once, DLQ)</td>
					<td><strong>Yes</strong></td>
					<td>AWS docs; API Gateway → SQS → Lambda is the canonical serverless ingestion pattern</td>
			</tr>
			<tr>
					<td>Database (majority write) + claim queue (<code>SKIP LOCKED</code> / <code>findOneAndUpdate</code>)</td>
					<td><strong>Yes</strong></td>
					<td>Gray; Craig Ringer, &ldquo;What is SKIP LOCKED for?&rdquo; (2015) — fine at thousands/s, painful at millions/s</td>
			</tr>
			<tr>
					<td>Redis (without AOF)</td>
					<td><strong>No</strong> — accelerator only</td>
					<td>CAP/durability: async replication has RPO &gt; 0; acked writes can vanish on failover (Brewer 2012; Gilbert &amp; Lynch 2002)</td>
			</tr>
	</tbody>
</table>
<p>BullMQ&rsquo;s production guide — <a href="https://docs.bullmq.io/guide/going-to-production">Going to production</a>:</p>
<blockquote>
<p><em>&ldquo;Since BullMQ is based on Redis, persistence needs to be configured manually&hellip; We recommend enabling AOF&hellip; <code>maxmemory-policy</code> must be <code>noeviction</code>. This is the <strong>only</strong> setting that guarantees the correct behavior of the queues.&rdquo;</em></p>
</blockquote>
<p>While we wait for AOF to become available, I found literature that raised alarm bells: <strong>Sidekiq</strong> has documented job loss on hard failover (unless you are on Pro with <code>super_fetch</code>), and <strong>Celery&rsquo;s</strong> Redis visibility-timeout semantics bite in exactly the same way. The synthesis rule is short: <strong>your ACK&rsquo;s durability is the minimum of everything you wrote before acking.</strong></p>
<h2 id="queueing-theory-bursts-knees-and-backlogs">Queueing theory: bursts, knees, and backlogs</h2>
<p><em>This section is more about literature, with the design-specific things towards the end. Much of it was done by AI.</em></p>
<p>Exam windows are a queueing-theory problem wearing a school uniform.</p>
<p><strong>Little&rsquo;s Law, L = λW</strong> (John D. C. Little, <em>Operations Research</em>, 1961): concurrency equals arrival rate times latency. If service latency spikes 100x, in-flight work spikes 100x — threads, heap, and database sessions become a queue you never designed. It is why we bound consumers rather than &ldquo;drain as fast as possible&rdquo;, and why a slow downstream doesn&rsquo;t get to inflate the submission request.</p>
<p><strong>The utilization curve (M/M/1, Kingman&rsquo;s formula; Gross et al., <em>Fundamentals of Queueing Theory</em>)</strong>: latency behaves like ≈ 1/(1−ρ) — flat until about 70% utilization, then a knee, then a wall. Every exam window walks toward that knee. This is also why an autoscaler that only reacts to CPU is too blunt for bursts: the queue signals load before the CPU does.</p>
<p><strong>AWS Builders&rsquo; Library, &ldquo;Avoiding insurmountable queue backlogs&rdquo; (David Yanacek)</strong> — <a href="https://aws.amazon.com/builders-library/avoiding-insurmountable-queue-backlogs/">aws.amazon.com/builders-library/avoiding-insurmountable-queue-backlogs</a> — is the essential operational text: queues are <strong>bimodal</strong> (a fast mode and a backlog mode); backlog mode is dramatically slower to drain than to fill, so an hour of outage can need ~2x capacity for an hour to recover; backpressure must kick in <em>before</em> the backlog; alarm on <strong>message age</strong>, not just depth; DLQ volume is an availability metric.</p>
<p><strong>Google SRE Book, &ldquo;Handling Overload&rdquo;</strong> (<a href="https://sre.google/sre-book/handling-overload/">sre.google/sre-book/handling-overload</a>) and <strong>&ldquo;Addressing Cascading Failures&rdquo;</strong> (ch. 22) supply the controls: load shedding, criticality tiers, adaptive client-side throttling, and retry budgets (≤3 attempts, ≤10% retry ratio) — with the design goal that a backend keeps serving its provisioned rate while receiving 2–10x excess.</p>
<p>Also worth knowing: <strong>Nichols &amp; Jacobson, &ldquo;Controlling Queue Delay&rdquo; (ACM Queue 2012)</strong> — bufferbloat: unbounded buffering trades latency for throughput until the buffer itself collapses. Queues are never free; every unbounded queue is a latency debt. And <strong>Dean &amp; Barroso, &ldquo;The Tail at Scale&rdquo; (CACM 2013)</strong> — fan-out amplifies tail latency, which matters when one submit triggers grading, recalc, projections, and notifications.</p>
<p>The exam-specific corollaries:</p>
<ul>
<li>The deadline is a self-inflicted load spike. You cannot flatten it (students submit when they submit), so you absorb it: an accept-only gate plus bounded, idempotent consumers.</li>
<li>Retries are the second self-inflicted spike. A transient failure right at the deadline would otherwise multiply traffic — which is what retry budgets, backoff, and jitter are for.</li>
<li>Consumer lag is the metric to watch, not queue depth. Depth tells you how much work is queued; age tells you whether students are waiting.</li>
<li>Per-student concurrency is naturally bounded (one active attempt), so the burst is wide, not deep — thousands of small, independent transactions rather than one hot document.</li>
</ul>
<h2 id="idempotency-how-exactly-once-is-actually-achieved">Idempotency: how &ldquo;exactly once&rdquo; is actually achieved</h2>
<p>You cannot deliver a message exactly once; you can only make delivering it twice harmless. <strong>At-least-once delivery + idempotent effects = effectively-once.</strong></p>
<p>The database arbitrates &ldquo;first writer wins&rdquo; with a <strong>unique key</strong> — the per-attempt idempotency key. The gate checks it against both finalized submissions and pending queue documents, and the key stays unique when the worker finalizes. Retried submits and duplicate deliveries land on the same document; the second writer gets a duplicate-key error and is resolved to the stored attempt, not to a failure. The key is derived from natural identity, not generated randomly, so network retries, double-clicks, and tab reloads all converge.</p>
<p>Consumers are idempotent the same way: upserts keyed by the result id, monotonic guards (only a newer change moves the latest state; the earliest creation timestamp is kept with a minimum operation), and changes recorded as a set. Re-running any consumer over any stretch of the log produces the same state.</p>
<p>Queues, notably, cannot do this for you. A queue can deduplicate within its retention window (with the right configuration), but after that, only the system of record can arbitrate a duplicate — which is why queue products document idempotent-consumer patterns (BullMQ ships an &ldquo;idempotent jobs&rdquo; page) instead of promising effective exactly-once. The best industry reference is <strong>Stripe&rsquo;s idempotency keys</strong> (&ldquo;Designing robust and predictable APIs with idempotency&rdquo;, Stripe blog, 2017) — the same contract we implement for exams. Not exactly Stripe&rsquo;s scale (yet).</p>
<p>Streaming-theory equivalents: <strong>MillWheel (Akidau et al., VLDB 2013)</strong> and the <strong>Dataflow model (VLDB 2015)</strong> — dedup via stable IDs.</p>
<p>One practical footnote: make the acceptance check read the same store the ACK promises. An attempt-key lookup served from a cache is not an idempotency guard; it is a cache hit with a database problem behind it.</p>
<h2 id="reading-path">Reading path</h2>
<p><strong>One hour:</strong> the <a href="https://microservices.io/patterns/data/transactional-outbox.html">microservices.io transactional outbox page</a>; the <a href="https://docs.bullmq.io/guide/going-to-production">BullMQ production guide</a>; <a href="https://aws.amazon.com/builders-library/avoiding-insurmountable-queue-backlogs/">AWS, &ldquo;Avoiding insurmountable queue backlogs&rdquo;</a>; <a href="https://sre.google/sre-book/handling-overload/">SRE ch. 21 &ldquo;Handling Overload&rdquo;</a>.</p>
<p><strong>Half a day:</strong> Richardson, <em>Microservices Patterns</em>, ch. 3–4 (IPC and transactional messaging); Hohpe &amp; Woolf, <em>Enterprise Integration Patterns</em> (the four patterns above); Gray&rsquo;s &ldquo;Queues are Databases&rdquo;.</p>
<p><strong>Deep/formal:</strong> Helland&rsquo;s two papers; Kreps, &ldquo;The Log&rdquo;; Gross et al., <em>Fundamentals of Queueing Theory</em>; ARIES; Dean &amp; Barroso, &ldquo;The Tail at Scale&rdquo;; Kafka exactly-once semantics; MillWheel.</p>
<p>The short version, if you take one line: <strong>the database is the record, the queue is a relay, and the ACK is only as durable as the least durable thing you wrote before saying it.</strong></p>
<h2 id="conclusion">Conclusion</h2>
<p>Before computers, an exam ended with a handover: the student gave the script to the proctor, and from that moment the paper was the institution&rsquo;s responsibility — one script, one record, one result weeks later. Everything this post described is that same handover, rebuilt in machines: the gate is the hand, the queue document is the receipt, the finalized transaction is the script reaching the examiner, and the log is the sealed archive. The medium changed, the failure modes changed, but the product did not: exactly one durable, attributable result per attempt, identical wherever it is read.</p>
<p>The deadline burst may change when the result is produced, never what it is. The exam window is that contract&rsquo;s first stress test; the report card months later is its acceptance test.</p>
]]></content:encoded></item></channel></rss>