<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Alex E</title>
    <description>The latest articles on DEV Community by Alex E (@alexander-efremov).</description>
    <link>https://dev.to/alexander-efremov</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3972169%2Fdeba24d7-a7da-4581-9c60-30514773514c.png</url>
      <title>DEV Community: Alex E</title>
      <link>https://dev.to/alexander-efremov</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/alexander-efremov"/>
    <language>en</language>
    <item>
      <title>From one owner to a replica set: synchronous replication in Squirix preview.8</title>
      <dc:creator>Alex E</dc:creator>
      <pubDate>Tue, 15 Sep 2026 06:56:02 +0000</pubDate>
      <link>https://dev.to/alexander-efremov/from-one-owner-to-a-replica-set-synchronous-replication-in-squirix-preview8-3jmd</link>
      <guid>https://dev.to/alexander-efremov/from-one-owner-to-a-replica-set-synchronous-replication-in-squirix-preview8-3jmd</guid>
      <description>&lt;h1&gt;
  
  
  From one owner to a replica set: synchronous replication in Squirix preview.8
&lt;/h1&gt;

&lt;p&gt;The &lt;a href="https://dev.to/alexander-efremov/when-the-disk-fills-up-pressure-and-tenant-isolation-in-squirix-preview7-3dpa"&gt;preview.7 article&lt;/a&gt; covered what happens when a node should stop accepting durable work: journal disk quotas with &lt;code&gt;JOURNAL_DISK_QUOTA&lt;/code&gt;, and per-principal backpressure keyed by JWT subject. That work made overload explicit on one node.&lt;/p&gt;

&lt;p&gt;It does not answer the next uncomfortable question: what happens when the node that owns your key is gone?&lt;/p&gt;

&lt;p&gt;Up to preview.7, Squirix durability was per node. A pipelined binary WAL, group commit, snapshot recovery, and durable idempotency outcomes (preview.6) survive a process crash — but only if the same disk comes back. Lose the owner and the key is unavailable until that node returns. No other node can serve it, because no other node has it.&lt;/p&gt;

&lt;p&gt;That question shaped &lt;code&gt;0.1.0-preview.8&lt;/code&gt;: replica sets with synchronous replicated mutations and majority commit. RF=1 keeps the old single-owner behavior. RF&amp;gt;=3 survives single-node loss on the remaining majority.&lt;/p&gt;

&lt;p&gt;Squirix is still an experimental preview, not a production-ready cache. Its APIs, wire format, and storage formats may change during &lt;code&gt;0.x&lt;/code&gt;. This article describes the current design and the reasoning behind it, not a compatibility promise.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure mode to avoid
&lt;/h2&gt;

&lt;p&gt;Imagine a three-node cluster holding a hot key on node A. Node A loses power. The journal on A is intact, but A is unreachable. Clients keep retrying the bootstrap endpoints, and every retry routes to the same answer: the owner is down, the data is on its disk, nobody else may serve it.&lt;/p&gt;

&lt;p&gt;The target contract is boring and explicit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;client mutation, operation_id = 4f...
                |
                v
owner appends to its log + replicates to the group
                |
                v
majority durably acknowledges (2 of 3)
                |
                v
owner applies in memory, returns success
                |
                v
owner dies -&amp;gt; remaining majority still holds the committed prefix
                |
                v
a new leader elected by the surviving majority serves the key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No acknowledged replicated write lives on exactly one disk. If the client got success at RF&amp;gt;1, a majority holds it. (RF=1 is the exception that proves the rule: its majority is one node, so a single disk still holds the only copy.) That is the whole point of making replication synchronous on the mutation path rather than shipping logs in the background and hoping.&lt;/p&gt;

&lt;p&gt;This is different from the "commit unknown" case in preview.6. There, durable bytes may exist on one node while the client saw only a timeout. Here, the ambiguity spans nodes: the owner may have replicated far enough that the outcome survives its own death. Clients need a stable answer for that case too, which is where the new &lt;code&gt;CommitOutcomeUnknown&lt;/code&gt; contract comes in (more below).&lt;/p&gt;

&lt;h2&gt;
  
  
  From single owner to replica set
&lt;/h2&gt;

&lt;p&gt;The Squirix server already routes each key to one owner with static consistent-hash routing. Preview.8 does not replace that. It widens that ownership into a replica group.&lt;/p&gt;

&lt;p&gt;The pieces landed in dependency order across the preview.8 window:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Who replicates what: a physical replica ring and topology fingerprint.&lt;/strong&gt; Key ownership comes from the existing consistent-hash ring, while replicas are the next RF−1 distinct node IDs on a separate sorted ring. The fingerprint hashes the canonical configuration, so a mismatched peer fails visibly instead of diverging silently;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One knob, three behaviors: &lt;code&gt;ReplicaCount&lt;/code&gt; activation guards.&lt;/strong&gt; RF=1, RF=2, and RF&amp;gt;=3 get different runtime behavior instead of one blurred mode:

&lt;ul&gt;
&lt;li&gt;RF=1 keeps the previous single-owner path with no replication, elections, or group checks running at all;&lt;/li&gt;
&lt;li&gt;RF=2 is a synchronous mirror whose majority of two stops writes when either member is lost, with no replacement elected;&lt;/li&gt;
&lt;li&gt;only RF&amp;gt;=3 gets majority commit, with the surviving majority electing a new leader;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replication stays server-internal: a separate contract with mTLS identity.&lt;/strong&gt; It lives on the internal listener and is deliberately separate from the shared &lt;code&gt;SquirixCache.proto&lt;/code&gt;, so cache clients never see replication RPCs;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Followers that can always catch up: an ordered log plus snapshots.&lt;/strong&gt; Every follower append is journal-backed with no memory-only shortcut, and replica-group snapshots carry idempotency state, so a lagging follower catches up by installation rather than replay alone;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Success means a majority has it: a durable commit pipeline.&lt;/strong&gt; It culminates in synchronous replicated mutations, with RF=1 behavior preserved: the server returns success only after a majority of the group durably acknowledges, and applies to memory only the committed prefix.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What differs in practice is what losing a member costs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;RF=1:  owner only, no replication, no failover
RF=2:  owner + 1 follower, majority = 2, mirror stops on any loss
RF&amp;gt;=3: owner + followers, majority commit, survivors elect a new leader
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Static membership is a deliberate constraint. Peers are configured explicitly; there is no dynamic rebalancing in this preview. That removes a whole class of membership races from the first replicated release and lets the safety work focus on election, commit, and leadership change.&lt;/p&gt;

&lt;h2&gt;
  
  
  The write path: majority before success
&lt;/h2&gt;

&lt;p&gt;For a replicated mutation, the ordering rule extends the preview.6 journal-first rule across nodes:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Do not return success before a majority of the configured group has durably acknowledged the mutation.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The path looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;validate and admit the mutation
            |
            v
reserve a log index in the group
            |
            v
append locally + replicate to followers
            |
            v
wait for durable majority acknowledgements
            |
            v
advance commit_index, apply in memory
            |
            v
