<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://alex-carvalho.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://alex-carvalho.github.io/" rel="alternate" type="text/html" /><updated>2026-07-21T20:40:02+00:00</updated><id>https://alex-carvalho.github.io/feed.xml</id><title type="html">Alex Carvalho</title><subtitle>A space where I share notes and insights on the subjects I&apos;m studying.</subtitle><author><name>Alex Carvalho</name></author><entry><title type="html">Understanding Grafana Loki: Labels, Streams, and Chunks</title><link href="https://alex-carvalho.github.io/2026/07/21/understanding-grafana-loki-labels-streams-chunks.html" rel="alternate" type="text/html" title="Understanding Grafana Loki: Labels, Streams, and Chunks" /><published>2026-07-21T00:00:00+00:00</published><updated>2026-07-21T00:00:00+00:00</updated><id>https://alex-carvalho.github.io/2026/07/21/understanding-grafana-loki-labels-streams-chunks</id><content type="html" xml:base="https://alex-carvalho.github.io/2026/07/21/understanding-grafana-loki-labels-streams-chunks.html"><![CDATA[<p>This is the first post in a series where I am studying Grafana Loki’s architecture from the ground up: why it exists, how logs move through the system, how the ring distributes work, how storage is organized, and how queries run without indexing every log line.</p>

<p>The first idea that changes how I think about Loki is also the easiest one to misunderstand: Loki does not build a full-text index of every log line.</p>

<p>That sounds limiting at first. Logs are text, so it feels natural to index the text. But Loki is built around a different tradeoff. It indexes labels, stores log lines in compressed chunks, and scans those chunks at query time after the labels have narrowed the search space.</p>

<p>The core rule is:</p>

<blockquote>
  <p>Use labels to find the right streams; scan chunks to find the matching lines.</p>
</blockquote>

<p>This is the same reason Loki often feels closer to Prometheus than to a traditional search engine. The index is not a giant map from every word to every log line. It is a smaller structure that helps Loki answer a different question first: which streams could contain the logs I care about?</p>

<h2 id="the-logging-problem-at-scale">The logging problem at scale</h2>

<p>Logs grow quickly because they are produced by almost everything:</p>

<ul>
  <li>Every service instance</li>
  <li>Every request path</li>
  <li>Every background job</li>
  <li>Every dependency call</li>
  <li>Every retry, timeout, warning, and error</li>
</ul>

<p>At small scale, indexing all of that text can feel convenient. You send logs to a search system, type a word, and expect matching lines to appear.</p>

<p>At larger scale, the convenience becomes expensive. A full-text log index has to process, tokenize, index, and retain a searchable representation of the log contents. That means more CPU during ingestion, more storage for the index, more background compaction, and more operational work to keep the cluster healthy.</p>

<p>The difficult part is that a lot of log data is rarely queried. Many logs are useful because they exist when something goes wrong, not because every word in them needs to be indexed forever.</p>

<p>Loki starts from that observation. Instead of treating logs as documents that must be fully indexed, it treats them as streams that can be selected by metadata.</p>

<h2 id="why-elasticsearch-is-not-always-the-right-fit">Why Elasticsearch is not always the right fit</h2>

<p>Elasticsearch is powerful when the main requirement is flexible search over document contents. If I need rich text search, relevance scoring, complex inverted indexes, and exploratory queries across arbitrary fields, that model makes sense.</p>

<p>But operational logs often have a different access pattern.</p>

<p>Usually I start with context:</p>

<ul>
  <li>Which application?</li>
  <li>Which namespace?</li>
  <li>Which environment?</li>
  <li>Which cluster?</li>
  <li>Which container?</li>
  <li>What time range?</li>
</ul>

<p>Only after that do I search for a specific word, error message, trace ID, or JSON field.</p>

<p>In other words, I rarely need to ask, “where did this word appear across every log ever stored?” More often I ask, “inside this service, during this time window, which lines mention this failure?”</p>

<p>Loki is optimized for that second question. It avoids the cost of indexing every log line by relying on labels to reduce the amount of data that must be scanned.</p>

<h2 id="labels-first">Labels first</h2>

<p>The most important Loki concept is the label set. A label is a key-value pair that describes where a log came from or what stable context it belongs to.</p>

<p>Examples:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>app="checkout"
namespace="production"
cluster="us-east-1"
container="api"
</code></pre></div></div>

<p>A Loki query begins with a label selector:</p>

<pre><code class="language-logql">{app="checkout", namespace="production"}
</code></pre>

<p>That selector does not search the log text. It selects streams whose labels match the query. After Loki has found candidate streams and chunks for the requested time range, the rest of the LogQL pipeline can filter the actual log lines:</p>

