<?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>Event-Driven on sysarcher/@ich_code</title><link>https://sysarcher.github.io/categories/event-driven/</link><description>Recent content in Event-Driven 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/event-driven/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></channel></rss>