return success
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each mutation completes only when covered by that majority-durability step — the same "completed means covered" idea as preview.6 group commit, lifted from one &lt;code&gt;fsync&lt;/code&gt; to a quorum of them.&lt;/p&gt;

&lt;p&gt;Two details matter in practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Log-index reservation can be refused when exhausted. That refusal is definitive for the attempt: nothing was appended, nothing was applied. It fails closed before durability, like memory admission and journal quota in earlier previews.&lt;/li&gt;
&lt;li&gt;Followers validate appends before copying entry payloads and gate tail reads, so a corrupt or out-of-range frame never becomes a committed prefix by accident.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Expiration follows the same pipeline rather than sneaking around it. Expiration candidates get their own operation identity and flow through the commit coordinator, so an expiration the leader observes becomes a committed entry applied identically on every replica — a follower never expires a key on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Write-ahead intent and the honest unknown
&lt;/h2&gt;

&lt;p&gt;Preview.6 already gave every mutation an opaque &lt;code&gt;operation_id&lt;/code&gt; with a request fingerprint, propagated across owner-routing hops so a retry on another entry node still deduplicates on the key owner.&lt;/p&gt;

&lt;p&gt;Preview.8 tightens the ordering inside that story: the server records a write-ahead idempotency intent before executing the mutation, not after. If the process dies between "accepted" and "applied," recovery still knows the operation existed and can resolve its outcome instead of executing it twice.&lt;/p&gt;

&lt;p&gt;Even so, replication adds a case where the server cannot honestly say "committed" or "rejected." The owner replicated to some followers, crashed before learning whether a majority persisted the entry, and the client saw a timeout. The outcome may have committed on the survivors.&lt;/p&gt;

&lt;p&gt;That is the new &lt;code&gt;CommitOutcomeUnknown&lt;/code&gt; contract. Mutations can now throw &lt;code&gt;CommitOutcomeUnknownException&lt;/code&gt; when the durable commit outcome is ambiguous. Retrying with the same &lt;code&gt;operation_id&lt;/code&gt; stays idempotent: either the original outcome is returned or the mutation executes exactly once under the same identity.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;client --operation_id=4f--&amp;gt; owner (crashes mid-commit)
   |
   +--retry, same operation_id--&amp;gt; new leader
                                      |
                                      +--&amp;gt; original outcome, or exactly-once execution
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is not a distributed transaction, and it is not presented as one. It is the same idempotency idea from preview.6, extended to the window where the owner set changes under the client.&lt;/p&gt;

&lt;h2&gt;
  
  
  Catch-up: logs first, snapshots when behind
&lt;/h2&gt;

&lt;p&gt;A follower that restarts or falls behind does not get a special path. It gets the same log, from the point it diverged.&lt;/p&gt;

&lt;p&gt;The follower log keeps an ordered, durable prefix with explicit term and index on every entry. Catch-up replays from the follower's last durable index: matching prefix is kept, conflicting suffix is replaced, missing suffix is appended. Only when the gap exceeds what the log retains does the leader send a snapshot.&lt;/p&gt;

&lt;p&gt;A snapshot is one atomic, checksummed file with the committed state and the record of completed operations, so retries stay idempotent after install.&lt;/p&gt;

&lt;p&gt;A follower installs a snapshot only from its own group, and only when the topology fingerprint matches and the configuration generation is compatible. Anything else is refused before a single byte is applied.&lt;/p&gt;

&lt;p&gt;Installing replaces the follower's committed prefix. Entries past the snapshot boundary may stay, but only when the follower's entry exactly at the boundary matches the snapshot's last-included index and term. On any divergence at that boundary, the follower's tail is discarded and replayed fresh from the leader log. Compaction afterwards trims only the journal prefix the snapshot covers, keeping the log header and the snapshot itself installable.&lt;/p&gt;