<pre><code class="language-logql">{app="checkout", namespace="production"} |= "payment failed"
</code></pre>

<p>The label selector is the narrowing step. The text filter is the scanning step.</p>

<p>That distinction is the center of Loki’s design.</p>

<h2 id="streams">Streams</h2>

<p>A stream is the sequence of log entries that share the exact same label set.</p>

<p>These two entries belong to the same stream if their labels are identical:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>{app="checkout", namespace="production", container="api"}
</code></pre></div></div>

<p>If one label value changes, Loki sees a different stream:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>{app="checkout", namespace="production", container="worker"}
</code></pre></div></div>

<p>This is useful because logs from the same source usually arrive together and are queried together. Loki can group them into chunks and index the metadata for those chunks instead of indexing every individual line.</p>

<p>The stream model also explains why labels must be chosen carefully. Labels define how many streams Loki has to manage.</p>

<h2 id="chunks">Chunks</h2>

<p>Loki stores the actual log lines in chunks. A chunk contains log entries for a stream over a time range. It is compressed and eventually flushed to storage.</p>

<p>A simplified way to see it is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>stream labels
    -&gt; chunk from 10:00 to 10:15
    -&gt; chunk from 10:15 to 10:30
    -&gt; chunk from 10:30 to 10:45
</code></pre></div></div>

<p>The index can tell Loki which chunks might matter for a query. The chunk contains the real log content.</p>

<p>This is different from a full-text search system. A full-text index tries to answer “which documents contain this term?” directly from the index. Loki’s index answers “which chunks belong to streams matching these labels and this time range?” Then Loki reads and filters those chunks.</p>

<p>The benefit is that the index can stay much smaller. The cost is that queries may need to scan chunk data, especially if the label selector is broad.</p>

<h2 id="object-storage">Object storage</h2>

<p>Loki is designed to use object storage as the long-term place for chunks and, in modern single-store setups, index data too. The Grafana documentation describes Loki storage around a small index plus compressed chunks in object stores such as S3.</p>

<p>This matters because object storage changes the cost model. Instead of keeping a large search index on expensive local disks across many indexing nodes, Loki can store compressed log data in a cheaper backend and scale the read path when queries need to inspect it.</p>

<p>That is one reason the design is attractive for large volumes of logs. Loki does not make querying free, but it tries to make storing logs less expensive and simpler to operate.</p>

<h2 id="cardinality-is-the-trap">Cardinality is the trap</h2>

<p>The label model only works if labels stay low-cardinality.</p>

<p>Cardinality means the number of distinct values a label can have. A label like <code class="language-plaintext highlighter-rouge">environment</code> is usually low-cardinality:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>environment="dev"
environment="staging"
environment="production"
</code></pre></div></div>

<p>A label like <code class="language-plaintext highlighter-rouge">trace_id</code> is high-cardinality:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>trace_id="0242ac120002"
trace_id="ae91bc78f1d4"
trace_id="f0c41e9b77aa"
</code></pre></div></div>

<p>Every unique label set creates a separate stream. If a value changes for every request, then Loki has to manage a huge number of short-lived streams. That creates a bigger index, more tiny chunks, more flushing, and worse query behavior.</p>

<p>Good labels usually describe stable origin or routing context:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">app</code></li>
  <li><code class="language-plaintext highlighter-rouge">namespace</code></li>
  <li><code class="language-plaintext highlighter-rouge">cluster</code></li>
  <li><code class="language-plaintext highlighter-rouge">environment</code></li>
  <li><code class="language-plaintext highlighter-rouge">container</code></li>
</ul>

<p>Bad labels usually describe individual events:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">request_id</code></li>
  <li><code class="language-plaintext highlighter-rouge">trace_id</code></li>
  <li><code class="language-plaintext highlighter-rouge">user_id</code></li>
  <li><code class="language-plaintext highlighter-rouge">order_id</code></li>
  <li><code class="language-plaintext highlighter-rouge">timestamp</code></li>
  <li>Raw URL paths with unbounded IDs</li>
</ul>

<p>This is the part of Loki that feels simple but has deep operational consequences. Loki is not saying metadata does not matter. It is saying that indexed labels should be metadata with controlled cardinality.</p>

<h2 id="structured-metadata">Structured metadata</h2>

<p>The uncomfortable question is: what should I do with fields that are useful but too high-cardinality for labels?</p>

<p>For example, trace IDs are extremely useful when connecting logs to traces. But using <code class="language-plaintext highlighter-rouge">trace_id</code> as a label would create many streams, often one per request.</p>

<p>Structured metadata is Loki’s answer for some of this middle ground. It lets metadata travel with the log entry without becoming part of the indexed label set. The data can still be used in queries, but it does not expand the index the same way a label would.</p>