&lt;p&gt;Three concrete cases — in all of them the leader holds entries 1 through 6, and a primed entry (like &lt;code&gt;3'&lt;/code&gt;) means the same index written in an older term:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;leader:   [1][2][3][4][5][6]

Case 1 — small gap, conflicting tail.
The follower is almost current, but its last entry belongs to an older term:
follower: [1][2][3']           -&amp;gt; drop 3', copy 3..6 from the leader
result:   [1][2][3][4][5][6]

Case 2 — gap larger than the retained log.
The follower is so far behind that replaying entry by entry is no longer possible:
follower: [1]                  -&amp;gt; install the snapshot (state through 5),
                                  then replay entry 6 from the leader
result:   snapshot[1..5] + [6]

Case 3 — the snapshot boundary itself was written in another term.
The follower's tail overlaps the snapshot but disagrees with it at the boundary:
follower: [1][2][3][4][5']     -&amp;gt; install the snapshot (state through 5),
                                  discard the conflicting 5', replay 6
result:   snapshot[1..5] + [6]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The idempotency state rides along on purpose. A snapshot with values but without retry identity lets a delayed retry execute twice after old segments are compacted away. Group snapshots carry committed outcomes so catch-up preserves retry semantics, not just data.&lt;/p&gt;

&lt;h2&gt;
  
  
  What preview.8 changes
&lt;/h2&gt;

&lt;p&gt;The replication work in &lt;code&gt;0.1.0-preview.8&lt;/code&gt; includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;replica sets RF 1–5 with static membership and a physical replica ring;&lt;/li&gt;
&lt;li&gt;synchronous replicated mutations with durable majority commit; RF=1 behavior preserved;&lt;/li&gt;
&lt;li&gt;write-ahead idempotency intent before mutation execution;&lt;/li&gt;
&lt;li&gt;the &lt;code&gt;CommitOutcomeUnknown&lt;/code&gt; contract for ambiguous durable outcomes;&lt;/li&gt;
&lt;li&gt;ordered follower-log recovery, catch-up, and snapshot installation with idempotency state;&lt;/li&gt;
&lt;li&gt;a server-only replication gRPC contract on the internal listener with mTLS identity.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Some of that improves availability. Most of it exists because awkward timing now spans nodes: an owner that dies after replicating halfway, a follower that returns with a conflicting suffix, a retry that arrives at a new leader. The happy path — three healthy nodes agreeing — is necessary, but the cases above are where the design is decided.&lt;/p&gt;

&lt;h2&gt;
  
  
  What preview.8 does not promise
&lt;/h2&gt;

&lt;p&gt;This work makes single-node loss survivable for RF&amp;gt;=3, but Squirix is still early software.&lt;/p&gt;

&lt;p&gt;Replication is synchronous and per key group; multi-key operations are not transactions across owners. RF=2 never promotes after peer loss. Quorum reads and leader election details will be covered in part 2; the operator surface (opt-in, metrics, RF=3 compose demo) in part 3 — links below.&lt;/p&gt;

&lt;p&gt;The on-disk format may change during &lt;code&gt;0.x&lt;/code&gt;. Synchronous replication is not a substitute for backups or recovery testing. Preview.8 is a stronger foundation for availability, not a production-readiness claim.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing thought
&lt;/h2&gt;

&lt;p&gt;Durability work asks: what survives a crash? Replication work asks: what survives the loss of the machine that holds the answer?&lt;/p&gt;

&lt;p&gt;The difficult part is keeping the same honesty when the owner changes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;success means a majority holds it, not one disk;&lt;/li&gt;
&lt;li&gt;unknown means unknown, with a stable identity to retry;&lt;/li&gt;
&lt;li&gt;a follower that returns gets the log it missed, or a snapshot that preserves retry identity — never a guess.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Preview.8 makes those boundaries explicit across nodes. The next article goes one level deeper into how the cluster decides who may speak for a group: terms, votes, fencing, and why RF=2 stays a mirror.&lt;/p&gt;

&lt;p&gt;If you run replicated .NET state or care about quorum commit semantics, comments are welcome on where this model still surprises — especially majority-commit latency, &lt;code&gt;CommitOutcomeUnknown&lt;/code&gt; handling, and snapshot-vs-log catch-up thresholds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;p&gt;Squirix docs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/squirix/squirix" rel="noopener noreferrer"&gt;https://github.com/squirix/squirix&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Release &lt;code&gt;v0.1.0-preview.8&lt;/code&gt;: &lt;a href="https://github.com/squirix/squirix/releases/tag/v0.1.0-preview.8" rel="noopener noreferrer"&gt;https://github.com/squirix/squirix/releases/tag/v0.1.0-preview.8&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Replication consensus ADR: &lt;a href="https://github.com/squirix/squirix/blob/main/docs/architecture/replication-consensus.md" rel="noopener noreferrer"&gt;https://github.com/squirix/squirix/blob/main/docs/architecture/replication-consensus.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Clustering: &lt;a href="https://github.com/squirix/squirix/blob/main/docs/clustering.md" rel="noopener noreferrer"&gt;https://github.com/squirix/squirix/blob/main/docs/clustering.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Bootstrap client failover: &lt;a href="https://github.com/squirix/squirix/blob/main/docs/bootstrap-client-failover.md" rel="noopener noreferrer"&gt;https://github.com/squirix/squirix/blob/main/docs/bootstrap-client-failover.md&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Earlier in this series:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What happens after a write? (preview.6): &lt;a href="https://dev.to/alexander-efremov/what-happens-after-a-write-reworking-squirixs-wal-in-preview6-5ha4"&gt;https://dev.to/alexander-efremov/what-happens-after-a-write-reworking-squirixs-wal-in-preview6-5ha4&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;When the disk fills up (preview.7): &lt;a href="https://dev.to/alexander-efremov/when-the-disk-fills-up-pressure-and-tenant-isolation-in-squirix-preview7-3dpa"&gt;https://dev.to/alexander-efremov/when-the-disk-fills-up-pressure-and-tenant-isolation-in-squirix-preview7-3dpa&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next (forthcoming):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Who is the leader? Elections, fencing, and why RF=2 can't fail over (part 2)&lt;/li&gt;
&lt;li&gt;Operating a replica set (part 3)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Try it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet add package squirix &lt;span class="nt"&gt;--version&lt;/span&gt; 0.1.0-preview.8
dotnet add package squirix.server &lt;span class="nt"&gt;--version&lt;/span&gt; 0.1.0-preview.8
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>distributedsystems</category>
      <category>opensource</category>
      <category>csharp</category>
      <category>dotnet</category>
    </item>
    <item>
      <title>When the disk fills up: pressure and tenant isolation in Squirix preview.7</title>
      <dc:creator>Alex E</dc:creator>
      <pubDate>Sun, 26 Jul 2026 15:03:06 +0000</pubDate>
      <link>https://dev.to/alexander-efremov/when-the-disk-fills-up-pressure-and-tenant-isolation-in-squirix-preview7-3dpa</link>
      <guid>https://dev.to/alexander-efremov/when-the-disk-fills-up-pressure-and-tenant-isolation-in-squirix-preview7-3dpa</guid>
      <description>&lt;p&gt;In my &lt;a href="https://dev.to/__2d3e61e/what-happens-after-a-write-reworking-squirixs-wal-in-preview6-5ha4"&gt;preview.6 article&lt;/a&gt;, I wrote about what happens after a write is accepted: binary journal frames, group commit, crash recovery, and durable retries. That work made the durability boundaries easier to name.&lt;/p&gt;

&lt;p&gt;It does not answer the next uncomfortable question: what happens when the node should &lt;strong&gt;stop accepting&lt;/strong&gt; more durable work?&lt;/p&gt;

&lt;p&gt;A journal that never refuses appends will eventually fill the disk. A shared concurrency gate that never distinguishes callers will let one noisy tenant starve everyone else. From the outside, both failures can look like "the cache is down," even while the process is still alive.&lt;/p&gt;

&lt;p&gt;Those questions shaped &lt;code&gt;0.1.0-preview.7&lt;/code&gt;: journal disk quotas that reject durable writes with a stable error, and per-principal backpressure keyed by JWT subject (or connection id when there is no subject).&lt;/p&gt;

&lt;p&gt;Squirix is still an experimental preview, not a production-ready cache. Its APIs and storage formats may change during &lt;code&gt;0.x&lt;/code&gt;. This article describes the current design and the reasoning behind it, not a compatibility promise.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure mode we wanted to avoid
&lt;/h2&gt;

&lt;p&gt;Imagine a node that keeps appending journal segments until the volume is full. Depending on the OS and I/O path, the next write might throw, hang, or leave a torn tail. Operators then see process crashes, opaque I/O exceptions, and readiness flaps that take the node out of load balancers — exactly when health and metrics are most useful.&lt;/p&gt;

&lt;p&gt;The contract we want instead is boring and explicit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;journal on-disk size approaches configured cap
                |
                v
durable append that would exceed the cap is rejected
                |
                v
client gets JOURNAL_DISK_QUOTA
(gRPC ResourceExhausted)
                |
                v
/health/ready stays healthy
/health/ready/details exposes journalDisk pressure
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rejection is definitive for that attempt. The process stays up. Readiness stays scrapeable so operators can reclaim space or raise the limit without first fighting a dead host.&lt;/p&gt;

&lt;p&gt;That is different from the "commit unknown" case in preview.6. There, durable bytes may already exist and the client only saw a timeout. Here, the server refused the append before it crossed the durability boundary. Clients should not invent success; they should back off until operators reclaim headroom or raise the cap.&lt;/p&gt;

&lt;h2&gt;
  
  
  A hard cap with soft observability
&lt;/h2&gt;

&lt;p&gt;Pipelined journal settings already expose &lt;code&gt;JournalMaxTotalBytesMb&lt;/code&gt; — a hard on-disk size for &lt;strong&gt;journal segments&lt;/strong&gt;, not snapshots or manifests. Preview.7 maps that cap into controlled write rejection. Segment-count and per-segment size caps can also reject with the same &lt;code&gt;JOURNAL_DISK_QUOTA&lt;/code&gt; code.&lt;/p&gt;

&lt;p&gt;The soft high-water for readiness details is fixed at 80% of the hard limit. Soft high-water is observability only: writes that still fit under the hard cap continue. Hard limit (&lt;code&gt;critical&lt;/code&gt;) is the observed state when &lt;code&gt;usedBytes &amp;gt;= maxBytes&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0% -------- 80% (high) -------- 100% (critical / hard cap)
|              |                      |
|   writes OK  |  writes OK if fit;   |  writeRejectionActive=true
|              |  oversize append     |  (usage already at cap)
|              |  still rejected      |
|              |  with JOURNAL_DISK_  |
|              |  QUOTA               |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important timing detail: rejection uses &lt;code&gt;usedBytes + appendBytes &amp;gt; maxBytes&lt;/code&gt;. An append can therefore be rejected while &lt;code&gt;state&lt;/code&gt; is still &lt;code&gt;high&lt;/code&gt; and &lt;code&gt;writeRejectionActive&lt;/code&gt; is still &lt;code&gt;false&lt;/code&gt;. &lt;code&gt;writeRejectionActive&lt;/code&gt; becomes &lt;code&gt;true&lt;/code&gt; only once usage has already reached the hard cap.&lt;/p&gt;

&lt;p&gt;A few details matter in practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The quota covers journal segments only.&lt;/li&gt;
&lt;li&gt;Rejected durable writes do not crash the process.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/health/ready&lt;/code&gt; remains healthy so the node can still expose pressure state.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/health/ready/details&lt;/code&gt; includes &lt;code&gt;journalDisk&lt;/code&gt; with &lt;code&gt;state&lt;/code&gt;, &lt;code&gt;maxBytes&lt;/code&gt;, &lt;code&gt;usedBytes&lt;/code&gt;, &lt;code&gt;highWaterBytes&lt;/code&gt;, and &lt;code&gt;writeRejectionActive&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The operator path is intentionally unexciting: confirm pressure, trigger or wait for snapshot plus compaction/retention, raise &lt;code&gt;JournalMaxTotalBytesMb&lt;/code&gt; only after confirming disk capacity, and treat &lt;code&gt;JOURNAL_DISK_QUOTA&lt;/code&gt; like other capacity rejections.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why readiness stays healthy under quota
&lt;/h2&gt;

&lt;p&gt;It is tempting to mark a node not-ready when the journal is full. That can remove it from traffic, but it also removes the cheapest way to inspect &lt;em&gt;why&lt;/em&gt; durable writes stopped.&lt;/p&gt;

&lt;p&gt;Preview.7 keeps &lt;code&gt;/health/ready&lt;/code&gt; healthy and puts pressure into details:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;load balancer / orchestrator
        |
        | GET /health/ready           --&amp;gt; 200 (still ready)
        |
        | GET /health/ready/details
        v
  journalDisk.state = critical
  writeRejectionActive = true
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cache clients already receive gRPC &lt;code&gt;ResourceExhausted&lt;/code&gt; with public code &lt;code&gt;JOURNAL_DISK_QUOTA&lt;/code&gt;. That is the write-path signal. Readiness is the operator-path signal. Mixing them turns every capacity event into a membership event.&lt;/p&gt;

&lt;p&gt;HTTP on the primary listener is limited to health and metrics in v0.1; there is no public REST cache mutation surface. Shared error helpers can project the same logical error as HTTP 429 in tests and internal adapters, but application clients should key off the gRPC status and &lt;code&gt;JOURNAL_DISK_QUOTA&lt;/code&gt; code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Backpressure is not memory pressure
&lt;/h2&gt;

&lt;p&gt;Squirix already separates several admission policies. Preview.7 does not merge them; it makes one of them fairer under multi-tenant load.&lt;/p&gt;

&lt;p&gt;Transport limits still protect auth, payload size, and deadlines. Memory pressure still admits work based on estimated cache size. Journal disk quota still protects on-disk journal growth. Runtime backpressure protects concurrent cache operations, queues, slowdown, and optional rate limits.&lt;/p&gt;

&lt;p&gt;Runtime backpressure sits after validation and before memory admission. A rejection there happens before logical cache operations enter memory admission or clustered/local paths, so rejected work does not append journal records, mutate local memory, update memory accounting, or record idempotency outcomes.&lt;/p&gt;

&lt;p&gt;That is the same "fail closed before durability" idea as memory admission — applied to concurrency and rate, not to estimated bytes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Isolating callers by principal
&lt;/h2&gt;

&lt;p&gt;Global in-flight and queue limits protect the node. They do not stop one authenticated client from consuming the whole budget.&lt;/p&gt;

&lt;p&gt;Preview.7 resolves a &lt;strong&gt;backpressure client id&lt;/strong&gt; for each cache operation and applies per-client concurrency and rate limits against that id:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;request
  |
  +--&amp;gt; HttpContext missing? ----------- yes --&amp;gt; runtime
  |
  +--&amp;gt; authenticated subject? --------- yes --&amp;gt; jwt:{subject}
  |
  +--&amp;gt; connection id present? --------- yes --&amp;gt; conn:{connectionId}
  |
  +-------------------------------------------&amp;gt; runtime
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Authenticated external callers with a subject land in &lt;code&gt;jwt:{subject}&lt;/code&gt; (&lt;code&gt;NameIdentifier&lt;/code&gt;, then inbound &lt;code&gt;sub&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Anonymous or subject-less HTTP callers land in &lt;code&gt;conn:{connectionId}&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;In-process callers without an &lt;code&gt;HttpContext&lt;/code&gt; share one &lt;code&gt;runtime&lt;/code&gt; bucket.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;v0.1 external auth is JWT-only. Inter-node cluster forwarding uses mTLS on the internal listener and typically lands in the &lt;code&gt;conn:&lt;/code&gt; or &lt;code&gt;runtime&lt;/code&gt; bucket rather than a shared external JWT subject. That is deliberate: external tenant isolation should not treat owner-forwarded RPCs as one giant client.&lt;/p&gt;

&lt;p&gt;Anonymous loopback clients on distinct TCP connections also do not share one JWT principal bucket. Without a principal, connection identity is the best stable key the host has.&lt;/p&gt;

&lt;h2&gt;
  
  
  What preview.7 changes
&lt;/h2&gt;

&lt;p&gt;The pressure work in &lt;code&gt;0.1.0-preview.7&lt;/code&gt; includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;mapping the journal total-size hard cap to controlled durable write rejection (&lt;code&gt;JOURNAL_DISK_QUOTA&lt;/code&gt;);&lt;/li&gt;
&lt;li&gt;exposing soft high-water (80%) on readiness details without blocking writes that still fit;&lt;/li&gt;
&lt;li&gt;keeping readiness healthy under quota so pressure remains observable;&lt;/li&gt;
&lt;li&gt;keying per-client backpressure off JWT subject, then connection id, then the shared &lt;code&gt;runtime&lt;/code&gt; bucket;&lt;/li&gt;
&lt;li&gt;tightening path validation and symlink helpers, and clearing analyzer/Sonar debt on client and server hot paths.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Some of that is housekeeping. Most of it exists because awkward timing is not only about crashes. A full volume, a noisy neighbor, or a readiness flap at the wrong moment can erase the operational story as thoroughly as a torn journal frame.&lt;/p&gt;

&lt;h2&gt;
  
  
  What preview.7 does not promise
&lt;/h2&gt;

&lt;p&gt;This work makes overload and disk exhaustion easier to reason about, but Squirix is still early software.&lt;/p&gt;

&lt;p&gt;Quotas are per node and cover journal segments on that node; there is no cluster-wide disk accountant. In-process callers share &lt;code&gt;runtime&lt;/code&gt;, and internal mTLS paths are not the same as external JWT tenants. Quota buys a clear error and time to act; it is not a substitute for capacity planning, compaction, backups, or recovery testing.&lt;/p&gt;

&lt;p&gt;Preview.7 is stronger operator-facing pressure control on top of the preview.6 durability foundation, not a production-readiness declaration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing thought
&lt;/h2&gt;

&lt;p&gt;Durability work asks: what survives a crash? Pressure work asks: what happens when survival is no longer free?&lt;/p&gt;

&lt;p&gt;A journal that always accepts appends will eventually lose the ability to flush cleanly. A concurrency gate that treats every caller as interchangeable will eventually amplify a noisy neighbor into a whole-node outage.&lt;/p&gt;

&lt;p&gt;The difficult part is naming the refusal precisely:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;rejected before durability (journal quota and backpressure both fail closed early);&lt;/li&gt;
&lt;li&gt;rejected for this principal only when the signal is backpressure — journal disk quota remains node-wide;&lt;/li&gt;
&lt;li&gt;still ready enough to inspect;&lt;/li&gt;
&lt;li&gt;safe for clients to treat as definitive for that attempt.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Preview.7 makes those refusals more explicit. That gives operators a capacity policy instead of unexplained I/O chaos, and it gives future multi-tenant work a clearer place to hang fairness without rewriting the journal story.&lt;/p&gt;

&lt;p&gt;If you operate multi-tenant .NET services or care about cache overload semantics, I would be glad to hear where this model still surprises you. Feedback on quota versus readiness, JWT versus connection isolation, and client reactions to &lt;code&gt;JOURNAL_DISK_QUOTA&lt;/code&gt; is especially welcome.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/squirix/squirix" rel="noopener noreferrer"&gt;Squirix on GitHub&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/squirix/squirix/releases/tag/v0.1.0-preview.7" rel="noopener noreferrer"&gt;Release &lt;code&gt;v0.1.0-preview.7&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/squirix/squirix/blob/main/docs/release-notes/v0.1.0.md" rel="noopener noreferrer"&gt;Squirix 0.1.0 release notes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/__2d3e61e/what-happens-after-a-write-reworking-squirixs-wal-in-preview6-5ha4"&gt;What happens after a write? (preview.6)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/squirix/squirix/blob/main/docs/operational-runbook.md#journal-disk-quota" rel="noopener noreferrer"&gt;Journal disk quota runbook&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/squirix/squirix/blob/main/docs/configuration.md#backpressure" rel="noopener noreferrer"&gt;Backpressure configuration&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet add package squirix &lt;span class="nt"&gt;--version&lt;/span&gt; 0.1.0-preview.7
dotnet add package squirix.server &lt;span class="nt"&gt;--version&lt;/span&gt; 0.1.0-preview.7
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>dotnet</category>
      <category>distributedsystems</category>
      <category>csharp</category>
      <category>opensource</category>
    </item>
    <item>
      <title>What happens after a write? Reworking Squirix's WAL in preview.6</title>
      <dc:creator>Alex E</dc:creator>
      <pubDate>Sun, 19 Jul 2026 07:51:43 +0000</pubDate>
      <link>https://dev.to/alexander-efremov/what-happens-after-a-write-reworking-squirixs-wal-in-preview6-5ha4</link>
      <guid>https://dev.to/alexander-efremov/what-happens-after-a-write-reworking-squirixs-wal-in-preview6-5ha4</guid>
      <description>&lt;p&gt;In my&lt;br&gt;
&lt;a href="https://dev.to/__2d3e61e/why-squirix-uses-a-strict-clientserver-architecture-for-a-net-distributed-cache-5086"&gt;first Squirix article&lt;/a&gt;,&lt;br&gt;
I wrote about why Squirix keeps a strict boundary between the client and the server. Applications use a typed client&lt;br&gt;
over gRPC; the server owns cache state, routing, persistence, recovery, and operational endpoints.&lt;/p&gt;

&lt;p&gt;That boundary makes ownership clear. It does not answer the uncomfortable question underneath it: what exactly happens&lt;br&gt;
between accepting a write and telling the client it succeeded?&lt;/p&gt;

&lt;p&gt;Preview.6 goes one level deeper into that path. What if the process dies after writing to disk but before sending the&lt;br&gt;
response? What survives a torn journal tail? How can a client retry without applying the same mutation twice?&lt;/p&gt;

&lt;p&gt;Those questions shaped the release: a pipelined binary write-ahead log, tighter snapshot and journal recovery, and&lt;br&gt;
idempotent mutation outcomes that survive a restart.&lt;/p&gt;

&lt;p&gt;Squirix is still an experimental preview, not a production-ready cache. Its APIs and storage formats may change during&lt;br&gt;
&lt;code&gt;0.x&lt;/code&gt;. This article describes the current design and the reasoning behind it, not a compatibility promise.&lt;/p&gt;
&lt;h2&gt;
  
  
  Follow one write
&lt;/h2&gt;

&lt;p&gt;The basic ordering rule is simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Append the mutation to the journal before applying it in memory, and do not return success before the required&lt;br&gt;
durability boundary.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For a normal durable mutation, the path looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;validate and admit the mutation
            |
            v
append a binary WAL record
            |
            v
cross the configured durability boundary
            |
            v
apply the mutation in memory
            |
            v
return success
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The order is deliberate. If the server changed memory first and appended to the journal afterward, a crash in between&lt;br&gt;
could make an acknowledged write disappear after restart.&lt;/p&gt;

&lt;p&gt;Idempotent RPCs take one deliberate variation. Squirix appends the mutation, applies it in memory to produce the&lt;br&gt;
response, appends that response as an idempotency outcome, then waits for a single durability boundary covering both&lt;br&gt;
records. The response is not returned before that wait completes.&lt;/p&gt;

&lt;p&gt;The mutation and its retry result share one durability commit, without pretending the network response is atomic with&lt;br&gt;
the disk write.&lt;/p&gt;

&lt;p&gt;Journal-first ordering avoids acknowledged-but-unrecoverable writes, but it exposes a different case. Suppose the&lt;br&gt;
journal becomes durable and the process dies before the response reaches the client. Recovery may replay the mutation&lt;br&gt;
even though the client observed only a timeout or broken connection.&lt;/p&gt;

&lt;p&gt;That outcome is not an ordinary failure. It is &lt;strong&gt;commit unknown&lt;/strong&gt;: the operation may have committed, but the client did&lt;br&gt;
not receive a definitive answer.&lt;/p&gt;

&lt;p&gt;This is different from a rejection before the journal. If the memory-admission gate rejects a growing entry, Squirix&lt;br&gt;
has not appended anything and has not changed cache state. That rejection is definitive. Once durable bytes may exist,&lt;br&gt;
the answer has to be more careful.&lt;/p&gt;
&lt;h2&gt;
  
  
  Moving file I/O off request threads
&lt;/h2&gt;

&lt;p&gt;Preview.6 replaces the previous journal path with a pipelined binary WAL backend. The goal was not simply to make file&lt;br&gt;
writes faster. It was to give journal work one owner and make backpressure visible.&lt;/p&gt;

&lt;p&gt;Request threads do not independently open files, encode records, and call &lt;code&gt;fsync&lt;/code&gt;. They submit append work to a bounded&lt;br&gt;
ring. A dedicated background thread named &lt;code&gt;squirix-journal-io&lt;/code&gt; owns the journal event loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;request threads
   |    |    |
   v    v    v
+-------------------+
| bounded WAL ring  |
+-------------------+
          |
          v
+------------------------+
| single journal thread  |
+------------------------+
          |
          v
   binary WAL segments
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The journal thread drains accepted work, encodes and batches frames, writes segment bytes, services durability&lt;br&gt;
deadlines, performs flushes, and coordinates segment rolls.&lt;/p&gt;

&lt;p&gt;The queue is bounded because storage can always fall behind incoming traffic. An unbounded queue would turn that&lt;br&gt;
mismatch into unbounded memory growth; the ring turns it into visible backpressure instead.&lt;/p&gt;

&lt;p&gt;The single writer also gives segment state one clear owner. Offsets, batches, durability state, and roll decisions do&lt;br&gt;
not need to be coordinated among arbitrary request threads.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why the journal is binary
&lt;/h2&gt;

&lt;p&gt;The new WAL writes binary frames rather than passing persistence records through JSON text.&lt;/p&gt;

&lt;p&gt;That gives the storage path predictable record sizes, explicit field validation, CRC32C checksums, and span-based&lt;br&gt;
encoding for the hot parts of the format. It also makes versioning a property of the storage format rather than an&lt;br&gt;
accidental consequence of a general-purpose serializer.&lt;/p&gt;

&lt;p&gt;This is partly a performance choice, but the main benefit is control. Recovery needs to distinguish a complete record&lt;br&gt;
from arbitrary bytes at the end of a file. A framed binary format can say exactly how long a record is and whether its&lt;br&gt;
checksum is valid.&lt;/p&gt;

&lt;p&gt;The event loop also coalesces encoded records into write batches. Writing bytes and declaring those bytes durable are&lt;br&gt;
separate steps, which is where group commit enters the design.&lt;/p&gt;
&lt;h2&gt;
  
  
  Sharing an &lt;code&gt;fsync&lt;/code&gt; with group commit
&lt;/h2&gt;

&lt;p&gt;Flushing every mutation separately gives straightforward semantics, but it also makes each request pay the full&lt;br&gt;
durability cost.&lt;/p&gt;

&lt;p&gt;With group commit enabled, several appends can share one flush:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;append A ─┐
append B ─┼── write batch ── fsync ── complete A, B, and C
append C ─┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each waiter is completed only after a durability flush covers the relevant appends. The journal thread flushes the&lt;br&gt;
current write batch, performs the storage flush, and then completes the captured batch of waiters. If the flush fails,&lt;br&gt;
every waiter in that batch fails; no caller is told that partial durability was enough.&lt;/p&gt;

&lt;p&gt;The group is bounded by configuration—a maximum wait and a maximum batch size—trading a small, controlled delay for&lt;br&gt;
fewer durability flushes. Workloads that prefer the strictest per-write latency can disable group commit.&lt;/p&gt;

&lt;p&gt;The important contract is not merely that an &lt;code&gt;fsync&lt;/code&gt; happened somewhere. It is that a completed waiter is covered by&lt;br&gt;
that durability flush.&lt;/p&gt;
&lt;h2&gt;
  
  
  The response can still get lost
&lt;/h2&gt;

&lt;p&gt;Even with correct WAL ordering, the server cannot make the network response atomic with the disk write.&lt;/p&gt;

&lt;p&gt;The failure window is easiest to see as a short sequence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. append operation A
2. flush operation A
3. crash before the response reaches the client
4. recover operation A from the WAL
5. client retries operation A
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without a stable request identity, step 5 could apply the same logical mutation twice. The risk is greatest for&lt;br&gt;
mutations whose effect is not naturally idempotent.&lt;/p&gt;

&lt;p&gt;Squirix mutation RPCs therefore carry an opaque &lt;code&gt;operation_id&lt;/code&gt;. The server associates the ID with a request fingerprint&lt;br&gt;
and the serialized response. A retry with the same ID and fingerprint can return the original result. Reusing the ID&lt;br&gt;
for a different request is rejected.&lt;/p&gt;

&lt;p&gt;In preview.6, that identity survives restart. Recovery rebuilds successful durable outcomes from operation metadata in&lt;br&gt;
the journal and from retained idempotency records in snapshots. The operation ID is also preserved when a receiving&lt;br&gt;
node forwards a mutation to the key owner.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;client
  |
  | operation_id = 4f...
  v
receiving node
  |
  | same operation_id
  v
owner node
  |
  +--&amp;gt; durable mutation record
  |
  +--&amp;gt; durable outcome metadata
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is not a distributed transaction, and I will not present it as one. It gives a supported mutation a stable identity&lt;br&gt;
across forwarding, timeouts, retries, and restarts—exactly the situations where commit unknown becomes operationally&lt;br&gt;
important.&lt;/p&gt;
&lt;h2&gt;
  
  
  Recovery starts by distrusting the files
&lt;/h2&gt;

&lt;p&gt;A snapshot is not valid merely because a file with the right name exists. Squirix reads and validates the complete&lt;br&gt;
snapshot into a temporary load result before applying its entries. The startup gate remains closed until snapshot&lt;br&gt;
restore and journal replay have both completed.&lt;/p&gt;

&lt;p&gt;The current recovery flow is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Read the manifest.&lt;/li&gt;
&lt;li&gt;Locate the referenced snapshot.&lt;/li&gt;
&lt;li&gt;Decode cache entries and retained idempotency records into a temporary load result.&lt;/li&gt;
&lt;li&gt;Validate the complete snapshot.&lt;/li&gt;
&lt;li&gt;Apply the validated snapshot state.&lt;/li&gt;
&lt;li&gt;Replay journal records after the snapshot watermark.&lt;/li&gt;
&lt;li&gt;Open the startup gate.
&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;manifest
   |
   v
snapshot ── validate all frames ── apply recovered state
   |
   v
last applied sequence N
   |
   v
replay WAL records where sequence &amp;gt; N
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The watermark matters. A snapshot represents state through a particular journal sequence. Replaying records at or&lt;br&gt;
before that sequence would apply mutations twice; skipping later records would lose committed state.&lt;/p&gt;

&lt;p&gt;Preview.6 freezes the metadata that describes the snapshot cut so that the entries and their sequence boundary refer&lt;br&gt;
to the same logical point.&lt;/p&gt;

&lt;p&gt;Snapshot publication follows the usual temporary-file pattern: write and flush a temporary file, publish the completed&lt;br&gt;
snapshot, and only then update the manifest reference. A crash during the temporary write must not make an incomplete&lt;br&gt;
snapshot authoritative.&lt;/p&gt;

&lt;p&gt;If the referenced snapshot is missing, unreadable, truncated, or fails checksum validation, Squirix discards all&lt;br&gt;
snapshot-derived state. It falls back to journal-only recovery only when the required journal history is still&lt;br&gt;
available. Otherwise recovery fails instead of guessing across a gap. Preview.6 also fixes the valid fallback path so&lt;br&gt;
replay starts from the earliest available segment when there is no snapshot watermark.&lt;/p&gt;
&lt;h2&gt;
  
  
  A torn tail does not erase the valid prefix
&lt;/h2&gt;

&lt;p&gt;A process can stop halfway through the final WAL frame. Recovery must not interpret those bytes as a complete mutation,&lt;br&gt;
but it should not discard every earlier record either.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[valid frame][valid frame][valid frame][partial bytes...]
                                      ^
                               recovery stops here
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Journal replay proceeds through complete, validated records in segment order. During startup, Squirix scans the active&lt;br&gt;
segment to the last valid frame boundary and truncates an incomplete tail before reopening it for writes.&lt;/p&gt;

&lt;p&gt;This is intentionally conservative:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;never invent a record from partial bytes;&lt;/li&gt;
&lt;li&gt;never replay beyond an invalid boundary;&lt;/li&gt;
&lt;li&gt;preserve the valid committed prefix.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The same principle applies to snapshots: do not start applying entries until the complete file has been decoded and&lt;br&gt;
validated. A clean journal fallback is easier to reason about than trusting a partly decoded snapshot.&lt;/p&gt;
&lt;h2&gt;
  
  
  Compaction must preserve retry semantics
&lt;/h2&gt;

&lt;p&gt;Compaction cannot keep only the latest key/value state.&lt;/p&gt;

&lt;p&gt;Imagine that operation A committed, its result was retained for deduplication, and old journal segments were then&lt;br&gt;
compacted away. If the snapshot contained cache values but not the idempotency record, a delayed retry of A could lose&lt;br&gt;
its original identity and execute again.&lt;/p&gt;

&lt;p&gt;Snapshots therefore include retained idempotency outcomes alongside cache entries. After compaction and restart,&lt;br&gt;
Squirix needs to recover both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cache state
+
retry identity and durable outcomes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Durability is not only the ability to reconstruct values. It also means reconstructing enough protocol state to handle&lt;br&gt;
uncertain retries safely.&lt;/p&gt;

&lt;h2&gt;
  
  
  What preview.6 changes
&lt;/h2&gt;

&lt;p&gt;The durability work in &lt;code&gt;0.1.0-preview.6&lt;/code&gt; includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a pipelined binary WAL;&lt;/li&gt;
&lt;li&gt;a dedicated single-writer journal event loop;&lt;/li&gt;
&lt;li&gt;bounded append backpressure;&lt;/li&gt;
&lt;li&gt;configurable group commit;&lt;/li&gt;
&lt;li&gt;journal-only recovery from the earliest available segment when the journal topology is valid;&lt;/li&gt;
&lt;li&gt;a stable snapshot cut and replay watermark;&lt;/li&gt;
&lt;li&gt;durable operation outcomes across restart;&lt;/li&gt;
&lt;li&gt;operation-ID propagation between nodes;&lt;/li&gt;
&lt;li&gt;retained idempotency records in snapshots;&lt;/li&gt;
&lt;li&gt;compaction cancellation during shutdown;&lt;/li&gt;
&lt;li&gt;lower allocation pressure on WAL hot paths.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Some changes improve throughput. Most exist because of awkward timing: a crash after a journal flush, a lost response,&lt;br&gt;
a truncated final frame, compaction followed by an old retry, or shutdown while persistence work is active.&lt;/p&gt;

&lt;p&gt;That is where durability engineering lives. The happy path is necessary, but it is rarely the part that keeps me&lt;br&gt;
thinking after the code is written.&lt;/p&gt;

&lt;h2&gt;
  
  
  What preview.6 does not promise
&lt;/h2&gt;

&lt;p&gt;This work makes the durability model easier to reason about, but Squirix is still early software.&lt;/p&gt;

&lt;p&gt;The on-disk format may change during &lt;code&gt;0.x&lt;/code&gt;. Durability is per node; preview.6 does not add replication or automatic&lt;br&gt;
failover. Operations spanning multiple owners are not globally atomic transactions. Serializer compatibility and&lt;br&gt;
restart behavior still need to be validated for the mutation paths a workload depends on.&lt;/p&gt;

&lt;p&gt;A WAL is not a substitute for replication, backups, or recovery testing. Preview.6 is a stronger durability foundation,&lt;br&gt;
not a production-readiness declaration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing thought
&lt;/h2&gt;

&lt;p&gt;Appending bytes is the easy part of a write-ahead log. Deciding what those bytes mean after a crash is the real work.&lt;/p&gt;

&lt;p&gt;The difficult part is naming every boundary precisely:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;accepted;&lt;/li&gt;
&lt;li&gt;appended;&lt;/li&gt;
&lt;li&gt;flushed;&lt;/li&gt;
&lt;li&gt;applied in memory;&lt;/li&gt;
&lt;li&gt;visible to the client;&lt;/li&gt;
&lt;li&gt;recoverable after restart;&lt;/li&gt;
&lt;li&gt;safe to retry.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Preview.6 makes those boundaries more explicit. That gives future work—replication, failover, and more advanced cluster&lt;br&gt;
behavior—a durability model it can build on instead of work around.&lt;/p&gt;

&lt;p&gt;If you work on storage engines or distributed .NET systems, I would be glad to hear where you think the remaining sharp&lt;br&gt;
edges are. The project is open source, and feedback on the WAL, recovery semantics, and retry model is especially&lt;br&gt;
welcome.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/squirix/squirix" rel="noopener noreferrer"&gt;Squirix on GitHub&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/squirix/squirix/blob/main/docs/release-notes/v0.1.0.md" rel="noopener noreferrer"&gt;Squirix 0.1.0 release notes&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>distributedsystems</category>
      <category>squirix</category>
    </item>
    <item>
      <title>Why Squirix uses a strict client/server architecture for a .NET distributed cache</title>
      <dc:creator>Alex E</dc:creator>
      <pubDate>Sun, 07 Jun 2026 07:12:52 +0000</pubDate>
      <link>https://dev.to/alexander-efremov/why-squirix-uses-a-strict-clientserver-architecture-for-a-net-distributed-cache-5086</link>
      <guid>https://dev.to/alexander-efremov/why-squirix-uses-a-strict-clientserver-architecture-for-a-net-distributed-cache-5086</guid>
      <description>&lt;p&gt;Squirix 0.1.0 is an early preview of a .NET distributed cache. A typed client SDK talks to a remote server over gRPC; the server owns state, routing, durability, and operational endpoints.&lt;/p&gt;

&lt;p&gt;This is the direction I am validating in 0.1.0 — not a claim that every cache must work this way. Embedded designs are fine for many workloads. Squirix targets a different shape: &lt;strong&gt;the application stays a client; the server owns the data lifecycle.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem with "just a cache library"
&lt;/h2&gt;

&lt;p&gt;A cache library is simple until you ask who owns what. When cache logic runs inside your app process, state, memory pressure, and persistence share the app's lifecycle. That works for local acceleration (&lt;code&gt;IMemoryCache&lt;/code&gt;), but gets ambiguous when you need shared state across instances, durability across restarts, independent health/metrics, or cluster routing.&lt;/p&gt;

&lt;p&gt;At that point you often have an implicit server with unclear boundaries. Squirix makes the split explicit from day one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Embedded mode vs client/server mode
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Embedded&lt;/strong&gt; — cache logic in or tightly coupled to the app process: in-memory caches, libraries that hide remote I/O, or a co-located server called via direct references. Low friction; you rarely expect separate health probes or journal compaction on "just a dependency."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Client/server&lt;/strong&gt; — the app holds no authoritative state. It connects over a wire contract; the server owns placement, mutations, durability, recovery, and admin/metrics endpoints. Redis and most production distributed caches follow this shape.&lt;/p&gt;

&lt;p&gt;Squirix 0.1.0 is client/server first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;application -&amp;gt; Squirix client SDK -&amp;gt; Squirix.Server node(s)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two packages, enforced at build time: &lt;a href="https://www.nuget.org/packages/squirix/" rel="noopener noreferrer"&gt;&lt;code&gt;squirix&lt;/code&gt;&lt;/a&gt; (client) and &lt;code&gt;squirix.server&lt;/code&gt; (runtime). The server does not reference the client assembly.&lt;/p&gt;

&lt;p&gt;You can embed the server in ASP.NET Core via &lt;code&gt;AddSquirixServer&lt;/code&gt; / &lt;code&gt;MapSquirixServer&lt;/code&gt;, but application access still goes through &lt;code&gt;SquirixClient.ConnectAsync(...)&lt;/code&gt; — even in the same process. That is hosting convenience, not embedded cache semantics in the app layer.&lt;/p&gt;

&lt;p&gt;Embedded mode is not bad — it optimizes for different goals. Squirix targets shared remote state, server-owned durability, and operability as infrastructure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Squirix chooses client/server first
&lt;/h2&gt;

&lt;p&gt;Reasoning behind the 0.1.0 shape:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Operational boundary&lt;/strong&gt; — deploy, probe, and upgrade cache nodes independently. Health (&lt;code&gt;/health/live&lt;/code&gt;, &lt;code&gt;/health/ready&lt;/code&gt;), admin (&lt;code&gt;/admin/whoami&lt;/code&gt;, &lt;code&gt;/admin/ring&lt;/code&gt;), and Prometheus at &lt;code&gt;/metrics&lt;/code&gt; live on the server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Server-owned lifecycle&lt;/strong&gt; — WAL journal, snapshots, compaction, and recovery stay in &lt;code&gt;squirix.server&lt;/code&gt;, not in every application.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lighter client package&lt;/strong&gt; — run the server as its own process or container; apps only reference the client SDK.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clustering path&lt;/strong&gt; — static consistent-hash routing in 0.1.0 is early, but routing and failover can evolve server-side without rewriting &lt;code&gt;ICache&amp;lt;T&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Durability isolation&lt;/strong&gt; — recovery and compaction failures stay out of application request threads while semantics harden.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How this affects the public API
&lt;/h2&gt;

&lt;p&gt;You connect via &lt;code&gt;SquirixClient.ConnectAsync(...)&lt;/code&gt; and work with typed &lt;code&gt;ICache&amp;lt;T&amp;gt;&lt;/code&gt; and explicit read results (&lt;code&gt;CacheValueResult&amp;lt;T&amp;gt;&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;System.Threading&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Squirix&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;None&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;SquirixClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ConnectAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"http://localhost:5001"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetCacheAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"demo"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SetAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"greeting"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"hello"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;lookup&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetValueAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"greeting"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lookup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Found&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;Console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lookup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Production server hosting uses &lt;code&gt;AddSquirixServer&lt;/code&gt; / &lt;code&gt;MapSquirixServer&lt;/code&gt;. Transport is &lt;strong&gt;gRPC&lt;/strong&gt; (&lt;code&gt;SquirixCache.proto&lt;/code&gt;); cache operations, health, and admin routes are also available over &lt;strong&gt;HTTP/2 REST&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What exists in Squirix 0.1.0
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;0.1.0-preview.1&lt;/code&gt;&lt;/strong&gt;, .NET 10 only:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Client/server split (&lt;code&gt;squirix&lt;/code&gt; + &lt;code&gt;squirix.server&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Typed &lt;code&gt;ICache&amp;lt;T&amp;gt;&lt;/code&gt; — basic KV + expiration&lt;/li&gt;
&lt;li&gt;gRPC + HTTP/2 REST cache endpoints&lt;/li&gt;
&lt;li&gt;Per-node journal, snapshots, compaction, recovery&lt;/li&gt;
&lt;li&gt;Health, readiness, admin routes; Prometheus metrics; OpenTelemetry journal tracing&lt;/li&gt;
&lt;li&gt;Static consistent-hash single-owner routing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For local h2c dev: &lt;code&gt;$env:DOTNET_SYSTEM_NET_HTTP_SOCKETSHTTPHANDLER_HTTP2UNENCRYPTEDSUPPORT = "1"&lt;/code&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What is still experimental
&lt;/h2&gt;

&lt;p&gt;Early preview — &lt;strong&gt;not production-ready&lt;/strong&gt;. API, wire format, and on-disk layouts may change during 0.x.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/squirix/squirix" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · &lt;a href="https://www.nuget.org/packages/squirix/" rel="noopener noreferrer"&gt;NuGet&lt;/a&gt; · &lt;a href="https://github.com/squirix/squirix/blob/main/docs/release-notes/v0.1.0.md" rel="noopener noreferrer"&gt;Release notes&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/squirix/squirix/blob/main/docs/architecture.md" rel="noopener noreferrer"&gt;Architecture&lt;/a&gt; · &lt;a href="https://github.com/squirix/squirix/blob/main/docs/server-mode.md" rel="noopener noreferrer"&gt;Server mode&lt;/a&gt; · &lt;a href="https://github.com/squirix/squirix/blob/main/docs/diagnostics.md" rel="noopener noreferrer"&gt;Diagnostics&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>distributedsystems</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