<p>A useful mental model is:</p>

<ul>
  <li>Labels decide where Loki should look first.</li>
  <li>Structured metadata gives extra context after Loki has found candidate log entries.</li>
  <li>Log line filters inspect the text itself.</li>
</ul>

<p>I will explore more about it in a future post.</p>

<h2 id="why-this-design-works">Why this design works</h2>

<p>Loki’s architecture makes more sense when I separate storage cost from query cost.</p>

<p>Full-text indexing pays more during ingestion and storage so arbitrary text searches can be faster later. Loki pays less during ingestion and storage by keeping a smaller index, then accepts that some queries will scan compressed chunks.</p>

<p>That is a good tradeoff when:</p>

<ul>
  <li>Logs are very large in volume.</li>
  <li>Most queries start with service, namespace, environment, or cluster.</li>
  <li>The team can control label cardinality.</li>
  <li>Object storage is the desired long-term backend.</li>
  <li>The system is used for observability workflows, not general document search.</li>
</ul>

<p>It is a worse tradeoff when:</p>

<ul>
  <li>Users expect arbitrary full-text search across everything.</li>
  <li>Label selection is too broad.</li>
  <li>High-cardinality values are accidentally promoted to labels.</li>
  <li>The system produces many short-lived streams.</li>
  <li>Queries frequently need needle-in-a-haystack searches over huge time ranges.</li>
</ul>

<p>Loki is not magic. It is a set of constraints. When the constraints match the workload, the design is elegant. When they are ignored, the same design can become painful.</p>

<h2 id="the-mental-model">The mental model</h2>

<p>The best simplified model I have found is this:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>labels + time range
    -&gt; find streams
    -&gt; find chunk references
    -&gt; read compressed chunks
    -&gt; filter log lines
    -&gt; return matches
</code></pre></div></div>

<p>The index is there to avoid reading everything. It is not there to answer every possible text question by itself.</p>

<p>This is why Loki’s label design matters so much. Labels are the map. Chunks are the archive. LogQL is the tool that narrows, scans, parses, and aggregates.</p>

<h2 id="final-thought">Final thought</h2>

<p>Loki does not index log contents because it is choosing a different center of gravity. It assumes that logs are usually searched with context first and text second.</p>

<p>That choice gives Loki its shape: labels, streams, chunks, object storage, and query-time scanning. Once that tradeoff is clear, the rest of the architecture starts to feel less surprising.</p>

<p>The next question is what happens when a log actually arrives. That path goes through distributors, the ring, replication, ingesters, the WAL, chunks, and finally object storage.</p>

<h2 id="references">References</h2>

<ul>
  <li><a href="https://grafana.com/docs/loki/latest/get-started/">Grafana Loki documentation: Get started</a></li>
  <li><a href="https://grafana.com/docs/loki/latest/configure/storage/">Grafana Loki documentation: Storage</a></li>
  <li><a href="https://grafana.com/docs/loki/latest/get-started/labels/cardinality/">Grafana Loki documentation: Cardinality</a></li>
  <li><a href="https://grafana.com/docs/loki/latest/get-started/labels/structured-metadata/">Grafana Loki documentation: Structured metadata</a></li>
</ul>]]></content><author><name>Alex Carvalho</name></author><category term="Observability" /><category term="Grafana Loki" /><category term="Logging" /><summary type="html"><![CDATA[This is the first post in a series where I am studying Grafana Loki’s architecture from the ground up: why it exists, how logs move through the system, how the ring distributes work, how storage is organized, and how queries run without indexing every log line.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://alex-carvalho.github.io/assets/images/loki/loki-labels-streams-chunks-cover.png" /><media:content medium="image" url="https://alex-carvalho.github.io/assets/images/loki/loki-labels-streams-chunks-cover.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Understanding the Gossip Protocol</title><link href="https://alex-carvalho.github.io/2026/07/13/understanding-gossip-protocol.html" rel="alternate" type="text/html" title="Understanding the Gossip Protocol" /><published>2026-07-13T00:00:00+00:00</published><updated>2026-07-13T00:00:00+00:00</updated><id>https://alex-carvalho.github.io/2026/07/13/understanding-gossip-protocol</id><content type="html" xml:base="https://alex-carvalho.github.io/2026/07/13/understanding-gossip-protocol.html"><![CDATA[<p>I have been studying how distributed systems keep track of their members, and the Gossip Protocol is one of those ideas that explains how a cluster can share information without placing a central coordinator in the middle of every decision.</p>

<p>The core rule is:</p>

<blockquote>
  <p>Each node periodically tells a few randomly chosen peers what it knows, and those peers repeat the process.</p>
</blockquote>

<p>No single message reaches the entire cluster. Instead, information spreads over several rounds, much like a rumour. Given enough exchanges, the nodes converge on a similar view of the world.</p>

<p>Gossip is commonly used for information that can be <strong>eventually consistent</strong>: cluster membership, health observations, service metadata, configuration hints, or other state where a brief delay between nodes is acceptable. It is not a consensus protocol. It does not make every node agree at the same instant, nor does it decide the order of critical operations.</p>

<h2 id="why-a-cluster-needs-gossip">Why a cluster needs gossip</h2>

<p>Imagine a cluster with hundreds of nodes. A central registry can tell every member about a join, a departure, or a failure, but that registry becomes an important dependency. It must be reachable, it must handle all update traffic, and it must be highly available if the rest of the system depends on it.</p>

<p>Gossip distributes both the work and the knowledge. Each node only communicates with a few peers during a round, rather than broadcasting directly to every other member. Nodes also relay information they learned from somebody else, so a new update can move through the cluster even if its original sender does not contact everyone itself.</p>

<p>This makes gossip useful when the cluster should continue operating through the loss of an individual node or a central control-plane component. A node still needs an initial contact point—a seed peer—to enter the group, but that seed is an entry point rather than the permanent owner of membership.</p>

<h2 id="how-information-spreads">How information spreads</h2>

<p>A simplified gossip round looks like this:</p>

<ol>
  <li>A node observes or creates an update, such as “node C joined” or “the version of this metadata is now 8.”</li>
  <li>It selects one or more peers, usually at random.</li>
  <li>It sends the update, or a compact summary of the updates it knows.</li>
  <li>Recipients merge any information that is newer than their local state.</li>
  <li>Those recipients choose peers in later rounds and relay the update further.</li>
</ol>

<p>The random selection is important. Repeated random exchanges spread information widely without requiring every node to maintain a full broadcast schedule. Many implementations use a push model, where a node sends updates; a pull model, where it asks peers for missing state; or a push-pull combination.</p>

<h2 id="the-membership-view-is-local">The membership view is local</h2>

<p>A gossip-based membership system does not usually have one authoritative table that every process reads from. Each process keeps its own local view and updates it as messages arrive.</p>

<p>For a short time, node A may know about a join or a failure that node B has not heard about yet. That is not necessarily an error; it is the expected consequence of eventual consistency. The protocol is designed so that the difference disappears as updates are relayed.</p>

<p>To make merging deterministic, an update needs a version. A simple representation is:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">type</span> <span class="n">Version</span> <span class="k">struct</span> <span class="p">{</span>
    <span class="n">Epoch</span>     <span class="kt">int64</span>
    <span class="n">Heartbeat</span> <span class="kt">uint64</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The exact format varies, but the rule is the same: a receiver accepts a record only if it is newer than what it already knows. A heartbeat can advance while a process is alive. An epoch, incarnation number, or generation lets a restarted process begin a fresh sequence without being mistaken for an old message.</p>

<p>For example, an implementation might order versions like this:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>incoming epoch &gt; local epoch
or
incoming epoch == local epoch and incoming heartbeat &gt; local heartbeat
</code></pre></div></div>

<p>This protects the cluster from accepting stale state after messages are delayed, duplicated, or received out of order. A new epoch wins even when its heartbeat starts again at <code class="language-plaintext highlighter-rouge">1</code>.</p>

<h2 id="failure-detection-is-a-suspicion-not-a-fact">Failure detection is a suspicion, not a fact</h2>

<p>Gossip is often paired with failure detection, but they are separate concerns. Gossip spreads the observation; failure detection decides when there is enough evidence to treat a peer as unavailable.</p>

<p>One simple approach is to track when a peer last sent newer state:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>no newer heartbeat for longer than the timeout → suspect the peer
</code></pre></div></div>

<p>The word <em>suspect</em> matters. A node may be slow, temporarily unreachable, partitioned from the observer, or affected by packet loss. None of those conditions proves that its process has stopped. More complete membership protocols therefore use suspicion periods, indirect checks, and refutation mechanisms before spreading a final failure state.</p>

<p>Timeouts are a real tradeoff:</p>
<ul>
  <li>A shorter timeout reacts quickly but increases false positives.</li>
  <li>A longer timeout is more conservative but leaves failed members visible for longer.</li>
  <li>A network partition can make two healthy groups each conclude that the other group is unavailable.</li>
</ul>

<p>The important lesson is that failure detection describes an observer’s view of the network, not an absolute fact about another machine.</p>

<h2 id="what-gossip-gives-us">What gossip gives us</h2>

<p>Gossip is a good fit when a system values availability, decentralisation, and scalable dissemination.</p>

<ul>
  <li><strong>No central dissemination bottleneck:</strong> each member helps distribute updates.</li>
  <li><strong>Resilience to individual failures:</strong> the loss of one node does not prevent other nodes from sharing information.</li>
  <li><strong>Scalable communication pattern:</strong> a node talks to only a small subset of the cluster in each round.</li>
  <li><strong>Simple membership growth:</strong> a new member can join through a seed peer and learn about the rest of the cluster over time.</li>
  <li><strong>Natural fit for soft state:</strong> information such as membership and health can tolerate brief disagreement while it converges.</li>
</ul>

<p>These properties are why gossip appears in systems such as <a href="https://cassandra.apache.org/doc/latest/cassandra/architecture/gossip.html">Apache Cassandra</a>, which uses it to share cluster state, and in HashiCorp’s <a href="https://github.com/hashicorp/memberlist">Memberlist</a>, a library for cluster membership and failure detection.</p>

<h2 id="the-costs-and-limitations">The costs and limitations</h2>

<p>Gossip avoids a central bottleneck by accepting different costs. It is not the right primitive for every distributed-systems problem.</p>

<ul>
  <li><strong>Eventual, not immediate, consistency:</strong> different nodes can briefly hold different views of the cluster.</li>
  <li><strong>Network overhead:</strong> nodes keep sending background messages even when little state changes.</li>
  <li><strong>Convergence time:</strong> an update needs several rounds to reach most or all members.</li>
  <li><strong>False failure reports:</strong> timeouts and partitions can make healthy nodes look unavailable.</li>
  <li><strong>Payload management:</strong> naively sending the full membership table becomes expensive as the cluster grows.</li>
  <li><strong>No agreement on critical decisions:</strong> gossip cannot safely replace consensus when the system needs one ordered, durable answer—such as electing a leader, committing a transaction, or issuing a unique lock.</li>
</ul>

<p>Good implementations control these costs with bounded fan-out, compact state, retransmission limits, failure-suspicion rules, and tuning for the network they operate in. The details differ by system, but the central balance is consistent: faster dissemination costs more traffic; more conservative detection reduces false positives but delays reaction.</p>

<h2 id="a-small-go-proof-of-concept">A small Go proof of concept</h2>

<p>To make the protocol concrete, I built a <a href="https://github.com/alex-carvalho/sandbox/tree/master/distributed-systems/gossip/minimal">minimal Go implementation</a>. It uses UDP, gossips a full table of <code class="language-plaintext highlighter-rouge">(epoch, heartbeat)</code> values, selects random active peers, and marks a peer as failed when its heartbeat stops advancing past a timeout.</p>

<p>It is intentionally a learning tool rather than a production design. In particular, sending the complete table makes the merge rule easy to see, but makes each message grow with membership size.</p>

<p>I then rebuilt the same membership experiment with <a href="https://github.com/alex-carvalho/sandbox/tree/master/distributed-systems/gossip/memberlist">HashiCorp Memberlist</a>. The application provides node identity, bind address, seed peers, and join/leave callbacks, while Memberlist handles the specialised membership and failure-detection machinery. A clean shutdown calls <code class="language-plaintext highlighter-rouge">Leave</code>, so the rest of the cluster can learn about the departure without waiting for a timeout.</p>

<h2 id="final-thought">Final thought</h2>

<p>Gossip does not create a perfectly synchronized cluster. It gives a distributed system a decentralised way to spread information until members converge, without making one node responsible for every update.</p>

<p>The central tradeoff is also its strength: gossip gives up immediate global agreement in exchange for resilience and scalable dissemination. The two PoCs helped me see the mechanics, but the protocol is useful because of that broader design choice.</p>

<h2 id="references">References</h2>

<ul>
  <li><a href="https://github.com/alex-carvalho/sandbox/tree/master/distributed-systems/gossip/minimal">Minimal heartbeat gossip PoC</a></li>
  <li><a href="https://github.com/alex-carvalho/sandbox/tree/master/distributed-systems/gossip/memberlist">HashiCorp Memberlist gossip PoC</a></li>
  <li><a href="https://github.com/hashicorp/memberlist">HashiCorp Memberlist</a></li>
  <li><a href="https://cassandra.apache.org/doc/latest/cassandra/architecture/gossip.html">Apache Cassandra: Gossip</a></li>
</ul>]]></content><author><name>Alex Carvalho</name></author><category term="Distributed Systems" /><category term="Gossip" /><category term="Go" /><summary type="html"><![CDATA[I have been studying how distributed systems keep track of their members, and the Gossip Protocol is one of those ideas that explains how a cluster can share information without placing a central coordinator in the middle of every decision.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://alex-carvalho.github.io/assets/images/gossip/gossip-membership-flow.svg" /><media:content medium="image" url="https://alex-carvalho.github.io/assets/images/gossip/gossip-membership-flow.svg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Understanding Write-Ahead Logging (WAL)</title><link href="https://alex-carvalho.github.io/2026/07/07/write-ahead-log-wal-in-go.html" rel="alternate" type="text/html" title="Understanding Write-Ahead Logging (WAL)" /><published>2026-07-07T00:00:00+00:00</published><updated>2026-07-07T00:00:00+00:00</updated><id>https://alex-carvalho.github.io/2026/07/07/write-ahead-log-wal-in-go</id><content type="html" xml:base="https://alex-carvalho.github.io/2026/07/07/write-ahead-log-wal-in-go.html"><![CDATA[<p>I have been studying storage internals, and Write-Ahead Logging (WAL) is one of those ideas that looks simple at first but explains a lot about how databases survive crashes.</p>

<p>The core rule is:</p>

<blockquote>
  <p>Before changing the main data structure, write the change to a durable log.</p>
</blockquote>

<p>That rule is the foundation for crash recovery in many databases and storage engines. If a process dies after the log record is safely persisted but before the main data files are updated, the system can recover by replaying the log instead of guessing what happened.</p>

<p>Writing every changed data page directly to disk on every commit would be expensive. A WAL makes the commit path cheaper because the database can append records sequentially, flush that log, and apply the heavier data-file work later.</p>

<p>PostgreSQL describes this same central idea in its <a href="https://www.postgresql.org/docs/current/wal-intro.html">WAL documentation</a>: data-file changes are written only after the corresponding WAL records have been flushed to permanent storage.</p>

<p><img src="/assets/images/wal/wal-recovery-flow.svg" alt="WAL recovery flow" /></p>

<h2 id="what-wal-gives-us">What WAL gives us</h2>

<ul>
  <li><strong>Durability:</strong> once the log record is safely flushed, the change can be recovered.</li>
  <li><strong>Crash recovery:</strong> after a restart, the database can replay log records that were not yet reflected in the main storage.</li>
  <li><strong>Sequential write path:</strong> appending to a log is usually cheaper than immediately rewriting many random data pages.</li>
  <li><strong>Replication and backups:</strong> many systems stream or archive WAL records to replicas or backup storage.</li>
  <li><strong>Point-in-time recovery:</strong> if old WAL segments are archived, the database can restore a previous backup and replay logs until a chosen moment.</li>
</ul>

<p>WAL does not make storage simple. It moves complexity into a controlled place: the log format, flush policy, recovery rules, checkpoints, and cleanup.</p>

<h2 id="the-write-path">The write path</h2>

<p>A simplified WAL write path looks like this:</p>

<ol>
  <li>A transaction changes data.</li>
  <li>The database creates one or more WAL records describing the change.</li>
  <li>The WAL records are appended to the current log file.</li>
  <li>On commit, the required WAL bytes are flushed to durable storage.</li>
  <li>The change can be applied to memory or data pages.</li>
  <li>Dirty pages can be written to the main data files later.</li>
</ol>

<p>The important ordering is: the log must reach durable storage before the database depends on the corresponding data change being durable.</p>

<p>Some systems flush on every commit. Others group many commits into one flush to improve throughput. Some expose weaker durability settings where a committed transaction may be lost after a power failure. That is a performance and durability tradeoff.</p>

<h2 id="wal-record-anatomy-simple-representation">WAL record anatomy (simple representation)</h2>

<p>A WAL is a sequence of records. The exact format depends on the database, but a record usually contains enough information to validate, order, and replay a change.</p>

<p>Common fields are: an <strong>LSN</strong> to place the record in the log timeline, a <strong>record type</strong> to say what happened, optional <strong>transaction metadata</strong>, a <strong>payload length</strong>, the <strong>payload</strong> itself, and often a <strong>checksum</strong> to detect corrupted or partial writes.</p>

<p><img src="/assets/images/wal/wal-record-format.svg" alt="WAL binary record format" /></p>

<p>The diagram is simplified, but the principle is the same: recovery must be able to scan a byte stream and know exactly where each record starts, where it ends, whether it is valid, and how it should be replayed.</p>

<h2 id="lsn-the-timeline-of-the-log">LSN: the timeline of the log</h2>

<p>The LSN gives the log a stable order. It can be a record number, byte offset, or another monotonically increasing value.</p>

<p>LSNs help the database answer practical questions: which changes happened first, what has already been flushed, where recovery should start, which segments can be deleted, and how far a replica is behind. In many systems, data pages also store the latest LSN that affected them, so recovery can skip changes already present on disk.</p>

<h2 id="checksums-and-partial-writes">Checksums and partial writes</h2>

<p>Crashes do not always leave files in a clean state. The last WAL record might be complete, missing bytes, or partially written. Storage can also return corrupted data.</p>

<p>That is why production WAL formats usually include checksums or CRCs. During recovery, the database validates record length and checksum, then stops at the first invalid tail record instead of replaying garbage bytes.</p>

<h2 id="commit-records-and-transaction-boundaries">Commit records and transaction boundaries</h2>

<p>For a single operation, WAL recovery is easy: replay the operation.</p>

<p>Transactions make the problem more interesting. A transaction may generate many WAL records before it commits. After a crash, recovery must know whether those records belong to a committed transaction or an incomplete one.</p>

<p>Many systems solve this by writing explicit transaction records:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>BEGIN tx42
UPDATE account:1
UPDATE account:2
COMMIT tx42
</code></pre></div></div>

<p>If recovery sees the updates but not the commit, it should not expose the transaction as committed. Depending on the engine design, recovery may redo committed work, undo incomplete work, or use a combination of redo and undo.</p>

<h2 id="checkpoints">Checkpoints</h2>

<p>If the database only appended to WAL forever, recovery would eventually become slow. Replaying months of logs after every restart is not practical.</p>

<p>A checkpoint records that the main data files are durable up to a known point in the log. After a checkpoint, recovery can start near that point instead of replaying from the beginning of time.</p>

<p>The simplified idea is:</p>

<ol>
  <li>Flush dirty data pages to disk.</li>
  <li>Record a checkpoint LSN.</li>
  <li>During recovery, start from the latest valid checkpoint.</li>
  <li>Keep only WAL segments still needed for recovery, replicas, backups, or point-in-time restore.</li>
</ol>

<p>The details differ by engine, but the motivation is similar: bound recovery time and control log growth.</p>

<h2 id="rotation-truncation-and-archiving">Rotation, truncation, and archiving</h2>

<p>WAL files are usually split into segments instead of one giant file. Segmenting makes log management easier.</p>

<p>A database may:</p>

<ul>
  <li><strong>Rotate</strong> to a new WAL segment after the current segment reaches a size limit.</li>
  <li><strong>Recycle</strong> old segment files by reusing their space.</li>
  <li><strong>Truncate</strong> log records that are no longer needed.</li>
  <li><strong>Archive</strong> old segments for backups or point-in-time recovery.</li>
  <li><strong>Retain</strong> segments needed by replicas that are still catching up.</li>
</ul>

<p>This is one of the operationally important parts of WAL. If old logs cannot be removed because a replica is down or archiving is broken, disk usage can grow until the database is in trouble.</p>

<h2 id="recovery">Recovery</h2>

<p>On startup after an unclean shutdown, the database runs recovery.</p>

<p>A simplified recovery process looks like:</p>

<ol>
  <li>Find the latest valid checkpoint.</li>
  <li>Open WAL from the checkpoint position.</li>
  <li>Read records in LSN order.</li>
  <li>Validate each record checksum and length.</li>
  <li>Redo committed changes that are not reflected in data files.</li>
  <li>Ignore, roll back, or clean up incomplete transactions, depending on the engine.</li>
  <li>Stop at the first invalid or incomplete tail record.</li>
  <li>Make the database available again.</li>
</ol>

<p>The key idea is that WAL turns a crash into a deterministic replay problem.</p>

<h2 id="wal-and-performance">WAL and performance</h2>

<p>WAL improves the write path because sequential appends are efficient. It also allows the database to delay random data-page writes.</p>

<p>But WAL is not free:</p>

<ul>
  <li>Every durable commit eventually needs a flush.</li>
  <li>Checksums, encoding, and compression cost CPU.</li>
  <li>Checkpoints can create I/O spikes.</li>
  <li>Long-running transactions or replicas can prevent log cleanup.</li>
  <li>Large WAL files can slow recovery.</li>
  <li>Misconfigured durability settings can trade correctness for speed.</li>
</ul>

<p>Good WAL design is always a balance between latency, throughput, recovery time, and storage usage.</p>

<h2 id="wal-in-real-systems">WAL in real systems</h2>

<p>This idea appears in many storage systems, sometimes with slightly different names:</p>

<ul>
  <li><strong>PostgreSQL:</strong> uses WAL for crash recovery, replication, backups, and point-in-time recovery.</li>
  <li><strong>SQLite:</strong> has a WAL mode where changes are appended to a separate WAL file before being checkpointed back into the database file.</li>
  <li><strong>MySQL/InnoDB:</strong> uses a redo log for crash recovery. The redo log stores changes that can be replayed during initialization after an unexpected shutdown.</li>
  <li><strong>RocksDB:</strong> uses WAL files to reconstruct memtables after a failure.</li>
  <li><strong>Cassandra:</strong> uses a commit log before data is written into memtables and later flushed to SSTables.</li>
  <li><strong>etcd:</strong> stores Raft entries in a WAL so the node can recover consensus state after restart.</li>
  <li><strong>Prometheus:</strong> uses a WAL to protect the in-memory head block of its local time-series database. On restart, Prometheus replays the WAL to recover samples that were not yet compacted into blocks.</li>
  <li><strong>Grafana Loki:</strong> uses a WAL in ingesters to persist acknowledged log data on local disk before it is flushed to long-term storage.</li>
</ul>

<p>The names are not always identical, but the pattern is similar: append first, recover later.</p>

<h2 id="a-small-go-proof-of-concept">A small Go proof of concept</h2>

<p>To make the idea concrete, I built a small Go proof of concept: <a href="https://github.com/alex-carvalho/sandbox/tree/master/devops/storage/wal">github.com/alex-carvalho/sandbox/devops/storage/wal</a>.</p>

<p>It is intentionally small: a key-value map, a binary <code class="language-plaintext highlighter-rouge">wal.log</code>, and a recovery step that rebuilds memory from the log.</p>

<p>The demo writes a few operations:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">write</span><span class="p">(</span><span class="n">wal</span><span class="o">.</span><span class="n">OpSet</span><span class="p">,</span> <span class="s">"username1"</span><span class="p">,</span> <span class="s">"john"</span><span class="p">)</span>
<span class="n">write</span><span class="p">(</span><span class="n">wal</span><span class="o">.</span><span class="n">OpSet</span><span class="p">,</span> <span class="s">"username2"</span><span class="p">,</span> <span class="s">"alex"</span><span class="p">)</span>
<span class="n">write</span><span class="p">(</span><span class="n">wal</span><span class="o">.</span><span class="n">OpDelete</span><span class="p">,</span> <span class="s">"username1"</span><span class="p">,</span> <span class="s">""</span><span class="p">)</span>
</code></pre></div></div>

<p>Each record contains:</p>

<ul>
  <li><strong>LSN:</strong> a monotonically increasing Log Sequence Number.</li>
  <li><strong>OpType:</strong> the operation type, currently <code class="language-plaintext highlighter-rouge">SET</code> or <code class="language-plaintext highlighter-rouge">DELETE</code>.</li>
  <li><strong>KeyLen:</strong> the number of bytes in the key.</li>
  <li><strong>ValLen:</strong> the number of bytes in the value.</li>
  <li><strong>Key:</strong> the key bytes.</li>
  <li><strong>Value:</strong> the value bytes.</li>
</ul>

<h2 id="final-thought">Final thought</h2>

<p>The main lesson is that durability does not require immediately rewriting the whole database. A sequential log can act as the bridge between fast in-memory changes and safe on-disk recovery.</p>

<p>That is the elegance of WAL: the database can move quickly while still leaving itself a path back after a crash.</p>

<h2 id="references">References</h2>

<ul>
  <li><a href="https://github.com/alex-carvalho/sandbox/tree/master/devops/storage/wal">My Go WAL PoC</a></li>
  <li><a href="https://www.postgresql.org/docs/current/wal-intro.html">PostgreSQL: Write-Ahead Logging</a></li>
  <li><a href="https://sqlite.org/wal.html">SQLite: Write-Ahead Logging</a></li>
  <li><a href="https://dev.mysql.com/doc/refman/8.4/en/innodb-redo-log.html">MySQL InnoDB: Redo Log</a></li>
  <li><a href="https://github.com/facebook/rocksdb/wiki/Write-Ahead-Log-File-Format">RocksDB: Write Ahead Log File Format</a></li>
  <li><a href="https://cassandra.apache.org/doc/latest/cassandra/architecture/storage-engine.html">Cassandra: Storage Engine</a></li>
  <li><a href="https://pkg.go.dev/go.etcd.io/etcd/server/v3/storage/wal">etcd: WAL package</a></li>
  <li><a href="https://prometheus.io/docs/prometheus/latest/storage/">Prometheus: Storage</a></li>
  <li><a href="https://grafana.com/docs/loki/latest/operations/storage/wal/">Grafana Loki: Write Ahead Log</a></li>
</ul>]]></content><author><name>Alex Carvalho</name></author><category term="Storage" /><category term="Databases" /><category term="Reliability" /><summary type="html"><![CDATA[I have been studying storage internals, and Write-Ahead Logging (WAL) is one of those ideas that looks simple at first but explains a lot about how databases survive crashes.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://alex-carvalho.github.io/assets/images/wal/wal_cover.png" /><media:content medium="image" url="https://alex-carvalho.github.io/assets/images/wal/wal_cover.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>