<?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://redixhumayun.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://redixhumayun.github.io/" rel="alternate" type="text/html" /><updated>2026-04-16T00:08:42+00:00</updated><id>https://redixhumayun.github.io/feed.xml</id><title type="html">Zaid Humayun’s Blog</title><subtitle>Personal Website</subtitle><entry><title type="html">Zero-Copy Pages in Rust: Or How I Learned To Stop Worrying And Love Lifetimes</title><link href="https://redixhumayun.github.io/databases/2026/04/14/zero-copy-pages-in-rust.html" rel="alternate" type="text/html" title="Zero-Copy Pages in Rust: Or How I Learned To Stop Worrying And Love Lifetimes" /><published>2026-04-14T00:00:00+00:00</published><updated>2026-04-14T00:00:00+00:00</updated><id>https://redixhumayun.github.io/databases/2026/04/14/zero-copy-pages-in-rust</id><content type="html" xml:base="https://redixhumayun.github.io/databases/2026/04/14/zero-copy-pages-in-rust.html"><![CDATA[<p><em>You can find the source code for the project <a href="https://github.com/redixhumayun/simpledb/">here</a></em></p>

<p>Zero-copy is a way to elide CPU copies between the kernel and user space buffers that is particularly useful in high throughput applications like database engines. It makes a huge difference in performance under high load, particularly when your working set is no longer cache resident.</p>

<h2 id="what-is-zero-copy">What Is Zero-Copy</h2>

<p>Here is what a typical database engine looks like. For this post, focus on two copy boundaries: the OS boundary, and the path from the buffer pool into the layers above it.</p>

<pre class="ascii-art"><code>  ┌─────────────────────────────────────────────────────────┐
  │                      Query Layer                        │
  └────────────────────────┬────────────────────────────────┘
                           │
  ┌────────────────────────▼────────────────────────────────┐
  │                    Execution Engine                     │
  └────────────────────────┬────────────────────────────────┘
                           │
  ┌────────────────────────▼────────────────────────────────┐
  │                    Transaction Manager                  │
  └──────────┬─────────────┴─────────────────┬──────────────┘
             │                               │
  ┌──────────▼──────────┐       ┌────────────▼────────────┐
  │    Lock Manager     │       │      Log Manager        │
  └─────────────────────┘       └─────────────────────────┘
                           │  fresh copies into higher layers
  ┌────────────────────────▼────────────────────────────────┐
  │                    Buffer Pool                          │
  └────────────────────────┬────────────────────────────────┘
                           │  copy at OS boundary
  ┌────────────────────────▼────────────────────────────────┐
  │                    Disk                                 │
  └─────────────────────────────────────────────────────────┘
</code></pre>

<p>Trying to build a high performance engine requires eliding any non-useful work as far as possible and copying data falls squarely in this category.</p>

<p>Think of each copy operation as an equivalent of <code>memcpy()</code><label for="sn-memcpy" class="margin-toggle sidenote-number"></label><input type="checkbox" id="sn-memcpy" class="margin-toggle" /><span class="sidenote">memcpy can actually cause <a href="https://www.intel.com/content/www/us/en/developer/articles/technical/performance-optimization-of-memcpy-in-dpdk.html">pipeline stalls</a> which is something you want to avoid in high perf applications.</span> which requires the CPU to copy data from a source and put it into a destination. You’re spending cycles on non-essential work and this can cause eviction of hot data from CPU caches.</p>

<p><a href="https://www.linuxjournal.com/article/6345">Here’s</a> a great example of the lifecycle that a typical read or write operation goes through. All those CPU copies are useless work that are burning cycles.</p>

<table>
  <thead>
    <tr>
      <th style="text-align: center"><img src="/assets/img/zero-copy/read_write_lifecycle.png" alt="" /></th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: center"><em>Image taken from https://www.linuxjournal.com/article/6345</em></td>
    </tr>
  </tbody>
</table>

<p>Now, let’s focus on eliminating copies at the layer between the buffer pool and disk first.</p>

<h2 id="the-buffer-pool-and-direct-io">The Buffer Pool And Direct IO</h2>

<p><label for="mn-1" class="margin-toggle">⊕</label><input type="checkbox" id="mn-1" class="margin-toggle" /><span class="marginnote"><a href="https://lkml.org/lkml/2002/5/11/58">Here’s</a> a famous tirade from Linus Torvalds on the direct IO interface. He doesn’t like database developers.</span></p>

<p>The buffer pool opens and stores file descriptors with the <code>open()</code> syscall. When we call <code>read()</code> and <code>write()</code> on those file descriptors it goes through the whole cycle you saw earlier with copies between userspace, kernel and DMA.</p>

<p>An easy win here is to use direct IO with the <a href="https://man7.org/linux/man-pages/man2/open.2.html">O_DIRECT</a> flag<label for="sn-1" class="margin-toggle sidenote-number"></label><input type="checkbox" id="sn-1" class="margin-toggle" /><span class="sidenote">A large number of modern databases use this approach, although there are holdouts like Postgres.</span>. This will force the application to bypass the OS page cache<label for="sn-swiotlb" class="margin-toggle sidenote-number"></label><input type="checkbox" id="sn-swiotlb" class="margin-toggle" /><span class="sidenote">This assumes 64-bit DMA capable hardware. On systems with 32-bit DMA devices or confidential computing VMs (AMD SEV, Intel TDX), the kernel may silently introduce a SWIOTLB bounce buffer, reintroducing a CPU copy. See the Linux kernel docs on <a href="https://docs.kernel.org/core-api/swiotlb.html">swiotlb</a>.</span>.</p>

<p><code>O_DIRECT</code> requires that the buffers submitted are pointer aligned, along with I/O length and file offset. In Rust, we guarantee the former with <code>#[repr(align(4096))]</code> on the buffer holding our page, and 4 KiB page-sized reads and writes at page-aligned offsets satisfy the rest. Without this, <code>O_DIRECT</code> reads or writes would often fail with <code>EINVAL</code><label for="sn-einval" class="margin-toggle sidenote-number"></label><input type="checkbox" id="sn-einval" class="margin-toggle" /><span class="sidenote">Here’s a <a href="https://gist.github.com/redixhumayun/8f402d30ffc8437e043394b9c003698b">gist</a> showing this in C — the first program uses malloc (not 4096-aligned) and the write fails, the second uses posix_memalign and succeeds.</span>.</p>

<p>Since we’re bypassing the kernel page cache we don’t get useful boosts like <a href="https://lwn.net/Articles/888715/">readahead</a> or <a href="https://www.thomas-krenn.com/en/wiki/Linux_Page_Cache_Basics">write coalescing</a> but this is exactly why a buffer pool is so important in a database.</p>

<p>The buffer pool is a replacement for the OS page cache designed with specific workloads in mind. It’s always helpful to think of this in terms of mechanism + policy.</p>

<p>Mechanism: A fixed size page table which serves page requests for the layers above and evicts some pages to make room for others.</p>

<p>Policy: A way to decide which page to evict (eviction policy)</p>

<pre class="ascii-art"><code>  ┌─────────────────────────────────────────────────────────┐
  │                      Query Layer                        │
  │                                                         │
  │   TableScan / IndexScan / BTreeScan                     │
  │   (iterates rows, calls next(), get_value())            │
  └────────────────────────┬────────────────────────────────┘
                           │ uses
  ┌────────────────────────▼────────────────────────────────┐
  │                    Transaction                          │
  │                                                         │
  │   tx_id | ConcurrencyManager | RecoveryManager          │
  └────────────────────────┬────────────────────────────────┘
                           │ pin() / unpin()
  ┌────────────────────────▼────────────────────────────────┐
  │                    Buffer Pool                          │
  │                                                         │
  │  ┌──────────┐  ┌──────────┐  ┌──────────┐               │
  │  │ Frame 0  │  │ Frame 1  │  │ Frame 2  │  ...          │
  │  │          │  │          │  │          │               │
  │  │ file:3   │  │ file:7   │  │  empty   │               │
  │  │ pins: 1  │  │ pins: 0  │  │  pins: 0 │               │
  │  │          │  │          │  │          │               │
  │  │ 4KB data │  │ 4KB data │  │          │               │
  │  └──────────┘  └──────────┘  └──────────┘               │
  │                                                         │
  │  policy: LRU | CLOCK | SIEVE                            │
  └────────────────────────┬────────────────────────────────┘
                           │ 
                           │ 
  ┌────────────────────────▼────────────────────────────────┐
  │                       Disk                              │
  │                                                         │
  │    [ file:1 ]  [ file:3 ]  [ file:7 ]  [ file:9 ]       │
  └─────────────────────────────────────────────────────────┘
</code></pre>

<p>Choosing the right policy for your system depends on the characteristics of your workload but most systems typically go with <a href="https://www.cs.cornell.edu/courses/cs4410/2018su/lectures/lec15-thrashing.html">CLOCK</a> which is an LRU approximation. <a href="https://en.wikipedia.org/wiki/Cache_replacement_policies">Here’s</a> a non-exhaustive list of replacement policies used in buffer pools.</p>

<p><code>O_DIRECT</code> removes the copy at the OS boundary. The next problem is avoiding fresh copies once the page is already in the buffer pool.</p>

<h2 id="eliminating-copies-from-the-read-path">Eliminating Copies From The Read Path</h2>

<p>So far, zero-copy has meant removing copies between the kernel and the buffer pool. From here on, I’m going to broaden it slightly to mean removing redundant copies inside the engine too.</p>

<p>Rust has a great and terrible way to avoid dealing with copies of data - references. It’s great because it’s a single character (<code>&amp;</code>), it’s terrible because now we have to learn to deal with <a href="https://doc.rust-lang.org/rust-by-example/scope/lifetime.html">lifetimes</a>.</p>

<p>The simplest way to think about lifetimes is that you are proving to the compiler that any reference held by type A will not outlive the data it points to.</p>

<p>Let’s start with defining the raw bytes for a single page like this</p>

<pre><code class="language-rust">pub struct PageBytes {
    bytes: [u8; PAGE_SIZE_BYTES as usize],
}
</code></pre>

<p>Now, we’ll define the data that is held within a single buffer pool frame. The <code>RwLock&lt;T&gt;</code> type here is our page latch.</p>

<pre><code class="language-rust">#[derive(Debug)]
pub struct BufferFrame {
    page: RwLock&lt;PageBytes&gt;,
}
</code></pre>

<p>Now, we’re going to store this frame’s data inside a <code>PageReadGuard</code>.</p>

<pre><code class="language-rust">// To keep the example small, the next type is schematic rather than literal.
// I'm using it to show the ownership tradeoff, not the exact implementation
// details of `RwLockReadGuard`.
/// Read guard providing shared access to a pinned page.
pub struct PageReadGuard {
    page: PageBytes,
}
</code></pre>

<p>This version is simple to model, but it bakes copying into the design. If every higher-level page object owns its own <code>PageBytes</code>, then constructing those objects from buffer-pool storage means materializing fresh owned values.</p>

<p>What we actually want is not ownership, but a borrowed view into bytes that already live somewhere else. We can model that by introducing a lifetime.</p>

<pre><code class="language-rust">pub struct PageReadGuard&lt;'a&gt; {
    page: &amp;'a PageBytes,
}
</code></pre>

<p>With this lifetime annotation, we are proving to the compiler that <code>PageReadGuard</code> will not outlive <code>PageBytes</code>, which means higher-level page objects can become views into existing bytes rather than owned copies.</p>

<p>In the real implementation, the field is <code>RwLockReadGuard&lt;'a, PageBytes&gt;</code> rather than <code>&amp;'a PageBytes</code>, but the ownership story is the same: the guard borrows the page bytes instead of owning them, and our wrapper carries that borrow forward.</p>

<pre><code class="language-rust">pub struct PageReadGuard&lt;'a&gt; {
    page: RwLockReadGuard&lt;'a, PageBytes&gt;
}
</code></pre>

<p>Typical database engines have two core page types - heap and btree pages. So let’s focus on the former. The structure of the page is going to be a standard <a href="https://siemens.blog/posts/database-page-layout/">slotted page</a> layout.</p>

<p>At this point, the bytes are already borrowed through <code>PageReadGuard&lt;'a&gt;</code>. Now, the question is where the guard should live and where the parsed references into the page should live.</p>

<p>The most natural thing to try is to keep everything in one struct.</p>

<pre><code class="language-rust">struct HeapPage&lt;'a&gt; {
    guard: PageReadGuard&lt;'a&gt;,
    header: &amp;'a [u8],
    line_pointers: &amp;'a [u8],
    record_space: &amp;'a [u8],
}
</code></pre>

<p>This leads us to the classic <a href="https://quinedot.github.io/rust-learning/pf-meta.html">self-referential struct</a> issue in Rust, which makes pointer invalidation very hard. Imagine for a moment, you have a struct and it has two fields - A and B, with B pointing to A. Now, your struct A moves. What does B point to? It’s going to continue pointing to where A was but that’s invalid and would lead to UB. There are ways around this in Rust with <a href="https://without.boats/blog/pin/"><code>Pin</code></a>, unsafe raw pointers, <code>Arc</code> pointers and external crates like <code>ouroboros</code>. But, all of these have overhead associated with them.</p>

<p>So, the next thing to try is to separate ownership of the guard from the parsed view into the page.</p>

<pre><code class="language-rust">pub struct HeapPage&lt;'a&gt; {
    guard: PageReadGuard&lt;'a&gt;,
}

pub struct HeapPageView&lt;'a&gt; {
    header: &amp;'a [u8],
    line_pointers: &amp;'a [u8],
    record_space: &amp;'a [u8],
    layout: &amp;'a Layout,
}

let page = HeapPage::new(guard);
let view = HeapPageView::new(&amp;'a page, layout); // borrows from page and page stays alive on stack
</code></pre>

<p>This works but runs into an issue when we want to mutate the bytes. Imagine that we want to insert a record into the heap page. This requires mutating <code>record_space</code> and also mutating <code>line_pointers</code>. Now, the bounds of both might have changed which means we have stale references in our struct. We need to drop the struct and re-create it again. While it is cheap, it’s a <a href="https://www.joelonsoftware.com/2002/11/11/the-law-of-leaky-abstractions/">leaky abstraction</a>.</p>

<p>A better form would be to do the following</p>

<pre><code class="language-rust">pub struct HeapPage&lt;'a&gt; {
    guard: PageReadGuard&lt;'a&gt;,
}

pub struct HeapPageView&lt;'a&gt; {
    header: &amp;'a [u8],
    body_bytes: &amp;'a [u8],
    layout: &amp;'a Layout,
}

let page = HeapPage::new(guard);
let view = HeapPageView::new(&amp;'a page, layout); // borrows from page and page stays alive on stack
</code></pre>

<p>In slotted pages, the header size is fixed and anytime we want to perform some operation we re-parse the body bytes using information from the header and get an accurate view into the bytes.</p>

<p>But, the above requires keeping the page and view alive on the stack and leaks implementation details up to the query layer. Once again, a <a href="https://www.joelonsoftware.com/2002/11/11/the-law-of-leaky-abstractions/">leaky abstraction</a>.</p>

<p>The version I went with flips this around.<label for="sn-indirection" class="margin-toggle sidenote-number"></label><input type="checkbox" id="sn-indirection" class="margin-toggle" /><span class="sidenote">The old computer science move: <a href="https://en.wikipedia.org/wiki/Fundamental_theorem_of_software_engineering">“Any problem in computer science can be solved with another level of indirection”</a>.</span> To make that concrete, a slotted page is divided into the header, the line pointers and the record space, so we’ll store those parsed references in <code>HeapPage</code> and keep the <code>PageReadGuard</code> in <code>HeapPageView</code>.</p>

<pre><code class="language-rust">pub struct HeapHeaderRef&lt;'a&gt; {
    bytes: &amp;'a [u8],
}

struct LinePtrBytes&lt;'a&gt; {
    bytes: &amp;'a [u8],
}

struct LinePtrArray&lt;'a&gt; {
    bytes: LinePtrBytes&lt;'a&gt;,
    len: usize,
    capacity: usize,
}

struct HeapRecordSpace&lt;'a&gt; {
    bytes: &amp;'a [u8],
    base_offset: usize,
}

struct HeapPage&lt;'a&gt; {
    header: HeapHeaderRef&lt;'a&gt;,
    line_pointers: LinePtrArray&lt;'a&gt;,
    record_space: HeapRecordSpace&lt;'a&gt;,
}

pub struct HeapPageView&lt;'a&gt; {
    guard: PageReadGuard&lt;'a&gt;,
    layout: &amp;'a Layout,
}
</code></pre>

<p>All of them share the exact same lifetime of <code>'a</code>, which means that any of these references held by another type will not outlive the type.</p>

<p>And all of these types are references into the exact same set of bytes held by <code>PageBytes</code> in the <code>BufferFrame</code>.</p>

<pre class="ascii-art"><code>PageBytes (owned by BufferFrame)
┌─────────────────────────────────────────────────────┐
│ Header (34 bytes)                                   │
│ page_type | slot_count | free_lower | free_upper .. │
├─────────────────────────────────────────────────────┤
│ Line Pointers (grows →)                             │
│ [ slot 0 ] [ slot 1 ] [ slot 2 ] ...                │
├─────────────────────────────────────────────────────┤
│ Free Space                                          │
├─────────────────────────────────────────────────────┤
│ Record Space (grows ←)                              │
│ ... [ tuple 2 ] [ tuple 1 ] [ tuple 0 ]             │
└─────────────────────────────────────────────────────┘
      │               │                    │
HeapHeaderRef&lt;'a&gt;  LinePtrArray&lt;'a&gt;  HeapRecordSpace&lt;'a&gt;
      │               │                    │
      └───────────────┴────────────────────┘
                      │
                 HeapPage&lt;'a&gt;  ←─────────────────────────┐
                                                         │
                                                  built from
                                                         │
                                            HeapPageView&lt;'a&gt;
                                          ┌──────────────────────────┐
                                          │ guard: PageReadGuard&lt;'a&gt; │
                                          │ layout: &amp;'a Layout       │
                                          └──────────────────────────┘
                                                   │
                                            owns the lock on
                                                   │
                                            PageBytes in BufferFrame
</code></pre>

<p>The link between them is a method on <code>HeapPageView</code> that constructs a <code>HeapPage</code> whenever some operation needs an interpreted view of the page bytes.</p>

<pre><code class="language-rust">impl&lt;'a&gt; HeapPageView&lt;'a&gt; {
    fn build_page(&amp;'a self) -&gt; HeapPage&lt;'a&gt; {
        HeapPage::new(self.guard.bytes()).unwrap()
    }

    pub fn row(&amp;self, slot: SlotId) -&gt; Option&lt;LogicalRow&lt;'_&gt;&gt; {
        let view = self.build_page();
        let mut current = slot;
        loop {
            match view.tuple_ref(current)? {
                //  code elided for simplicity
            }
        }
    }
}

impl&lt;'a&gt; HeapPage&lt;'a&gt; {
    fn new(bytes: &amp;'a [u8]) -&gt; SimpleDBResult&lt;Self&gt; {
        // Use shared parsing logic from PageKind trait
        let layout = Self::parse_layout(bytes)?;

        let header = HeapHeaderRef::new(layout.header);

        // Additional heap-specific validation
        let free_upper = header.free_upper() as usize;
        let page_size = PAGE_SIZE_BYTES as usize;
        if free_upper &lt; header.free_lower() as usize || free_upper &gt; page_size {
            return Err("heap page free_upper out of bounds".into());
        }

        let page = Self::from_parts(header, layout.line_ptrs, layout.records, layout.base_offset);
        assert_eq!(
            page.slot_count(),
            header.slot_count() as usize,
            "slot directory length must match header slot_count"
        );
        Ok(page)
    }
}
</code></pre>

<p>When the query layer wants to read something from the data pages, it will get a <code>HeapPageView</code> and any operation on the <code>HeapPageView</code> that requires access to some logical segment of data will construct a <code>HeapPage</code> which understands how the bytes on the page are laid out.</p>

<p>Now, you’re probably wondering about the cost of reconstructing the <code>HeapPage</code> each time. It’s actually really cheap because it’s composed entirely of arithmetic operations sprinkled with a few panics in case some invariants aren’t met. And arithmetic operations are <em>extremely</em> cheap for the CPU to perform, especially when compared to <code>memcpy()</code> operations.</p>

<table>
  <thead>
    <tr>
      <th style="text-align: center"><img src="/assets/img/zero-copy/cpu_ops_cost.png" alt="" /></th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: center"><em>Not all CPU operations are created equal. Source: <a href="https://ithare.com/infographics-operation-costs-in-cpu-clock-cycles/">ithare.com</a>, via Andrew Kelley’s <a href="https://youtu.be/IroPQ150F6c?t=409">talk on Data Oriented Design</a></em></td>
    </tr>
  </tbody>
</table>

<h2 id="eliminating-copies-from-the-write-path">Eliminating Copies From The Write Path</h2>

<p>In the previous section we saw <code>PageReadGuard</code>, <code>HeapPage</code> and <code>HeapPageView</code> which collectively constitute the read side path of the page. However, Rust has the principle of <a href="https://cmpt-479-982.github.io/week1/safety_features_of_rust.html#the-borrow-checker-and-the-aliasing-xor-mutability-principle">aliasing XOR mutability</a> and this means that we either get multiple <code>&amp;T</code> or a single <code>&amp;mut T</code>. Everything we saw above is on the <code>&amp;T</code> path and we need a <code>&amp;mut T</code> path.</p>

<pre><code class="language-rust">/// Write guard providing exclusive access to a pinned page.
pub struct PageWriteGuard&lt;'a&gt; {
    page: RwLockWriteGuard&lt;'a, PageBytes&gt;,
}

pub struct HeapPageMut&lt;'a&gt; {
    header: HeapHeaderMut&lt;'a&gt;,
    body_bytes: &amp;'a mut [u8],
}

pub struct HeapPageViewMut&lt;'a&gt; {
    guard: PageWriteGuard&lt;'a&gt;,
    layout: &amp;'a Layout,
}

impl&lt;'a&gt; HeapPageViewMut&lt;'a&gt; {
    fn build_mut_page(&amp;mut self) -&gt; SimpleDBResult&lt;HeapPageMut&lt;'_&gt;&gt; {
        HeapPageMut::new(self.guard.bytes_mut())
    }

    pub fn insert_tuple(&amp;mut self, tuple: &amp;[u8]) -&gt; SimpleDBResult&lt;SlotId&gt; {
        let mut page = self.build_mut_page()?;
        page.insert(tuple)
    }
}
</code></pre>

<p>Now, we abide by Rust’s aliasing rules and if we have a <code>BufferFrame</code>, we can either acquire the <code>read</code> latch multiple times and build the read path or acquire the <code>write</code> latch and build the write path.</p>

<pre class="ascii-art"><code>                    BufferFrame
                    RwLock&lt;PageBytes&gt;
                         │
              ┌──────────┴──────────┐
              │                     │
         read_page()           write_page()
              │                     │
    RwLockReadGuard          RwLockWriteGuard
    (shared, N readers)      (exclusive, 1 writer)
              │                     │
       PageReadGuard           PageWriteGuard
           &amp;'a [u8]               &amp;'a mut [u8]
              │                     │
         HeapPage              HeapPageMut
       (borrows &amp;'a [u8])        (borrows &amp;'a mut [u8])
              │                     │
       HeapPageView&lt;'a&gt;         HeapPageViewMut&lt;'a&gt;
</code></pre>

<p>And, of course, Rust’s borrow checker makes use-after-unpin a compile error, not a runtime hazard.</p>

<p>There’s one more asymmetry worth noting. <code>HeapPage</code> splits the bytes into three fields (header, line pointers, record space) because reads are idempotent and those boundaries never shift.</p>

<p><code>HeapPageMut</code> can’t do the same: a single insert moves both <code>free_lower</code> and <code>free_upper</code>, making any pre-split reference immediately stale. So <code>HeapPageMut</code> keeps a single <code>body_bytes: &amp;mut [u8]</code> and re-derives sub-regions from the header on each operation. Rust prevents aliased mutable access, but the deeper invariant that split points must always match the header has to be enforced through design.</p>

<h3 id="nested-borrows">Nested Borrows</h3>

<p>If you’ve noticed, so far we’ve been using only a single lifetime of <code>'a</code>, which makes sense because we’ve been borrowing everything from the same set of underlying bytes.</p>

<p>But, we’ve actually been imprecise and let the compiler handle some of the drudgery for us. The chain of borrows so far has been:</p>

<pre class="ascii-art"><code>PageBytes  →  RwLock{Read,Write}Guard&lt;'a&gt;  →  Page{Read,Write}Guard&lt;'a&gt;  →  HeapPage[Mut]&lt;'a&gt;  →  HeapPageView[Mut]&lt;'a&gt;
</code></pre>

<p>Each successive borrow nests within the previous one but the compiler allows us to do all of this with a single lifetime because of <a href="https://doc.rust-lang.org/nomicon/subtyping.html">lifetime variance</a>.</p>

<p>The crux of lifetime variance is that shared references are covariant and exclusive references are invariant. Another way this is frequently expressed is that <code>&amp;T</code> is covariant over <code>'a</code> and <code>&amp;mut T</code> is invariant over <code>'a</code>.</p>

<p>For covariant lifetimes, Rust can shorten a longer-lived <code>&amp;T</code> into a shorter-lived <code>&amp;T</code> when needed, which allows us to elide the nested lifetimes and let the compiler infer the intermediate lifetimes for us.</p>

<p>Mutable borrows are less forgiving because mutation makes those coercions much stricter. With <code>&amp;mut T</code>, Rust can’t be as flexible about the inner lifetime without risking that a shorter-lived reference gets written into a place that promised to hold a longer-lived one.</p>

<p>Here’s a concrete example that demonstrates the asymmetry. We define a struct with two lifetimes and try to shorten one in a function:</p>

<pre><code class="language-rust">struct Inner&lt;'a&gt; {
    data: &amp;'a [u8],
}

struct Outer&lt;'short, 'long&gt; {
    inner: &amp;'short Inner&lt;'long&gt;,
}

fn shorten&lt;'long, 'short&gt;(outer: Outer&lt;'short, 'long&gt;) -&gt; Outer&lt;'short, 'short&gt; { outer }
</code></pre>

<p>This compiles because <code>&amp;T</code> is covariant over <code>'long</code>. The function can coerce a reference where the data outlives the borrow.</p>

<p>Now, change the outer reference to mutable:</p>

<pre><code class="language-rust">struct Outer&lt;'short, 'long&gt; {
    inner: &amp;'short mut Inner&lt;'long&gt;,
}

fn shorten&lt;'long, 'short&gt;(outer: Outer&lt;'short, 'long&gt;) -&gt; Outer&lt;'short, 'short&gt; { outer }
</code></pre>

<p>This fails to compile. <code>&amp;mut T</code> is invariant over its lifetime parameter — it cannot be shortened even when the data outlives the borrow. The lifetimes must match exactly.</p>

<p>This is exactly what happens in <code>HeapPageViewMut::row_mut()</code> and the construction of <code>LogicalRowMut</code>:</p>

<pre><code class="language-rust">pub struct LogicalRowMut&lt;'row, 'page: 'row&gt; {
    view: &amp;'row mut HeapPageViewMut&lt;'page&gt;,
}

impl&lt;'a&gt; HeapPageViewMut&lt;'a&gt; {
    /// Decodes the live tuple at `slot` into a `LogicalRowMut` for editing.
    /// Changes are written back to the page automatically when the returned value is dropped.
    pub fn row_mut&lt;'row&gt;(
        &amp;'row mut self,
        slot: SlotId,
    ) -&gt; SimpleDBResult&lt;Option&lt;LogicalRowMut&lt;'row, 'a&gt;&gt;&gt; {
        //  code elided for simplicity
        Ok(Some(LogicalRowMut {
            view: self,
            slot,
            values,
            layout,
            dirty: false,
        }))
    }
}
</code></pre>

<p><code>'page</code> is the lifetime of the underlying page view, which already borrows the pinned page bytes. <code>'row</code> is the shorter lifetime of one exclusive edit session on top of that view. The relation <code>'page: 'row</code> says that the page view has to stay valid for at least as long as the mutable row editor borrowing it.</p>

<pre class="ascii-art"><code>Page bytes in BufferFrame
┌─────────────────────────────────────────────────────────────┐
│ header │ line pointers │ free space │ tuple 2 │ tuple 1 │… │
└─────────────────────────────────────────────────────────────┘
&lt;---------------------- borrowed for 'page ----------------------&gt;

                                 one call to row_mut()
                                            │
                                            ▼

                       LogicalRowMut&lt;'row, 'page&gt;
                       ┌─────────────────────────┐
                       │ edits one logical row   │
                       └─────────────────────────┘
                       &lt;---- borrowed for 'row ---&gt;

Constraint: 'page : 'row
</code></pre>

<h3 id="the-cost-of-safe-abstractions">The Cost Of Safe Abstractions</h3>

<p>The split into separate read and write types has a real ergonomic cost. <code>HeapPageViewMut</code> doesn’t automatically get <code>HeapPageView</code>’s read methods. In Rust’s standard library, <code>&amp;mut Vec&lt;T&gt;</code> coerces to <code>&amp;Vec&lt;T&gt;</code> automatically via <code>Deref</code>, so mutable references get all immutable methods for free.</p>

<p>This works because <code>Vec&lt;T&gt;</code> is backed by a single raw pointer internally. It implements <code>Deref&lt;Target=[T]&gt;</code> and <code>DerefMut&lt;Target=[T]&gt;</code> to give you either a <code>&amp;[T]</code> or <code>&amp;mut [T]</code>. From one pointer, the borrow checker decides at the call site whether you get a shared or exclusive slice based on how you borrowed it. The unsafe is inside <code>Vec</code>, audited once and invisible to callers.</p>

<pre><code class="language-rust">pub struct Vec&lt;T, #[unstable(feature = "allocator_api", issue = "32838")] A: Allocator = Global&gt; {
    buf: RawVec&lt;T, A&gt;,
    len: usize,
}

#[stable(feature = "rust1", since = "1.0.0")]
impl&lt;T, A: Allocator&gt; ops::Deref for Vec&lt;T, A&gt; {
    type Target = [T];

    #[inline]
    fn deref(&amp;self) -&gt; &amp;[T] {
        self.as_slice()
    }
}

pub const fn as_slice(&amp;self) -&gt; &amp;[T] {
    unsafe { slice::from_raw_parts(self.as_ptr(), self.len) }
}
</code></pre>

<p>Our design can’t do this. <code>PageReadGuard</code> and <code>PageWriteGuard</code> hold fundamentally different types — <code>RwLockReadGuard</code> and <code>RwLockWriteGuard</code> — that can’t be unified into a single raw pointer. The <code>RwLock</code> enforces the read/write distinction at runtime, so it has to be reflected as two distinct types at compile time. Any read method you want on <code>HeapPageViewMut</code> has to be written explicitly.</p>

<p>This is the tradeoff in Rust API design. There is no <code>unsafe</code>, and there is a clear separation of capabilities, but the ergonomics aren’t as nice as the ones unsafe-backed types get for free.</p>

<h2 id="conclusion">Conclusion</h2>

<p>By the end, the path looks more like this:</p>

<pre class="ascii-art"><code>  ┌─────────────────────────────────────────────────────────┐
  │                      Query Layer                        │
  └────────────────────────┬────────────────────────────────┘
                           │
  ┌────────────────────────▼────────────────────────────────┐
  │                    Execution Engine                     │
  └────────────────────────┬────────────────────────────────┘
                           │
  ┌────────────────────────▼────────────────────────────────┐
  │                    Transaction Manager                  │
  └──────────┬─────────────┴─────────────────┬──────────────┘
             │                               │
  ┌──────────▼──────────┐       ┌────────────▼────────────┐
  │    Lock Manager     │       │      Log Manager        │
  └─────────────────────┘       └─────────────────────────┘
                           │  borrowed views over page bytes
  ┌────────────────────────▼────────────────────────────────┐
  │                    Buffer Pool                          │
  └────────────────────────┬────────────────────────────────┘
                           │  `O_DIRECT` removes this copy
  ┌────────────────────────▼────────────────────────────────┐
  │                    Disk                                 │
  └─────────────────────────────────────────────────────────┘
</code></pre>

<p><code>O_DIRECT</code> removes the copy between disk and the buffer pool, and the page/view design removes fresh copies above the buffer pool by turning higher-level page objects into borrowed views over bytes that are already pinned in memory.</p>

<p>To me, the interesting part of this design is that it moves ownership into one place, the buffer pool. Everything above that is about views over the same set of bytes. This comes at the price of API ergonomics though. I had to structure the zero-copy page access so that the compiler can see the same invariants I care about. While it litters the code with lifetime annotations it eliminates a certain class of bugs and avoids redundant data movement.</p>]]></content><author><name></name></author><category term="databases" /><summary type="html"><![CDATA[You can find the source code for the project here]]></summary></entry><entry><title type="html">The Concurrency Trap: How An Atomic Counter Stalled A Pipeline</title><link href="https://redixhumayun.github.io/concurrency/2025/06/05/the-concurrency-trap-how-an-atomic-counter-stalled-a-pipeline.html" rel="alternate" type="text/html" title="The Concurrency Trap: How An Atomic Counter Stalled A Pipeline" /><published>2025-06-05T00:00:00+00:00</published><updated>2025-06-05T00:00:00+00:00</updated><id>https://redixhumayun.github.io/concurrency/2025/06/05/the-concurrency-trap-how-an-atomic-counter-stalled-a-pipeline</id><content type="html" xml:base="https://redixhumayun.github.io/concurrency/2025/06/05/the-concurrency-trap-how-an-atomic-counter-stalled-a-pipeline.html"><![CDATA[<p><em>Note: I wrote this post for Conviva. You can read the version published on Conviva’s website <a href="https://www.conviva.com/platform/the-concurrency-trap-how-an-atomic-counter-stalled-a-pipeline/">here</a></em></p>

<p>On February 2nd, <a href="https://www.conviva.com/">Conviva’s</a> streaming analytics platform suddenly ground to a crawl but only for one customer. P99 latency spiked without clear reason, pushing our DAG engine to its limits. What started as a puzzling slowdown soon became a deep dive into concurrency pitfalls.</p>

<p>Conviva’s platform is built to handle <a href="https://www.slideshare.net/slideshow/time-state-analytics-minneanalytics-2024-talk/270175638">5 trillion daily events</a>, powered by a <a href="https://en.wikipedia.org/wiki/Directed_acyclic_graph">DAG</a> (directed acyclic graph) based analytics engine. Each customer’s logic is compiled into a DAG, running concurrently on a custom actor model built atop Tokio.</p>

<p>This post unpacks how a seemingly innocuous atomic counter in a shared type registry became the bottleneck and what we learned about concurrency, cache lines, and the right data structures for the job. If you use Rust at scale, or plan to, you’ll enjoy this.</p>

<h2 id="setting-the-stage">Setting The Stage</h2>

<p>We intially tried debugging the issue by eliminating the obvious causes - watermarking, inaccurate metrics etc.</p>

<p><img src="/assets/img/conviva/rtve/traffic-from-gateway_blur.png" alt="" /></p>
<p align="center"><em>Traffic from gateway showing the P99 latency spike</em></p>

<p>There was some spirited discussion around whether the way <a href="https://github.com/tokio-rs/tokio">the Tokio runtime</a> was scheduling its tasks across physical threads was causing issues but that seemed improbable given that we use an actor system and each DAG processing task runs independently on a specific actor, and it was unlikely that multiple actors were being scheduled onto the same underlying physical thread.</p>

<p>There were additional lines of inquiry around whether HDFS writes were what was causing the lag to build up and eventually causing a backpressure throughout the system. More analysis of more graphs showed increased context switching during the incident but still with no clear evidence of the cause.</p>

<h2 id="analyzing-the-evidence">Analyzing The Evidence</h2>

<p>We were able to reproduce the issue by saving the event data to GCS buckets and replaying this in an environment enabled with <code>perf</code>. This was a relief because at least the issue wasn’t tied to the prod environment, which would have been a nightmare to debug.</p>

<p>We track active sessions across our system, so we have a reasonable measure of how much load our system is under. However, further analysis in the perf environment revealed that while there was a spike in the number of active sessions, those gradually dropped off while the DAG processing time continued to stay high.</p>

<p><img src="/assets/img/conviva/rtve/blurred_usecase_active_session.png" alt="" /></p>
<p align="center"><em>Session count tracker and processing time</em></p>

<p>While this was still puzzling, at least we had a clear indication of where to look - inside our DAG compiler/engine. All the clues pointed to this as being the source of the issue for the P99 latency spike and the backpressure we were seeing throughout the system.</p>

<p>While we knew where to look, this investigation had already taken weeks and things took a turn for the worse when we hit the issue again on February 23rd. However, there was more evidence coming our way about where to look. All Grafana metrics pointed to DAG processing actually being the cause of slowdown.  Another interesting graph that came up was this one displaying the jump in context switches during the incident. While it didn’t lead us directly to the root cause at that point, it became important later on as we identified the issue and resolved it because it tied in neatly with our analysis.</p>

<p><img src="/assets/img/conviva/rtve/context-switches.png" alt="" /></p>
<p align="center"><em>Context switches</em></p>

<h2 id="recreating-the-crime-scene">Recreating The Crime Scene</h2>

<p>Thanks to the earlier work in recreating the issue in a perf environment, we were able to generate these flamegraphs that highlighted hot paths in the code. The first one displays the flamegraph during normal traffic and the second one displays the flamegraph during the incident.</p>

<p><img src="/assets/img/conviva/rtve/perf_normal_traffic.svg" alt="" /></p>
<p align="center"><em>Normal traffic flamegraph</em></p>

<p><img src="/assets/img/conviva/rtve/perf_incident_traffic.svg" alt="" /></p>
<p align="center"><em>Incident traffic flamegraph</em></p>

<p>In the incident flamegraph, you can clearly see the dreaded wide bars which indicate longer processing time.</p>

<p>Looking carefully at the flamegraph generated during the incident, you can see a very high load for call paths involving <code>AtomicUsize::fetch_sub</code> which was being called from creating and dropping a <code>ReadGuard</code> in <a href="https://github.com/Cassy343/flashmap"><code>flashmap</code></a>, which we were using as a concurrent hash map. This concurrent hash map was being used as a type registry which was globally shared amongst all DAG’s across our system.</p>

<pre><code class="language-rust">use flashmap::{ReadHandle, WriteHandle};

pub struct TypeRegistry&lt;C: Clone, const N: usize = DEFAULT_N&gt; {
    writer: Mutex&lt;WriteHandle&lt;ShortTypeId, TypeMetadata&lt;C, N&gt;, BuildNoHashHasher&lt;ShortTypeId&gt;&gt;&gt;,
    pub(crate) reader: ReadHandle&lt;ShortTypeId, TypeMetadata&lt;C, N&gt;, BuildNoHashHasher&lt;ShortTypeId&gt;&gt;
}
</code></pre>

<p>In the context of this, the earlier graph about the context switches spiking during the incident makes some sense. The <code>ReadGuard</code> in the hot path of the flamegraph was responsible for handling reads from various threads and each thread would increment and decrement the counter.</p>

<p>Now, one important thing about the hashmap in the type registry is that it is <em>almost</em> read-only. It is initialized with some types on start-up and then only updated when a new type is seen but that rarely ever happened. However, on a critical path, it would check to see if the type was already registered which is where the atomic increments and decrements were occurring.</p>

<p>Now, the question was what to do about this. In the <a href="https://crates.io/crates/flashmap">flashmap documentation</a>, the performance comparison shows that in the read-heavy scenario <a href="https://github.com/xacrimon/dashmap"><code>dashmap</code></a> performed better in terms of both latency &amp; throughput. Unfortunately, replacing <code>flashmap</code> with <code>dashmap</code> did nothing to fix the performance problems. In fact, the flamegraphs turned out to be worse in the same situation with <code>dashmap</code>.</p>

<p><img src="/assets/img/conviva/rtve/perf_incident_traffic_dashmapv2.svg" alt="" /></p>
<p align="center"><em>Dashmap flamegraph</em></p>

<p>Finally, we implemented an <a href="https://github.com/vorner/arc-swap">ArcSwap</a> based solution and the flamegraph improved and the CPU load dropped to 40% in the perf environment.</p>

<pre><code class="language-rust">use arc_swap::ArcSwap;

pub struct TypeRegistry&lt;C: Clone, const N: usize = DEFAULT_N&gt; {
    pub(crate) types:
        ArcSwap&lt;HashMap&lt;ShortTypeId, TypeMetadata&lt;C, N&gt;, BuildNoHashHasher&lt;ShortTypeId&gt;&gt;&gt;,
}
</code></pre>

<h2 id="post-mortem">Post Mortem</h2>

<p>So, <code>ArcSwap</code> fixed the problem but let’s look at why it fixed the problem.</p>

<p>First, let’s dig into how concurrent hash maps typically operate. Many designs involve mechanisms like counters to track readers and writers, though the specifics can vary. For example, some implementations use a single, shared counter while others employ sharded designs or multiple counters to reduce contention.</p>

<p>For instance, <a href="https://github.com/xacrimon/dashmap/blob/master/src/lib.rs#L85-L89">Dashmap uses a sharded design</a> where each shard is a separate <code>HashMap</code> guarded by a <code>RWLock</code></p>

<pre><code class="language-rust">pub struct DashMap&lt;K, V, S = RandomState&gt; {
    shift: usize,
    shards: Box&lt;[CachePadded&lt;RwLock&lt;HashMap&lt;K, V&gt;&gt;&gt;]&gt;,
    hasher: S,
}
</code></pre>

<pre><code class="language-text">  [Core 1]             [Core 2]             [Core 3]
     |                   |                   |
     | read()            | read()            | read()
     |                   |                   |
     v                   v                   v
|-------------------Shared Read Counter-------------------|
                    (on one cache line)

                           CPU Caches
   ┌────────────┐     ┌────────────┐     ┌────────────┐
   │  Core 1    │     │  Core 2    │     │  Core 3    │
   │  Cache     │&lt;==&gt; │  Cache     │&lt;==&gt; │  Cache     │
   └────────────┘     └────────────┘     └────────────┘

       ↑ Cache line invalidated each time counter is written
       ↑ "Ping-pong" as cache line bounces across cores

⚠️ Every reader updates the same atomic/shared counter
⚠️ Constant inter-core cache line transfers = degraded perf
</code></pre>

<p>In cases where the data is guarded by a single, shared counter or resides on the same shard, contention can arise under high loads. This is because every CPU core attempting to increment or decrement the counter causes cache invalidation due to <a href="https://en.wikipedia.org/wiki/Cache_coherence">cache coherence</a>. Each modification forces the cache line containing the counter to “ping-pong” between cores, leading to degraded performance. To understand this better, look at this section below from a great PDF titled <a href="https://assets.bitbashing.io/papers/concurrency-primer.pdf">What every systems programmer should know about concurrency</a> by <a href="https://github.com/mrkline">Matt Kline</a>.</p>

<p><img src="/assets/img/conviva/rtve/cache_line_ping_pong.png" alt="" /></p>

<p>This also ties in with the context switching graph we saw earlier, which showed a spike in context switches during the incident.</p>

<p><em>Note: If you’re interested in understanding more about hardware caches and their implications, look at <a href="https://redixhumayun.github.io/performance/2025/01/27/cache-conscious-hash-maps.html">this post</a></em></p>

<p>Now, let’s contrast this with the approach that <code>ArcSwap</code> uses. <code>ArcSwap</code> follows the <a href="https://docs.kernel.org/RCU/whatisRCU.html">read-copy-update (RCU)</a> methodology where:</p>
<ul>
  <li>readers access the data without locking</li>
  <li>writers create a new copy of the data</li>
  <li>writers atomically swap in the new data</li>
  <li>old data is reclaimed later during a reclamation phase</li>
</ul>

<p>The <code>ArcSwap</code> repo even has a <a href="https://github.com/vorner/arc-swap/blob/b12da9d783d27111d31afc77e70b07ce6acdf9f6/src/lib.rs#L603">method called <code>rcu</code></a>.</p>

<div class="aside">This is analogous to how <a href="https://jepsen.io/consistency/models/snapshot-isolation">snapshot isolation</a> works in databases with multi-version concurrency control. The purpose is, of course, different but there are overlaps in the mechanism.<br /></div>

<p><code>ArcSwap</code> avoids cache contention issues for readers that typically crop up when updating a shared read counter with a <a href="https://github.com/vorner/arc-swap/blob/master/src/debt/list.rs#L335">thread-local epoch counter to track “debt”</a>.</p>

<p>A new version of the data is swapped in using the <a href="https://github.com/vorner/arc-swap/blob/master/src/strategy/hybrid.rs#L207">standard <code>cmp_xchg</code></a> operation. This marks the beginning of a new epoch, but the data associated with the old epoch isn’t cleaned up until all “debt” is paid off, that is until all readers of the previous epoch have finished.</p>

<pre><code class="language-text">  [Core 1]             [Core 2]             [Core 3]
     |                   |                   |
     | load()            | load()            | load()
     |                   |                   |
     v                   v                   v

[Thread-Local Epoch 1] [Thread-Local Epoch 2] [Thread-Local Epoch 3]
      (read guard)           (read guard)         (read guard)

      ┌─────────────────────────────┐
      │        ArcSwap&lt;T&gt;          │
      │ ┌────────────────────────┐ │
      │ │ Arc&lt;T&gt;: Current value  │ │ &lt;── atomic ptr (no cache bouncing)
      │ └────────────────────────┘ │
      └─────────────────────────────┘

     Writer swaps in new Arc&lt;T&gt; using atomic store()
     └── Old Arc&lt;T&gt; placed into deferred queue
         └── Only dropped when all read guards released

✅ No shared counters for read
✅ No cache line bouncing
✅ Readers are wait-free and isolated
</code></pre>

<p>The big difference between a concurrent hash map and <code>ArcSwap</code> is that <code>ArcSwap</code> requires swapping out the entirety of the underlying data with every write but trades this off with very cheap reads. Writers don’t even have to wait for all readers to finish since a new epoch is created with the new version of data.</p>

<p>Hash maps on the other hand allow updating invidual portions of data in the hash map but this is where it becomes important that we have an <em>almost</em> read-only scenario with a small dataset because the additional overhead of writes with <code>ArcSwap</code> is worth paying here since reads are faster.</p>

<h2 id="conclusion">Conclusion</h2>
<p>Given that we had a situation which was almost read-only with a small dataset, the overhead of a concurrent hash map was not suitable since we had no use case for frequent, granular updates. Trading that for <code>ArcSwap</code>, which is a specialized <code>AtomicRef</code>, something that is designed for occasional swaps where the entire ref is updated, turned out to be a much better fit.</p>]]></content><author><name></name></author><category term="concurrency" /><summary type="html"><![CDATA[Note: I wrote this post for Conviva. You can read the version published on Conviva’s website here]]></summary></entry><entry><title type="html">Cache Conscious Hash Maps</title><link href="https://redixhumayun.github.io/performance/2025/01/27/cache-conscious-hash-maps.html" rel="alternate" type="text/html" title="Cache Conscious Hash Maps" /><published>2025-01-27T00:00:00+00:00</published><updated>2025-01-27T00:00:00+00:00</updated><id>https://redixhumayun.github.io/performance/2025/01/27/cache-conscious-hash-maps</id><content type="html" xml:base="https://redixhumayun.github.io/performance/2025/01/27/cache-conscious-hash-maps.html"><![CDATA[<p><em><a href="https://github.com/redixhumayun/hashmap-rs">Here’s a link</a> to the code on GitHub</em></p>

<p>I’ve been trying to understand profiling and performance tooling better, and there didn’t seem to be a better way than to try to write a cache aware hash map.</p>

<p>The hard part here is getting familiar with the tooling and semantics of tools on different platforms (hint: avoid OSX)</p>

<h2 id="setting-up-the-tooling">Setting Up The Tooling</h2>

<p>My only recommendation in this section is to avoid using any platform other than Linux for profiling. <code>perf</code> is a godsend, and Apple is a locked down nightmare of an OS. The closest thing to <code>perf</code> on OSX is <code>dtrace</code> but <a href="https://stackoverflow.com/questions/60908765/mac-osx-using-dtruss">you need to disable</a> System Integrity Protection (SIP) on Mac for that. This is a very involved process that involves rebooting your Mac. Even then, I don’t think you get access to hardware counters on Mac.</p>

<p>Contrast this with Linux, where I managed to capture all the requirements to get up and running with all profiling metrics in this <a href="https://github.com/redixhumayun/learnings/issues/9">GitHub issue</a>. Additionally, there is great documentation for both <code>strace</code> and <code>perf</code> on Linux whereas documentation for <code>Instruments</code> is inadequate on OSX.</p>

<div class="aside">If you want full access to the hardware counters on Linux, I'd recommend not going for a virtual machine on the cloud. I attempted this with both a t-family instance and a c-family instance on AWS. Neither of them provide access to the underlying hardware counters despite the latter being a member of the bare metal family
<br /><br />
I purchased a <a href="https://www.amazon.in/dp/B079R5BWMY?ref=ppx_yo2ov_dt_b_fed_asin_title">refurbished Dell laptop</a> and installed Ubuntu on it.
</div>

<h2 id="building-a-hashmap">Building A HashMap</h2>

<p>Let’s dig into building the hashmap itself now. Everyone is familiar with the external API surface of a hashmap - 4 methods. There’s a few things we need to track internally too.</p>

<pre><code class="language-rust">struct HashMap {
  data: Vec&lt;T&gt;  //  not specifying what T is here
  size: usize,
  capacity: usize
}

pub fn new(capacity: usize) -&gt; Self
pub fn get(key: K) -&gt; Result&lt;Option&lt;V&gt;&gt;
pub fn insert(key: K, value: V) -&gt; Result&lt;()&gt;
pub fn delete(key: K) -&gt; Result&lt;()&gt;
fn get_load_factor() -&gt; f64
fn resize() -&gt; Result&lt;()&gt;
</code></pre>

<p>The load factor determines when the hash map needs to be resized. I’m setting it to 0.7 for all the examples here.</p>

<p>Building a hash map itself isn’t too hard, it’s figuring out how to handle collisions that’s challenging. There’s broadly 2 ways to do this:</p>

<ul>
  <li>chaining</li>
  <li>open addressing</li>
</ul>

<h3 id="chaining">Chaining</h3>

<p>The chained hash map is the most intuitive way to think about handling collisions - convert each entry into a linked list.</p>

<p>Now, you can append onto each entry any time you encounter the same index.</p>

<p><img src="/assets/img/cache-profiling/chaining.png" alt="" /></p>

<p>For chaining, the hashmap struct would look something like this</p>

<pre><code class="language-rust">struct Node&lt;K, V&gt; {
  key: K,
  value: V,
  next: Option&lt;Box&lt;Node&lt;K, V&gt;&gt;&gt;
}

struct LinkedList&lt;K,V&gt; {
  head: Option&lt;Box&lt;Node&lt;K, V&gt;&gt;&gt;
}

struct HashMap&lt;K, V&gt; {
  data: Vec&lt;LinkedList&lt;K, V&gt;&gt;,
  size: usize,
  capacity: usize
}
</code></pre>

<h3 id="open-addressing">Open Addressing</h3>

<p>The open addressing hash map, instead, tries to find the next empty slot in the vector. There’s a couple of different variations here:</p>

<ul>
  <li><a href="https://en.wikipedia.org/wiki/Linear_probing">linear probing</a></li>
  <li><a href="https://en.wikipedia.org/wiki/Quadratic_probing">quadratic probing</a></li>
  <li><a href="https://en.wikipedia.org/wiki/Double_hashing">double hashing</a></li>
</ul>

<p>We’ll use linear probing in this example to keep things simple.
The important thing to note is that with open addressing we expect better cache performance, and therefore better overall performance. If you want a detailed explanation of this, look at this <a href="https://stackoverflow.com/questions/49709873/cache-performance-in-hash-tables-with-chaining-vs-open-addressing">SO answer</a>. The tl;dr is that following the pointers in a linked list leads to cache trashing since the nodes are not “clustered” together in memory.</p>

<p><img src="/assets/img/cache-profiling/linear-probing.png" alt="" /></p>

<p>For open addressing the hash map struct would look something like this</p>

<pre><code class="language-rust">enum Entry&lt;K, V&gt; {
  Empty,
  Deleted(K),
  Occupied(K, V)
}

struct HashMap&lt;K, V&gt; {
  data: Vec&lt;Entry&lt;K, V&gt;&gt;,
  size: usize,
  capacity: usize,
}
</code></pre>

<h2 id="cache-hardware">Cache Hardware</h2>

<p>Now, let’s try and understand why we expect better performance from our open addressing implementation vs chaining implementation.</p>

<p>CPU caches come in 3 flavours - L1, L2 and a shared L3 cache. L3 connects out to RAM.</p>

<p><img src="/assets/img/cache-profiling/cache.svg" alt="" /></p>

<p>A CPU will first check its L1 cache for some data. Upon failing to find it in L1, it will search L2, then L3 and eventually RAM.</p>

<p>We want the CPU to stay as far up this hierarchy as possible because each cache miss means an extra fetch which wastes cycles.</p>

<p>Each cache has a unit called cache lines - these are the actual storage units. For instance, an L1 cache of size 128 KB with a 64 byte cache line, has 2,048 cache lines. We want our data to fit in these cache lines to prevent additional fetches.</p>

<p>Now, when the program first loads and a cache miss is encountered, the load will reach RAM and attempt to read the data. However, when the memory is read, the principle of <a href="https://www.geeksforgeeks.org/difference-between-spatial-locality-and-temporal-locality/">spatial locality</a> is followed and some surrounding memory is read as well.</p>

<p><img src="/assets/img/cache-profiling/cache-spatial-locality.png" alt="" /></p>

<p>If we are storing our data in a <code>Vec</code> with as few memory hops as possible, this is great because it is unlikely we’ll encounter a cache miss soon. However, if our data structure has a fragmented memory approach, like in <code>Vec&lt;LinkedLinked&lt;T&gt;&gt;</code>, then it is more likely we’ll end up encountering a cache miss again in the near future.</p>

<p>So, what we should expect to see is that the cache performance of the open addressing approach is better than the chaining approach.</p>

<h2 id="cache-performance">Cache Performance</h2>

<p>Let’s run our actual program and collect some performance metrics. 
I wrote some basic benchmarks and a test harness that you can see <a href="https://github.com/redixhumayun/hashmap-rs/blob/master/profiling.sh">here</a> and <a href="https://github.com/redixhumayun/hashmap-rs/blob/master/src/workloads.rs">here</a>.</p>

<p>Below are the cache counter stats, followed by CPU stats. For the cache stats, think of the first 2 lines as showing a rough aggregate. The <code>LLC</code> below stands for last level cache which is the L3 cache.</p>

<pre><code class="language-shell">  Performance counter stats for './target/release/hashmap -w load_factor -i chaining':

       762,544,185      cache-references                                                        (66.66%)
       597,421,905      cache-misses                     #   78.35% of all cache refs           (66.66%)
     8,145,732,959      L1-dcache-loads                                                         (66.67%)
       279,075,767      L1-dcache-load-misses            #    3.43% of all L1-dcache accesses   (66.68%)
       144,218,848      LLC-loads                                                               (66.67%)
       106,241,540      LLC-load-misses                  #   73.67% of all LL-cache accesses    (66.66%)

      14.427876578 seconds time elapsed

      13.234905000 seconds user
       1.191811000 seconds sys
  
  Performance counter stats for './target/release/hashmap -w load_factor -i open_addressing':

       654,770,201      cache-references                                                        (66.67%)
       509,578,087      cache-misses                     #   77.83% of all cache refs           (66.68%)
     5,511,667,961      L1-dcache-loads                                                         (66.67%)
       272,838,534      L1-dcache-load-misses            #    4.95% of all L1-dcache accesses   (66.67%)
        91,460,491      LLC-loads                                                               (66.66%)
        59,470,563      LLC-load-misses                  #   65.02% of all LL-cache accesses    (66.65%)

       9.068350211 seconds time elapsed

       7.816879000 seconds user
       1.250820000 seconds sys
</code></pre>

<pre><code class="language-shell">  Performance counter stats for './target/release/hashmap -w load_factor -i chaining':

                61      context-switches                 #    4.226 /sec
                 0      cpu-migrations                   #    0.000 /sec
    33,334,941,421      cycles                           #    2.309 GHz
    34,737,785,689      instructions                     #    1.04  insn per cycle
     6,377,780,492      branches                         #  441.853 M/sec
        37,015,167      branch-misses                    #    0.58% of all branches
         14,434.16 msec cpu-clock                        #    1.000 CPUs utilized
         14,434.20 msec task-clock                       #    1.000 CPUs utilized

      14.436026853 seconds time elapsed

      13.282750000 seconds user
       1.151891000 seconds sys

  Performance counter stats for './target/release/hashmap -w load_factor -i open_addressing':

                24      context-switches                 #    2.635 /sec
                 1      cpu-migrations                   #    0.110 /sec
    24,113,035,970      cycles                           #    2.647 GHz
    26,051,667,680      instructions                     #    1.08  insn per cycle
     4,472,654,897      branches                         #  491.004 M/sec
        30,880,291      branch-misses                    #    0.69% of all branches
          9,109.20 msec cpu-clock                        #    1.000 CPUs utilized
          9,109.21 msec task-clock                       #    1.000 CPUs utilized

       9.110056061 seconds time elapsed

       7.864538000 seconds user
       1.244926000 seconds sys
</code></pre>
<p>You can find a summary of the important metrics in the table below</p>

<table>
  <thead>
    <tr>
      <th>Metric</th>
      <th>Chaining</th>
      <th>Open Addressing</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Cache Miss Rate</td>
      <td>78.35%</td>
      <td>77.83%</td>
    </tr>
    <tr>
      <td>L1-DCache Miss Rate</td>
      <td>3.43%</td>
      <td>4.95%</td>
    </tr>
    <tr>
      <td>LLC Cache Miss Rate</td>
      <td>73.67%</td>
      <td>65.02%</td>
    </tr>
    <tr>
      <td>Execution Time (seconds)</td>
      <td>14.43</td>
      <td>9.07</td>
    </tr>
    <tr>
      <td>Instructions</td>
      <td>34,737,785,689</td>
      <td>26,051,667,680</td>
    </tr>
  </tbody>
</table>

<p>The results are surprising to say the least. Open addressing performs better overall as expected, but not for the reasons we expected. We expected better cache performance from open addressing but don’t see that at all.</p>

<p>In fact, the majority of the gains come from having to execute fewer instructions for open addressing. There’s no walking the chain and loading each node from memory in open addressing.</p>

<p>But, why is the cache performance so poor in open addressing? In fact, the cache performance of open addressing is actually worse at the L1 level than chaining and only slightly better at the L3 cache level.</p>

<p>To figure out why we are getting such poor cache performance, we need to understand the memory layout of our data structures and the size of our cache. This will help us figure out how many elements can be placed in a single cache line.</p>

<h2 id="cache-sizing">Cache Sizing</h2>

<p>Figuring out the size of the cache on Linux is straightforward. You run <code>lscpu | grep "cache"</code> and it gives you the information</p>

<pre><code class="language-shell">zaid-humayun@zaid-humayun-XPS-13-9370:~$ lscpu | grep "cache"
L1d cache:                            128 KiB (4 instances)
L1i cache:                            128 KiB (4 instances)
L2 cache:                             1 MiB (4 instances)
L3 cache:                             8 MiB (1 instance)
</code></pre>

<p>This tells me that I have 4 cores, each with an L1 cache (128KiB) and an L2 cache (1MiB) and finally one shared L3 cache (8MiB).</p>

<p>Next, we need to figure out the size of the cache line and it’s easy to do that with <code>cat /proc/cpuinfo | grep "cache_alignment"</code> which shows 64 bytes. So, we have 2,048 cache lines, since <code>64 bytes * 2048 = 128KiB</code></p>

<h2 id="memory-layout">Memory Layout</h2>

<p>Now, this is where the real meat of the problem is since this is what we control. Let’s look back at our data structures we were using again.</p>

<p>Before we get into this, a quick reminder that a <code>String</code> in Rust is represented by 24 bytes (8 for the heap pointer, 8 for the size &amp; 8 for the capacity).</p>

<p>For chaining, we are using a linked list to store the data. We can figure out the size of the <code>LinkedList</code> struct using <code>std::mem::size_of::&lt;LinkedList&gt;()</code> and when <code>K</code> and <code>V</code> are of type <code>String</code>, this turns out to be 8 bytes. With a cache line of 64 bytes, we can fit 8 entries per cache line. Here, <code>LinkedList</code> itself is an <code>Option&lt;Box&lt;Node&lt;K, V&gt;&gt;&gt;</code></p>

<div class="aside">
Some of you might be curious why the above is only 8 bytes when <code>Option&lt;T&gt;</code> typically takes an extra byte for it's discriminant so the overall size should be 16 bytes including padding (8 + 1).
<br /><br />
This is because of the Rust compiler's <a href="https://stackoverflow.com/a/46557737/6593789">null pointer optimization</a>, which is suprisingly hard to find a resource for. In our case, the type <code>LinkedList</code> is an <code>Option&lt;Box&lt;T&gt;&gt;</code> and <code>Box&lt;T&gt;</code> is a non-nullable type, so <code>Option&lt;Box&lt;T&gt;&gt;</code> is the same size as <code>Box&lt;T&gt;</code>
</div>

<p>Here’s a visualisation of how that is being laid out in the cache.</p>

<p><img src="/assets/img/cache-profiling/chaining-entry.png" alt="" /></p>

<p>This is good in terms of layout but each entry ends up being a pointer into the heap, so that’s not great because that means we always end up doing an extra fetch for each entry.</p>

<p>For open addressing, we are using an <code>Enum</code> where the memory layout is 48 bytes when <code>K</code> and <code>V</code> are of type <code>String</code>. It’s 48 bytes because the largest variant is a tuple of <code>String</code> and each <code>String</code> type is 24 bytes.</p>

<p><img src="/assets/img/cache-profiling/open-addressing-entry.png" alt="" /></p>

<p>Once we have padding alignment, we can only fit 1 element of our open addressing variant per cache line. This is significantly worse than the chaining layout but each entry here isn’t a pointer into heap memory (well, it is because the type is a <code>String</code> but we’ll get to that).</p>

<p>In fact, since we can fit 8 entries per cache line with the chaining approach, we see a slightly better performance in the L1 cache there (3.53% vs 4.95%). However, you can see the cost of pointer chasing with the total number of loads in the L1 cache (8.1B vs 5.5B). Even though chaining is more compact and cache efficient, the overall cost of pointer chasing ends up dominating the whole operation and open addressing performs better.</p>

<h2 id="types-and-pointer-chasing">Types And Pointer Chasing</h2>

<p>So, we know that memory layout of our data structures can have a major performance impact, but so could the actual types we use in the hash map.</p>

<p><code>Strings</code> inherently require pointer chasing. Regardless of how efficient we are with our memory layout for our struct, having a <code>String</code> in the key and value significantly impacts the cache performance.</p>

<p>However, if we replace the key-value pairs with <code>u64</code> then for open addressing each entry only takes 24 bytes now instead of 48, which means 2 entries per cache line instead of 1. So, I tried running the same profiling with <code>u64</code> key-value pairs.</p>

<pre><code class="language-shell">Performance counter stats for './target/release/hashmap -w load_factor -i chaining':

       372,807,778      cache-references                                                        (66.65%)
       252,436,979      cache-misses                     #   67.71% of all cache refs           (66.65%)
     2,380,114,906      L1-dcache-loads                                                         (66.67%)
       140,770,087      L1-dcache-load-misses            #    5.91% of all L1-dcache accesses   (66.69%)
        72,878,214      LLC-loads                                                               (66.68%)
        41,523,145      LLC-load-misses                  #   56.98% of all LL-cache accesses    (66.66%)

       6.273809754 seconds time elapsed

       5.943481000 seconds user
       0.330026000 seconds sys

 Performance counter stats for './target/release/hashmap -w load_factor -i open_addressing':

       217,178,237      cache-references                                                        (66.63%)
       167,946,747      cache-misses                     #   77.33% of all cache refs           (66.64%)
       880,710,536      L1-dcache-loads                                                         (66.68%)
        81,384,415      L1-dcache-load-misses            #    9.24% of all L1-dcache accesses   (66.71%)
        23,916,052      LLC-loads                                                               (66.70%)
        14,262,560      LLC-load-misses                  #   59.64% of all LL-cache accesses    (66.65%)

       2.421592557 seconds time elapsed

       2.128437000 seconds user
       0.293060000 seconds sys
</code></pre>

<p>Overall performance is considerably better, which validates our theory about <code>u64</code> being better for cache performance but we still need to improve on our cache performance with open addressing.</p>

<div class="aside">
Some of you may have noticed that when I had <code>Entry&lt;String, String&gt;</code> above, I needed 48 bytes whch is just the size of the two String pointers. 
<br /><br />
However, when I had <code>Entry&lt;u64, u64&gt;</code>, the size of the entry was 24 bytes (and not just 16 bytes which is the size of two <code>u64</code>). What gives?
<br /><br />
The Rust compiler is doing something called <a href="https://www.reddit.com/r/rust/comments/174ndzi/fun_fact_size_of_optionstring/">niche optimization</a> to reduce the size of the memory layout. Since, the <code>String</code> is backed by a non-nullable type in <code>Vec</code>, the compiler can use that to represent the null case. It can't do the same for <code>u64</code> since 0 is actually a valid value there.
</div>

<h2 id="compact-memory-layout">Compact Memory Layout</h2>

<p>Let’s design a more compact memory layout, something that will fit more easily into our cache. We’ll call this our open addressing compact variation, since it builds on top of open addressing.</p>

<p>We can try splitting up the state (whether the entry is free, occupied or tombstoned) and the actual data into separate vectors.</p>

<pre><code class="language-rust">pub struct HashMap&lt;K, V&gt;
where
    K: Key,
    V: Value,
{
    status_bits: Vec&lt;u8&gt;,
    entries: Vec&lt;(K, V)&gt;,
    capacity: usize,
    size: usize,
}
</code></pre>

<p>Now, when we have <code>K</code> and <code>V</code> as <code>u64</code>, each entry in the <code>entries</code> vector will be a tuple of size 16 bytes, which means 4 of them should fit in one cache line. To denote whether an entry is occupied, deleted or empty we’ll use 2 bits from the <code>status_bits</code> vector for each entry.</p>

<p><img src="/assets/img/cache-profiling/open-addressing-compact-entry.png" alt="" /></p>

<p>This means that we have to load two separate vectors into our cache but we still end up being more efficient. Each byte in the <code>status_bits</code> vector can hold the status of 4 entries, so a single entry of 8 bytes in the cache can hold the status of 32 entries.</p>

<p>Here’s some code that shows the indexing scheme. If you’re interesting in seeing the code in full context, <a href="https://github.com/redixhumayun/hashmap-rs/blob/master/src/open_addressing_compact.rs">look here</a>.</p>

<pre><code class="language-rust">// 2 bits per entry: 00 = empty, 01 = deleted, 11 = occupied
const EMPTY: u8 = 0b00;
const DELETED: u8 = 0b01;
const OCCUPIED: u8 = 0b11;

fn get_status(&amp;self, index: usize) -&gt; u8 {
    let byte_idx = index / 4;
    let bit_offset = (index % 4) * 2;
    (self.status_bits[byte_idx] &gt;&gt; bit_offset) &amp; 0b11
}

fn set_status(&amp;mut self, index: usize, status: u8) {
    let byte_idx = index / 4;
    let bit_offset = (index % 4) * 2;
    // Clear the two bits
    self.status_bits[byte_idx] &amp;= !(0b11 &lt;&lt; bit_offset);
    // Set the new status
    self.status_bits[byte_idx] |= (status &amp; 0b11) &lt;&lt; bit_offset;
}
</code></pre>

<p>Now, let’s try running our profiling again and see how we fare.</p>

<pre><code class="language-shell">Performance counter stats for './target/release/hashmap -w load_factor -i chaining':

       372,807,778      cache-references                                                        (66.65%)
       252,436,979      cache-misses                     #   67.71% of all cache refs           (66.65%)
     2,380,114,906      L1-dcache-loads                                                         (66.67%)
       140,770,087      L1-dcache-load-misses            #    5.91% of all L1-dcache accesses   (66.69%)
        72,878,214      LLC-loads                                                               (66.68%)
        41,523,145      LLC-load-misses                  #   56.98% of all LL-cache accesses    (66.66%)

       6.273809754 seconds time elapsed

       5.943481000 seconds user
       0.330026000 seconds sys

 Performance counter stats for './target/release/hashmap -w load_factor -i open_addressing':

       217,178,237      cache-references                                                        (66.63%)
       167,946,747      cache-misses                     #   77.33% of all cache refs           (66.64%)
       880,710,536      L1-dcache-loads                                                         (66.68%)
        81,384,415      L1-dcache-load-misses            #    9.24% of all L1-dcache accesses   (66.71%)
        23,916,052      LLC-loads                                                               (66.70%)
        14,262,560      LLC-load-misses                  #   59.64% of all LL-cache accesses    (66.65%)

       2.421592557 seconds time elapsed

       2.128437000 seconds user
       0.293060000 seconds sys

 Performance counter stats for './target/release/hashmap -w load_factor -i open_addressing_compact':

       171,399,933      cache-references                                                        (66.61%)
        86,106,253      cache-misses                     #   50.24% of all cache refs           (66.61%)
       955,677,380      L1-dcache-loads                                                         (66.66%)
        64,703,719      L1-dcache-load-misses            #    6.77% of all L1-dcache accesses   (66.73%)
        24,137,327      LLC-loads                                                               (66.73%)
         6,734,891      LLC-load-misses                  #   27.90% of all LL-cache accesses    (66.66%)

       1.617498666 seconds time elapsed

       1.411503000 seconds user
       0.205927000 seconds sys
</code></pre>

<table>
  <thead>
    <tr>
      <th>Metric</th>
      <th>Chaining</th>
      <th>Open Addressing</th>
      <th>Open Addressing Compact</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Cache Miss Rate</td>
      <td>67.71%</td>
      <td>77.33%</td>
      <td>50.24%</td>
    </tr>
    <tr>
      <td>L1-DCache Miss Rate</td>
      <td>5.91%</td>
      <td>9.24%</td>
      <td>6.77%</td>
    </tr>
    <tr>
      <td>LLC Cache Miss Rate</td>
      <td>56.98%</td>
      <td>59.64%</td>
      <td>27.90%</td>
    </tr>
    <tr>
      <td>Execution Time (seconds)</td>
      <td>6.27</td>
      <td>2.41</td>
      <td>1.616</td>
    </tr>
  </tbody>
</table>

<p>The overall cache misses have dropped to 50.24% for the open addressing compact variation, which is a ~50% improvement over open addressing. It also improves on the cache miss rate from the chaining implementation.</p>

<p>The more stark drop is in the LLC case where it has dropped from the high 50% range to 27.90%. That’s a change of over 50%! And, we can see this change reflected in the overall processing time of the load test too, which is a 1.5x improvement!</p>

<h2 id="conclusion">Conclusion</h2>

<p>Being performant with a cache line isn’t just about using a contiguous data structure like a <code>Vector</code> over a non-contiguous data structure like a <code>LinkedList</code>. It matters far more how many elements you can fit into a cache line and how many pointer chases you go through to get to the actual data.</p>

<p>In the case of <code>Strings</code>, it seems we are always doomed to an additional pointer chase which is why <code>u64</code> as keys is more cache performant. The best you could do here is to inline smaller strings onto the stack with something like <a href="https://crates.io/crates/smol_str">smol_str</a>, but this only works for strings upto 23 bytes long.</p>

<p>Thank you to <a href="https://x.com/debasishg">Debasish Ghosh</a> for reviewing a draft of this post.</p>]]></content><author><name></name></author><category term="performance" /><summary type="html"><![CDATA[Here’s a link to the code on GitHub]]></summary></entry><entry><title type="html">Async Runtimes Part III</title><link href="https://redixhumayun.github.io/async/2024/10/10/async-runtimes-part-iii.html" rel="alternate" type="text/html" title="Async Runtimes Part III" /><published>2024-10-10T00:00:00+00:00</published><updated>2024-10-10T00:00:00+00:00</updated><id>https://redixhumayun.github.io/async/2024/10/10/async-runtimes-part-iii</id><content type="html" xml:base="https://redixhumayun.github.io/async/2024/10/10/async-runtimes-part-iii.html"><![CDATA[<p><em><a href="https://github.com/redixhumayun/async-rust/tree/main/src/async_runtime">Here’s a link</a> to the code on GitHub.</em></p>

<p>This post is the third in a series about exploring what exactly an async runtime is, and what async I/O really means. <a href="/async/2024/08/05/async-runtimes.html">Here</a> is a link to part I where I built a basic future in Rust and polled it to completion with an executor, and <a href="/async/2024/09/18/async-runtimes-part-ii.html">here</a> is part II, where I built a simple event loop which uses the <code>kqueue</code> async io interface.</p>

<p>In this post, I’m going to combine learnings from both posts to build a simple, single-threaded async runtime in ~900 lines of Rust code.</p>

<p>All of this started with some fundamental <a href="https://x.com/redixhumayun/status/1833172458595054015">questions</a> I had about what exactly the term “async” means, what fibers, coroutines, green threads etc. really are.</p>

<h2 id="async-words">Async Words</h2>
<p>I want to first explore the terminology used in async because having a shared vocabulary makes it much easier to have abstract conversations. While it might just sound like jargon or yak-shaving, being able to clearly differentiate things in designs or conversations is critical because abstractions quickly pile on.</p>

<div class="aside">
A lot of the terminology shared here was picked up from reading <a href="https://www.packtpub.com/en-mt/product/asynchronous-programming-in-rust-9781805128137?srsltid=AfmBOop9MYJcpPaHbb-oI6EeQnTRr6GMu4GkcZF-fY8RtlSW5Z9igEJ2">Asynchronous Programming in Rust</a> by <a href="https://x.com/cf_samson">Carl Fredrik Samson</a>. The reason for this disclaimer is the same as in the book: this area is rife with overloaded terminology.
<br />
<br />
You are quite likely to come across different definitions for the same term. For instance, <a href="https://tokio.rs/tokio/tutorial/spawning">Tokio docs</a> call their tasks green threads but depending on what definition you go with <a href="https://x.com/cf_samson/status/1840511714724348174">that is not entirely accurate</a>.
<br />
<br />
In this post, we'll use the definition of green threads that specifically means stackful coroutines.
</div>

<p><img src="/assets/img/async/async_terms.png" alt="" /></p>
<figcaption style="text-align: center;">The State Of Async<a href="https://www.qovery.com/blog/a-guided-tour-of-streams-in-rust/"></a></figcaption>

<p>The image above gives a high-level overview of the state of async terminology. The broadest classifier of things are coroutines, of which there are two - stackful &amp; stackless.</p>

<p>Stackful coroutines are usually referred to by other names such as fibers or green threads. Stackless coroutines are just state machines under the hood, and they are sometimes called tasks. Both styles of coroutines are sometimes referred to as using the M:N threading model, where M user-space threads or tasks are multiplexed onto N threads of the underlying host system (<a href="https://x.com/kingprotty/status/1840413114187006198">thanks to King Protty for pointing this out on Twitter</a>).</p>

<p>The primary difference between the two types of coroutines is in the name - stackful have call stacks allocated (similar to OS thread stacks), stackless have no call stacks allocated to them.</p>

<p>Now, there are broadly two classes of schedulers(sometimes referred to as executors or runtimes):</p>
<ul>
  <li>Pre-emptive</li>
  <li>Co-operative</li>
</ul>

<p>Pre-emptive schedulers mean that the scheduler is capable of making a coroutine pause at any point during it’s execution. Co-operative schedulers mean that the scheduler is incapable of making a coroutine pause at any point during it’s execution, the coroutine is responsible for pausing at certain points so that it doesn’t block the scheduler.</p>

<p>With stackful coroutines, it is possible to have either a pre-emptive or co-operative scheduler (this is more of a spectrum, not binary). But, with stackless coroutines, you can only ever have a co-operative scheduler. The reason is that stackless coroutines compile down to state machines which don’t have a call stack allocated to them that stores execution information, so you can’t pause them at any point. Stackful coroutines on the other hand do have a call stack allocated to them which stores execution information. This allows the scheduler to stop &amp; resume them at any point.</p>

<div class="aside">A little bit of computing history for those of you interested in it. The first generations of the MacOS(v9 and earlier) had a cooperative scheduler, which could cause a poorly built application to take down the entire OS <br /><a href="https://pages.cs.wisc.edu/~remzi/OSTEP/intro.pdf">[From the OSTEP book]</a><br /></div>

<p>We’ll dig deeper into stackless coroutines and co-operative schedulers in this post, but if you’re interested in learning more about stackful coroutines, I highly recommend chapter 4 of <a href="https://www.packtpub.com/en-mt/product/asynchronous-programming-in-rust-9781805128137?srsltid=AfmBOor2hL_PXSAhdb5l5M2261s4zynuJnSLCMkP733MOmdJ8Y6-g8Lc">Asynchronous Programming in Rust</a>.</p>

<p>Stackful &amp; stackless coroutines have a famous allegory associated with them thanks to Bob Nystrom’s <a href="https://journal.stuffwithstuff.com/2015/02/01/what-color-is-your-function/">What Colour Is Your Function</a> blog post from back in 2015. In the post, he presents the case for why he thinks stackless coroutines are the wrong abstraction to represent concurrency in a language.</p>

<p>Let’s look at some examples - consider the Go code below</p>

<pre><code class="language-go">package main

import "fmt"

func f(n int) {
  for i := 0; i &lt; 10; i++ {
    fmt.Println(n, ":", i)
  }
}

func main() {
  go f(0)
  var input string
  fmt.Scanln(&amp;input)
}
</code></pre>

<p>It kicks off a goroutine in the background and waits for some input from the user. Notice that you don’t have to do anything explicit in terms of making this asynchronous apart from using the <code>go</code> keyword. The functions have no “colour”.</p>

<p>Now, here is the equivalent JavaScript code (I’m using JS here because it’s syntactically easier to parse than Rust). Now, you use the <code>async</code> keyword to denote that some function can run in the background and it needs to have <code>await</code> called against it.</p>

<pre><code class="language-javascript">const readline = require('readline').promises;

async function f(n) {
    for (let i = 0; i &lt; 10; i++) {
        console.log(n, ":", i);
        // Simulate some asynchronous work
        await new Promise(resolve =&gt; setTimeout(resolve, 100));
    }
}

async function main() {
    // Create a promise that resolves when f(0) completes
    const task = f(0);

    // Set up readline interface
    const rl = readline.createInterface({
        input: process.stdin,
        output: process.stdout
    });

    // Wait for user input
    await rl.question('Press Enter to exit...');

    // Close the readline interface
    rl.close();

    // Ensure f(0) has completed
    await task;
}

(async () =&gt; {
    try {
        await main();
    } catch (error) {
        console.error(error);
    }
})();
</code></pre>

<p>The biggest downside is that you also need to denote your <code>main</code> function as <code>async</code> now because you can never call an <code>async</code> function from a regular function. This is where your code gets “coloured”. This might not seem like a big deal but consider a situation where you are trying to do simple iteration in your code but need to call an <code>async</code> function there now. Now, your iteration code also requires <code>async</code> against it even if it doesn’t actually do any I/O waiting. It becomes hard to differentiate which parts of your code are actually doing I/O operations, which voids doing function colouring to begin with. Eventually, your code just ends up becoming the colour of <code>async</code>.</p>

<p>In the JS code, you’ll also notice <code>await</code>, which you can think of as the points at which this coroutine is yielding back to the scheduler (it’s similar to the <code>yield</code> keyword used in generators, and generators are similar to async functions).</p>

<p>This is why <code>async/await</code> is considered co-operative - the scheduler has no way of stopping a future/promise in the midst of it’s execution because there is nowhere to save it’s execution information so it can resume later.
In the equivalent Go code, there was no <code>yield</code> or <code>await</code> keyword - the language’s compiler allows each thread that is spun up to execute for some fixed amount of time before stopping it, which as of <a href="https://github.com/golang/go/blob/go1.19.1/src/runtime/proc.go#L5279-L5281">Go <code>1.19.1</code> is <code>10ms</code></a>(<a href="https://stackoverflow.com/questions/73915144/why-is-go-considered-partially-preemptive">source</a>).</p>

<p>Because of these details, there is a lot more implicit “magic” happening with Go’s runtime but it’s probably safer because it’s harder to shoot yourself in the foot. Conversely with the <code>async/await</code> situation, things are more explicit, but one poorly misplaced synchronous operation between two <code>await</code> operations could block your runtime. Rust uses <code>async/await</code> but doesn’t bundle a runtime with the language and depends on the ecosystem to provide a runtime. This choice providers users more power since they can choose a runtime based on the workload but adds a lot of mental overhead (a recurring theme with Rust).</p>

<p>In a language without “coloured” functions, since each coroutine is allocated a stack to keep track of it’s execution status, there is more overhead which isn’t present for stackless coroutines. Therefore, you should be able to spin up a greater number of stackless coroutines thus providing greater concurrency (this statement is caveated by implementation details of the application itlsef, though. Just because you can spin up more coroutines does not necessarily mean you are actually providing more concurrency and therefore greater throughput).</p>

<p><em>There is a certain irony to the terminology here - runtimes without “coloured” functions spin up “green” threads, whereas runtimes with “coloured” functions have no colour to their tasks.</em></p>

<div class="aside">
Interestingly enough, Rust pre-1.0 actually <a href="https://github.com/rust-lang/rfcs/blob/master/text/0230-remove-runtime.md">had stackful coroutines</a> as part of it's runtime but decided to remove them because of binary size overhead and to not diverge the threading API. Zig, on the other hand, has <code>async/await</code> but without <a href="https://kristoff.it/blog/zig-colorblind-async-await/">function colouring</a>.
</div>

<p>Now that we’ve explored the differences between stackful &amp; stackless coroutines, let’s get down to building a simple runtime for stackless coroutines.</p>

<h2 id="state-machines">State Machines</h2>
<p>The most important thing to understand when it comes to stackless coroutines is that they are typically compiled down to state machines which are then “run” to completion.</p>

<p>The important thing to note here is that the moment you use the async keyword in Rust, the compiler transforms that into code that resembles a state machine. Look at this <a href="https://play.rust-lang.org/?version=nightly&amp;mode=debug&amp;edition=2021&amp;gist=da3fc94f155324c5daeec9b1d6cacf49">Rust Playground link</a> as an example. It just has the following Rust code</p>

<pre><code class="language-rust">fn main() {
    async {
        println!("Hello world");
    };
}
</code></pre>

<p>If you click the view MIR option in the playground, you’ll see the code generated by the compiler for the async block above.</p>

<p>The “run” to completion bit is important above because these state machines are lazy - they need to actually be run, sometimes repeatedly. This is the job of the executor/scheduler.</p>

<p>Taking a more complete example involving Rust futures, consider the code below</p>

<pre><code class="language-rust">async fn example() {
    let a = async_fn_1().await;
    let r = sync_fn();
    let b = async_fn_2().await;
}
</code></pre>

<p>which will compile down to something resembling the following</p>

<pre><code class="language-rust">enum ExampleState {
    Start,
    AwaitingFutureA,
    AwaitingFutureB,
    Done
}

struct Example {
    state: ExampleState,
    output: Option&lt;()&gt;,
}

impl Future for Example {
    //  the logic to move the state machine through it's various states
}
</code></pre>

<p>Rememeber that Rust futures are stackless coroutines that cannot be pre-empted by the scheduler. If your <code>sync_fn()</code> above takes too long, you are actually blocking this task and the thread of the executor this task is running on.</p>

<h2 id="building-a-runtime">Building A Runtime</h2>
<p>Let’s start writing some code to build a basic single-threaded runtime. Here are the major components we are going to be building:</p>
<ul>
  <li>tasks (wrappers around futures)</li>
  <li>waker</li>
  <li>reactor</li>
  <li>sync thread pool</li>
  <li>executor</li>
</ul>

<p><em>If you’re unsure what any of these terms mean, refer back to parts i and ii of this series</em></p>

<p>Here’s a great visual representation of what we’re trying to build using a TCP socket as an example.</p>

<p><img src="/assets/img/async/future_quovery.com:blog:a-guided-tour-of-streams-in-rust:.avif" alt="" /></p>
<figcaption style="text-align: center;">Image source: <a href="https://www.qovery.com/blog/a-guided-tour-of-streams-in-rust/">A Guided Tour Of Streams In Rust</a></figcaption>

<p>We’ll tackle this component by component starting with actually representing our futures first.</p>

<h3 id="tasks">Tasks</h3>
<p>Representing tasks is fairly simple, they are just wrappers around futures with some additional metadata:</p>

<pre><code class="language-rust">pub struct Task {
    pub id: usize,
    pub future: RefCell&lt;Pin&lt;Box&lt;dyn Future&lt;Output = ()&gt; + 'static&gt;&gt;&gt;,
}

impl Display for Task {
    fn fmt(&amp;self, f: &amp;mut std::fmt::Formatter&lt;'_&gt;) -&gt; std::fmt::Result {
        write!(f, "task {}", self.id)
    }
}

impl std::fmt::Debug for Task {
    fn fmt(&amp;self, f: &amp;mut std::fmt::Formatter&lt;'_&gt;) -&gt; std::fmt::Result {
        write!(f, "task {}, ", self.id)
    }
}
</code></pre>

<p>If you’re interested in understanding the <code>Pin&lt;Box&lt;T&gt;&gt;</code> verbosity, checkout <a href="https://fasterthanli.me/articles/pin-and-suffering">fasterthanlime’s post</a>. Put very simply, it’s to ensure that the future is not moved from the memory region it’s stored in.</p>

<h3 id="waker">Waker</h3>
<p>The waker is probably the component with the most complicated code, mostly because I chose to implement it in unsafe Rust. If you’re following along and trying to build this on your own, you can always use the <a href="https://docs.rs/futures/latest/futures/task/trait.ArcWake.html">simpler <code>ArcWake</code> implementation</a>. I chose not to do that mainly because I never intended to make this runtime multi-threaded and I like the masochism of unsafe Rust.</p>

<p>Before I jump into the code for the waker, I need to explain a fat pointer. A pointer is typically one word size since it only holds a memory address. However, a fat pointer is more than one word size since it holds a memory address and some additional data (in this case, a <code>vtable</code>).</p>

<p><em>Note: If you want to look at a better implementation of the code below, look at the <a href="https://github.com/rust-lang/futures-rs/blob/master/futures-task/src/waker.rs#L31">std lib’s implementation</a></em></p>

<p>Here’s the code for the waker, and I’ll walk through it after</p>

<pre><code class="language-rust">pub struct MyWaker {
    task: Rc&lt;Task&gt;,
    sender: Sender&lt;Rc&lt;Task&gt;&gt;,
}

impl MyWaker {
    const VTABLE: RawWakerVTable =
        RawWakerVTable::new(Self::clone, Self::wake, Self::wake_by_ref, Self::drop);

    pub fn new(task: Rc&lt;Task&gt;, sender: Sender&lt;Rc&lt;Task&gt;&gt;) -&gt; Waker {
        let pointer = Rc::into_raw(Rc::new(MyWaker { task, sender })) as *const ();
        let vtable = &amp;MyWaker::VTABLE;
        unsafe { Waker::from_raw(RawWaker::new(pointer, vtable)) }
    }

    unsafe fn clone(ptr: *const ()) -&gt; RawWaker {
        let waker = std::mem::ManuallyDrop::new(Rc::from_raw(ptr as *const MyWaker));
        let cloned_waker = Rc::clone(&amp;waker);
        let raw_pointer = Rc::into_raw(cloned_waker);
        RawWaker::new(raw_pointer as *const (), &amp;Self::VTABLE)
    }

    unsafe fn wake(ptr: *const ()) {
        let waker = Rc::from_raw(ptr as *const MyWaker);
        waker.sender.send(Rc::clone(&amp;waker.task)).unwrap();
    }

    unsafe fn wake_by_ref(ptr: *const ()) {
        let waker = &amp;*(ptr as *const MyWaker);
        waker.sender.send(Rc::clone(&amp;waker.task)).unwrap();
    }

    unsafe fn drop(ptr: *const ()) {
        drop(Rc::from_raw(ptr as *const MyWaker));
    }
}
</code></pre>

<p>I have a custom <code>MyWaker</code> struct which holds the task and a sender to a channel. Apart from that, there is the implementation of the dynamic virtual table.</p>

<p>The <code>clone</code> method above uses <code>std::mem::ManuallyDrop</code> to ensure that the default destructor for <code>Rc</code> doesn’t run when the scope ends (I spent 6 hours chasing down that bug).</p>

<p>The <code>wake</code> &amp; <code>wake_by_ref</code> methods send the task onto a channel so that it can be polled by the executor.</p>

<h3 id="task-queue">Task Queue</h3>
<p>I didn’t explicitly mention this component in the list above because it’s tiny and could typically be rolled into the executor. But, it’s where tasks are sent to &amp; then read from. Fairly self-explanatory code.</p>

<pre><code class="language-rust">pub struct TaskQueue {
    pub tasks: Vec&lt;Rc&lt;Task&gt;&gt;,
    sender: Sender&lt;Rc&lt;Task&gt;&gt;,
    receiver: Receiver&lt;Rc&lt;Task&gt;&gt;,
}

impl TaskQueue {
    pub fn new() -&gt; Self {
        let (sender, recv) = mpsc::channel();
        Self {
            tasks: Vec::new(),
            sender,
            receiver: recv,
        }
    }

    pub fn sender(&amp;self) -&gt; Sender&lt;Rc&lt;Task&gt;&gt; {
        self.sender.clone()
    }

    pub fn receive(&amp;mut self) {
        while let Ok(task) = self.receiver.try_recv() {
            self.tasks.push(task);
        }
    }

    pub fn pop(&amp;mut self) -&gt; Option&lt;Rc&lt;Task&gt;&gt; {
        self.tasks.pop()
    }

    pub fn len(&amp;self) -&gt; usize {
        self.tasks.len()
    }

    pub fn is_empty(&amp;self) -&gt; bool {
        self.tasks.len() == 0
    }
}
</code></pre>

<h3 id="reactor">Reactor</h3>
<p>I already covered reactors in detail in <a href="(/async/2024/09/18/async-runtimes-part-ii.html)">part ii</a>, so refer to that if you want more detail. The big update to this component is that I now store the <code>wakers</code> that are built for each task, so that the task can be enqueued when the reactor receives an event.</p>

<pre><code class="language-rust">#[derive(Debug)]
pub struct Event {
    pub fd: usize,
    pub readable: bool,
    pub writable: bool,
}

impl Event {
    pub fn none(fd: usize) -&gt; Event {
        Event {
            fd,
            readable: false,
            writable: false,
        }
    }

    pub fn readable(fd: usize) -&gt; Event {
        Event {
            fd,
            readable: true,
            writable: false,
        }
    }

    pub fn writable(fd: usize) -&gt; Event {
        Event {
            fd,
            readable: false,
            writable: true,
        }
    }

    pub fn all(fd: usize) -&gt; Event {
        Event {
            fd,
            readable: true,
            writable: true,
        }
    }
}

#[derive(Debug)]
pub enum InterestType {
    Read,
    Write,
}

pub struct Reactor {
    kqueue_fd: RawFd,
    notifier: (UnixStream, UnixStream),
    readable: HashMap&lt;usize, Vec&lt;Waker&gt;&gt;,
    writable: HashMap&lt;usize, Vec&lt;Waker&gt;&gt;,
}

impl Reactor {
    /// Create a reactor instance
    pub fn new() -&gt; std::io::Result&lt;Self&gt; {
        let kq = unsafe { libc::kqueue() };
        if kq &lt; 0 {
            return Err(std::io::Error::last_os_error());
        }
        let (reader, writer) = UnixStream::pair()?;
        reader.set_nonblocking(true)?;
        writer.set_nonblocking(true)?;
        let reactor = Reactor {
            kqueue_fd: kq.as_raw_fd(),
            notifier: (reader, writer),
            readable: HashMap::new(),
            writable: HashMap::new(),
        };

        reactor.modify(
            reactor.notifier.0.as_raw_fd(),
            Event::readable(reactor.notifier.0.as_raw_fd().try_into().unwrap()),
        )?;
        Ok(reactor)
    }

    /// Function to determine what interests this source has
    fn get_interest(&amp;self, source: usize) -&gt; Event {
        match (
            self.readable.contains_key(&amp;source),
            self.writable.contains_key(&amp;source),
        ) {
            (false, false) =&gt; Event::none(source),
            (true, false) =&gt; Event::readable(source),
            (false, true) =&gt; Event::writable(source),
            (true, true) =&gt; Event::all(source),
        }
    }

    /// Function to register interest for a specific source
    pub fn register_interest(
        &amp;mut self,
        source: i32,
        interest: InterestType,
        context: &amp;mut Context,
    ) {
        match interest {
            InterestType::Read =&gt; {
                self.readable
                    .entry(source as usize)
                    .and_modify(|v| v.push(context.waker().clone()))
                    .or_insert(vec![context.waker().clone()]);
                self.modify(source, Event::readable(source as usize))
                    .unwrap();
            }
            InterestType::Write =&gt; {
                self.writable
                    .entry(source as usize)
                    .and_modify(|v| v.push(context.waker().clone()))
                    .or_insert(vec![context.waker().clone()]);
                self.modify(source, Event::writable(source as usize))
                    .unwrap();
            }
        }
    }

    pub fn get_wakers(&amp;mut self, events: Vec&lt;Event&gt;) -&gt; Vec&lt;Waker&gt; {
        let mut wakers = Vec::new();
        for event in events {
            if event.readable {
                if let Some(readable_wakers) = self.readable.remove(&amp;event.fd) {
                    wakers.extend(readable_wakers);
                }
            } else if event.writable {
                if let Some(writable_wakers) = self.writable.remove(&amp;event.fd) {
                    wakers.extend(writable_wakers);
                }
            }
        }
        wakers
    }

    pub fn waiting_on_events(&amp;self) -&gt; bool {
        if self.readable.is_empty() &amp;&amp; self.writable.is_empty() {
            return false;
        }
        true
    }

    /// Function to accept the source to register an interest in and the type of interest
    pub fn add(&amp;mut self, source: RawFd) -&gt; std::io::Result&lt;()&gt; {
        self.modify(source, self.get_interest(source as usize))
    }

    /// A helper notify method to unblock the scheduler
    pub fn notify(&amp;mut self) -&gt; std::io::Result&lt;usize&gt; {
        self.notifier.1.write(&amp;[1])
    }

    /// The function that removes interest for a file descriptor with the actual underlying syscall
    pub fn remove(&amp;mut self, fd: RawFd, ev: Event) -&gt; std::io::Result&lt;()&gt; {
        self.readable.remove(&amp;(fd as usize));
        self.writable.remove(&amp;(fd as usize));
        let registered_interest = self.get_interest(fd as _);
        let mut changelist = Vec::new();
        if ev.readable &amp;&amp; registered_interest.readable {
            changelist.push(kevent {
                ident: fd as _,
                filter: EVFILT_READ,
                flags: EV_DELETE,
                fflags: 0,
                data: 0,
                udata: ev.fd as *mut c_void,
            });
        }
        if ev.writable &amp;&amp; registered_interest.writable {
            changelist.push(kevent {
                ident: fd as _,
                filter: EVFILT_WRITE,
                flags: EV_DELETE,
                fflags: 0,
                data: 0,
                udata: ev.fd as *mut c_void,
            });
        }

        if changelist.is_empty() {
            return Ok(());
        }

        let result = unsafe {
            kevent(
                self.kqueue_fd,
                changelist.as_mut_ptr(),
                changelist.len() as i32,
                std::ptr::null_mut(),
                0,
                std::ptr::null(),
            )
        };
        if result &lt; 0 {
            error!(
              "There was an error while attempting to modify the kqueue list for {} for event {:?}",
              fd, ev
            );
            return Err(std::io::Error::last_os_error());
        }
        Ok(())
    }

    /// The function that registers interest with the actual underlying syscall
    fn modify(&amp;self, fd: RawFd, ev: Event) -&gt; std::io::Result&lt;()&gt; {
        debug!("Adding file {} for event {:?} to reactor", fd, ev);
        let mut changelist = Vec::new();
        if ev.readable {
            changelist.push(kevent {
                ident: fd as _,
                filter: EVFILT_READ,
                flags: EV_ADD | EV_ONESHOT,
                fflags: 0,
                data: 0,
                udata: ev.fd as *mut c_void,
            });
        }

        if ev.writable {
            changelist.push(kevent {
                ident: fd as _,
                filter: EVFILT_WRITE,
                flags: EV_ADD | EV_ONESHOT,
                fflags: 0,
                data: 0,
                udata: ev.fd as *mut c_void,
            });
        }

        if changelist.is_empty() {
            return Ok(());
        }

        let result = unsafe {
            kevent(
                self.kqueue_fd,
                changelist.as_ptr(),
                changelist.len() as i32,
                std::ptr::null_mut(),
                0,
                std::ptr::null(),
            )
        };

        if result &lt; 0 {
            return Err(std::io::Error::last_os_error());
        }
        Ok(())
    }

    /// Blocking poll function to get events
    pub fn poll(&amp;mut self) -&gt; std::io::Result&lt;Vec&lt;Event&gt;&gt; {
        let mut events: Vec&lt;libc::kevent&gt; = Vec::new();
        let result = unsafe {
            events.resize(1, std::mem::zeroed());
            kevent(
                self.kqueue_fd,
                std::ptr::null(),
                0,
                events.as_mut_ptr(),
                1,
                std::ptr::null(),
            )
        };

        if result &lt; 0 {
            return Err(std::io::Error::last_os_error());
        }

        let mapped_events: std::io::Result&lt;Vec&lt;_&gt;&gt; = events
            .iter()
            .map(|event| {
                let ident = event.ident;
                let filter = event.filter;

                if ident == self.notifier.0.as_raw_fd().try_into().unwrap() {
                    let mut buf = [0; 8];
                    self.notifier.0.read(&amp;mut buf)?;
                    self.modify(
                        self.notifier.0.as_raw_fd(),
                        Event::readable(self.notifier.0.as_raw_fd().try_into().unwrap()),
                    )?;
                }

                let event = Event {
                    fd: ident,
                    readable: filter == EVFILT_READ,
                    writable: filter == EVFILT_WRITE,
                };
                Ok(event)
            })
            .collect();
        info!("Received events {:?}", mapped_events);

        mapped_events
    }
}
</code></pre>
<p>The code above creates a <code>kqueue</code> file descriptor and then starts listening for events in one-shot mode on specific file descriptors. <code>kqueue</code> is only available on OSX &amp; BSD distributions, the equivalent for Linux would be <code>epoll</code> but they provide a similar API.</p>

<p>The code also uses a Unix pipe hack to unblock the scheduler (I’ll show that below) since I just block the scheduler while listening for events from <code>kqueue</code>. I imagine that a production grade scheduler would typically provide a timeout.</p>

<p>Pay attention to the <code>register_interest</code> and <code>get_interest</code> methods here that update the <code>readable</code> &amp; <code>writable</code> hash maps, this is where the <code>wakers</code> are stored.</p>

<div class="aside">
I already mentioned this in part II but it bears repeating because I think it's crucial.
There are two style of async IO interfaces provided by the OS - readiness based &amp; completion based. 
<br /><br />
<code>epoll</code> and <code>kqueue</code> fall under the readiness based model. These syscalls only indicate when a file descriptor is available to be read from or written to. There's the overhead of an additional syscall to actually do the writing or reading in this case. Because this API only determines readiness, it is mostly suitable for doing network operations - files are always considered "ready" to read from so there is no sense in wasting cycles polling their file descriptors.
<br />
<br />
<code>io_uring</code> on the other hand falls under the completion based model. This model involves providing the file descriptors you're interested in reading from or writing to along with a buffer into which the results can be written. It avoids the overhead of the additional syscall and also <a href="https://github.com/ziglang/zig/issues/8224#issuecomment-848587146">provides a unifying interface</a> for both network &amp; file IO operations. If you're interested, check out Jens Axboe's work on <a href="https://github.com/axboe/liburing">liburing</a>
</div>

<h3 id="sync-thread-pool">Sync Thread Pool</h3>
<p>I mentioned earlier that we typically don’t want to block within our <code>async</code> code, but sometimes you need to perform a blocking operation, like reading from a file when <code>io_uring</code> isn’t available.</p>

<p>In these situations, it’s helpful to have a separate thread pool which can be used to run these blocking tasks in a non-blocking manner. This frees up the executor to run other tasks and check with this task periodically.</p>

<pre><code class="language-rust">use super::reactor::Reactor;

pub struct FileIOPool {
    sender: Sender&lt;FileReaderTask&gt;,
}

pub struct FileReaderTask {
    pub path: PathBuf,
    pub responder: Sender&lt;std::io::Result&lt;Vec&lt;u8&gt;&gt;&gt;,
}

impl FileIOPool {
    pub fn new(num_threads: usize, shutdown_rx: Receiver&lt;()&gt;) -&gt; Self {
        let (sender, receiver) = channel::&lt;FileReaderTask&gt;();
        let recv = Arc::new(Mutex::new(receiver));
        let shutdown_rx = Arc::new(Mutex::new(shutdown_rx));
        for _ in 0..num_threads {
            let recv_clone = Arc::clone(&amp;recv);
            let shutdown_rx_clone = Arc::clone(&amp;shutdown_rx);
            std::thread::spawn(move || loop {
                let task = recv_clone.lock().unwrap().try_recv();
                let shutdown_signal = shutdown_rx_clone.lock().unwrap().try_recv();
                match (task, shutdown_signal) {
                    (Ok(task), _) =&gt; {
                        let result = std::fs::read(task.path);
                        let _ = task.responder.send(result);
                    }
                    (_, Ok(())) =&gt; {
                        debug!("File io pool received shutdown signal, shutting down");
                        break;
                    }
                    (Err(_), Err(_)) =&gt; {}
                }
            });
        }
        Self { sender }
    }

    pub fn read_file(&amp;self, path: PathBuf, reactor: Rc&lt;RefCell&lt;Reactor&gt;&gt;) -&gt; ReadFileFuture {
        let (file_completion_sender, file_completion_recv) =
            std::sync::mpsc::channel::&lt;std::io::Result&lt;Vec&lt;u8&gt;&gt;&gt;();
        let file_reader_task = FileReaderTask {
            path,
            responder: file_completion_sender,
        };
        self.sender
            .send(file_reader_task)
            .expect("Error while sending the file reader task to io pool");
        ReadFileFuture {
            reactor,
            receiver: file_completion_recv,
        }
    }
}

pub struct ReadFileFuture {
    reactor: Rc&lt;RefCell&lt;Reactor&gt;&gt;,
    receiver: Receiver&lt;std::io::Result&lt;Vec&lt;u8&gt;&gt;&gt;,
}

impl Future for ReadFileFuture {
    type Output = std::io::Result&lt;Vec&lt;u8&gt;&gt;;
    fn poll(
        self: std::pin::Pin&lt;&amp;mut Self&gt;,
        cx: &amp;mut std::task::Context&lt;'_&gt;,
    ) -&gt; std::task::Poll&lt;Self::Output&gt; {
        match self.receiver.try_recv() {
            Ok(data) =&gt; return std::task::Poll::Ready(data),
            Err(e) =&gt; match e {
                TryRecvError::Empty =&gt; {
                    debug!("received empty from the file reader task receiver");
                    self.reactor.borrow_mut().notify()?; //  force the reactor to wake up so scheduler can continue
                    cx.waker().wake_by_ref();
                    return std::task::Poll::Pending;
                }
                TryRecvError::Disconnected =&gt; {
                    return std::task::Poll::Ready(Err(std::io::Error::new(
                        std::io::ErrorKind::Other,
                        "Received Disconnected while waiting for file read to complete",
                    )))
                }
            },
        }
    }
}
</code></pre>

<p>Every time the thread pool receives a task to read a file, it returns a future which can be polled to check whether the file reading has completed.</p>

<h3 id="executor">Executor</h3>
<p>Finally, the center piece of all of this - the executor. There’s a lot of code here but it’s a simple component to understand. It’s simpler to think of this as a combination of 2 components:</p>
<ul>
  <li>event loop</li>
  <li>futures poller</li>
</ul>

<p>In the “hot” loop of this component, it listens for events from the reactor and also checks if the task queue has any tasks on it to poll, and it does this combination every iteration.</p>

<pre><code class="language-rust">pub struct Executor {
    task_queue: Rc&lt;RefCell&lt;TaskQueue&gt;&gt;,
    reactor: Rc&lt;RefCell&lt;Reactor&gt;&gt;,
    monotonic_clock: Mutex&lt;usize&gt;,
}

impl Executor {
    pub fn new(
        task_queue: Rc&lt;RefCell&lt;TaskQueue&gt;&gt;,
        reactor: Rc&lt;RefCell&lt;Reactor&gt;&gt;,
    ) -&gt; std::io::Result&lt;Self&gt; {
        Ok(Self {
            task_queue,
            reactor,
            monotonic_clock: Mutex::new(0),
        })
    }

    pub fn block_on&lt;F&gt;(&amp;self, future: F) -&gt; F::Output
    where
        F: Future&lt;Output = ()&gt; + 'static,
    {
        let task = Task {
            id: *self.monotonic_clock.lock().unwrap(),
            future: RefCell::new(Box::pin(future)),
        };
        *self.monotonic_clock.lock().unwrap() += 1;
        self.task_queue
            .borrow()
            .sender()
            .send(Rc::new(task))
            .unwrap();
        self.run();
    }

    pub fn spawn&lt;F&gt;(&amp;self, future: F) -&gt; F::Output
    where
        F: Future&lt;Output = ()&gt; + 'static,
    {
        let task = Task {
            id: *self.monotonic_clock.lock().unwrap(),
            future: RefCell::new(Box::pin(future)),
        };
        *self.monotonic_clock.lock().unwrap() += 1;
        self.task_queue
            .borrow()
            .sender()
            .send(Rc::new(task))
            .unwrap();
        self.reactor.borrow_mut().notify().unwrap();
    }

    fn run(&amp;self) {
        loop {
            self.task_queue.borrow_mut().receive();
            loop {
                let task = {
                    if let Some(task) = self.task_queue.borrow_mut().pop() {
                        task
                    } else {
                        break;
                    }
                };

                let waker = MyWaker::new(Rc::clone(&amp;task), self.task_queue.borrow().sender());
                let mut context = Context::from_waker(&amp;waker);
                match task.future.borrow_mut().as_mut().poll(&amp;mut context) {
                    std::task::Poll::Ready(_output) =&gt; {
                        debug!(
                            "The future for task {} has completed and returned on thread {:?}",
                            task.id,
                            std::thread::current().id()
                        );
                    }
                    std::task::Poll::Pending =&gt; {
                        debug!(
                            "The future for task {} is pending on thread {:?}",
                            task.id,
                            std::thread::current().id()
                        );
                    }
                };
            }

            self.task_queue.borrow_mut().receive();
            if !self.reactor.borrow().waiting_on_events() &amp;&amp; self.task_queue.borrow().is_empty() {
                debug!("no events to wait on and no events in the queue, so breaking out");
                break;
            }

            if self.reactor.borrow().waiting_on_events() {
                debug!("waiting on events from the reactor");
                match self.wait_for_io() {
                    Ok(events) =&gt; self.wake_futures_on_io(events),
                    Err(e) =&gt; {
                        if e.kind() == std::io::ErrorKind::Interrupted {
                            break;
                        }
                        eprintln!("Error while waiting for IO events :{}", e);
                    }
                }
            }
        }
    }

    fn wait_for_io(&amp;self) -&gt; std::io::Result&lt;Vec&lt;Event&gt;&gt; {
        self.reactor.borrow_mut().poll()
    }

    fn wake_futures_on_io(&amp;self, events: Vec&lt;Event&gt;) {
        let wakers = self.reactor.borrow_mut().get_wakers(events);
        let _ = wakers
            .into_iter()
            .map(|waker| waker.wake())
            .collect::&lt;Vec&lt;_&gt;&gt;();
    }
}
</code></pre>

<p>It has two public API methods - <code>block_on</code> and <code>spawn</code>. These are the equivalent of the <a href="https://docs.rs/tokio/latest/tokio/">Tokio</a> methods and do roughly the same thing. <code>block_on</code> is used for the top level future and blocks the main thread waiting for this future to complete, and <code>spawn</code> is used for nested futures.</p>

<p>You’ll notice that all my futures have an output of the unit type and have a <code>static</code> lifetime. The former is done to simplify the code since this runtime is only going to service a web server, which typically write their results back via the TCP stream.</p>

<p>I did the latter because it was the easiest way to set up everything without having to worry about lifetimes. If you’re interested in a way that futures can be built without any lifetimes at all, read <a href="https://emschwartz.me/async-rust-can-be-a-pleasure-to-work-with-without-send-sync-static/">this great post</a> by Evan Schwartz.</p>

<p>And with that, we’re done with the main internal components of our system.</p>

<h2 id="custom-futures">Custom Futures</h2>
<p>Now, that we have internal components mainly set up, let’s switch focus over to writing some custom futures which can be run on the executor.</p>

<p>We’re going to be building a basic web server on top of this, so let’s buid a simple TCP listener &amp; client.</p>

<h3 id="tcp-listener">TCP Listener</h3>
<p>Here’s the code for the listener. Notice that it registers interest with the reactor upon recognizing that there are no clients attempting to connect right now. Also, notice the <code>drop</code> functionality where it unregisters itself from the reactor.</p>

<p>The <code>listener.set_nonblocking(true)</code> is important here because otherwise the <code>connect</code> function call will block on the async task, rendering the entire runtime pointless.
When the listener notices that there are no clients trying to connect, it registers interest with the reactor and unblocks the scheduler.</p>

<pre><code class="language-rust">pub struct TcpListener {
    listener: std::net::TcpListener,
    reactor: Rc&lt;RefCell&lt;Reactor&gt;&gt;,
}

impl TcpListener {
    pub fn bind(addr: &amp;str, reactor: Rc&lt;RefCell&lt;Reactor&gt;&gt;) -&gt; std::io::Result&lt;TcpListener&gt; {
        let listener = std::net::TcpListener::bind(addr)?;
        listener.set_nonblocking(true)?;
        Ok(Self { listener, reactor })
    }

    pub fn accept(&amp;self) -&gt; std::io::Result&lt;ListenerFuture&gt; {
        Ok(ListenerFuture {
            listener: &amp;self.listener,
            reactor: Rc::clone(&amp;self.reactor),
        })
    }
}

impl Drop for TcpListener {
    fn drop(&amp;mut self) {
        self.reactor
            .borrow_mut()
            .remove(
                self.listener.as_raw_fd(),
                Event::all(self.listener.as_raw_fd() as _),
            )
            .unwrap();
    }
}

pub struct ListenerFuture&lt;'listener&gt; {
    listener: &amp;'listener std::net::TcpListener,
    reactor: Rc&lt;RefCell&lt;Reactor&gt;&gt;,
}

impl Future for ListenerFuture&lt;'_&gt; {
    type Output = std::io::Result&lt;(std::net::TcpStream, std::net::SocketAddr)&gt;;
    fn poll(
        mut self: std::pin::Pin&lt;&amp;mut Self&gt;,
        cx: &amp;mut std::task::Context&lt;'_&gt;,
    ) -&gt; std::task::Poll&lt;Self::Output&gt; {
        debug!("Received a poll call on the ListenerFuture");
        match self.listener.accept() {
            Ok((stream, addr)) =&gt; std::task::Poll::Ready(Ok((stream, addr))),
            Err(e) if e.kind() == std::io::ErrorKind::WouldBlock =&gt; {
                debug!("Attempting to accept a connection on the listener WouldBlock, so registering with reactor and yielding control back to the executor");
                let fd = self.listener.as_raw_fd();
                self.as_mut().reactor.borrow_mut().register_interest(
                    fd,
                    super::reactor::InterestType::Read,
                    cx,
                );
                std::task::Poll::Pending
            }
            Err(e) =&gt; {
                eprintln!("received an error in the ListenerFuture {}", e);
                std::task::Poll::Ready(Err(e))
            }
        }
    }
}
</code></pre>

<h3 id="tcp-client">TCP Client</h3>
<p>The TCP client is a little more involved but it’s useful to break this down into a set of smaller components as well. While handling a request:</p>
<ul>
  <li>the client reads bytes from the stream</li>
  <li>reads a response file from the local fs using the threadpool</li>
  <li>writes a response back to the stream</li>
</ul>

<p>Here’s the code</p>

<pre><code class="language-rust">pub struct TcpClient {
    client: TcpStream,
    _addr: SocketAddr,
    reactor: Rc&lt;RefCell&lt;Reactor&gt;&gt;,
    file_io_pool: Rc&lt;RefCell&lt;FileIOPool&gt;&gt;,
}

impl TcpClient {
    pub fn new(
        client: TcpStream,
        addr: SocketAddr,
        reactor: Rc&lt;RefCell&lt;Reactor&gt;&gt;,
        file_io_pool: Rc&lt;RefCell&lt;FileIOPool&gt;&gt;,
    ) -&gt; Self {
        client.set_nonblocking(true).unwrap();
        Self {
            client,
            _addr: addr,
            reactor,
            file_io_pool,
        }
    }

    pub async fn handle_request(&amp;mut self) -&gt; std::io::Result&lt;()&gt; {
        debug!("handling client request");
        self.read().await?;
        let file_path = PathBuf::from("hello.html");
        let bytes = self
            .file_io_pool
            .borrow()
            .read_file(file_path, Rc::clone(&amp;self.reactor))
            .await
            .expect("Error while reading the response file");
        let mut response = Vec::new();
        let headers = format!(
            "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
            bytes.len()
        );
        response.extend_from_slice(headers.as_bytes());
        response.extend_from_slice(&amp;bytes);
        self.write(response).await?;
        self.client.shutdown(std::net::Shutdown::Write)?;
        Ok(())
    }

    fn read(&amp;self) -&gt; AsyncTcpReader {
        AsyncTcpReader {
            client: &amp;self.client,
            reactor: Rc::clone(&amp;self.reactor),
            buffer: Vec::with_capacity(1024),
            total_read: 0,
        }
    }

    fn write(&amp;self, buffer: Vec&lt;u8&gt;) -&gt; AsyncTcpWriter {
        AsyncTcpWriter {
            client: &amp;self.client,
            reactor: Rc::clone(&amp;self.reactor),
            buffer,
            bytes_written: 0,
        }
    }
}

impl Drop for TcpClient {
    fn drop(&amp;mut self) {
        self.reactor
            .borrow_mut()
            .remove(
                self.client.as_raw_fd(),
                Event::all(self.client.as_raw_fd().try_into().unwrap()),
            )
            .unwrap();
    }
}

pub struct AsyncTcpReader&lt;'read_stream&gt; {
    client: &amp;'read_stream TcpStream,
    reactor: Rc&lt;RefCell&lt;Reactor&gt;&gt;,
    buffer: Vec&lt;u8&gt;,
    total_read: usize,
}

impl&lt;'read_stream&gt; Future for AsyncTcpReader&lt;'read_stream&gt; {
    type Output = std::io::Result&lt;usize&gt;;
    fn poll(
        mut self: std::pin::Pin&lt;&amp;mut Self&gt;,
        cx: &amp;mut std::task::Context&lt;'_&gt;,
    ) -&gt; std::task::Poll&lt;Self::Output&gt; {
        loop {
            let mut chunk = [0u8; 1024];
            match self.client.read(&amp;mut chunk) {
                Ok(0) =&gt; {
                    // received EOF
                    self.buffer.clear();
                    return std::task::Poll::Ready(Ok(self.total_read));
                }
                Ok(n) =&gt; {
                    self.buffer.extend_from_slice(&amp;chunk);
                    self.total_read += n;
                    let headers = std::str::from_utf8(&amp;chunk[..n]).unwrap();
                    if headers.ends_with("\r\n\r\n") {
                        self.buffer.clear();
                        return std::task::Poll::Ready(Ok(self.total_read));
                    }
                    return std::task::Poll::Pending;
                }
                Err(e) if e.kind() == std::io::ErrorKind::WouldBlock =&gt; {
                    self.reactor.borrow_mut().register_interest(
                        self.client.as_raw_fd(),
                        InterestType::Read,
                        cx,
                    );
                    return std::task::Poll::Pending;
                }
                Err(e) =&gt; return std::task::Poll::Ready(Err(e)),
            }
        }
    }
}

pub struct AsyncTcpWriter&lt;'write_stream&gt; {
    client: &amp;'write_stream TcpStream,
    reactor: Rc&lt;RefCell&lt;Reactor&gt;&gt;,
    buffer: Vec&lt;u8&gt;,
    bytes_written: usize,
}

impl&lt;'write_stream&gt; Future for AsyncTcpWriter&lt;'write_stream&gt; {
    type Output = std::io::Result&lt;usize&gt;;
    fn poll(
        mut self: std::pin::Pin&lt;&amp;mut Self&gt;,
        cx: &amp;mut std::task::Context&lt;'_&gt;,
    ) -&gt; std::task::Poll&lt;Self::Output&gt; {
        let this = self.as_mut().get_mut();
        loop {
            match this.client.write(&amp;this.buffer[this.bytes_written..]) {
                Ok(n) =&gt; {
                    this.bytes_written += n;
                    if this.bytes_written &gt;= this.buffer.len() {
                        return std::task::Poll::Ready(Ok(this.bytes_written));
                    }
                }
                Err(e) if e.kind() == std::io::ErrorKind::WouldBlock =&gt; {
                    this.reactor.borrow_mut().register_interest(
                        this.client.as_raw_fd(),
                        InterestType::Write,
                        cx,
                    );
                    return std::task::Poll::Pending;
                }
                Err(e) =&gt; {
                    return std::task::Poll::Ready(Err(e));
                }
            }
        }
    }
}
</code></pre>

<p>Notice that because we have a threadpool via which to read files, all of the 3 tasks above become asynchronous.</p>

<h2 id="wiring-it-all-up">Wiring It All Up</h2>
<p>Now, that we have all the different components, we need to wire everything up together.</p>

<p>Here’s the main function where we’ll build the top-level future and then pass it along to the executor. It’s quite ugly because of the excessive <code>Rc</code> syntax, but that’s just Rust I suppose.</p>

<pre><code class="language-rust">use std::{cell::RefCell, rc::Rc};

use log::debug;
use timer_future::async_runtime::{
    client::TcpClient, executor::Executor, file_io_pool::FileIOPool, listener::TcpListener,
    reactor::Reactor, task_queue::TaskQueue,
};

fn main() {
    env_logger::init();
    let task_queue = Rc::new(RefCell::new(TaskQueue::new()));
    let reactor = Rc::new(RefCell::new(Reactor::new().unwrap()));
    let (shutdown_tx, shutdown_rx) = std::sync::mpsc::channel();
    setup_ctrlc_handler(shutdown_tx);
    let file_io_pool = Rc::new(RefCell::new(FileIOPool::new(5, shutdown_rx)));
    let runtime = Rc::new(Executor::new(task_queue, Rc::clone(&amp;reactor)).unwrap());
    let runtime_clone = Rc::clone(&amp;runtime);
    runtime.block_on(async move {
        let listener = TcpListener::bind("localhost:8000", Rc::clone(&amp;reactor)).unwrap();
        while let Ok((client, addr)) = listener.accept().unwrap().await {
            debug!("Received a client connection from client {:?}", client);
            let reactor_clone = Rc::clone(&amp;reactor);
            let file_io_pool_clone = Rc::clone(&amp;file_io_pool);
            runtime_clone.spawn(async move {
                let mut tcp_client =
                    TcpClient::new(client, addr, reactor_clone, Rc::clone(&amp;file_io_pool_clone));
                tcp_client
                    .handle_request()
                    .await
                    .expect("Error occurred while handling the tcp request");
            });
            debug!("Handed off client connection to executor");
        }
    });
    println!("Done executing the top level future");
}

fn setup_ctrlc_handler(shutdown_tx: std::sync::mpsc::Sender&lt;()&gt;) {
    ctrlc::set_handler(move || {
        shutdown_tx
            .send(())
            .expect("failed to send shutdown signal");
    })
    .unwrap();
}
</code></pre>

<p>There’s some additional functionality around shutting down the entire runtime by listening for a <code>SIGINT</code>, but the meat of the code is in the <code>block_on</code> function. The <code>connect</code> loop listens for connections, and upon receiving one immediately spawns a separate task for the client.</p>

<p>That was a lot of code, so let me leave you with another diagram illustrating what we’ve built. It’s very similar to the diagram I shared earlier with some more detail attached.</p>

<p><img src="/assets/img/async/async_runtime.png" alt="" /></p>

<p>It looks a lot more complicated but it’s just showing more of the detail in the system, the core components remain the same. I’m showing multiple cores being utilized in the diagram, whereas we just utilized one core.</p>

<h2 id="conclusion">Conclusion</h2>
<p>So, there we have it - a single-threaded async runtime which shows how to use async IO to wait on events and poll tasks via a scheduler in ~900 lines of Rust code. Building this prototype was a great way for me to intuit the core ideas behind an async runtime for myself.</p>

<p>Of course, there’s a lot we skipped over here for the sake of simplicity:</p>
<ul>
  <li>most production schedulers are multi-threaded with tasks being multiplexed across these threads (for our example, we’d end up having to use <code>Arc</code> instead of <code>Rc</code>)</li>
  <li>timers in an async runtime &amp; cancellable tasks</li>
  <li>work stealing across threads, <a href="https://tokio.rs/blog/2019-10-scheduler">something Tokio famously does</a></li>
  <li><a href="https://without.boats/blog/thread-per-core/">thread-per-core model with tasks pinned to specific threads</a></li>
</ul>

<p>I hope this post gives you a better idea of what’s going on under the hood of an async runtime.</p>

<h2 id="references">References</h2>
<ol>
  <li><a href="https://www.youtube.com/watch?v=yfcJGEISsLc&amp;list=PLb1VOxJqFzDd05_aDQEm6KVblhee_KStX&amp;index=5">Async I/O in Depth video series</a></li>
  <li><a href="https://emschwartz.me/async-rust-can-be-a-pleasure-to-work-with-without-send-sync-static/">Async Rust Without Send, Sync Or Static</a></li>
  <li><a href="https://github.com/PacktPublishing/Asynchronous-Programming-in-Rust">Asynchronous Programming In Rust</a></li>
</ol>]]></content><author><name></name></author><category term="async" /><summary type="html"><![CDATA[Here’s a link to the code on GitHub.]]></summary></entry><entry><title type="html">Async Runtimes Part II</title><link href="https://redixhumayun.github.io/async/2024/09/18/async-runtimes-part-ii.html" rel="alternate" type="text/html" title="Async Runtimes Part II" /><published>2024-09-18T00:00:00+00:00</published><updated>2024-09-18T00:00:00+00:00</updated><id>https://redixhumayun.github.io/async/2024/09/18/async-runtimes-part-ii</id><content type="html" xml:base="https://redixhumayun.github.io/async/2024/09/18/async-runtimes-part-ii.html"><![CDATA[<p><em><a href="https://github.com/redixhumayun/async-rust">Here’s a link</a> to the code on GitHub</em></p>

<p>This post is a follow up to a <a href="/async/2024/08/05/async-runtimes.html">previous post</a>, where I built a basic custom <code>Future</code> and hooked that up to an <code>Executor</code> and polled the future to completion.</p>

<p>This time I’m going to build a single-threaded event loop that uses async I/O interfaces in ~500 lines of Rust code.</p>

<h2 id="reactors">Reactors</h2>
<p>Async runtimes have an important component called <code>reactors</code>. These components sit on top of the OS and listen for events from the underlying file descriptors. This is what allows an application to offload a lot of the work.</p>

<p>For instance, consider that your application is listening on a TCP socket. Without an async runtime, you’d typically block on the main thread and listen for connections. Then, as you receive each connection, you’d offload the incoming request onto a separate thread or pass it to a thread pool.</p>

<p>This works fine, but you can make use of certain syscalls to improve your throughput with fewer number of threads (even a single thread).</p>

<p><img src="/assets/img/async/reactor.png" alt="" /></p>

<p>Let’s start with building a simple reactor.</p>

<div class="aside">
I mostly recreated this by studying the <a href="https://github.com/smol-rs/polling">polling crate from smol-rs</a>. You could recreate this project by using the crate instead of doing the syscalls yourself, with the added advantage of portability. I built mine only using kqueue since I'm doing this on MacOS.
<br />
<br />
Another great crate to study would probably be <a href="https://github.com/tokio-rs/mio">mio which is what Tokio is built on</a>
</div>

<p>I’ve used the <a href="https://github.com/rust-lang/libc"><code>libc</code> crate</a> to allow a FFI to C so that syscalls can be made. It’s a simple design, just stores the events and has a notification mechanism built on <a href="https://en.wikipedia.org/wiki/STREAMS">Unix streams</a>.</p>

<pre><code class="language-rust">pub struct Reactor {
    kq: RawFd, //  the kqueue fd
    events: Vec&lt;libc::kevent&gt;,
    capacity: usize,
    notifier: (UnixStream, UnixStream),
}
</code></pre>

<p>The <code>reactor</code> exposes the following API:</p>

<ul>
  <li><code>add</code> to start watching new file descriptors</li>
  <li><code>delete</code> to stop watching file descriptors</li>
  <li><code>notify</code> to unblock the reactor</li>
  <li><code>poll</code> to ask for events from the OS in a blocking manner</li>
</ul>

<p>The <code>poll</code> is the most important method because that’s where the blocking routine comes in. To implement this we’re going to have to make use I/O multiplexing systems like <a href="https://wiki.netbsd.org/tutorials/kqueue_tutorial/"><code>kqueue</code> (macOS)</a>, <a href="https://stackoverflow.com/questions/13845634/general-explanation-of-how-epoll-works"><code>epoll</code>(Linux)</a> and <code>IOCP</code>(Windows). This is all part of the “async i/o” umbrella that I’m trying to demystify for myself.</p>

<p>The basic idea behind these interfaces is that you can use a single application level thread to monitor all the I/O sources you are interested in <strong>for readiness</strong>.</p>

<div class="aside">
I used to think <code>io_uring</code> fell under the same category as the above interfaces, but that's not true. Typically, there are two API styles - readiness based and completion based.
<br />
<br />
<code>epoll</code> and <code>kqueue</code> fall under the readiness based model. These syscalls only indicate when a file descriptor is available to be read from or written to. There's the overhead of an additional syscall to actually do the writing or reading in this case.
<br />
<br />
<code>io_uring</code> on the other hand falls under the completion based model. This model involves providing the file descriptors you're interested in reading from or writing to and getting the actual results back. It avoids the overhead of the additional syscall.
<br />
<br />
One interesting difference is that it makes no sense to use the readiness based model when doing file I/O. It doesn't mean anything to wait for file "readiness" in this case since files are always ready for I/O.
<br />
<br />
If you are interested in learning more, check out <a href="https://www.youtube.com/watch?v=Ul8OO4vQMTw">this talk by King Protty</a>
</div>

<p>The implementation for the <code>Reactor</code> is provided below.</p>

<pre><code class="language-rust">impl Reactor {
    pub fn new() -&gt; std::io::Result&lt;Self&gt; {
        let kq = unsafe { libc::kqueue() };
        if kq &lt; 0 {
            return Err(std::io::Error::last_os_error());
        }
        let (read_stream, write_stream) = UnixStream::pair()?;
        read_stream.set_nonblocking(true)?;
        write_stream.set_nonblocking(true)?;
        let reactor = Self {
            kq,
            events: Vec::new(),
            capacity: 1,
            notifier: (read_stream, write_stream),
        };
        reactor.modify(
            reactor.notifier.0.as_raw_fd(),
            Event::readable(reactor.notifier.0.as_raw_fd()),
        )?;
        Ok(reactor)
    }

    pub fn add(&amp;mut self, fd: RawFd, ev: Event) -&gt; std::io::Result&lt;()&gt; {
        self.modify(fd, ev)
    }

    pub fn delete(&amp;mut self, fd: RawFd) -&gt; std::io::Result&lt;()&gt; {
        self.modify(fd, Event::none(fd))
    }

    pub fn notify(&amp;mut self) -&gt; std::io::Result&lt;()&gt; {
        self.notifier.1.write(&amp;[1])?;
        Ok(())
    }

    fn modify(&amp;self, fd: RawFd, ev: Event) -&gt; std::io::Result&lt;()&gt; {
        let read_flags = if ev.readable {
            EV_ADD | EV_ONESHOT
        } else {
            EV_DELETE
        };
        let changes = [kevent {
            ident: fd as usize,
            filter: EVFILT_READ,
            flags: read_flags,
            fflags: 0,
            data: 0,
            udata: std::ptr::null_mut(),
        }];
        let result = unsafe {
            kevent(
                self.kq,
                changes.as_ptr(),
                1,
                std::ptr::null_mut(),
                0,
                std::ptr::null_mut(),
            )
        };
        if result &lt; 0 {
            return Err(std::io::Error::last_os_error());
        }
        Ok(())
    }

    pub fn poll(&amp;mut self) -&gt; std::io::Result&lt;Vec&lt;Event&gt;&gt; {
        let max_capacity = self.capacity as c_int;
        self.events.clear();
        let result = unsafe {
            self.events
                .resize(max_capacity as usize, std::mem::zeroed());
            let result = kevent(
                self.kq,
                std::ptr::null(),
                0,
                self.events.as_mut_ptr(),
                max_capacity,
                std::ptr::null(),
            );
            result
        };
        if result &lt; 0 {
            //  return the last OS error
            return Err(std::io::Error::last_os_error());
        }

        let mut mapped_events = Vec::new();
        for i in 0..result as usize {
            let kevent = &amp;self.events[i];
            let ident = kevent.ident;
            let filter = kevent.filter;

            let mut buf = [0; 8];
            if ident == self.notifier.0.as_raw_fd() as usize {
                self.notifier.0.read(&amp;mut buf)?;
                self.modify(
                    self.notifier.0.as_raw_fd(),
                    Event::readable(self.notifier.0.as_raw_fd()),
                )?;
            }

            let mut event = Event {
                fd: ident,
                readable: false,
                writable: false,
            };

            match filter {
                EVFILT_READ =&gt; event.readable = true,
                EVFILT_WRITE =&gt; event.writable = true,
                _ =&gt; {}
            };
            self.modify(event.fd as i32, Event::readable(event.fd as i32))?;
            mapped_events.push(event);
        }
        Ok(mapped_events)
    }
}
</code></pre>

<p>The <code>notify</code> method is an interesting little hack because it writes dummy data into one end of the Unix socket, and because we tell <code>kqueue</code> we are interested in the other end of the stream, we have a way to unblock the <code>kevent</code> syscall in the <code>poll</code> method.</p>

<h2 id="event-loops">Event Loops</h2>
<p>In the previous post, we discussed <code>Executors</code> which were a component responsible for polling futures to completion. In the context of asynchronous I/O, you need an analogous event loop which is constantly monitoring for I/O events and then reacting based on those events.</p>

<p>Let’s build a dead simple event loop which will wait on I/O events and also handle a small set of tasks. To handle the tasks, we’ll create a task queue component and feed that into our event loop to read from.</p>

<p>We’ll have 3 types of tasks - registration tasks, unregistration tasks and scheduled tasks.</p>

<p>The scheduled tasks are a way of some object telling the event loop that it wants to be polled, which is where the Unix pipe hack comes in.</p>

<pre><code class="language-rust">pub struct RegistrationTask {
    pub fd: usize,
    pub reference: Box&lt;dyn EventHandler&gt;,
}

pub struct UnregistrationTask {
    pub fd: usize,
}

pub struct ScheduledTask {
    pub fd: usize,
}

pub enum Task {
    RegistrationTask(RegistrationTask),
    UnregistrationTask(UnregistrationTask),
    ScheduledTask(ScheduledTask),
}

impl Display for Task {
    fn fmt(&amp;self, f: &amp;mut std::fmt::Formatter&lt;'_&gt;) -&gt; std::fmt::Result {
        match self {
            Task::RegistrationTask(task) =&gt; write!(f, "RegistrationTask: fd {}", task.fd),
            Task::UnregistrationTask(task) =&gt; write!(f, "UnregistrationTask: fd {}", task.fd),
            Task::ScheduledTask(task) =&gt; write!(f, "ScheduledTask: fd {}", task.fd),
        }
    }
}

pub struct TaskQueue {
    pub queue: Vec&lt;Task&gt;,
}

impl TaskQueue {
    pub fn new() -&gt; Self {
        Self { queue: Vec::new() }
    }

    pub fn add_task(&amp;mut self, task: Task) {
        self.queue.push(task);
    }
}
</code></pre>

<p>Now, for the event loop itself.</p>

<pre><code class="language-rust">struct EventLoop {
    reactor: Arc&lt;Mutex&lt;Reactor&gt;&gt;,
    task_queue: Arc&lt;Mutex&lt;TaskQueue&gt;&gt;,
    references: HashMap&lt;usize, Box&lt;dyn EventHandler&gt;&gt;,
}
</code></pre>

<p>I’m using the <code>Box&lt;dyn EventHandler&gt;</code> to hold a reference to the object backing the file descriptor (more on that below).</p>

<p>There is the overhead of dynamic dispatch here but we’re not trying to build a performant system, so that’s okay.</p>

<pre><code class="language-rust">impl EventLoop {
    fn new(reactor: Arc&lt;Mutex&lt;Reactor&gt;&gt;, task_queue: Arc&lt;Mutex&lt;TaskQueue&gt;&gt;) -&gt; Self {
        Self {
            reactor,
            task_queue,
            references: HashMap::new(),
        }
    }

    /// Add a reference to the object backing the file descriptor
    fn register(&amp;mut self, fd: usize, reference: Box&lt;dyn EventHandler&gt;) {
        self.references.insert(fd, reference);
    }

    /// Remove the reference backing the file descriptor
    fn unregister(&amp;mut self, fd: usize) {
        self.references.remove(&amp;fd);
    }

    fn process_tasks(&amp;mut self) {
        let mut tasks_to_process = Vec::new();

        {
            // Collect tasks to process
            let mut task_queue = self.task_queue.lock().unwrap();
            while let Some(task) = task_queue.queue.pop() {
                tasks_to_process.push(task);
            }
        }

        // Process collected tasks
        for task in tasks_to_process {
            match task {
                Task::RegistrationTask(registration_task) =&gt; {
                    self.register(registration_task.fd, registration_task.reference);
                }
                Task::UnregistrationTask(unregistration_task) =&gt; {
                    self.unregister(unregistration_task.fd);
                }
                Task::ScheduledTask(scheduled_task) =&gt; {
                    if let Some(reference) = self.references.get_mut(&amp;scheduled_task.fd) {
                        reference.poll();
                    }
                }
            }
        }
    }

    fn handle_events(&amp;mut self, events: Vec&lt;Event&gt;) {
        for event in events {
            if let Some(reference) = self.references.get_mut(&amp;event.fd) {
                reference.event(event);
            }
        }
    }

    fn run(&amp;mut self) {
        loop {
            self.process_tasks();
            let events = self
                .reactor
                .lock()
                .unwrap()
                .poll()
                .expect("Error polling the reactor");

            self.handle_events(events);
        }
    }
}
</code></pre>

<p>The most important part of the <code>EventLoop</code> is the <code>run</code> function at the bottom. We do three things here - get the list of tasks on the task queue and take action on them, poll the <code>Reactor</code> for any new events and handle any events that are returned.</p>

<p>When an event is returned, the <code>EventLoop</code> calls the <code>event</code> function on the object backing the file descriptor.</p>

<p>It’s worth it to stop and paint a picture here of what is going on since we’ve added a lot to our runtime in this section.</p>

<p><img src="/assets/img/async/system_overview.png" alt="" /></p>

<p>This is mostly the same as the previous image, except that I’ve expanded on what the application really contains. But there’s still that <code>EventHandler</code> box I haven’t really explained.</p>

<h2 id="eventhandler">EventHandler</h2>

<p>This is a really simple part of the system. It’s just some object that handles events. That’s all it is!</p>

<p>The object is expected to conform to some interface like the one below</p>

<pre><code class="language-rust">trait EventHandler {
    fn event(&amp;mut self, event: Event);
    fn poll(&amp;mut self);
}
</code></pre>

<p>Now, what objects do we need to conform to this trait? If we’re setting up a TCP listener, we need the object serving as a listener to conform to that trait, so let’s do that first.</p>

<pre><code class="language-rust">enum AsyncTcpListenerState {
    WaitingForConnection,
    Accepting(TcpStream),
}

struct AsyncTcpListener {
    listener: TcpListener,
    fd: usize,
    reactor: Arc&lt;Mutex&lt;Reactor&gt;&gt;,
    task_queue: Arc&lt;Mutex&lt;TaskQueue&gt;&gt;,
    state: Option&lt;AsyncTcpListenerState&gt;,
}
</code></pre>

<p>The <code>AsyncTcpListener</code> is the object backing the TCP server file descriptor. It maintains some internal state to make it easier to encode logic, so let’s look at the implementation.</p>

<pre><code class="language-rust">impl AsyncTcpListener {
    fn new(
        listener: TcpListener,
        reactor: Arc&lt;Mutex&lt;Reactor&gt;&gt;,
        task_queue: Arc&lt;Mutex&lt;TaskQueue&gt;&gt;,
    ) -&gt; std::io::Result&lt;Self&gt; {
        let fd = listener.as_raw_fd();
        reactor.lock().unwrap().add(fd, Event::readable(fd))?;
        Ok(AsyncTcpListener {
            listener,
            fd: fd as usize,
            reactor,
            task_queue,
            state: Some(AsyncTcpListenerState::WaitingForConnection),
        })
    }
}

impl EventHandler for AsyncTcpListener {
    fn event(&amp;mut self, event: Event) {
        match event.readable {
            true =&gt; match self.listener.accept() {
                Ok((client, addr)) =&gt; {
                    self.state.replace(AsyncTcpListenerState::Accepting(client));
                    self.task_queue
                        .lock()
                        .unwrap()
                        .add_task(Task::ScheduledTask(ScheduledTask { fd: self.fd }));
                }
                Err(e) =&gt; eprintln!("Error accepting connection: {}", e),
            },
            false =&gt; {
                panic!("AsyncTcpListener received an event that is not readable")
            }
        }
    }

    fn poll(&amp;mut self) {
        match self.state.take() {
            Some(AsyncTcpListenerState::Accepting(client)) =&gt; {
                let client = AsyncTcpClient::new(
                    client,
                    Arc::clone(&amp;self.reactor),
                    Arc::clone(&amp;self.task_queue),
                )
                .unwrap();
                self.task_queue
                    .lock()
                    .unwrap()
                    .add_task(Task::RegistrationTask(RegistrationTask {
                        fd: client.fd,
                        reference: Box::new(client),
                    }));
            }
            Some(AsyncTcpListenerState::WaitingForConnection) =&gt; {
                panic!("The WaitingForConnection state should not be reached in the poll fn for listener")
            }
            None =&gt; {
                panic!("No state found in the poll fn for listener")
            }
        }
    }
}
</code></pre>

<p>Now, when we receive a connection from the internet, we need to do something similar for each client because we need an object backing the file descriptors for clients as well.</p>

<pre><code class="language-rust">#[derive(Debug)]
enum AsyncTcpClientState {
    Waiting,
    Reading,
    Writing,
    Close,
    Closed,
}

struct AsyncTcpClient {
    client: TcpStream,
    fd: usize,
    reactor: Arc&lt;Mutex&lt;Reactor&gt;&gt;,
    task_queue: Arc&lt;Mutex&lt;TaskQueue&gt;&gt;,
    state: Option&lt;AsyncTcpClientState&gt;,
}
</code></pre>

<p>There’s a lot more states but that’s entirely implementation dependent. You could make do with just 2 or 3 states too. It all comes down to how you want to encapsulate the logic.</p>

<p>Now, here’s the implementation for the client</p>

<pre><code class="language-rust">impl AsyncTcpClient {
    fn new(
        client: TcpStream,
        reactor: Arc&lt;Mutex&lt;Reactor&gt;&gt;,
        task_queue: Arc&lt;Mutex&lt;TaskQueue&gt;&gt;,
    ) -&gt; std::io::Result&lt;Self&gt; {
        let fd = client.as_raw_fd();
        reactor.lock().unwrap().add(fd, Event::readable(fd))?;
        Ok(Self {
            client,
            fd: fd as usize,
            reactor,
            task_queue,
            state: Some(AsyncTcpClientState::Waiting),
        })
    }
}

impl EventHandler for AsyncTcpClient {
    fn event(&amp;mut self, event: Event) {
        match self.state.take() {
            Some(AsyncTcpClientState::Waiting) =&gt; {
                if event.readable {
                    self.state.replace(AsyncTcpClientState::Reading);
                    self.task_queue
                        .lock()
                        .unwrap()
                        .add_task(Task::ScheduledTask(ScheduledTask { fd: self.fd }));
                }
            }
            Some(s) =&gt; {
                self.state.replace(s);
            }
            None =&gt; {
                panic!("state was none");
            }
        }
    }

    fn poll(&amp;mut self) {
        match self.state.take() {
            None =&gt; {}
            Some(AsyncTcpClientState::Waiting) =&gt; {
                panic!("The Waiting state should not be reached in the poll fn for client")
            }
            Some(AsyncTcpClientState::Reading) =&gt; {
                let reader = BufReader::new(&amp;self.client);
                let http_request: Vec&lt;_&gt; = reader
                    .lines()
                    .map(|line| line.unwrap())
                    .take_while(|line| !line.is_empty())
                    .collect();
                if http_request
                    .iter()
                    .next()
                    .unwrap()
                    .contains("GET / HTTP/1.1")
                {
                    self.state.replace(AsyncTcpClientState::Writing);
                } else {
                    eprintln!("received invalid request, closing the socket connection");
                    self.state.replace(AsyncTcpClientState::Close);
                }
                self.state.replace(AsyncTcpClientState::Writing);
                self.task_queue
                    .lock()
                    .unwrap()
                    .add_task(Task::ScheduledTask(ScheduledTask { fd: self.fd }));
                self.reactor.lock().unwrap().notify().unwrap();
            }
            Some(AsyncTcpClientState::Writing) =&gt; {
                let path = Path::new("hello.html");
                let content = std::fs::read(path).unwrap();
                let response = format!(
                    "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
                    content.len(),
                    String::from_utf8_lossy(&amp;content)
                );
                self.client.write_all(response.as_bytes()).unwrap();
                self.state.replace(AsyncTcpClientState::Close);
                self.task_queue
                    .lock()
                    .unwrap()
                    .add_task(Task::ScheduledTask(ScheduledTask { fd: self.fd }));
                self.reactor.lock().unwrap().notify().unwrap();
            }
            Some(AsyncTcpClientState::Close) =&gt; {
                //  remove the client fd from the reactor and unregister from the event loop
                self.reactor
                    .lock()
                    .unwrap()
                    .delete(self.fd.try_into().unwrap())
                    .unwrap();
                self.task_queue
                    .lock()
                    .unwrap()
                    .add_task(Task::UnregistrationTask(UnregistrationTask { fd: self.fd }));
                self.client.shutdown(std::net::Shutdown::Both).unwrap();
                self.state.replace(AsyncTcpClientState::Closed);
            }
            Some(AsyncTcpClientState::Closed) =&gt; {}
        }
    }
}
</code></pre>

<p>This looks like a lot of code, but both of these objects are just state machines backing a file descriptor. There’s nothing more to it. The client encapsulates logic in it’s state machine like reading from a file and then writing back to the socket, and the listener only has 2 states - either accepting connections or waiting for new connections.</p>

<p>You could do fancy things with the client like spawning threads when reading from a file to ensure that you don’t block the event loop in any way, but for our experimental purposes this should be good enough.</p>

<p>Each of the objects above implement the <code>EventHandler</code> trait - which means they both have a <code>poll</code> &amp;&amp; <code>event</code> method.</p>

<p>The former is called by the event loop when it is scheduled to do so - that is, it gets a task from the task queue telling it to <code>poll</code> a specific object.</p>

<p>The latter is called when the event loop receives a new event for a specific file descriptor. It then calls the object backing that file descriptor.</p>

<h2 id="wiring-it-all-up">Wiring It All Up</h2>
<p>Okay, we understand each of the components on their own but we need to wire up everything to see how it works together.</p>

<pre><code class="language-rust">fn main() {
    let reactor = Arc::new(Mutex::new(Reactor::new().unwrap()));
    let task_queue = Arc::new(Mutex::new(TaskQueue::new()));
    let mut event_loop = EventLoop::new(Arc::clone(&amp;reactor), Arc::clone(&amp;task_queue));

    //  start listener
    let tcp_listener = TcpListener::bind("127.0.0.1:8000").unwrap();
    let listener =
        AsyncTcpListener::new(tcp_listener, Arc::clone(&amp;reactor), Arc::clone(&amp;task_queue)).unwrap();
    task_queue
        .lock()
        .unwrap()
        .add_task(Task::RegistrationTask(RegistrationTask {
            fd: listener.fd,
            reference: Box::new(listener),
        }));

    //  start the event loop
    event_loop.run();
}
</code></pre>

<p>We create all our objects and pass them into the event loop. We then create a <code>TcpListener</code> and construct a backing object on top of it. Next, we push a task into the task queue to register the new file descriptor and it’s backing object with the event loop and we fire off the event loop.</p>

<p>I’m placing the <code>run</code> function of the event loop below again because it’s the central piece of this entire system. When we call <code>process_tasks</code>, the event loop will pick up the <code>RegistrationTask</code> and add the file descriptor and the backing object to it’s references.</p>

<p>Next, it will listen for events from the <code>reactor</code> and act on those events. When it receives an event, it gets it for a specific file descriptor, so it will look up the file descriptor in it’s references, find the backing object and call the object’s <code>event</code> method.</p>

<pre><code class="language-rust">fn run(&amp;mut self) {
  loop {
      self.process_tasks();
      let events = self
          .reactor
          .lock()
          .unwrap()
          .poll()
          .expect("Error polling the reactor");

      self.handle_events(events);
  }
}
</code></pre>

<p>I mentioned earlier that the scheduled tasks are the way of some object telling the event loop that it wants to be polled, and that’s what the event loop does in the <code>process_tasks</code> method. It just calls the <code>poll</code> method on the underlying object.</p>

<pre><code class="language-rust">fn process_tasks(&amp;mut self) {
    let mut tasks_to_process = Vec::new();

    {
        // Collect tasks to process
        let mut task_queue = self.task_queue.lock().unwrap();
        while let Some(task) = task_queue.queue.pop() {
            tasks_to_process.push(task);
        }
    }

    // Process collected tasks
    for task in tasks_to_process {
        match task {
            Task::RegistrationTask(registration_task) =&gt; {
                self.register(registration_task.fd, registration_task.reference);
            }
            Task::UnregistrationTask(unregistration_task) =&gt; {
                self.unregister(unregistration_task.fd);
            }
            Task::ScheduledTask(scheduled_task) =&gt; {
                if let Some(reference) = self.references.get_mut(&amp;scheduled_task.fd) {
                    reference.poll();
                }
            }
        }
    }
}
</code></pre>

<p>Now, if you go back and look at the code for the <code>AsyncTcpClient</code>, you’ll see calls to <code>notify</code> on the <code>reactor</code> placed at the end of each state transition. The purpose of that call is for the object to tell the event loop that it needs to be polled because it’s ready to move forward (this is where the Unix pipe hack comes in).</p>

<p>Well, there you have it - a single-threaded async runtime in ~500 lines of code.</p>

<p>I intend to continue the series with another post by trying to hook up my futures and executor with the event loop and reactor. Should be interesting!</p>

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

<ol>
  <li><a href="https://www.youtube.com/playlist?list=PLb1VOxJqFzDd05_aDQEm6KVblhee_KStX">YouTube Playlist On Async I/O In Rust</a></li>
  <li><a href="https://github.com/smol-rs/polling">Polling Crate</a></li>
  <li><a href="https://github.com/tokio-rs/mio">MIO Crate</a></li>
  <li><a href="https://en.wikipedia.org/wiki/STREAMS">Unix Streams</a></li>
  <li><a href="https://emschwartz.me/async-rust-can-be-a-pleasure-to-work-with-without-send-sync-static/">Async Rust By Evan Schwartz</a></li>
  <li><a href="https://github.com/nyxtom/async-in-depth-rust-series">Async I/O In Rust Git Repo</a></li>
  <li><a href="https://www.youtube.com/watch?v=Ul8OO4vQMTw">King Protty’s Talk On Zig I/O Concurrency</a></li>
</ol>]]></content><author><name></name></author><category term="async" /><summary type="html"><![CDATA[Here’s a link to the code on GitHub]]></summary></entry><entry><title type="html">Async Runtimes</title><link href="https://redixhumayun.github.io/async/2024/08/05/async-runtimes.html" rel="alternate" type="text/html" title="Async Runtimes" /><published>2024-08-05T00:00:00+00:00</published><updated>2024-08-05T00:00:00+00:00</updated><id>https://redixhumayun.github.io/async/2024/08/05/async-runtimes</id><content type="html" xml:base="https://redixhumayun.github.io/async/2024/08/05/async-runtimes.html"><![CDATA[<p><em><a href="https://github.com/redixhumayun/async-rust">Here’s a link</a> to the code on a GitHub repo</em></p>

<p>I’m trying to understand <code>async</code> runtimes better, specifically in Rust. This post is a short attempt to build the most basic example of a future and execute it.</p>

<p>A better and more detailed version of this can be found in <a href="https://rust-lang.github.io/async-book/01_getting_started/01_chapter.html">this book</a>.</p>

<h2 id="futures">Futures</h2>
<p>Creating a future in Rust is very straightforward. You just need to implement the <code>Future</code> trait, which requires one method, <code>poll</code>, to be implemented.</p>

<p>Let’s build a simple future.</p>

<pre><code class="language-rust">use std::{
    future::Future,
    sync::{
        mpsc::{sync_channel, Receiver, SyncSender},
        Arc, Mutex,
    },
    task::{Context, Poll, Waker},
    thread,
    time::Duration,
};

use futures::{
    future::{BoxFuture, FutureExt},
    task::{waker_ref, ArcWake},
};

struct SharedState {
    completed: bool,
    waker: Option&lt;Waker&gt;,
}

pub struct TimerFuture {
    shared_state: Arc&lt;Mutex&lt;SharedState&gt;&gt;,
}
</code></pre>

<p>The <code>Waker</code> referenced above is a way for the task to inform it’s executor that it should be polled again. You can read more from the docs <a href="https://doc.rust-lang.org/std/task/struct.Waker.html">here</a>.</p>

<p>Now, let’s make it a future and construct an instance of it.</p>

<p><em>Note: If you want to understand more about what <code>Pin</code> is and why it’s required, <a href="https://blog.cloudflare.com/pin-and-unpin-in-rust">look here</a>. Long story short - it’s a way for Rust to ensure that the data being pointed at isn’t moved around in memory</em></p>

<pre><code class="language-rust">impl Future for TimerFuture {
    type Output = ();
    fn poll(
        self: std::pin::Pin&lt;&amp;mut Self&gt;,
        cx: &amp;mut std::task::Context&lt;'_&gt;,
    ) -&gt; std::task::Poll&lt;Self::Output&gt; {
        let mut shared_state = self.shared_state.lock().unwrap();
        if shared_state.completed {
            return Poll::Ready(());
        }
        shared_state.waker = Some(cx.waker().clone());
        return Poll::Pending;
    }
}

impl TimerFuture {
    fn new(duration: Duration) -&gt; Self {
        let shared_state = Arc::new(Mutex::new(SharedState {
            completed: false,
            waker: None,
        }));
        let thread_shared_state = Arc::clone(&amp;shared_state);
        thread::spawn(move || {
            thread::sleep(duration);
            let mut shared_state = thread_shared_state.lock().unwrap();
            shared_state.completed = true;
            if let Some(waker) = shared_state.waker.take() {
                waker.wake();
            }
        });
        TimerFuture { shared_state }
    }
}
</code></pre>

<p>There we go, we now have a future. Now, we need to run this future to completion.</p>

<h2 id="executing-a-future">Executing A Future</h2>
<p>Let’s get some conceptual modeling out of the way first. We have the following terms - <code>Spawner</code>, <code>Executor</code>, <code>Task</code>, <code>Future</code> &amp; <code>Waker</code>. Let’s build the mental model bottom up.</p>

<p>A <code>Future</code> is something that will complete at some point in time.</p>

<p>A <code>Waker</code> is a way for the future to ensure that it is polled again (typically by placing the future back onto the executor queue)</p>

<p>A <code>Task</code> is a wrapper around a <code>Future</code> and a <code>Waker</code>.</p>

<p>A <code>Spawner</code> is a component that constructs a <code>Task</code> and provides it to the <code>Executor</code>.</p>

<p>An <code>Executor</code> constantly checks it’s list of <code>Tasks</code> and polls their futures for completion</p>

<p>A picture is worth a thousand words, so here’s one</p>

<p><img src="/assets/img/async/executor_runtime.png" alt="" /></p>

<p>Let’s write some code to represent this.</p>

<h3 id="overall-structure">Overall Structure</h3>
<p>Here’s the basic structs for <code>Task</code>, <code>Spawner</code> &amp; <code>Executor</code>.</p>

<pre><code class="language-rust">struct Task {
    future: Mutex&lt;BoxFuture&lt;'static, ()&gt;&gt;,
    task_sender: SyncSender&lt;Arc&lt;Task&gt;&gt;,
}

struct Spawner {
    task_sender: SyncSender&lt;Arc&lt;Task&gt;&gt;,
}

struct Executor {
    task_queue: Receiver&lt;Arc&lt;Task&gt;&gt;,
}
</code></pre>

<p><code>Task</code> and <code>Spawner</code> keep putting tasks onto the channel and the <code>Executor</code> has the receiving end of the channel and keeps polling the futures within these tasks.</p>

<h3 id="task">Task</h3>
<pre><code class="language-rust">struct Task {
    future: Mutex&lt;BoxFuture&lt;'static, ()&gt;&gt;,
    task_sender: SyncSender&lt;Arc&lt;Task&gt;&gt;,
}
</code></pre>

<p>The <code>BoxFuture</code> is an aliased type for <code>pub type BoxFuture&lt;'a, T&gt; = Pin&lt;alloc::boxed::Box&lt;dyn Future&lt;Output = T&gt; + Send + 'a&gt;&gt;</code>.</p>

<p>It’s a complicated type but it essentially means that I have a <code>Box</code> container around a dynamic <code>Future</code> and I do not want the data within the <code>Box</code> container to be moved around in memory so I wrap it in a <code>Pin</code>.</p>

<blockquote>
  <p>The easiest way to understand <code>Pin</code> is to work through a simple example. Let’s create a simple <code>Pin</code> wrapper and try to move it. The example below compiles with an error because <code>String</code> does not have <code>Copy</code> semantics, so when it tries to move the value, it cannot because the container has been pinned in place.</p>
  <pre><code class="language-rust">#[derive(Debug)]
struct MyData {
    value: String,
}
let my_data = Pin::new(Box::new(MyData {
    value: String::from("hello"),
}));
let moved_data = Box::new(MyData {
    value: my_data.value,
});
error[E0507]: cannot move out of dereference of `Pin&lt;Box&lt;MyData&gt;&gt;`
  --&gt; src/main.rs:40:16
   |
40 |         value: my_data.value,
   |                ^^^^^^^^^^^^^ move occurs because value has type `String`, which does not implement the `Copy` trait
   |
help: consider cloning the value if the performance cost is acceptable
   |
40 |         value: my_data.value.clone(),
   |                             ++++++++
</code></pre>
</blockquote>

<p>Now, we said earlier that <code>Task</code> is a wrapper for a <code>Future</code> and a <code>Wake</code> object. By implementing the trait below, we allow a <code>Waker</code> object to be constructed from a <code>Task</code>. More on that below.</p>
<pre><code class="language-rust">impl ArcWake for Task {
    fn wake_by_ref(arc_self: &amp;Arc&lt;Self&gt;) {
        let arc_clone = Arc::clone(&amp;arc_self);
        arc_self.task_sender.send(arc_clone).unwrap();
    }
}
</code></pre>

<h3 id="spawner">Spawner</h3>
<pre><code class="language-rust">struct Spawner {
    task_sender: SyncSender&lt;Arc&lt;Task&gt;&gt;,
}
</code></pre>

<p>This component pushes new tasks onto the channel.</p>

<pre><code class="language-rust">impl Spawner {
    fn spawn(&amp;self, future: impl Future&lt;Output = ()&gt; + Send + 'static) {
        let box_future = future.boxed();
        let task = Arc::new(Task {
            future: Mutex::new(box_future),
            task_sender: self.task_sender.clone(),
        });
        self.task_sender.send(task).unwrap();
    }
}
</code></pre>

<h3 id="executor">Executor</h3>
<pre><code class="language-rust">struct Executor {
    task_queue: Receiver&lt;Arc&lt;Task&gt;&gt;,
}
</code></pre>

<p>This component blocks on the receiver until it receives new tasks. Once it gets these tasks, it polls them to determine if they are complete.</p>

<p>There is also a convenience method for constructing an <code>Executor</code> and <code>Spawner</code>.</p>

<pre><code class="language-rust">impl Executor {
    fn run(&amp;self) {
        while let Ok(task) = self.task_queue.recv() {
            let mut fut = task.future.lock().unwrap();
            let waker = waker_ref(&amp;task);
            let context = &amp;mut Context::from_waker(&amp;waker);
            if fut.as_mut().poll(context).is_ready() {
                println!("The future is done running");
            }
        }
    }

    fn executor_and_spawner() -&gt; (Executor, Spawner) {
        let (sync_sender, receiver) = sync_channel(10000);
        let executor = Executor {
            task_queue: receiver,
        };
        let spawner = Spawner {
            task_sender: sync_sender,
        };
        (executor, spawner)
    }
}
</code></pre>

<p>In the <code>run</code> method, you can see the <code>Waker</code> object being constructed from the <code>Task</code>. This is possible because we implemented the <code>ArcWake</code> trait for the <code>Task</code> above, and we are providing it in the <code>Context</code> object to the <code>poll</code> method.</p>

<p>Now, when the future is ready and it calls the <code>wake</code> method on the <code>Wake</code> object, the method in the <code>ArcWake</code> trait is executed.</p>

<h3 id="putting-it-together">Putting It Together</h3>

<p>Here’s a simple test to demonstrate how this works together</p>

<pre><code class="language-rust">#[cfg(test)]
mod tests {
  use super::*;

  #[test]
  fn spawn_tasks() {
    let (executor, spawner) = Executor::executor_and_spawner();
    spawner.spawn(async {
        println!("Hello");
        TimerFuture::new(Duration::from_secs(2)).await;
        println!("World");
    });
    drop(spawner);
    executor.run();
  }
}
</code></pre>

<p>The output should be <code>Hello</code>, followed by a 2 second pause and then a <code>World</code>.</p>

<p>Here’s a flow diagram for what is going on</p>

<pre><code>-&gt; Spawner creates task 
-&gt; Spawner puts task into execution queue 
-&gt; Executor polls the future in the task and finds it pending 
-&gt; The future will keep track of the Waker object 
-&gt; Duration elapses 
-&gt; The future calls the `.wake` method 
-&gt; The `ArcWake` implementation on Task is triggered 
-&gt; The task is again placed on the queue 
-&gt; Executor polls the future and finds it completed
</code></pre>

<h3 id="conclusion">Conclusion</h3>

<p>So, there you have it - a very, very simple implementation of a single threaded executor.</p>

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

<ul>
  <li><a href="https://blog.cloudflare.com/pin-and-unpin-in-rust">Cloudflare Blog On Pin And Unpin</a></li>
  <li><a href="https://rust-lang.github.io/async-book/01_getting_started/01_chapter.html">Asynchronous Programming In Rust</a></li>
  <li><a href="https://github.com/redixhumayun/async-rust">GitHub Repo</a></li>
  <li><a href="https://www.qovery.com/blog/a-guided-tour-of-streams-in-rust/">A Guided Tour Of Streams In Rust</a></li>
  <li><a href="https://fasterthanli.me/articles/pin-and-suffering">Pin And Suffering</a></li>
  <li><a href="https://tontinton.com/posts/scheduling-internals/">Scheduling Internals</a></li>
</ul>]]></content><author><name></name></author><category term="async" /><summary type="html"><![CDATA[Here’s a link to the code on a GitHub repo]]></summary></entry><entry><title type="html">Distributed Transactions With Percolator</title><link href="https://redixhumayun.github.io/databases/2024/06/23/distributed-transactions-with-percolator.html" rel="alternate" type="text/html" title="Distributed Transactions With Percolator" /><published>2024-06-23T00:00:00+00:00</published><updated>2024-06-23T00:00:00+00:00</updated><id>https://redixhumayun.github.io/databases/2024/06/23/distributed-transactions-with-percolator</id><content type="html" xml:base="https://redixhumayun.github.io/databases/2024/06/23/distributed-transactions-with-percolator.html"><![CDATA[<p><em>This post covers a course that is part of TiDB’s talent plan on building a simple implementation of Percolator. You can find the course <a href="https://github.com/pingcap/talent-plan">here</a></em></p>

<p>I stumbled upon the <a href="https://github.com/pingcap/talent-plan">talent plan course by TiDB</a> and decided to use it to understand distributed transactions (a.k.a Percolator) better.</p>

<p>The point of the Percolator course is to <a href="https://storage.googleapis.com/pub-tools-public-publication-data/pdf/36726.pdf">read the paper</a> and implement it in Rust, so I’ll be diving into bits and pieces of the paper as well.</p>

<div class="aside">
The excerpts from the paper are all rendered in an aside like this
</div>

<h2 id="overview">Overview</h2>
<p>Percolator came out of Google in 2010 as a way to run distributed transactions on top of Bigtable, Google’s distributed storage system. At the time Google wanted to run transactions on a regular DBMS but nothing came close to handling the volume Google had, so they built a distributed transaction model on top of their own distributed data source.</p>

<div class="aside">
The indexing system could store the repository in a
DBMS and update individual documents while using
transactions to maintain invariants. However, existing
DBMSs can’t handle the sheer volume of data: Google’s
indexing system stores tens of petabytes across thousands of machines [30]. Distributed storage systems like
Bigtable [9] can scale to the size of our repository but
don’t provide tools to help programmers maintain data
invariants in the face of concurrent updates.
</div>

<h2 id="mechanics">Mechanics</h2>
<p>The mechanics of Percolator are deceptively simple. It’s a straightforward algorithm to understand, albeit with a lot of bookkeeping involved. Let’s look at how it would work for a simple key-value store.</p>

<p>Every key has 3 columns attached to it - a <code>Data</code> column, a <code>Lock</code> column and a <code>Write</code> column. Every transaction commit has 2 phases - a pre-write phase and a commit phase.</p>

<p>The pre-write phase involves writing to the <code>Data</code> &amp; <code>Lock</code> columns - that is, whatever the new value the transaction wants to write and an associated lock. Now, the transaction hasn’t committed yet, so this newly written value is not visible yet.</p>

<p>The transaction commit phase involves some validation checks and assuming those validation checks pass, the lock is removed and a pointer to the <code>Data</code> column is placed in the <code>Write</code> column.</p>

<p>Let’s run through an example transaction. (<a href="https://tikv.org/deep-dive/distributed-transaction/percolator/">Look here</a> for a detailed example from TiKV).</p>

<p>I’ll work through an example that uses markers more pertinent to the codebase for the course. The examples provided in the paper and the blog post from TiKV use slightly different markers in the rows, but they’re functionally the same.</p>

<h4 id="initial-state">Initial State</h4>
<p>Let’s say we start out with a simple key value store that represents the accounts of individuals. There are 2 account holders Bob and Joe with an initial balance of 10$ and 2$ respectively. We will run a transaction that will transfer 7$ from Bob to Joe.</p>

<p>The image below shows the initial state we will start from.</p>

<p><img src="/assets/img/databases/percolator/transaction_1.png" alt="" /></p>

<p>The key is a tuple representing the <code>(key, timestamp)</code> of the transaction that wrote that data into it. The value can either be a set of bytes representing the data or a timestamp serving as a pointer (The value can also have a third variant, which is wall clock timestamp. I’ll get to that next). The write column has a pointer pointing to the timestamp in the data column where the data actually resides as a value.</p>

<p>You’ll notice that the timestamps in the data column and the write column are different because they represent the start and commit timestamps of the transaction that wrote this data and committed it. An invariant that must always be held is that <code>commit timestamp &gt; start timestamp</code>. This is ensured by a timestamp oracle, which is a fancy term for a server that hands out <em>strictly</em> monotonically increasing values. The <em>strictly</em> is doing a lot of work here because it means that you are guaranteed to never see a timestamp more than once.</p>

<div class="aside">
The timestamp oracle is a server that hands out timestamps in strictly increasing order. Since every transaction
requires contacting the timestamp oracle twice, this service must scale well. The oracle periodically allocates
a range of timestamps by writing the highest allocated
timestamp to stable storage; given an allocated range of
timestamps, the oracle can satisfy future requests strictly
from memory
</div>

<h4 id="pre-write">Pre-Write</h4>
<p>Now, let’s say the transaction begins that will transfer the amount of 7$ from Bob to Joe. This transaction will begin with a timestamp of <code>t2</code>. The first thing this transaction does it to acquire a lock on both keys that are involved in the transaction, and write down the data that should be the end result of this transaction.</p>

<p><img src="/assets/img/databases/percolator/transaction_2.png" alt="" /></p>

<p>The entries in the two columns are different - one of them is selected as the primary lock (the key with a # symbol) and the other (or others, depending on the number of keys involved in the transaction) are selected as secondaries. The primary lock is primarily used as a synchronization point in case of any cleanup required. This cleanup is usually done if a transaction has crashed or failed to remove all of it’s locks.</p>

<div class="aside">
It is very difficult for A to be perfectly confident in
its judgment that B is failed; as a result we must avoid
a race between A cleaning up B’s transaction and a notactually-failed B committing the same transaction. Percolator handles this by designating one cell in every
transaction as a synchronizing point for any commit or
cleanup operations. This cell’s lock is called the primary
lock. Both A and B agree on which lock is primary (the
location of the primary is written into the locks at all
other cells).
</div>

<h4 id="commit">Commit</h4>
<p>As part of the commit phase, the transaction will perform some validation checks (this is implementation dependent but the main check is that the primary lock hasn’t been removed). Assuming these validation checks pass, the transaction will erase the locks placed on all the rows and place a new entry in the write column serving as a pointer to the entry in the data column that was created in the pre-commit phase. It marks the new entry in the write column with a commit timestamp.</p>

<p><img src="/assets/img/databases/percolator/transaction_3.png" alt="" /></p>

<h2 id="implementation">Implementation</h2>

<p>Let’s dive into an implementation of the Percolator algorithm. The base for this was taken from <a href="https://github.com/pingcap/talent-plan">the TiKV course</a>. If you just want to jump straight to my full implementation, <a href="https://github.com/redixhumayun/talent-plan/tree/master/courses/dss/percolator">go here</a>.</p>

<p>The course gives you a client and server implementation with the functionality stubbed out. The goal is for you to write that functionality. Very helpfully, the course gives you a network implementation between the client and the server which simulates faults like dropped requests which help you test your implementation. (<a href="https://x.com/redixhumayun/status/1766768668980199500">testing distributed systems is more than half the problem</a>).</p>

<h3 id="write-path">Write Path</h3>

<p>Let’s cover the write path first. A transaction client write’s all data into a private buffer until it is time to actually perform the commit.</p>

<pre><code class="language-rust">#[derive(Clone)]
pub struct Client {
    // Your definitions here.
    tso_client: TSOClient,
    txn_client: TransactionClient,
    transaction: Option&lt;Transaction&gt;,
}

#[derive(Clone, Debug)]
pub struct KVPair {
    key: Vec&lt;u8&gt;,
    value: Vec&lt;u8&gt;,
}

#[derive(Clone)]
pub struct Transaction {
    pub start_ts: u64,
    pub write_buffer: Vec&lt;KVPair&gt;,
}

impl Transaction {
    pub fn new(start_ts: u64) -&gt; Self {
        Transaction {
            start_ts,
            write_buffer: Vec::new(),
        }
    }
}

impl Client {
    /// Creates a new Client.
    pub fn new(tso_client: TSOClient, txn_client: TransactionClient) -&gt; Client {
        // Your code here.
        Client {
            tso_client,
            txn_client,
            transaction: None,
        }
    }

    /// Gets a timestamp from a TSO.
    pub fn get_timestamp(&amp;self) -&gt; Result&lt;u64&gt; {
        let rpc = || self.tso_client.get_timestamp(&amp;TimestampRequest {});
        match executor::block_on(self.call_with_retry(rpc)) {
            Ok(ts) =&gt; Ok(ts.timestamp),
            Err(e) =&gt; Err(e),
        }
    }

    /// Begins a new transaction.
    pub fn begin(&amp;mut self) {
        let ts = self
            .get_timestamp()
            .expect("unable to get a timestamp from the oracle");
        let transaction = Transaction::new(ts);
        self.transaction = Some(transaction);
    }

    /// Sets keys in a buffer until commit time.
    pub fn set(&amp;mut self, key: Vec&lt;u8&gt;, value: Vec&lt;u8&gt;) {
        // Your code here.
        if let Some(transaction) = self.transaction.as_mut() {
            transaction.write_buffer.push(KVPair { key, value });
            return;
        }
        panic!("attempting to set a key value pair without a txn");
    }
}
</code></pre>

<p>Now, when the transaction performs the commit it runs through the two phases - pre-write and commit per key-value pair on the client.</p>

<pre><code class="language-rust">/// Commits a transaction.
    pub fn commit(&amp;self) -&gt; Result&lt;bool&gt; {
        //  PRE-WRITE PHASE
        let transaction = self.transaction.as_ref().expect("transaction not found");
        let kv_pair = &amp;transaction.write_buffer;
        let primary = kv_pair
            .first()
            .expect("cannot find the first key value pair");
        let secondaries = &amp;kv_pair[1..];
        //  acquire a lock on the primary first
        let args = PrewriteRequest {
            timestamp: transaction.start_ts,
            kv_pair: Some(KvPair {
                key: primary.key.clone(),
                value: primary.value.clone(),
            }),
            primary: Some(KvPair {
                key: primary.key.clone(),
                value: primary.value.clone(),
            }),
        };
        let rpc = || self.txn_client.prewrite(&amp;args);
        if executor::block_on(self.call_with_retry(rpc))?.res == false {
            return Ok(false);
        }
        //  acquire locks on the secondaries now
        for kv_pair in secondaries {
            let args = PrewriteRequest {
                timestamp: transaction.start_ts,
                kv_pair: Some(KvPair {
                    key: kv_pair.key.clone(),
                    value: kv_pair.value.clone(),
                }),
                primary: Some(KvPair {
                    key: primary.key.clone(),
                    value: primary.value.clone(),
                }),
            };
            let rpc = || self.txn_client.prewrite(&amp;args);
            if executor::block_on(self.call_with_retry(rpc))?.res == false {
                return Ok(false);
            }
        }
        //  END PRE-WRITE PHASE

        //  COMMIT PHASE
        let commit_ts = self.get_timestamp()?;
        assert!(
            commit_ts &gt; transaction.start_ts,
            "panic because the commit ts is not strictly greater than the start ts of the txn"
        );
        let args = CommitRequest {
            start_ts: transaction.start_ts,
            commit_ts,
            is_primary: true,
            kv_pair: Some(KvPair {
                key: primary.key.clone(),
                value: primary.value.clone(),
            }),
        };
        let rpc = || self.txn_client.commit(&amp;args);
        match executor::block_on(self.call_with_retry(rpc)) {
            Ok(response) =&gt; {
                return Ok(response.res);
            }
            Err(e) =&gt; match e {
                labrpc::Error::Other(e_string) if e_string == "reqhook" =&gt; {
                    return Ok(false);
                }
                _ =&gt; return Err(e),
            },
        }
        for kv_pair in secondaries {
            let args = CommitRequest {
                start_ts: transaction.start_ts,
                commit_ts,
                is_primary: false,
                kv_pair: Some(KvPair {
                    key: primary.key.clone(),
                    value: primary.value.clone(),
                }),
            };
            let rpc = || self.txn_client.commit(&amp;args);
            match executor::block_on(self.call_with_retry(rpc)) {
                Ok(response) =&gt; return Ok(response.res),
                Err(e) =&gt; match e {
                    labrpc::Error::Other(e_string) =&gt; {
                        if e_string == "reqhook" {
                            return Ok(true);
                        }
                    }
                    _ =&gt; return Err(e),
                },
            }
        }
        //  END COMMIT PHASE
        Ok(true)
    }
</code></pre>

<p>There’s a bunch of additional code ensuring that the RPC call is retried x number of times, but the gist is the same as that of the paper.</p>

<p>Now, for the server side, we have 3 main components - a memory storage server, a key value table and the timestamp oracle. The key value table holds the actual data and the memory storage server manages the business logic of the transaction.</p>

<pre><code class="language-rust">// KvTable is used to simulate Google's Bigtable.
// It provides three columns: Write, Data, and Lock.
#[derive(Clone, Default)]
pub struct KvTable {
    write: BTreeMap&lt;Key, Value&gt;,
    data: BTreeMap&lt;Key, Value&gt;,
    lock: BTreeMap&lt;Key, Value&gt;,
}

impl KvTable {
    // Reads the latest key-value record from a specified column
    // in MemoryStorage with a given key and a timestamp range.
    #[inline]
    fn read(
        &amp;self,
        key: Vec&lt;u8&gt;,
        column: Column,
        ts_start_inclusive: Option&lt;u64&gt;,
        ts_end_inclusive: Option&lt;u64&gt;,
    ) -&gt; Option&lt;(Key, Value)&gt; {
        let col = match column {
            Column::Data =&gt; &amp;self.data,
            Column::Lock =&gt; &amp;self.lock,
            Column::Write =&gt; &amp;self.write,
        };
        let mut res = None;
        let mut max_timestamp_seen = 0;

        for ((k, ts), value) in col.iter() {
            if k == &amp;key
                &amp;&amp; ts_start_inclusive.map_or(true, |start| *ts &gt;= start)
                &amp;&amp; ts_end_inclusive.map_or(true, |end| *ts &lt;= end)
                &amp;&amp; *ts &gt;= max_timestamp_seen
            {
                max_timestamp_seen = *ts;
                res = Some(((k.clone(), *ts), value.clone()));
            }
        }
        res
    }

    // Writes a record to a specified column in MemoryStorage.
    #[inline]
    fn write(&amp;mut self, key: Vec&lt;u8&gt;, column: Column, ts: u64, value: Value) {
        let col = match column {
            Column::Data =&gt; &amp;mut self.data,
            Column::Lock =&gt; &amp;mut self.lock,
            Column::Write =&gt; &amp;mut self.write,
        };
        col.insert((key, ts), value);
    }

    #[inline]
    // Erases a record from a specified column in MemoryStorage.
    fn erase(&amp;mut self, key: Vec&lt;u8&gt;, column: Column, commit_ts: u64) {
        let col = match column {
            Column::Data =&gt; &amp;mut self.data,
            Column::Lock =&gt; &amp;mut self.lock,
            Column::Write =&gt; &amp;mut self.write,
        };
        let mut keys_to_remove = Vec::new();
        for ((k, ts), _) in col.iter() {
            if k == &amp;key &amp;&amp; *ts == commit_ts {
                keys_to_remove.push((k.clone(), *ts));
            }
        }
        for key in keys_to_remove {
            let value = col.remove(&amp;key);
            assert!(value.is_some());
        }
    }
}
</code></pre>

<pre><code class="language-rust">// MemoryStorage is used to wrap a KvTable.
// You may need to get a snapshot from it.
#[derive(Clone, Default)]
pub struct MemoryStorage {
    data: Arc&lt;Mutex&lt;KvTable&gt;&gt;,
}

#[async_trait::async_trait]
impl transaction::Service for MemoryStorage {
  // example prewrite RPC handler.
    async fn prewrite(&amp;self, req: PrewriteRequest) -&gt; labrpc::Result&lt;PrewriteResponse&gt; {
        let primary = req.primary.ok_or_else(|| {
            labrpc::Error::Other("primary kv_pair is missing in the prewrite request".to_string())
        })?;
        let kv_pair = req.kv_pair.ok_or_else(|| {
            labrpc::Error::Other("kv_pair is missing in the prewrite request".to_string())
        })?;
        let mut storage = self.data.lock().unwrap();
        match storage.read(
            kv_pair.key.clone(),
            Column::Write,
            Some(req.timestamp),
            None,
        ) {
            Some(_) =&gt; return Ok(PrewriteResponse { res: false }),
            None =&gt; (),
        };
        match storage.read(kv_pair.key.clone(), Column::Lock, Some(0), None) {
            Some(_) =&gt; return Ok(PrewriteResponse { res: false }),
            None =&gt; (),
        };
        //  all checks completed, place data and lock
        storage.write(
            kv_pair.key.clone(),
            Column::Data,
            req.timestamp,
            Value::Vector(kv_pair.value.clone()),
        );
        if primary == kv_pair {
            storage.write(
                kv_pair.key.clone(),
                Column::Lock,
                req.timestamp,
                Value::LockPlacedAt(SystemTime::now()),
            );
        } else {
            storage.write(
                kv_pair.key.clone(),
                Column::Lock,
                req.timestamp,
                Value::Vector(primary.key),
            );
        }
        Ok(PrewriteResponse { res: true })
    }

    // example commit RPC handler.
    async fn commit(&amp;self, req: CommitRequest) -&gt; labrpc::Result&lt;CommitResponse&gt; {
        let mut storage = self.data.lock().unwrap();
        let kv_pair = req
            .kv_pair
            .expect("kv_pair is missing in the commit request");
        if req.is_primary {
            //  check lock on primary still holds
            match storage.read(
                kv_pair.key.clone(),
                Column::Lock,
                Some(req.start_ts),
                Some(req.start_ts),
            ) {
                Some(_) =&gt; (),
                None =&gt; {
                    return Ok(CommitResponse { res: false });
                }
            };
        }

        //  create write and remove lock
        storage.write(
            kv_pair.key.clone(),
            Column::Write,
            req.commit_ts,
            Value::Timestamp(req.start_ts),
        );
        storage.erase(kv_pair.key, Column::Lock, req.start_ts);
        Ok(CommitResponse { res: true })
    }
}
</code></pre>

<p>While doing the prewrite phase, you’ll notice that there is a check to determine what the primary lock is supposed to be. The value written into the primary lock is the wall clock time when it is being placed. This is important during the read path.</p>

<p>The commit handler is a lot simpler - the only validation it does is to determine that if the primary is being committed that the lock is still valid. It doesn’t need to do that for the secondaries.</p>

<h4 id="read-path">Read Path</h4>

<p>Let’s deal with a straightforward read path first - one where it doesn’t encounter any locks. <a href="https://tikv.org/deep-dive/distributed-transaction/percolator/">This post from TiKV</a> has a great explanation of the read path, so I’m going to use the text from there as an explanation.</p>

<p><img src="/assets/img/databases/percolator/tikv_blog_read_path.png" alt="" /></p>

<p>The basic idea is that you first check if there are any pending locks. Assuming there are none, fetch the latest write record within range and use the pointer there to the data column to get the actual value.</p>

<pre><code class="language-rust">#[async_trait::async_trait]
impl transaction::Service for MemoryStorage {
  // example get RPC handler.
    async fn get(&amp;self, req: GetRequest) -&gt; labrpc::Result&lt;GetResponse&gt; {
        loop {
            let mut storage = self.data.lock().unwrap();
            let is_row_locked =
                storage.read(req.key.clone(), Column::Lock, Some(0), Some(req.timestamp));
            if is_row_locked.is_some() {
                drop(storage);
                self.back_off_maybe_clean_up_lock(req.timestamp, req.key.clone());
                std::thread::sleep(Duration::from_millis(100));
                continue;
            }

            let start_ts =
                match storage.read(req.key.clone(), Column::Write, Some(0), Some(req.timestamp)) {
                    Some(((_, commit_ts), value)) =&gt; match value {
                        Value::Timestamp(start_ts) =&gt; start_ts,
                        _ =&gt; {
                            return Err(labrpc::Error::Other(format!(
                                "unexpected value found in write column for key {:?} at ts {}",
                                req.key, commit_ts
                            )))
                        }
                    },
                    None =&gt; {
                        return Ok(GetResponse {
                            success: false,
                            value: Vec::new(),
                        });
                    }
                };

            let data = match storage.read(
                req.key.clone(),
                Column::Data,
                Some(start_ts),
                Some(start_ts),
            ) {
                Some(((_, _), value)) =&gt; match value {
                    Value::Vector(bytes) =&gt; bytes,
                    _ =&gt; {
                        return Err(labrpc::Error::Other(format!(
                            "unexpected value found in data column for key {:?} at ts {}",
                            req.key, start_ts
                        )));
                    }
                },
                None =&gt; {
                    return Err(labrpc::Error::Other(format!(
                        "No value found in data column for key {:?} at timestamp {}",
                        req.key, start_ts
                    )));
                }
            };

            return Ok(GetResponse {
                success: true,
                value: data,
            });
        }
    }
}
</code></pre>

<p>The code is verbose because of all the match guards but hopefully straighforward to follow. (On another note, I find myself writing very verbose Rust code which is what the language seems to lend itself to.)</p>

<p>Now, let’s dive into dealing with pending locks and potentially failed transactions on the read path. First, let’s look at what the paper says about dealing with these transactions.</p>

<div class="aside">
Transaction processing is complicated by the possibility of client failure (tablet server failure does not affect
the system since Bigtable guarantees that written locks
persist across tablet server failures). If a client fails while
a transaction is being committed, locks will be left behind. Percolator must clean up those locks or they will
cause future transactions to hang indefinitely. Percolator
takes a lazy approach to cleanup: when a transaction A
encounters a conflicting lock left behind by transaction
B, A may determine that B has failed and erase its locks.
<p>.......</p>
<p>.......</p>
<p>.......</p>
When a client crashes during the second phase of
commit, a transaction will be past the commit point
(it has written at least one write record) but will still
5
have locks outstanding. We must perform roll-forward on
these transactions. A transaction that encounters a lock
can distinguish between the two cases by inspecting the
primary lock: if the primary lock has been replaced by a
write record, the transaction which wrote the lock must
have committed and the lock must be rolled forward, otherwise it should be rolled back (since we always commit
the primary first, we can be sure that it is safe to roll back
if the primary is not committed). To roll forward, the
transaction performing the cleanup replaces the stranded
lock with a write record as the original transaction would
have done.
</div>

<p>So Percolator distinguishes between crashes by determining when a client has crashed.</p>

<ol>
  <li>
    <p>If it crashes after the pre-commit phase but before the commit phase, the transaction must be rolled back. The way to determine this is to check if the <code>TTL</code> on the wall clock time for the primary lock has elapsed.</p>
  </li>
  <li>
    <p>If the client crashed after committing the primary lock in the commit phase but before committing the secondary locks, the transaction must be rolled forward since the only synchronization point is the primary lock itself.</p>
  </li>
</ol>

<p>Here’s all the code related to that. It’s slightly complicated by the fact that a <code>get</code> request might come across a secondary lock, in which case it needs handle the indirection. I’ve tried to document this function as clearly as possible so it’s easier to follow.</p>

<pre><code class="language-rust">impl MemoryStorage {
    fn back_off_maybe_clean_up_lock(&amp;self, start_ts: u64, key: Vec&lt;u8&gt;) {
        //  STEPS:
        //  1. Recheck the condition that prompted this call by re-acquiring lock. Things might have changed
        //  2. Check if the lock is the primary lock. If secondary lock, get primary lock
        //  3. If primary lock present and
        //      a. has expired, roll-back the txn
        //      b. has not expired, do nothing and retry after some time
        //  4. If primary lock not present and
        //      a. Data found in Write column, roll-forward the txn
        //      b. No data found in Write column, remove stale lock

        let mut storage = self.data.lock().unwrap();
        let ((key, start_ts), value) =
            match storage.read(key.clone(), Column::Lock, Some(0), Some(start_ts)) {
                Some((key, value)) =&gt; (key, value),
                None =&gt; return,
            };

        let is_primary_lock = match value {
            Value::LockPlacedAt(creation_time) =&gt; true,
            Value::Vector(ref data) =&gt; false,
            Value::Timestamp(_) =&gt; panic!(
                "unexpected value of bytes found in lock column, expected SystemTime or Vec&lt;u8&gt;"
            ),
        };

        if is_primary_lock {
            if self.check_if_primary_lock_expired(value) {
                self.remove_lock_and_rollback(&amp;mut storage, key, start_ts);
            }
            return;
        }

        //  handle the secondary lock here
        let primary_key = value
            .as_vector()
            .expect("unexpected value in lock column, expected Vec&lt;u8&gt;");
        match storage.read(primary_key.clone(), Column::Lock, Some(0), Some(start_ts)) {
            Some(((_, conflicting_start_ts), value)) =&gt; {
                if self.check_if_primary_lock_expired(value) {
                    self.remove_lock_and_rollback(&amp;mut storage, primary_key, conflicting_start_ts);
                    self.remove_lock_and_rollback(&amp;mut storage, key, start_ts);
                    return;
                }
            }
            None =&gt; {
                //  the primary lock is gone, check for data
                match storage.read(primary_key, Column::Write, None, None) {
                    None =&gt; {
                        self.remove_lock_and_rollback(&amp;mut storage, key, start_ts);
                    }
                    Some(((_, commit_ts), value)) =&gt; {
                        let start_ts = value
                            .as_timestamp()
                            .expect("unexpected value in write column, expected ts");
                        self.remove_lock_and_roll_forward(&amp;mut storage, key, start_ts, commit_ts);
                    }
                }
            }
        }
    }

    fn check_if_primary_lock_expired(&amp;self, value: Value) -&gt; bool {
        let lock_creation_time = value
            .as_lock_placed_at()
            .expect("unexpected value in lock column, expected SystemTime");
        let ttl_duration = Duration::from_nanos(TTL);
        let future_time = lock_creation_time + ttl_duration;
        future_time &lt; SystemTime::now()
    }

    fn remove_lock_and_rollback(
        &amp;self,
        storage: &amp;mut std::sync::MutexGuard&lt;KvTable&gt;,
        key: Vec&lt;u8&gt;,
        timestamp: u64,
    ) {
        storage.erase(key.clone(), Column::Lock, timestamp);
        storage.erase(key.clone(), Column::Data, timestamp);
    }

    fn remove_lock_and_roll_forward(
        &amp;self,
        storage: &amp;mut std::sync::MutexGuard&lt;KvTable&gt;,
        key: Vec&lt;u8&gt;,
        start_ts: u64,
        commit_ts: u64,
    ) {
        storage.erase(key.clone(), Column::Lock, start_ts);
        storage.write(key, Column::Write, commit_ts, Value::Timestamp(start_ts));
    }
}
</code></pre>

<h3 id="aside">Aside</h3>
<p>As an aside, something that bothered me while doing this course was that failed transactions are only cleaned up on the read path. What happens if we have a set of transactions that do only writes with no reads and they all keep failing? Theoretically, we could have an entire database locked up that would require a read request to come in and start freeing up locked rows.</p>

<p>I’m basing all this on the pseudo-code that the paper presents, part of which I have included below.</p>

<pre><code>1  class Transaction {
2  struct Write { Row row; Column col; string value; };
3  vector&lt;Write&gt; writes ;
4  int start ts ;
5 
6  Transaction() : start ts (oracle.GetTimestamp()) {}
7  void Set(Write w) { writes .push back(w); }
8  bool Get(Row row, Column c, string* value) {
9  while (true) {
10 bigtable::Txn T = bigtable::StartRowTransaction(row);
11 // Check for locks that signal concurrent writes.
12 if (T.Read(row, c+"lock", [0, start ts ])) {
13 // There is a pending lock; try to clean it and wait
14 BackoffAndMaybeCleanupLock(row, c);
15 continue;
16 }
Figure 6: Pseudocode for Percolator transaction protocol.
</code></pre>

<p>I still don’t have a satisfactory answer to this question. I imagine different implementations of Percolator include cleanup logic on the write path as well.</p>

<h2 id="references">References</h2>
<ol>
  <li><a href="https://github.com/pingcap/talent-plan">The TiKV courses</a></li>
  <li><a href="https://storage.googleapis.com/pub-tools-public-publication-data/pdf/36726.pdf">The Percolator paper</a></li>
  <li><a href="https://tikv.org/deep-dive/distributed-transaction/percolator/">TiKV’s blog post about Percolator</a></li>
  <li><a href="https://github.com/redixhumayun/talent-plan">My solution</a></li>
  <li><a href="https://github.com/makisevon/dss/tree/main/percolator/src">Alternate solution 1</a></li>
  <li><a href="https://github.com/madsim-rs/percolator/tree/main/src">Alternate solution 2</a></li>
</ol>]]></content><author><name></name></author><category term="databases" /><summary type="html"><![CDATA[This post covers a course that is part of TiDB’s talent plan on building a simple implementation of Percolator. You can find the course here]]></summary></entry><entry><title type="html">Building An LSM Engine Part I</title><link href="https://redixhumayun.github.io/databases/2024/06/18/building-an-lsm-engine-part-i.html" rel="alternate" type="text/html" title="Building An LSM Engine Part I" /><published>2024-06-18T00:00:00+00:00</published><updated>2024-06-18T00:00:00+00:00</updated><id>https://redixhumayun.github.io/databases/2024/06/18/building-an-lsm-engine-part-i</id><content type="html" xml:base="https://redixhumayun.github.io/databases/2024/06/18/building-an-lsm-engine-part-i.html"><![CDATA[<p><em>I’m writing the posts in this series based on <a href="https://skyzh.github.io/mini-lsm/00-preface.html">a course</a> I completed. I can’t recommend the course highly enough. If you’re actually interested in really intuiting an LSM engine, I recommend you do the course yourself.</em></p>

<p>This is a series of posts around building an LSM-based key-value store. It will include flushing memtables to disk, compaction and a very minimal implementation of serializable snapshot isolation (SSI) using Write Snapshot Isolation (WSI). If you want to see the complete code for all sections of this series, <a href="https://github.com/redixhumayun/mini-lsm">check it out here</a>. If you want to see the complete code for only this post, <a href="https://github.com/redixhumayun/mini-lsm/commit/f88ba7a87e2a1f076153b2b49759b1da4734fa83">check out this commit</a>. I’m going to follow the pattern of the course, so one post for every week of the course. The commits on my repo follow the same pattern, so you should be able to easily tell which commit to checkout.</p>

<h2 id="goals">Goals</h2>

<p>By the end of the post we will have covered the following 3 goals:</p>

<ol>
  <li>Writing to an in-memory buffer</li>
  <li>Flushing the in-memory buffer to disk</li>
  <li>Running point and range queries on the sources</li>
</ol>

<h2 id="introduction">Introduction</h2>

<p>If you want an overview of what LSM trees are and how different components work together without diving into too much code, I recommend looking at <a href="https://buttondown.email/jaffray/archive/the-three-places-for-data-in-an-lsm/">this post by Justin Jaffrey</a> and <a href="https://garrensmith.com/Databases/Log+Structured+Merge+Tree">this one by Garren Smith</a>.</p>

<p>Typically, LSM engines have 3 main components:</p>

<ol>
  <li>The in memory component (memtable)</li>
  <li>Flushing data to disk (sorted string table)</li>
  <li>Combining data from multiple sstables together (compaction)</li>
</ol>

<p>We’ll cover compaction in a future post.</p>

<p><img src="/assets/img/databases/lsm/lsm_enegine_overview.png" alt="" /></p>

<p>The write path looks like this. <code>Client put request -&gt; add to memtable -&gt; eventually flush to sstable</code>. The eventually flush to SST is usually implementation defined where there is a limit to the memtable buffer. When this buffer is exceeded, the memtable is frozen and then converted to a sorted string table at some later point. Let’s jump into the code.</p>

<h2 id="the-database-engine">The Database Engine</h2>

<p>We need a database struct that can hold the reference to the memtable and everything else we need. So, let’s define those structs first.</p>

<pre><code class="language-rust">/// A thin wrapper for `LsmStorageInner` and the user interface for MiniLSM.
pub struct MiniLsm {
    pub(crate) inner: Arc&lt;LsmStorageInner&gt;,
    /// Notifies the L0 flush thread to stop working. (In week 1 day 6)
    flush_notifier: crossbeam_channel::Sender&lt;()&gt;,
    /// The handle for the compaction thread. (In week 1 day 6)
    flush_thread: Mutex&lt;Option&lt;std::thread::JoinHandle&lt;()&gt;&gt;&gt;,
    /// Notifies the compaction thread to stop working. (In week 2)
    compaction_notifier: crossbeam_channel::Sender&lt;()&gt;,
    /// The handle for the compaction thread. (In week 2)
    compaction_thread: Mutex&lt;Option&lt;std::thread::JoinHandle&lt;()&gt;&gt;&gt;,
}

impl Drop for MiniLsm {
    fn drop(&amp;mut self) {
        self.compaction_notifier.send(()).ok();
        self.flush_notifier.send(()).ok();
    }
}

impl MiniLsm {
    pub fn close(&amp;self) -&gt; Result&lt;()&gt; {
        let mut flush_thread = self.flush_thread.lock();
        if let Some(flush_thread) = flush_thread.take() {
            match flush_thread.join() {
                std::result::Result::Ok(_) =&gt; (),
                Err(e) =&gt; eprintln!("flush thread panicked: {:?}", e),
            }
        }
        Ok(())
    }

    /// Start the storage engine by either loading an existing directory or creating a new one if the directory does
    /// not exist.
    pub fn open(path: impl AsRef&lt;Path&gt;, options: LsmStorageOptions) -&gt; Result&lt;Arc&lt;Self&gt;&gt; {
        let inner = Arc::new(LsmStorageInner::open(path, options)?);
        let (tx1, rx) = crossbeam_channel::unbounded();
        let compaction_thread = inner.spawn_compaction_thread(rx)?;
        let (tx2, rx) = crossbeam_channel::unbounded();
        let flush_thread = inner.spawn_flush_thread(rx)?;
        Ok(Arc::new(Self {
            inner,
            flush_notifier: tx2,
            flush_thread: Mutex::new(flush_thread),
            compaction_notifier: tx1,
            compaction_thread: Mutex::new(compaction_thread),
        }))
    }
}
</code></pre>

<p>Now, let’s define the structs for the actual state.</p>

<pre><code class="language-rust">/// Represents the state of the storage engine.
#[derive(Clone)]
pub struct LsmStorageState {
    /// The current memtable.
    pub memtable: Arc&lt;MemTable&gt;,
    /// Immutable memtables, from latest to earliest.
    pub imm_memtables: Vec&lt;Arc&lt;MemTable&gt;&gt;,
    /// L0 SSTs, from latest to earliest.
    pub l0_sstables: Vec&lt;usize&gt;,
    /// SsTables sorted by key range; L1 - L_max for leveled compaction, or tiers for tiered
    /// compaction.
    pub levels: Vec&lt;(usize, Vec&lt;usize&gt;)&gt;,
    /// SST objects.
    pub sstables: HashMap&lt;usize, Arc&lt;SsTable&gt;&gt;,
}

pub(crate) struct LsmStorageInner {
    pub(crate) state: Arc&lt;RwLock&lt;Arc&lt;LsmStorageState&gt;&gt;&gt;,
    pub(crate) state_lock: Mutex&lt;()&gt;,
    path: PathBuf,
    pub(crate) block_cache: Arc&lt;BlockCache&gt;,
    next_sst_id: AtomicUsize,
    pub(crate) options: Arc&lt;LsmStorageOptions&gt;,
    pub(crate) compaction_controller: CompactionController,
    pub(crate) manifest: Option&lt;Manifest&gt;,
    pub(crate) mvcc: Option&lt;LsmMvccInner&gt;,
    pub(crate) compaction_filters: Arc&lt;Mutex&lt;Vec&lt;CompactionFilter&gt;&gt;&gt;,
}
</code></pre>

<p>You’ll notice that there are two separate locks here - one for the accessing the LSM state and another which is a simple mutex. The idea is, to increase the write throughput (avoiding time waiting for disk I/O), any time you want to modify the actual state of the LSM engine (think either flushing a memtable or compacting SST’s), you acquire the <code>state_lock</code> mutex in conjunction with the <code>write</code> lock to make the actual changes. Any other time where you just want to write to the memtable, you can do a <code>read</code> lock on <code>state</code>.</p>

<p>If you depended on the <code>write</code> lock alone, you would acquire it and then be stuck waiting for disk I/O while preventing any writes to the memtable resulting in a latency spike.</p>

<p>There are also a few channels set up for cross thread communication - namely for flushing the frozen memtable and for running a compaction.</p>

<table>
  <thead>
    <tr>
      <th style="text-align: center"><img src="/assets/img/databases/lsm/lsm_tree_graphic.png" alt="lsm-tree.jpg" /></th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: center"><em>Source: https://www.creativcoder.dev/blog/what-is-a-lsm-tree</em></td>
    </tr>
  </tbody>
</table>

<h2 id="the-memtable">The MemTable</h2>

<p>Here’s the definition for the <code>MemTable</code>.</p>

<pre><code class="language-rust">pub struct MemTable {
    id: usize,
    map: Arc&lt;SkipMap&lt;KeyBytes, Bytes&gt;&gt;,
    approximate_size: Arc&lt;AtomicUsize&gt;,
    wal: Option&lt;Wal&gt;,
}
</code></pre>

<p>I’m using the <a href="https://docs.rs/crossbeam-skiplist/latest/crossbeam_skiplist/"><code>crossbeam</code> crate’s implementation of a <code>SkipList</code></a>. It supports concurrency out of the box, so no need to wrap it in a mutex. Also, all key value pairs will be written as bytes and the <a href="https://crates.io/crates/bytes"><code>bytes</code> crate</a> provides an implementation which stores references to the underlying bytes on disk, so cloning is cheap.</p>

<p><code>KeyBytes</code> is a wrapper around <code>bytes::Bytes</code>, which you can understand better by looking <a href="https://github.com/redixhumayun/mini-lsm/blob/00e3cbe4eaf3b4b1f83c422d50e98b6cb399cd3c/mini-lsm-starter/src/key.rs">at this file in the repo</a>.</p>

<p>The implementation for <code>put</code> and <code>delete</code> on the memtable is relatively straightforward. The <code>delete</code> implementation is LSM engines requires writing a tombstone value for the key, so it just uses the same <code>put</code> interface.</p>

<pre><code class="language-rust">impl Memtable {
  pub fn put(&amp;self, _key: &amp;[u8], _value: &amp;[u8]) -&gt; Result&lt;()&gt; {
    self.map
        .insert(Bytes::from(_key.to_vec()), Bytes::from(_value.to_vec()));
    let current_size = self
        .approximate_size
        .load(std::sync::atomic::Ordering::Relaxed);
    let key_value_size = _key.len() + _value.len();
    let new_size = current_size + key_value_size;
    self.approximate_size
        .store(new_size, std::sync::atomic::Ordering::Relaxed);
    Ok(())
  }
}
</code></pre>

<p>Now, the more interesting part here is freezing the memtable, which is handled in <code>LsmStorageInner</code>.</p>

<pre><code class="language-rust">impl LsmStorageInner {
    /// Put a key-value pair into the storage by writing into the current memtable.
    pub fn put(&amp;self, _key: &amp;[u8], _value: &amp;[u8]) -&gt; Result&lt;()&gt; {
        let state = self.state.read();
        let result = state.memtable.put(_key, _value);
        if state.memtable.approximate_size() &gt;= self.options.target_sst_size {
            let state_lock = self.state_lock.lock();
            if state.memtable.approximate_size() &gt;= self.options.target_sst_size {
                drop(state);
                self.force_freeze_memtable(&amp;state_lock)?
            }
        }
        result
    }

    /// Remove a key from the storage by writing an empty value.
    pub fn delete(&amp;self, _key: &amp;[u8]) -&gt; Result&lt;()&gt; {
        let state = self.state.read();
        let result = state.memtable.put(_key, &amp;[]);
        if state.memtable.approximate_size() &gt;= self.options.target_sst_size {
            let state_lock = self.state_lock.lock();
            if state.memtable.approximate_size() &gt;= self.options.target_sst_size {
                drop(state);
                self.force_freeze_memtable(&amp;state_lock)?
            }
        }
        result
    }

    /// Force freeze the current memtable to an immutable memtable
    pub fn force_freeze_memtable(&amp;self, _state_lock_observer: &amp;MutexGuard&lt;'_, ()&gt;) -&gt; Result&lt;()&gt; {
        let state_read = self.state.read();
        let current_memtable = Arc::clone(&amp;state_read.memtable);
        drop(state_read);

        let new_memtable = Arc::new(MemTable::create(self.next_sst_id()));

        let mut state_guard = self.state.write();
        let state = Arc::make_mut(&amp;mut state_guard);
        state.imm_memtables.insert(0, current_memtable);
        state.memtable = new_memtable;

        Ok(())
    }
}
</code></pre>

<p>We make a simple check to determine if the memtable has exceeded the size limit, and if so freeze the memtable and place it inside a vector. More importantly, we need to create a new memtable to accept future writes. For that tiny duration, we obviously can’t accept any new writes.</p>

<p>This is great, so far we can write to the memtable. Now, what if you want to read the value back from the memtable. For now, since our write path consists of only the memtables, we can just iterate over them and check if any of them have the key we are looking for. Currently, we can support point queries but not range queries, so let’s go ahead and support that.</p>

<pre><code class="language-rust">/// Get a key from the storage. In day 7, this can be further optimized by using a bloom filter.
impl LsmStorageInner {
    pub fn get(&amp;self, key: &amp;[u8]) -&gt; Result&lt;Option&lt;Bytes&gt;&gt; {
        //  first probe the memtables
        let state_guard = self.state.read();
        let mut memtables = Vec::new();
        memtables.push(Arc::clone(&amp;state_guard.memtable));
        memtables.extend(
            state_guard
                .imm_memtables
                .iter()
                .map(|memtable| Arc::clone(memtable)),
        );
        for memtable in memtables {
            if let Some(value) = memtable.get(key) {
                if value.is_empty() {
                    return Ok(None);
                }
                return Ok(Some(value));
            }
        }
    }
}
</code></pre>

<p>This works because, by default, the <code>SkipMap</code> stores data in a sorted order.</p>

<p>Awesome! We have in-memory buffer we can write to and read from. If only we had unlimited memory, but alas!</p>

<h2 id="sorted-string-table">Sorted String Table</h2>

<p>Without infinite memory, let’s turn our attention to on-disk representations for our data. Let’s start building this out visually and then represent that in code. We can put all our data into blocks and then use those blocks as part of a file.</p>

<p>We will also need to store some metadata for the file so that we can quickly tell what portion of the file we want to read. We don’t want to be loading the entire file into memory, especially when we can get away with reading just a specific block within the file.</p>

<p>Here’s the encoding format we will use. We include a bloom filter with each SST to quickly tell us whether a key does not exist in the SST file. This is a great addition because bloom filters can tell us with a 100% certainty whether something does <em>not</em> exist, saving us the I/O of loading any blocks from this file.</p>

<p><em>If you want to understand bloom filters better, <a href="https://samwho.dev/bloom-filters/">this is a great post by Sam Who</a></em></p>

<pre><code>-----------------------------------------------------------------------------------------------------
|         Block Section         |                            Meta Section                           |
-----------------------------------------------------------------------------------------------------
| data block | ... | data block | metadata | meta block offset | bloom filter | bloom filter offset |
|                               |  varlen  |         u32       |    varlen    |        u32          |
-----------------------------------------------------------------------------------------------------
</code></pre>

<p>Now, let’s dive a little deeper and look at what each individual block looks like.</p>

<pre><code>----------------------------------------------------------------------------------------------------
|             Data Section             |              Offset Section             |      Extra      |
----------------------------------------------------------------------------------------------------
| Entry #1 | Entry #2 | ... | Entry #N | Offset #1 | Offset #2 | ... | Offset #N | num_of_elements |
----------------------------------------------------------------------------------------------------
</code></pre>

<p>And below is what each entry within a block looks like</p>

<pre><code>-----------------------------------------------------------------------
|                           Entry #1                            | ... |
-----------------------------------------------------------------------
| key_len (2B) | key (keylen) | value_len (2B) | value (varlen) | ... |
-----------------------------------------------------------------------
</code></pre>

<p>Let’s represent this in code. First thing is a struct that can help us quickly build individual blocks out. There’s a simple encoding scheme in the block which uses key overlap with the first key to compress the size of the data.</p>

<pre><code class="language-rust">use crate::key::{Key, KeySlice, KeyVec};

use super::Block;

/// Builds a block.
pub struct BlockBuilder {
    /// Offsets of each key-value entries.
    offsets: Vec&lt;u16&gt;,
    /// All serialized key-value pairs in the block.
    data: Vec&lt;u8&gt;,
    /// The expected block size.
    block_size: usize,
    /// The first key in the block
    first_key: KeyVec,
}

impl BlockBuilder {
    /// Creates a new block builder.
    pub fn new(block_size: usize) -&gt; Self {
        BlockBuilder {
            offsets: Vec::new(),
            data: Vec::new(),
            block_size,
            first_key: Key::new(),
        }
    }

    /// Adds a key-value pair to the block. Returns false when the block is full.
    #[must_use]
    pub fn add(&amp;mut self, key: KeySlice, value: &amp;[u8]) -&gt; bool {
        //  get the overlap of the key with the first key
        let key_overlap = key
            .into_inner()
            .iter()
            .zip(self.first_key.as_key_slice().into_inner().iter())
            .take_while(|(a, b)| a == b)
            .count() as u16;
        let key_overlap_bytes = key_overlap.to_le_bytes();
        let rest_of_key = &amp;(key.into_inner())[key_overlap as usize..];
        let rest_of_key_len = (rest_of_key.len() as u16).to_le_bytes();

        let value_length = value.len();
        let value_length_bytes = (value_length as u16).to_le_bytes();
        let entry_size = 2 + rest_of_key.len() + 2 + value_length + 2;

        if self.data.len() + self.offsets.len() + entry_size &gt; self.block_size
            &amp;&amp; self.first_key.raw_ref().len() &gt; 0
        {
            return false;
        }

        self.offsets.push(self.data.len() as u16);

        self.data.extend_from_slice(&amp;key_overlap_bytes);
        self.data.extend_from_slice(&amp;rest_of_key_len);
        self.data.extend_from_slice(rest_of_key);
        self.data.extend_from_slice(&amp;value_length_bytes);
        self.data.extend_from_slice(value);

        if self.first_key.raw_ref().len() == 0 {
            let mut new_key = Key::new();
            new_key.set_from_slice(key);
            self.first_key = new_key;
        }
        true
    }

    /// Check if there is no key-value pair in the block.
    pub fn is_empty(&amp;self) -&gt; bool {
        self.offsets.is_empty()
    }

    /// Finalize the block.
    pub fn build(self) -&gt; Block {
        Block {
            data: self.data,
            offsets: self.offsets,
        }
    }

    pub fn size(&amp;self) -&gt; usize {
        self.offsets.len()
    }
}
</code></pre>

<p>Next, we need a struct that can help us build out an SSTable in a similar fashion by using the block builder</p>

<pre><code class="language-rust">use std::path::Path;
use std::sync::Arc;

use anyhow::Result;

use super::{bloom::Bloom, BlockMeta, FileObject, SsTable};
use crate::{
    block::{Block, BlockBuilder},
    key::{Key, KeySlice},
    lsm_storage::BlockCache,
};

/// Builds an SSTable from key-value pairs.
pub struct SsTableBuilder {
    builder: BlockBuilder,
    first_key: Vec&lt;u8&gt;,
    last_key: Vec&lt;u8&gt;,
    data: Vec&lt;u8&gt;,
    pub(crate) meta: Vec&lt;BlockMeta&gt;,
    block_size: usize,
    key_hashes: Vec&lt;u32&gt;,
}

impl SsTableBuilder {
    /// Create a builder based on target block size.
    pub fn new(block_size: usize) -&gt; Self {
        SsTableBuilder {
            builder: BlockBuilder::new(block_size),
            first_key: Vec::new(),
            last_key: Vec::new(),
            data: Vec::new(),
            meta: Vec::new(),
            block_size,
            key_hashes: Vec::new(),
        }
    }

    /// Adds a key-value pair to SSTable.
    ///
    /// Note: You should split a new block when the current block is full.(`std::mem::replace` may
    /// be helpful here)
    pub fn add(&amp;mut self, key: KeySlice, value: &amp;[u8]) {
        if self.first_key.is_empty() {
            self.first_key = key.to_key_vec().raw_ref().to_vec();
        }

        self.key_hashes
            .push(farmhash::fingerprint32(key.into_inner()));

        if self.builder.add(key, value) {
            self.last_key = key.to_key_vec().raw_ref().to_vec();
            return;
        }

        self.freeze_block();

        assert!(self.builder.add(key, value));
        self.first_key = key.to_key_vec().raw_ref().to_vec();
        self.last_key = key.to_key_vec().raw_ref().to_vec();
    }

    /// This function will take current block builder, build it and replace it with a fresh block builder
    /// It will add the block to the SSTable data and then create and store the metadata for this block
    fn freeze_block(&amp;mut self) {
        //  the block is full, split block and replace older builder
        let builder = std::mem::replace(&amp;mut self.builder, BlockBuilder::new(self.block_size));
        let block = builder.build();
        let encoded_block = Block::encode(&amp;block);

        //  get metadata for split block
        let block_meta = BlockMeta {
            offset: self.data.len(),
            first_key: Key::from_vec(self.first_key.clone()).into_key_bytes(),
            last_key: Key::from_vec(self.last_key.clone()).into_key_bytes(),
        };

        self.data.extend_from_slice(&amp;encoded_block);
        self.meta.push(block_meta);
    }

    /// Get the estimated size of the SSTable.
    ///
    /// Since the data blocks contain much more data than meta blocks, just return the size of data
    /// blocks here.
    pub fn estimated_size(&amp;self) -&gt; usize {
        self.data.len()
    }

    /// Builds the SSTable and writes it to the given path. Use the `FileObject` structure to manipulate the disk objects.
    pub fn build(
        mut self,
        id: usize,
        block_cache: Option&lt;Arc&lt;BlockCache&gt;&gt;,
        path: impl AsRef&lt;Path&gt;,
    ) -&gt; Result&lt;SsTable&gt; {
        self.freeze_block();

        //  create the bloom filter
        let bits_per_key = Bloom::bloom_bits_per_key(self.key_hashes.len(), 0.01);
        let bloom_filter = Bloom::build_from_key_hashes(&amp;self.key_hashes, bits_per_key);

        let mut encoded_sst: Vec&lt;u8&gt; = Vec::new();
        encoded_sst.extend_from_slice(&amp;self.data);

        //  encode meta section for each block and add it to encoding
        let mut encoded_meta: Vec&lt;u8&gt; = Vec::new();
        BlockMeta::encode_block_meta(&amp;self.meta, &amp;mut encoded_meta);
        encoded_sst.extend_from_slice(&amp;encoded_meta);

        //  encode the meta block offset in the next 4 bytes
        let data_len = (self.data.len() as u32).to_le_bytes();
        encoded_sst.extend_from_slice(&amp;data_len);

        //  encode the bloom filter and add it to encoded table
        let bloom_filter_offset = encoded_sst.len() as u32;
        bloom_filter.encode(&amp;mut encoded_sst);
        encoded_sst.extend_from_slice(&amp;bloom_filter_offset.to_le_bytes());

        //  write the entire encoding to disk
        let file = FileObject::create(path.as_ref(), encoded_sst)?;
        Ok(SsTable {
            file,
            block_meta_offset: self.data.len(),
            id,
            block_cache: None,
            first_key: self.meta.first().unwrap().first_key.clone(),
            last_key: self.meta.last().unwrap().last_key.clone(),
            block_meta: self.meta,
            bloom: Some(bloom_filter),
            max_ts: 0,
        })
    }

    #[cfg(test)]
    pub(crate) fn build_for_test(self, path: impl AsRef&lt;Path&gt;) -&gt; Result&lt;SsTable&gt; {
        self.build(0, None, path)
    }
}
</code></pre>

<p>Now, there’s a couple of missing pieces regarding the encoding &amp; decoding of the table and the blocks, so let’s fill those out. Here’s the encoding and decoding for the block.</p>

<pre><code class="language-rust">#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BlockMeta {
    /// Offset of this data block.
    pub offset: usize,
    /// The first key of the data block.
    pub first_key: KeyBytes,
    /// The last key of the data block.
    pub last_key: KeyBytes,
}

impl BlockMeta {
    /// Encode block meta to a buffer.
    /// You may add extra fields to the buffer,
    /// in order to help keep track of `first_key` when decoding from the same buffer in the future.
    pub fn encode_block_meta(block_meta: &amp;[BlockMeta], buf: &amp;mut Vec&lt;u8&gt;) {
        for ind_block_meta in block_meta {
            let offset_bytes = (ind_block_meta.offset as u16).to_le_bytes();
            buf.extend_from_slice(&amp;offset_bytes);

            let first_key_length = ind_block_meta.first_key.len() as u16;
            let first_key_length_bytes = first_key_length.to_le_bytes();
            buf.extend_from_slice(&amp;first_key_length_bytes);
            buf.extend_from_slice(ind_block_meta.first_key.raw_ref());

            let last_key_length = ind_block_meta.last_key.len() as u16;
            let last_key_length_bytes = last_key_length.to_le_bytes();
            buf.extend_from_slice(&amp;last_key_length_bytes);
            buf.extend_from_slice(ind_block_meta.last_key.raw_ref());
        }
    }

    /// Decode block meta from a buffer.
    pub fn decode_block_meta(mut buf: impl Buf) -&gt; Vec&lt;BlockMeta&gt; {
        let mut block_metas: Vec&lt;BlockMeta&gt; = Vec::new();

        while buf.remaining() &gt; 0 {
            let offset = buf.get_u16_le() as usize;

            let first_key_length = buf.get_u16_le() as usize;
            let mut first_key = vec![0; first_key_length];
            buf.copy_to_slice(&amp;mut first_key);

            let last_key_length = buf.get_u16_le() as usize;
            let mut last_key = vec![0; last_key_length];
            buf.copy_to_slice(&amp;mut last_key);

            block_metas.push(BlockMeta {
                offset,
                first_key: Key::from_vec(first_key).into_key_bytes(),
                last_key: Key::from_vec(last_key).into_key_bytes(),
            });
        }

        block_metas
    }
}
</code></pre>

<p>Finally, we need the ability to open a file from disk and decode the bytes. So, let’s add that in as well.</p>

<pre><code class="language-rust">pub(crate) mod bloom;
mod builder;
mod iterator;

use std::fs::File;
use std::path::Path;
use std::sync::Arc;

use anyhow::Result;
pub use builder::SsTableBuilder;
use bytes::Buf;
pub use iterator::SsTableIterator;

use crate::block::Block;
use crate::key::{Key, KeyBytes, KeySlice};
use crate::lsm_storage::BlockCache;

use self::bloom::Bloom;

/// A file object.
pub struct FileObject(Option&lt;File&gt;, u64);

impl FileObject {
    pub fn read(&amp;self, offset: u64, len: u64) -&gt; Result&lt;Vec&lt;u8&gt;&gt; {
        use std::os::unix::fs::FileExt;
        let mut data = vec![0; len as usize];
        self.0
            .as_ref()
            .unwrap()
            .read_exact_at(&amp;mut data[..], offset)?;
        Ok(data)
    }

    pub fn size(&amp;self) -&gt; u64 {
        self.1
    }

    /// Create a new file object (day 2) and write the file to the disk (day 4).
    pub fn create(path: &amp;Path, data: Vec&lt;u8&gt;) -&gt; Result&lt;Self&gt; {
        std::fs::write(path, &amp;data)?;
        File::open(path)?.sync_all()?;
        Ok(FileObject(
            Some(File::options().read(true).write(false).open(path)?),
            data.len() as u64,
        ))
    }

    pub fn open(path: &amp;Path) -&gt; Result&lt;Self&gt; {
        let file = File::options().read(true).write(false).open(path)?;
        let size = file.metadata()?.len();
        Ok(FileObject(Some(file), size))
    }
}

/// An SSTable.
pub struct SsTable {
    /// The actual storage unit of SsTable, the format is as above.
    pub(crate) file: FileObject,
    /// The meta blocks that hold info for data blocks.
    pub(crate) block_meta: Vec&lt;BlockMeta&gt;,
    /// The offset that indicates the start point of meta blocks in `file`.
    pub(crate) block_meta_offset: usize,
    id: usize,
    block_cache: Option&lt;Arc&lt;BlockCache&gt;&gt;,
    first_key: KeyBytes,
    last_key: KeyBytes,
    pub(crate) bloom: Option&lt;Bloom&gt;,
    /// The maximum timestamp stored in this SST, implemented in week 3.
    max_ts: u64,
}

impl SsTable {
    #[cfg(test)]
    pub(crate) fn open_for_test(file: FileObject) -&gt; Result&lt;Self&gt; {
        Self::open(0, None, file)
    }

    /// Open SSTable from a file.
    pub fn open(id: usize, block_cache: Option&lt;Arc&lt;BlockCache&gt;&gt;, file: FileObject) -&gt; Result&lt;Self&gt; {
        let len = file.size();
        //  read the last 4 bytes to get the bloom filter offset
        let bloom_filter_offset_raw = file.read(len - 4, 4)?;
        let bloom_filter_offset = (&amp;bloom_filter_offset_raw[..]).get_u32_le() as u64;

        //  use the bloom filter offset to read the data starting
        let raw_bloom_filter = file.read(bloom_filter_offset, len - bloom_filter_offset - 4)?;
        let bloom_filter = Bloom::decode(&amp;raw_bloom_filter)?;

        //  read the 4 bytes before the bloom offset to get the meta offset
        let raw_meta_offset = file.read(bloom_filter_offset - 4, 4)?;
        let meta_offset = (&amp;raw_meta_offset[..]).get_u32_le() as u64;

        //  use the meta offset to read the metadata from the file
        let raw_meta = file.read(meta_offset as u64, bloom_filter_offset - 4 - meta_offset)?;
        let meta = BlockMeta::decode_block_meta(raw_meta.as_slice());

        Ok(SsTable {
            file,
            block_meta_offset: meta_offset as usize,
            id,
            block_cache,
            first_key: meta.first().unwrap().first_key.clone(),
            last_key: meta.last().unwrap().last_key.clone(),
            block_meta: meta,
            bloom: Some(bloom_filter),
            max_ts: 0,
        })
    }

    /// Create a mock SST with only first key + last key metadata
    pub fn create_meta_only(
        id: usize,
        file_size: u64,
        first_key: KeyBytes,
        last_key: KeyBytes,
    ) -&gt; Self {
        Self {
            file: FileObject(None, file_size),
            block_meta: vec![],
            block_meta_offset: 0,
            id,
            block_cache: None,
            first_key,
            last_key,
            bloom: None,
            max_ts: 0,
        }
    }

    /// Read a block from the disk.
    pub fn read_block(&amp;self, block_idx: usize) -&gt; Result&lt;Arc&lt;Block&gt;&gt; {
        let block_offset_start = self.block_meta[block_idx].offset;
        let block_offset_end = if block_idx + 1 &lt; self.block_meta.len() {
            self.block_meta[block_idx + 1].offset
        } else {
            self.block_meta_offset
        };
        let block_len = block_offset_end - block_offset_start;
        let block_data_raw = self
            .file
            .read(block_offset_start as u64, block_len as u64)?;
        let block_data = Block::decode(&amp;block_data_raw);
        Ok(Arc::new(block_data))
    }

    /// Read a block from disk, with block cache. (Day 4)
    pub fn read_block_cached(&amp;self, block_idx: usize) -&gt; Result&lt;Arc&lt;Block&gt;&gt; {
        if let Some(block_cache) = &amp;self.block_cache {
            let block = block_cache
                .try_get_with((self.id, block_idx), || self.read_block(block_idx))
                .map_err(|e| {
                    println!("Error: {:?}", e);
                    anyhow::anyhow!(e)
                })?;
            Ok(block)
        } else {
            self.read_block(block_idx)
        }
    }

    /// Find the block that may contain `key`.
    /// Note: You may want to make use of the `first_key` stored in `BlockMeta`.
    /// You may also assume the key-value pairs stored in each consecutive block are sorted.
    pub fn find_block_idx(&amp;self, key: KeySlice) -&gt; usize {
        self.block_meta
            .partition_point(|meta| meta.first_key.as_key_slice() &lt;= key) //  parition_point does binary search
            .saturating_sub(1)
    }

    /// Get number of data blocks.
    pub fn num_of_blocks(&amp;self) -&gt; usize {
        self.block_meta.len()
    }

    pub fn first_key(&amp;self) -&gt; &amp;KeyBytes {
        &amp;self.first_key
    }

    pub fn last_key(&amp;self) -&gt; &amp;KeyBytes {
        &amp;self.last_key
    }

    pub fn table_size(&amp;self) -&gt; u64 {
        self.file.1
    }

    pub fn sst_id(&amp;self) -&gt; usize {
        self.id
    }

    pub fn max_ts(&amp;self) -&gt; u64 {
        self.max_ts
    }
}
</code></pre>

<p>Now, we need to wire this all up back to our storage engine. We will have a flush thread running which executes every X number of milliseconds and it will pick the oldest frozen memtable and attempt to flush that.</p>

<p>Don’t worry about the fact that the sstable is pushed into something called L0, we’ll get to that later.</p>

<pre><code class="language-rust">impl LsmStorageInner {
    /// Force flush the earliest-created immutable memtable to disk
    pub fn force_flush_next_imm_memtable(&amp;self) -&gt; Result&lt;()&gt; {
        let _state_lock = self.state_lock.lock();
        let oldest_memtable = {
            let state_guard = self.state.write();
            let oldest_memtable = state_guard
                .imm_memtables
                .last()
                .expect("No memtable found")
                .clone();
            oldest_memtable
        };

        let mut sst_builder = SsTableBuilder::new(self.options.block_size);
        oldest_memtable.flush(&amp;mut sst_builder)?;
        let sst = Arc::new(sst_builder.build(
            oldest_memtable.id(),
            Some(Arc::clone(&amp;self.block_cache)),
            self.path_of_sst(oldest_memtable.id()),
        )?);

        {
            let mut state_guard = self.state.write();
            let mut snapshot = state_guard.as_ref().clone();
            let oldest_memtable = snapshot.imm_memtables.pop().expect("No memtable found");
            snapshot.l0_sstables.insert(0, oldest_memtable.id());
            snapshot.sstables.insert(oldest_memtable.id(), sst);
            *state_guard = Arc::new(snapshot);
        }

        Ok(())
    }

    fn trigger_flush(&amp;self) -&gt; Result&lt;()&gt; {
        {
            let state_guard = self.state.read();
            if state_guard.imm_memtables.len() &lt; self.options.num_memtable_limit {
                return Ok(());
            }
        }

        self.force_flush_next_imm_memtable()?;
        Ok(())
    }

    pub(crate) fn spawn_flush_thread(
        self: &amp;Arc&lt;Self&gt;,
        rx: crossbeam_channel::Receiver&lt;()&gt;,
    ) -&gt; Result&lt;Option&lt;std::thread::JoinHandle&lt;()&gt;&gt;&gt; {
        let this = self.clone();
        let handle = std::thread::spawn(move || {
            let ticker = crossbeam_channel::tick(Duration::from_millis(50));
            loop {
                crossbeam_channel::select! {
                    recv(ticker) -&gt; _ =&gt; if let Err(e) = this.trigger_flush() {
                        eprintln!("flush failed: {}", e);
                    },
                    recv(rx) -&gt; _ =&gt; return
                }
            }
        });
        Ok(Some(handle))
    }
}
</code></pre>

<p>Great! We now have the mechanism to take our frozen memtable, write that to a disk friendly format, encode it and then read it back if we want to later.</p>

<p>Now, we already have a mechanism to read back from the memtable, so we also need a way to read back from an SSTable. For the memtable we just iterated over all the memtables in reverse chronological order and checked if we could find the key. The first instance we found was the key we were looking for.</p>

<p>We could do the exact same thing with the SSTables as well. First check memtables for the key and if it can’t be found there start checking the SSTables one by one, and that would actually work fine. However, let’s make this a bit more efficient in a way we can also support range queries.</p>

<h2 id="its-iterators-all-the-way-down">It’s Iterators All The Way Down</h2>

<p>Assume that we have a database engine that ends up in the following state.</p>

<pre><code>put("x", 5)
put("y", 3)
put("x", Tombstone)
//  freeze and flush occurs
put("x", 9)
put("y", 3)
put("y", 1)
//  freeze and flush occurs
put("a", 12)
put("b", Tombstone)
</code></pre>

<p>Now, at this point the state looks like</p>

<pre><code>memtable ("a" -&gt; 12, "b" -&gt; Tombstone)
l0 -&gt; sst1 ("x" -&gt; 9, "y" -&gt; 3, "y" -&gt; 1), sst2 ("x" -&gt; 5, "y" -&gt; 3, "x" -&gt; Tombstone)
</code></pre>

<p>We have two different data sources here and if a user wanted to get all the key-value pairs in storage, we would have to iterate over every source simultaneously and figure out what the correct value for it is.</p>

<p>So, let’s define a trait that an iterator over any data source would need to stick to. This makes it easier to define new iterators as and when required.</p>

<pre><code class="language-rust">
pub trait StorageIterator {
    type KeyType&lt;'a&gt;: PartialEq + Eq + PartialOrd + Ord
    where
        Self: 'a;

    /// Get the current value.
    fn value(&amp;self) -&gt; &amp;[u8];

    /// Get the current key.
    fn key(&amp;self) -&gt; Self::KeyType&lt;'_&gt;;

    /// Check if the current iterator is valid.
    fn is_valid(&amp;self) -&gt; bool;

    /// Move to the next position.
    fn next(&amp;mut self) -&gt; anyhow::Result&lt;()&gt;;

    /// Number of underlying active iterators for this iterator.
    fn num_active_iterators(&amp;self) -&gt; usize {
        1
    }
}
</code></pre>

<p>The trait has an associated type for the type of key it returns.</p>

<h3 id="memtable-scan">MemTable Scan</h3>

<p>So, let’s go ahead and define the memtable iterator first so that we can do a scan across a memtable.</p>

<pre><code class="language-rust">type SkipMapRangeIter&lt;'a&gt; =
    crossbeam_skiplist::map::Range&lt;'a, Bytes, (Bound&lt;Bytes&gt;, Bound&lt;Bytes&gt;), Bytes, Bytes&gt;;

#[self_referencing]
pub struct MemTableIterator {
    /// Stores a reference to the skipmap.
    map: Arc&lt;SkipMap&lt;Bytes, Bytes&gt;&gt;,
    /// Stores a skipmap iterator that refers to the lifetime of `MemTableIterator` itself.
    #[borrows(map)]
    #[not_covariant]
    iter: SkipMapRangeIter&lt;'this&gt;,
    /// Stores the current key-value pair.
    item: (Bytes, Bytes),
}

impl StorageIterator for MemTableIterator {
    type KeyType&lt;'a&gt; = KeySlice&lt;'a&gt;;

    fn value(&amp;self) -&gt; &amp;[u8] {
        self.with_item(|item| &amp;item.1)
    }

    fn key(&amp;self) -&gt; KeySlice {
        let key = self.with_item(|item| &amp;item.0);
        Key::from_slice(key)
    }

    fn is_valid(&amp;self) -&gt; bool {
        !self.with_item(|item| item.0.is_empty())
    }

    fn next(&amp;mut self) -&gt; Result&lt;()&gt; {
        let next_entry = self.with_iter_mut(|iter| {
            iter.next()
                .map(|entry| (entry.key().clone(), entry.value().clone()))
                .unwrap_or_else(|| (Bytes::from_static(&amp;[]), Bytes::from_static(&amp;[])))
        });
        self.with_item_mut(|item| *item = next_entry);
        Ok(())
    }
}

impl MemTable {
    /// Get an iterator over a range of keys.
    pub fn scan(&amp;self, _lower: Bound&lt;&amp;[u8]&gt;, _upper: Bound&lt;&amp;[u8]&gt;) -&gt; MemTableIterator {
        let mut iterator = MemTableIteratorBuilder {
            map: self.map.clone(),
            iter_builder: |map| map.range((map_bound(_lower), map_bound(_upper))),
            item: (Bytes::new(), Bytes::new()),
        }
        .build();
        iterator.next().unwrap();
        iterator
    }
}
</code></pre>

<p>So, this was relatively simple to do because we can build an iterator using the <code>Range</code> operator provided by <code>crossbeam</code>.</p>

<h3 id="sstable-scan">SSTable Scan</h3>

<p>Okay, now we need something that can scan the sorted string table. Now, when scanning an SSTable, each SSTable is composed of individual blocks and those blocks are composed of key-value pairs, so we actually need 2 iterators - one for a block and one for a table itself.</p>

<p>Let’s define the one for the block first. There’s nothing fancy in this iterator, at it’s heart it uses binary search to look through the list of keys. However, since we’re dealing with raw bytes, there’s a fair amount of decoding that goes on here.</p>

<pre><code class="language-rust">use std::sync::Arc;

use bytes::Buf;

use crate::key::{KeySlice, KeyVec};

use super::Block;

/// Iterates on a block.
pub struct BlockIterator {
    /// The internal `Block`, wrapped by an `Arc`
    block: Arc&lt;Block&gt;,
    /// The current key, empty represents the iterator is invalid
    key: KeyVec,
    /// the value range from the block
    value_range: (usize, usize),
    /// Current index of the key-value pair, should be in range of [0, num_of_elements)
    idx: usize,
    /// The first key in the block
    first_key: KeyVec,
}

impl BlockIterator {
    fn new(block: Arc&lt;Block&gt;) -&gt; Self {
        Self {
            first_key: BlockIterator::decode_first_key(&amp;block),
            block,
            key: KeyVec::new(),
            value_range: (0, 0),
            idx: 0,
        }
    }

    fn decode_first_key(block: &amp;Arc&lt;Block&gt;) -&gt; KeyVec {
        let mut buf = &amp;block.data[..];
        let overlap_length = buf.get_u16_le(); //  read the overlap length (should be 0)
        assert_eq!(overlap_length, 0);
        let key_length = buf.get_u16_le(); //  read the key length
        let key = &amp;buf[..key_length as usize];
        KeyVec::from_vec(key.to_vec())
    }

    /// Creates a block iterator and seek to the first entry.
    pub fn create_and_seek_to_first(block: Arc&lt;Block&gt;) -&gt; Self {
        let mut iter = BlockIterator::new(block);
        iter.seek_to_first();
        iter
    }

    /// Creates a block iterator and seek to the first key that &gt;= `key`.
    pub fn create_and_seek_to_key(block: Arc&lt;Block&gt;, key: KeySlice) -&gt; Self {
        let mut iter = BlockIterator::new(block);
        iter.seek_to_key(key);
        iter
    }

    /// Returns the key of the current entry.
    pub fn key(&amp;self) -&gt; KeySlice {
        self.key.as_key_slice()
    }

    /// Returns the value of the current entry.
    pub fn value(&amp;self) -&gt; &amp;[u8] {
        let value_range = self.value_range;
        let value_raw = &amp;self.block.data[value_range.0..value_range.1];
        value_raw
    }

    /// Returns true if the iterator is valid.
    /// Note: You may want to make use of `key`
    pub fn is_valid(&amp;self) -&gt; bool {
        if self.key.is_empty() {
            return false;
        }
        true
    }

    /// Seeks to the first key in the block.
    pub fn seek_to_first(&amp;mut self) {
        self.seek_to(0);
        self.idx = 0;
    }

    fn seek_to(&amp;mut self, index: usize) {
        let offset = self.block.offsets[index] as usize;
        let data_to_consider = &amp;self.block.data[offset..];

        let (key_overlap_length_raw, rest) = data_to_consider.split_at(2);
        let key_overlap_length = u16::from_le_bytes(key_overlap_length_raw.try_into().unwrap());

        let (key_length_raw, rest) = rest.split_at(2);
        let key_length = u16::from_le_bytes(key_length_raw.try_into().unwrap());

        let (key, rest) = rest.split_at(key_length as usize);
        let key_overlap = &amp;(self.first_key.clone().into_inner())[..key_overlap_length as usize];
        let mut full_key = Vec::new();
        full_key.extend_from_slice(&amp;key_overlap);
        full_key.extend_from_slice(&amp;key);
        self.key = KeyVec::from_vec(full_key);

        let (value_length_raw, rest) = rest.split_at(2);
        let value_length = u16::from_le_bytes(value_length_raw.try_into().unwrap());

        let (_, _) = rest.split_at(value_length as usize);
        let new_value_start = offset + 2 + 2 + key_length as usize;
        self.value_range = (
            new_value_start + 2,
            new_value_start + 2 + value_length as usize,
        );
    }

    /// Move to the next key in the block.
    pub fn next(&amp;mut self) {
        self.idx += 1;

        if self.idx &gt;= self.block.offsets.len() {
            self.key.clear();
            self.value_range = (0, 0);
            return;
        }

        self.seek_to(self.idx);
    }

    /// Seek to the first key that &gt;= `key`.
    /// Note: You should assume the key-value pairs in the block are sorted when being added by
    /// callers.
    pub fn seek_to_key(&amp;mut self, key: KeySlice) {
        let mut low = 0;
        let mut high = self.block.offsets.len() - 1;

        while low &lt;= high {
            let mid = low + (high - low) / 2;
            self.seek_to(mid);
            self.idx = mid;
            let mid_key = self.key.as_key_slice();

            match mid_key.cmp(&amp;key) {
                std::cmp::Ordering::Less =&gt; low = mid + 1,
                std::cmp::Ordering::Greater =&gt; {
                    if mid == 0 {
                        break;
                    }
                    high = mid - 1;
                }
                std::cmp::Ordering::Equal =&gt; return,
            }
        }

        if low &gt;= self.block.offsets.len() {
            self.key.clear();
            self.value_range = (0, 0);
            return;
        }
        self.idx = low;
        self.seek_to(self.idx);
    }
}
</code></pre>

<p>Okay, now let’s focus on the table iterator. The table iterator will use the block iterator from above and will determine which block needs to be read. If the iterator just needs to go to the first key, it will read the first block and if the iterator needs to look for a specific key, it will do a binary search on the metadata of the blocks to determine the key.</p>

<pre><code class="language-rust">use std::sync::Arc;

use anyhow::Result;

use super::SsTable;
use crate::{block::BlockIterator, iterators::StorageIterator, key::KeySlice};

/// An iterator over the contents of an SSTable.
pub struct SsTableIterator {
    table: Arc&lt;SsTable&gt;,
    blk_iter: BlockIterator,
    blk_idx: usize,
}

impl SsTableIterator {
    /// Create a new iterator and seek to the first key-value pair in the first data block.
    pub fn create_and_seek_to_first(table: Arc&lt;SsTable&gt;) -&gt; Result&lt;Self&gt; {
        //  get the first block from the sstable and build an iterator on top of it
        let block = table.read_block_cached(0)?;
        let block_iterator = BlockIterator::create_and_seek_to_first(block);
        let iter = SsTableIterator {
            table,
            blk_iter: block_iterator,
            blk_idx: 0,
        };
        Ok(iter)
    }

    /// Seek to the first key-value pair in the first data block.
    pub fn seek_to_first(&amp;mut self) -&gt; Result&lt;()&gt; {
        //  get metata for first block index
        let block = self.table.read_block_cached(0)?;
        let block_iterator = BlockIterator::create_and_seek_to_first(block);
        self.blk_idx = 0;
        self.blk_iter = block_iterator;
        Ok(())
    }

    fn seek_to(table: &amp;Arc&lt;SsTable&gt;, key: KeySlice) -&gt; Result&lt;(usize, BlockIterator)&gt; {
        let mut block_index = table.find_block_idx(key);
        let block = table.read_block_cached(block_index).unwrap();
        let mut block_iter = BlockIterator::create_and_seek_to_key(block, key);
        if !block_iter.is_valid() {
            block_index += 1;
            if block_index &lt; table.num_of_blocks() {
                block_iter =
                    BlockIterator::create_and_seek_to_first(table.read_block_cached(block_index)?);
            }
        }
        Ok((block_index, block_iter))
    }

    /// Create a new iterator and seek to the first key-value pair which &gt;= `key`.
    pub fn create_and_seek_to_key(table: Arc&lt;SsTable&gt;, key: KeySlice) -&gt; Result&lt;Self&gt; {
        let (block_index, block_iter) = Self::seek_to(&amp;table, key)?;
        let iter = SsTableIterator {
            table,
            blk_iter: block_iter,
            blk_idx: block_index,
        };
        Ok(iter)
    }

    /// Seek to the first key-value pair which &gt;= `key`.
    /// Note: You probably want to review the handout for detailed explanation when implementing
    /// this function.
    pub fn seek_to_key(&amp;mut self, key: KeySlice) -&gt; Result&lt;()&gt; {
        let (block_index, block_iter) = Self::seek_to(&amp;self.table, key)?;
        self.blk_iter = block_iter;
        self.blk_idx = block_index;
        Ok(())
    }
}

impl StorageIterator for SsTableIterator {
    type KeyType&lt;'a&gt; = KeySlice&lt;'a&gt;;

    /// Return the `key` that's held by the underlying block iterator.
    fn key(&amp;self) -&gt; KeySlice {
        self.blk_iter.key()
    }

    /// Return the `value` that's held by the underlying block iterator.
    fn value(&amp;self) -&gt; &amp;[u8] {
        self.blk_iter.value()
    }

    /// Return whether the current block iterator is valid or not.
    fn is_valid(&amp;self) -&gt; bool {
        self.blk_iter.is_valid()
    }

    /// Move to the next `key` in the block.
    /// Note: You may want to check if the current block iterator is valid after the move.
    fn next(&amp;mut self) -&gt; Result&lt;()&gt; {
        if self.is_valid() {
            self.blk_iter.next();
            if !self.blk_iter.is_valid() {
                self.blk_idx += 1;
                if self.blk_idx &lt; self.table.num_of_blocks() {
                    let new_block = self.table.read_block_cached(self.blk_idx)?;
                    let new_block_iter = BlockIterator::create_and_seek_to_first(new_block);
                    self.blk_iter = new_block_iter;
                }
            }
        }
        Ok(())
    }
}
</code></pre>

<p>This is great, we now have two iterators for 2 different types of data - one for the memtable and one for the sstable. But we have multiple iterators for the memtable and multiple iterators for the sstable. We need a way to combine them.</p>

<h3 id="merging-iterators">Merging Iterators</h3>

<p>Now, we need a way to represent multiple iterators of the same type as one iterator. This is the classic k-way merge algorithm implemented using a min heap.</p>

<p>First, let’s define a custom heap that will compare our keys to determine which key should be the first one.</p>

<pre><code class="language-rust">struct HeapWrapper&lt;I: StorageIterator&gt;(pub usize, pub Box&lt;I&gt;);

impl&lt;I: StorageIterator&gt; PartialEq for HeapWrapper&lt;I&gt; {
    fn eq(&amp;self, other: &amp;Self) -&gt; bool {
        self.partial_cmp(other).unwrap() == cmp::Ordering::Equal
    }
}

impl&lt;I: StorageIterator&gt; Eq for HeapWrapper&lt;I&gt; {}

impl&lt;I: StorageIterator&gt; PartialOrd for HeapWrapper&lt;I&gt; {
    #[allow(clippy::non_canonical_partial_ord_impl)]
    fn partial_cmp(&amp;self, other: &amp;Self) -&gt; Option&lt;cmp::Ordering&gt; {
        match self.1.key().cmp(&amp;other.1.key()) {
            cmp::Ordering::Greater =&gt; Some(cmp::Ordering::Greater),
            cmp::Ordering::Less =&gt; Some(cmp::Ordering::Less),
            cmp::Ordering::Equal =&gt; self.0.partial_cmp(&amp;other.0),
        }
        .map(|x| x.reverse())
    }
}

impl&lt;I: StorageIterator&gt; Ord for HeapWrapper&lt;I&gt; {
    fn cmp(&amp;self, other: &amp;Self) -&gt; cmp::Ordering {
        self.partial_cmp(other).unwrap()
    }
}
</code></pre>

<p>Now, we can define the actual merge iterator.</p>

<pre><code class="language-rust">pub struct MergeIterator&lt;I: StorageIterator&gt; {
    iters: BinaryHeap&lt;HeapWrapper&lt;I&gt;&gt;,
    current: Option&lt;HeapWrapper&lt;I&gt;&gt;,
}

impl&lt;I: StorageIterator&gt; MergeIterator&lt;I&gt; {
    pub fn create(iters: Vec&lt;Box&lt;I&gt;&gt;) -&gt; Self {
        if iters.len() == 0 {
            return MergeIterator {
                iters: BinaryHeap::new(),
                current: None,
            };
        }

        let mut heap: BinaryHeap&lt;HeapWrapper&lt;I&gt;&gt; = BinaryHeap::new();

        //  if none of the iterators are valid, just pick the last one as current
        if iters.iter().all(|iter| !iter.is_valid()) {
            let mut iters = iters;
            return MergeIterator {
                iters: heap,
                current: Some(HeapWrapper(0, iters.pop().unwrap())),
            };
        }

        for (index, iter) in iters.into_iter().enumerate() {
            if iter.is_valid() {
                let heap_wrapper = HeapWrapper(index, iter);
                heap.push(heap_wrapper);
            }
        }

        let current = heap.pop().unwrap();
        MergeIterator {
            iters: heap,
            current: Some(current),
        }
    }
}

impl&lt;I: 'static + for&lt;'a&gt; StorageIterator&lt;KeyType&lt;'a&gt; = KeySlice&lt;'a&gt;&gt;&gt; StorageIterator
    for MergeIterator&lt;I&gt;
{
    type KeyType&lt;'a&gt; = KeySlice&lt;'a&gt;;

    fn key(&amp;self) -&gt; KeySlice {
        self.current.as_ref().unwrap().1.key()
    }

    fn value(&amp;self) -&gt; &amp;[u8] {
        self.current.as_ref().unwrap().1.value()
    }

    fn is_valid(&amp;self) -&gt; bool {
        self.current
            .as_ref()
            .map(|heap_wrapper| heap_wrapper.1.is_valid())
            .unwrap_or(false)
    }

    fn next(&amp;mut self) -&gt; Result&lt;()&gt; {
        let current = self.current.as_mut().unwrap();

        //  Check if there are any keys that are identical - advance the lower ranked iterators in that case
        while let Some(mut heap_wrapper) = self.iters.peek_mut() {
            if heap_wrapper.1.key() == current.1.key() {
                //  The current and the heap top have the same key. Ignore the heap top key because we organised by reverse
                //  chronological order when building the heap. The value in current should be what's upheld. Advance the top
                if let Err(e) = heap_wrapper.1.next() {
                    PeekMut::pop(heap_wrapper);
                    return Err(e);
                }

                if !heap_wrapper.1.is_valid() {
                    PeekMut::pop(heap_wrapper);
                }
            } else {
                break;
            }
        }

        //  advance the current iterator
        current.1.next()?;

        //  check if the current iterator continues to be valid - if not, replace with the top
        if !current.1.is_valid() {
            if let Some(heap_wrapper) = self.iters.pop() {
                self.current = Some(heap_wrapper);
            }
            return Ok(());
        }

        //  check if the current iterator should be replaced by the top value in the heap
        if let Some(mut heap_wrapper) = self.iters.peek_mut() {
            if current &lt; &amp;mut heap_wrapper {
                std::mem::swap(current, &amp;mut *heap_wrapper);
            }
        }
        Ok(())
    }

    fn num_active_iterators(&amp;self) -&gt; usize {
        let heap_active_iters: usize = self
            .iters
            .iter()
            .map(|iter| iter.1.num_active_iterators())
            .sum();
        let current_active_iters: usize = self
            .current
            .iter()
            .map(|iter| iter.1.num_active_iterators())
            .sum();
        heap_active_iters + current_active_iters
    }
}
</code></pre>

<p>Let’s take a quick break and look at where we’re at in terms of scanning the data right now.</p>

<p><img src="/assets/img/databases/lsm/iters.png" alt="" /></p>

<p>So we have the memtable iterator, the sst iterator &amp; the merge iterator defined. It’s important to note that the order of iterators matters because we always want to check the latest sources of data first - that is the memtables in reverse chronological order and then the sstables in reverse chronological order.</p>

<p>Now, we need one more iterator which can combine two merge iterators into one so that we can search across multiple sources of data in the scan interface - this is the two merge iterator which is defined below.</p>

<pre><code class="language-rust">use anyhow::Result;

use super::StorageIterator;

/// Merges two iterators of different types into one. If the two iterators have the same key, only
/// produce the key once and prefer the entry from A.
pub struct TwoMergeIterator&lt;A: StorageIterator, B: StorageIterator&gt; {
    a: A,
    b: B,
    // Add fields as need
    use_iterator: u8, // this can be 0 (use a), 1 (use b), 2 (use both)
}

impl&lt;
        A: 'static + StorageIterator,
        B: 'static + for&lt;'a&gt; StorageIterator&lt;KeyType&lt;'a&gt; = A::KeyType&lt;'a&gt;&gt;,
    &gt; TwoMergeIterator&lt;A, B&gt;
{
    pub fn create(a: A, b: B) -&gt; Result&lt;Self&gt; {
        let use_iterator = TwoMergeIterator::decide_which_iter_to_use(&amp;a, &amp;b);
        Ok(TwoMergeIterator { a, b, use_iterator })
    }

    fn decide_which_iter_to_use(a: &amp;A, b: &amp;B) -&gt; u8 {
        if !a.is_valid() &amp;&amp; b.is_valid() {
            return 1;
        }
        if a.is_valid() &amp;&amp; !b.is_valid() {
            return 0;
        }
        if !a.is_valid() &amp;&amp; !b.is_valid() {
            return u8::MAX;
        }
        if a.key() &lt; b.key() {
            0
        } else if a.key() &gt; b.key() {
            1
        } else {
            2
        }
    }
}

impl&lt;
        A: 'static + StorageIterator,
        B: 'static + for&lt;'a&gt; StorageIterator&lt;KeyType&lt;'a&gt; = A::KeyType&lt;'a&gt;&gt;,
    &gt; StorageIterator for TwoMergeIterator&lt;A, B&gt;
{
    type KeyType&lt;'a&gt; = A::KeyType&lt;'a&gt;;

    fn key(&amp;self) -&gt; Self::KeyType&lt;'_&gt; {
        if self.use_iterator == 0 || self.use_iterator == 2 {
            return self.a.key();
        }
        self.b.key()
    }

    fn value(&amp;self) -&gt; &amp;[u8] {
        if self.use_iterator == 0 || self.use_iterator == 2 {
            return self.a.value();
        }
        self.b.value()
    }

    fn is_valid(&amp;self) -&gt; bool {
        if self.use_iterator == u8::MAX {
            false
        } else if self.use_iterator == 0 {
            self.a.is_valid()
        } else {
            self.b.is_valid()
        }
    }

    fn next(&amp;mut self) -&gt; Result&lt;()&gt; {
        if self.use_iterator == 0 {
            //  advance the first iterator because the second one wasn't used
            if self.a.is_valid() {
                self.a.next()?;
            }
        } else if self.use_iterator == 1 {
            //  advance the second iterator because the first one wasn't used
            if self.b.is_valid() {
                self.b.next()?;
            }
        } else if self.use_iterator == 2 {
            //  advance both
            if self.a.is_valid() {
                self.a.next()?;
            }
            if self.b.is_valid() {
                self.b.next()?;
            }
        }
        self.use_iterator = TwoMergeIterator::decide_which_iter_to_use(&amp;self.a, &amp;self.b);
        Ok(())
    }

    fn num_active_iterators(&amp;self) -&gt; usize {
        self.a.num_active_iterators() + self.b.num_active_iterators()
    }
}
</code></pre>

<p>Now, this would have actually been enough at this point. However, the course defines two additional types of iterators to make changing things easier, so let’s just add those. However, the hierarchy of the iterators is the same as we saw in the above diagram. We will add 2 new layers but nothing fundamentally changed.</p>

<pre><code class="language-rust">use std::{
    io::{self, ErrorKind},
    ops::Bound,
};

use anyhow::Result;
use bytes::Bytes;

use crate::{
    iterators::{
        merge_iterator::MergeIterator, two_merge_iterator::TwoMergeIterator, StorageIterator,
    },
    mem_table::MemTableIterator,
    table::SsTableIterator,
};

/// Represents the internal type for an LSM iterator. This type will be changed across the tutorial for multiple times.
type LsmIteratorInner =
    TwoMergeIterator&lt;MergeIterator&lt;MemTableIterator&gt;, MergeIterator&lt;SsTableIterator&gt;&gt;;

pub struct LsmIterator {
    inner: LsmIteratorInner,
    upper_bound: Bound&lt;Bytes&gt;,
    is_valid: bool,
}

impl LsmIterator {
    pub(crate) fn new(iter: LsmIteratorInner, upper_bound: Bound&lt;Bytes&gt;) -&gt; Result&lt;Self&gt; {
        let mut lsm_iter = Self {
            is_valid: iter.is_valid(),
            inner: iter,
            upper_bound,
        };

        //  when an iterator is first created, there is the possibility that
        //  the very first key-value pair is a tombstone so need to account for that
        lsm_iter.skip_deleted_values()?;

        Ok(lsm_iter)
    }

    //  if the value associated with the key after calling next is an empty string
    //  this marks a tombstone. this key should be skipped so that the consumer
    //  of this iterator does not see it
    fn skip_deleted_values(&amp;mut self) -&gt; Result&lt;()&gt; {
        while self.inner.is_valid() &amp;&amp; self.inner.value().is_empty() {
            self.inner.next()?;
            if !self.inner.is_valid() {
                self.is_valid = false;
                return Ok(());
            }
            match self.upper_bound.as_ref() {
                Bound::Included(key) =&gt; {
                    if self.inner.key().raw_ref() &gt; key {
                        //invalidate the iterator
                        self.is_valid = false;
                    }
                }
                Bound::Excluded(key) =&gt; {
                    if self.inner.key().raw_ref() &gt;= key {
                        //  invalidate the iterator
                        self.is_valid = false;
                    }
                }
                Bound::Unbounded =&gt; {}
            }
        }
        Ok(())
    }
}

impl StorageIterator for LsmIterator {
    type KeyType&lt;'a&gt; = &amp;'a [u8];

    fn is_valid(&amp;self) -&gt; bool {
        self.is_valid
    }

    fn key(&amp;self) -&gt; &amp;[u8] {
        self.inner.key().into_inner()
    }

    fn value(&amp;self) -&gt; &amp;[u8] {
        self.inner.value()
    }

    fn next(&amp;mut self) -&gt; Result&lt;()&gt; {
        self.inner.next()?;
        if !self.inner.is_valid() {
            self.is_valid = false;
            return Ok(());
        }
        match self.upper_bound.as_ref() {
            Bound::Included(key) =&gt; {
                if self.inner.key().raw_ref() &gt; key {
                    //invalidate the iterator
                    self.is_valid = false;
                }
            }
            Bound::Excluded(key) =&gt; {
                if self.inner.key().raw_ref() &gt;= key {
                    //  invalidate the iterator
                    self.is_valid = false;
                }
            }
            Bound::Unbounded =&gt; {}
        }
        self.skip_deleted_values()?;
        Ok(())
    }

    fn num_active_iterators(&amp;self) -&gt; usize {
        self.inner.num_active_iterators()
    }
}

/// A wrapper around existing iterator, will prevent users from calling `next` when the iterator is
/// invalid. If an iterator is already invalid, `next` does not do anything. If `next` returns an error,
/// `is_valid` should return false, and `next` should always return an error.
pub struct FusedIterator&lt;I: StorageIterator&gt; {
    iter: I,
    has_errored: bool,
}

impl&lt;I: StorageIterator&gt; FusedIterator&lt;I&gt; {
    pub fn new(iter: I) -&gt; Self {
        Self {
            iter,
            has_errored: false,
        }
    }
}

impl&lt;I: StorageIterator&gt; StorageIterator for FusedIterator&lt;I&gt; {
    type KeyType&lt;'a&gt; = I::KeyType&lt;'a&gt; where Self: 'a;

    fn is_valid(&amp;self) -&gt; bool {
        if self.has_errored {
            return false;
        }
        self.iter.is_valid()
    }

    fn key(&amp;self) -&gt; Self::KeyType&lt;'_&gt; {
        self.iter.key()
    }

    fn value(&amp;self) -&gt; &amp;[u8] {
        self.iter.value()
    }

    fn next(&amp;mut self) -&gt; Result&lt;()&gt; {
        if self.has_errored {
            return Err(io::Error::new(ErrorKind::Other, "The iterator has errored").into());
        }
        if !self.is_valid() {
            return Ok(());
        }
        match self.iter.next() {
            Ok(_) =&gt; Ok(()),
            Err(e) =&gt; {
                self.has_errored = true;
                Err(e)
            }
        }
    }

    fn num_active_iterators(&amp;self) -&gt; usize {
        self.iter.num_active_iterators()
    }
}
</code></pre>

<p>We are finally at a point where we can run a scan on the entire data store and get accurate results. So, let’s define the interface and implement it for that.</p>

<pre><code class="language-rust">/// Create an iterator over a range of keys.
    pub fn scan(
        &amp;self,
        lower: Bound&lt;&amp;[u8]&gt;,
        upper: Bound&lt;&amp;[u8]&gt;,
    ) -&gt; Result&lt;FusedIterator&lt;LsmIterator&gt;&gt; {
        //  create the merge iterator for the memtables here
        let state_guard = self.state.read();
        let mut memtables = Vec::new();
        memtables.push(Arc::clone(&amp;state_guard.memtable));
        memtables.extend(
            state_guard
                .imm_memtables
                .iter()
                .map(|memtable| Arc::clone(memtable)),
        );
        let mut memtable_iterators = Vec::new();
        for memtable in memtables {
            //  create a memtable iterator for each memtable
            let iterator = memtable.scan(lower, upper);
            memtable_iterators.push(Box::new(iterator));
        }
        let memtable_merge_iterator = MergeIterator::create(memtable_iterators);
        drop(state_guard);

        //  create the merge iterator for the SsTables here
        let snapshot = {
            let state_guard = self.state.read();
            Arc::clone(&amp;state_guard)
        };

        //  retrieve the ids of the sstables
        let sstable_ids = &amp;*snapshot.l0_sstables;
        let mut sstable_iterators: Vec&lt;Box&lt;SsTableIterator&gt;&gt; = Vec::new();
        for sstable_id in sstable_ids {
            let sstable = snapshot.sstables.get(&amp;sstable_id).unwrap();
            //  need to skip building any iterators that cannot contain the key
            if !self.range_overlap(lower, upper, sstable.first_key(), sstable.last_key()) {
                continue;
            }

            let sstable_iter = match lower {
                Bound::Included(key) =&gt; SsTableIterator::create_and_seek_to_key(
                    Arc::clone(sstable),
                    KeySlice::from_slice(key),
                )?,
                Bound::Excluded(key) =&gt; {
                    let mut iterator = SsTableIterator::create_and_seek_to_key(
                        Arc::clone(sstable),
                        KeySlice::from_slice(key),
                    )?;
                    if iterator.is_valid() &amp;&amp; iterator.key().raw_ref() == key {
                        iterator.next()?;
                    }
                    iterator
                }
                Bound::Unbounded =&gt; SsTableIterator::create_and_seek_to_first(Arc::clone(sstable))?,
            };
            sstable_iterators.push(Box::new(sstable_iter));
        }
        let sstable_merge_iterator = MergeIterator::create(sstable_iterators);

        let lsm_iterator = LsmIterator::new(
            TwoMergeIterator::create(memtable_merge_iterator, sstable_merge_iterator)?,
            map_bound(upper),
        )?;

        Ok(FusedIterator::new(lsm_iterator))
    }
</code></pre>

<p>And here’s how we’d use the above interface by utilising Rust’s bounds.</p>

<pre><code class="language-rust">lsm.put(b"a", "1");
lsm.put(b"b", "2");
let mut iter = lsm.scan(std::ops::Bound::Unbounded, std::ops::Bound::Unbounded)?;
let mut cnt = 0;
while iter.is_valid() {
    println!(
        "{:?}={:?}",
        Bytes::copy_from_slice(iter.key()),
        Bytes::copy_from_slice(iter.value()),
    );
    iter.next()?;
    cnt += 1;
}
</code></pre>

<h2 id="recap">Recap</h2>

<p>Congrats on making it this far in the post. To recap, here’s what we’ve covered:</p>

<ol>
  <li>Writing user data to an in-memory buffer called the memtable</li>
  <li>Flushing data from the memtable to a sorted string table</li>
  <li>Servicing point and range queries via iterators</li>
</ol>

<p>This was quite a lengthy post and involved a ton of code. If you want to play around with the engine to get a feel for it, clone the repo and run it locally with</p>

<p><code>cargo run --bin mini-lsm-cli -- --compaction none</code></p>

<p>You can run commands like</p>

<p><code>fill 1000 3000</code> -&gt; put values within that range</p>

<p><code>get 1001</code> -&gt; get a specific value</p>

<p><code>scan 1001 1005</code> -&gt; scan a specific range</p>

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

<ol>
  <li><a href="https://github.com/redixhumayun/mini-lsm">The repo</a></li>
  <li><a href="https://buttondown.email/jaffray/archive/the-three-places-for-data-in-an-lsm/">Three places for data</a></li>
  <li><a href="https://garrensmith.com/Databases/Log+Structured+Merge+Tree">Log structured merge tree</a></li>
  <li><a href="https://www.creativcoder.dev/blog/what-is-a-lsm-tree">What is an LSM tree</a></li>
  <li><a href="https://samwho.dev/bloom-filters/">Bloom filters</a></li>
</ol>]]></content><author><name></name></author><category term="databases" /><summary type="html"><![CDATA[I’m writing the posts in this series based on a course I completed. I can’t recommend the course highly enough. If you’re actually interested in really intuiting an LSM engine, I recommend you do the course yourself.]]></summary></entry><entry><title type="html">Race Conditions &amp;amp; Data Races</title><link href="https://redixhumayun.github.io/concurrency/2024/05/17/data-race-vs-race-condition.html" rel="alternate" type="text/html" title="Race Conditions &amp;amp; Data Races" /><published>2024-05-17T00:00:00+00:00</published><updated>2024-05-17T00:00:00+00:00</updated><id>https://redixhumayun.github.io/concurrency/2024/05/17/data-race-vs-race-condition</id><content type="html" xml:base="https://redixhumayun.github.io/concurrency/2024/05/17/data-race-vs-race-condition.html"><![CDATA[<p>I’ve been using Rust at work for the last few months and keep hearing about “fearless concurrency” in Rust. I’m still not entirely sure what that means but I mistakenly assumed that it meant that race conditions were impossible in Rust. This is obviously wrong, but it took me a while to understand why.</p>

<h2 id="race-conditions-in-rust">Race Conditions In Rust</h2>

<p>Here’s some code written in Rust that has a race condition in it because of application logic. It uses the classic example of tranferring money from one account to another but does it in an extremely silly way</p>

<pre><code class="language-rust">struct Account {
    balance: Mutex&lt;i32&gt;,
}

fn transfer2(amount: i32, account_from: Arc&lt;Account&gt;, account_to: Arc&lt;Account&gt;) -&gt; &amp;'static str {
    // First atomic block
    let bal;
    {
        let from_balance = account_from.balance.lock().unwrap();
        bal = *from_balance;
    }

    // Check balance
    if bal &lt; amount {
        return "NOPE";
    }

    // Second atomic block
    {
        let mut to_balance = account_to.balance.lock().unwrap();
        *to_balance += amount;
    }

    // Third atomic block
    {
        let mut from_balance = account_from.balance.lock().unwrap();
        *from_balance -= amount;
    }

    "YEP"
}

fn main() {
    let account_from = Arc::new(Account {
        balance: Mutex::new(100),
    });
    let account_to = Arc::new(Account {
        balance: Mutex::new(50),
    });

    let account_from_clone = Arc::clone(&amp;account_from);
    let account_to_clone = Arc::clone(&amp;account_to);

    let handle = thread::spawn(move || {
        let result = transfer2(30, account_from_clone, account_to_clone);
        println!("Transfer result: {}", result);
    });

    // Simulate another transfer in the main thread
    let result = transfer2(80, Arc::clone(&amp;account_from), Arc::clone(&amp;account_to));
    println!("Transfer result: {}", result);

    handle.join().unwrap();
}
</code></pre>

<p>Here’s a simple execution trace that triggers the race condition in the code</p>

<pre><code>T1 -&gt; amount = 30, from_balance = 100, to_balance = 50
T2 -&gt; amount = 80, from_balance = 100, to_balance = 50
Both T1 and T2 pass the check of bal &gt;= amount
T1 -&gt; amount = 30, from_balance = 100, to_balance = 80
T2 -&gt; amount = 80, from_balance = 100, to_balance = 160
T1 -&gt; amount = 30, from_balance = 70, to_balance = 160
T2 -&gt; amount = 80, from_balance = -10, to_balance = 160
</code></pre>

<p>The execution results in the bank account being overdrawn. However, the Rust compiler allows this code to be compiled because there is no verifiable way to prevent every possible logical race condition.</p>

<p>It is impossible to prevent all classes of race errors <a href="https://doc.rust-lang.org/nomicon/races.html#:~:text=However%20Rust%20does%20not%20prevent,by%20frameworks%20such%20as%20RTIC.">unless you control the scheduler</a>.</p>

<h2 id="deadlocks-in-rust">Deadlocks in Rust</h2>

<p>Now, we’ve seen that Rust’s concurrency patterns can’t prevent application logic race conditions. Rust also cannot prevent your code from deadlocking, which is a specific case of a race condition where your program halts execution. This is also an application logic error.</p>

<p>Assume that you have a program that is attempting to acquire locks in a specific order. We’ll have two locks - A and B  and two programs, one of which attempts to acquire A first and then B, and the other program which does the reverse.</p>

<p>Here’s some sample code which demonstrates that</p>

<pre><code class="language-rust">use std::sync::{Arc, Mutex};
use std::thread;

struct Locks {
    lock_a: Mutex&lt;u64&gt;,
    lock_b: Mutex&lt;u64&gt;,
}

impl Locks {
    fn acquire(&amp;self) {
        println!("running acquire");
        let mut a = self.lock_a.lock().unwrap();
        println!("got lock a");
        thread::sleep(std::time::Duration::from_millis(1000));
        let mut b = self.lock_b.lock().unwrap();
        println!("got lock b");
        *a += 1;
        *b += 1;
        println!("The sum is {}", *a + *b);
    }

    fn acquire_rev(&amp;self) {
        println!("running acquire rev");
        thread::sleep(std::time::Duration::from_millis(100));
        let mut b = self.lock_b.lock().unwrap();
        println!("got lock b");
        let mut a = self.lock_a.lock().unwrap();
        println!("got lock a");
        *b += 1;
        *a += 1;
        println!("The reverse sum is {}", *b + *a);
    }
}

fn main() {
    let mut handles = vec![];

    let locks = Arc::new(Locks {
        lock_a: Mutex::new(1),
        lock_b: Mutex::new(2),
    });

    for _ in 0..100 {
        let locks = Arc::clone(&amp;locks);
        let handle = thread::spawn(move || {
            locks.acquire();
            locks.acquire_rev();
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }
}
</code></pre>

<p>I’ve introduced a couple of sleeps in the code and added some logs to increase the likelihood of the code deadlocking. Also, this code is extremely silly (for illustrative purposes, obviously) but the point remains that this code compiles perfectly and deadlocks.</p>

<p>It’s impossible for the Rust compiler to catch the issue here because this is an application logic error. As far as the compiler is concerned, everything is wrapped behind an appropriate mutex and the data behind the mutex is being read correctly.</p>

<p>Now, let’s look at a subset of a race condition called a data race - something that the Rust compiler can definitely catch.</p>

<h2 id="data-races">Data Races</h2>

<pre><code class="language-rust">use std::sync::{Arc, Mutex};
use std::thread;

struct Counter {
    counter: Mutex&lt;u64&gt;,
}

impl Counter {
    fn increment_1(&amp;self) {
        let mut counter = self.counter.lock().unwrap();
        *counter += 1;
        println!("The new value of counter in increment_1 {}", counter);
    }

    fn increment_2(&amp;self) {
        let mut counter = self.counter.lock().unwrap();
        *counter += 1;
        println!("The new value of counter in increment_2 {}", counter);
    }
}

fn main() {
    let mut handles = vec![];

    let counter = Arc::new(Counter { counter: 0.into() });

    for _ in 0..100 {
        let counter_1 = Arc::clone(&amp;counter);
        let counter_2 = Arc::clone(&amp;counter);
        let handle_1 = thread::spawn(move || {
            counter_1.increment_1();
        });
        let handle_2 = thread::spawn(move || {
            counter_2.increment_2();
        });
        handles.push(handle_1);
        handles.push(handle_2);
    }

    for handle in handles {
        handle.join().unwrap();
    }
}
</code></pre>

<p>There is no way to write this code in Rust such that the memory location represented by counter could potentially be updated by two threads at once. First, I need to wrap the <code>Counter</code> object in an <code>Arc</code> because I need to send it across threads. <code>Arc</code> gives me a thread safe read counter.</p>

<p>However, if I tried to remove the <code>Mutex</code> around <code>counter</code>, I would need to make the methods <code>increment_1</code> and <code>increment_2</code> use the signature <code>&amp;mut self</code>. Now, if I were to try to call a mutable method on an object wrapped in an <code>Arc</code> like the code below, I get an error</p>

<pre><code class="language-rust">use std::sync::{Arc, Mutex};
use std::thread;

struct Counter {
    counter: u64,
}

impl Counter {
    fn increment_1(&amp;mut self) {
        let mut counter = self.counter;
        counter += 1;
        self.counter = counter;
        println!("The new value of counter in increment_1 {}", counter);
    }

    fn increment_2(&amp;mut self) {
        let mut counter = self.counter;
        counter += 1;
        self.counter = counter;
        println!("The new value of counter in increment_2 {}", counter);
    }
}

fn main() {
    let mut handles = vec![];

    let counter = Arc::new(Counter { counter: 0 });

    for _ in 0..100 {
        let counter_1 = Arc::clone(&amp;counter);
        let counter_2 = Arc::clone(&amp;counter);
        let handle_1 = thread::spawn(move || {
            counter_1.increment_1();
        });
        let handle_2 = thread::spawn(move || {
            counter_2.increment_2();
        });
        handles.push(handle_1);
        handles.push(handle_2);
    }

    for handle in handles {
        handle.join().unwrap();
    }
}
</code></pre>

<p>The error states that</p>

<pre><code>error[E0596]: cannot borrow data in an `Arc` as mutable
  --&gt; src/main.rs:33:13
   |
33 |             counter_1.increment_1();
   |             ^^^^^^^^^ cannot borrow as mutable
   |
   = help: trait `DerefMut` is required to modify through a dereference, but it is not implemented for `Arc&lt;Counter&gt;`
</code></pre>

<p>So, if I want to share some state across threads, I am forced to wrap that data in a <code>Mutex</code>, preventing any kind of data race.</p>

<p>There is no way to write this code with a race condition which the Rust compiler will allow.</p>

<p>Let’s look at a simpler case of how Rust prevents data races by ignoring threads.</p>

<h2 id="one-mutable-reference-or-multiple-immutable-references">One Mutable Reference Or Multiple Immutable References</h2>

<p>If you’ve read <a href="https://doc.rust-lang.org/book/">the Rust Book</a>, you’ve probably heard about how you can have either one mutable reference to an object or multiple immutable references. This is similar to the idea of a <code>RWLock</code>, where you can have multiple readers or a single writer at any given point of time.</p>

<p>Here’s a small example in Rust showing how the compiler prevents this class of errors</p>

<pre><code class="language-rust">struct Data {
    var_a: u64,
    var_b: u64,
}

fn main() {
    let mut data = Data { var_a: 1, var_b: 2 };
    let a = &amp;data.var_a;
    data.var_a += 1;
    println!("var a {}", data.var_a);
    println!("a {}", a);
}
</code></pre>

<p>If you try to compile the above code, you see the following error</p>

<pre><code>error[E0506]: cannot assign to `data.var_a` because it is borrowed
  --&gt; src/main.rs:61:5
   |
60 |     let a = &amp;data.var_a;
   |             ----------- `data.var_a` is borrowed here
61 |     data.var_a += 1;
   |     ^^^^^^^^^^^^^^^ `data.var_a` is assigned to here but it was already borrowed
62 |     println!("var a {}", data.var_a);
63 |     println!("a {}", a);
   |                      - borrow later used here
</code></pre>

<p>The error is essentially saying that because the variable <code>a</code> borrows <code>data.var_a</code>, <code>data.var_a</code> cannot later be changed later since this invalidates the reference that <code>a</code> holds. This is preventing a data race because the memory location that <code>a</code> points to cannot be changed if <code>a</code> is going to be used.</p>

<p>Here’s a different version of the above code with methods implemented on the <code>Data</code> struct but demonstrating the same principle regarding data race prevention.</p>

<pre><code class="language-rust">struct Data {
    var_a: u64,
    var_b: u64,
}

impl Data {
    fn get_a(&amp;self) -&gt; &amp;u64 {
        &amp;self.var_a
    }

    fn increment_a(&amp;mut self) {
        self.var_a += 1;
    }
}

fn main() {
    let mut data = Data { var_a: 1, var_b: 2 };
    let a_ref = data.get_a();
    data.increment_a();
    println!("The ref {}", a_ref);
}
</code></pre>

<p>This code gives the following error</p>

<pre><code>error[E0502]: cannot borrow `data` as mutable because it is also borrowed as immutable
   --&gt; src/main.rs:109:5
    |
108 |     let a_ref = data.get_a();
    |                 ---- immutable borrow occurs here
109 |     data.increment_a();
    |     ^^^^^^^^^^^^^^^^^^ mutable borrow occurs here
110 |     println!("The ref {}", a_ref);
    |                            ----- immutable borrow later used here
</code></pre>

<p>The basic underlying principle behind Rust preventing data races is quite simple: you can either have a single mutable reference to some location in memory and mutate that data in any way you wish, or you can hold multiple immutable references to some location in memory and read from it via as many threads as you’d like.</p>

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

<ol>
  <li><a href="https://blog.regehr.org/archives/490">Race Condition vs Data Race</a></li>
  <li><a href="https://doc.rust-lang.org/nomicon/races.html#:~:text=However%20Rust%20does%20not%20prevent,by%20frameworks%20such%20as%20RTIC.">Rustnomicon on data races and race conditions</a></li>
</ol>]]></content><author><name></name></author><category term="concurrency" /><summary type="html"><![CDATA[I’ve been using Rust at work for the last few months and keep hearing about “fearless concurrency” in Rust. I’m still not entirely sure what that means but I mistakenly assumed that it meant that race conditions were impossible in Rust. This is obviously wrong, but it took me a while to understand why.]]></summary></entry><entry><title type="html">Build You A Raft - Part II</title><link href="https://redixhumayun.github.io/databases/2024/03/10/build-you-a-raft-part-ii.html" rel="alternate" type="text/html" title="Build You A Raft - Part II" /><published>2024-03-10T00:00:00+00:00</published><updated>2024-03-10T00:00:00+00:00</updated><id>https://redixhumayun.github.io/databases/2024/03/10/build-you-a-raft-part-ii</id><content type="html" xml:base="https://redixhumayun.github.io/databases/2024/03/10/build-you-a-raft-part-ii.html"><![CDATA[<p>This post is a follow up to my <a href="/databases/2024/02/26/build-you-a-raft-part-i.html">previous post</a> about how to implement the Raft consensus protocol in Rust. In the previous post I went through the basics of how to set up the Raft cluster and implement the logic required for the RPC’s.</p>

<p>In this post, I’m going to focus more on how to go about testing the cluster. While building the Raft implementation, I realised that <a href="https://x.com/redixhumayun/status/1754745602049774077?s=20">half the battle with distributed systems</a> is in building a useful test harness. This has become such a problem in the distributed systems space that companies like <a href="https://apple.github.io/foundationdb/testing.html">FoundationDB</a> and <a href="https://github.com/tigerbeetle/tigerbeetle/blob/main/src/simulator.zig">TigerBeetle</a> have written something called a deterministic simulation testing (DST) engine. It’s a fancy term for a more advanced form of fuzz testing (atleast from what I understand).</p>

<p>The founder of FoundationDB <a href="https://www.youtube.com/watch?v=4fFDFbi3toc">gave a talk</a> about why they went about building a DST simulator and he later went on to found <a href="https://antithesis.com/">Antithesis</a> whose entire business is around trying to provide a generalizable DST engine to other companies to test their products!</p>

<p>Anyway, back to writing a <em>much</em> simpler test cluster in Raft!</p>

<h2 id="mocking">Mocking</h2>

<p>While I was trying to write my Raft implementation, there was a very helpful <a href="https://x.com/cfcosta_/status/1755967315747725574?s=20">Twitter reply</a> about mocking away the network and the clock so that you’re essentially testing a deterministic state machine (Raft itself, however, isn’t deterministic).</p>

<p>In the previous post we saw an <code>RPCManager</code> to facilitate communication and a <code>tick</code> method to logically advance the time in the cluster. These methods are defined for each node.</p>

<p>This <code>tick</code> function which I’ve reproduced below calls an <code>advance_time_by</code> method, which in turn calls <code>clock.advance</code> on a node. Each node has a <code>clock</code> which is injected into it.</p>

<pre><code class="language-rust">struct RaftNode&lt;T: RaftTypeTrait, S: StateMachine&lt;T&gt;, F: RaftFileOps&lt;T&gt;&gt; {
    id: ServerId,
    state: Mutex&lt;RaftNodeState&lt;T&gt;&gt;,
    state_machine: S,
    config: ServerConfig,
    peers: Vec&lt;ServerId&gt;,
    to_node_sender: mpsc::Sender&lt;MessageWrapper&lt;T&gt;&gt;,
    from_rpc_receiver: mpsc::Receiver&lt;MessageWrapper&lt;T&gt;&gt;,
    rpc_manager: CommunicationLayer&lt;T&gt;,
    persistence_manager: F,
    clock: RaftNodeClock,
}

impl RaftNodeClock {
    fn advance(&amp;mut self, duration: Duration) {
        match self {
            RaftNodeClock::RealClock(_) =&gt; (), //  this method should do nothing for a real clock
            RaftNodeClock::MockClock(clock) =&gt; clock.advance(duration),
        }
    }
}

fn advance_time_by(&amp;mut self, duration: Duration) {
    self.clock.advance(duration);
}

fn tick(&amp;mut self) {
    let mut state_guard = self.state.lock().unwrap();
    match state_guard.status {
        RaftNodeStatus::Leader =&gt; {
            //  the leader should send heartbeats to all followers
            self.send_heartbeat(&amp;state_guard);
        }
        RaftNodeStatus::Candidate | RaftNodeStatus::Follower =&gt; {
            //  the candidate or follower should check if the election timeout has elapsed
            self.check_election_timeout(&amp;mut state_guard);
        }
    }
    drop(state_guard);

    //  listen to incoming messages from the RPC manager
    self.listen_for_messages();
    self.advance_time_by(Duration::from_millis(1));
}
</code></pre>

<h3 id="clocks">Clocks</h3>

<p>I defined two variants of a clock - a mock and a real implementation to use for my cluster (another area I found where Rust’s enum pattern matching really shines).</p>

<pre><code class="language-rust">use std::time::{Duration, Instant};

pub trait Clock {
    fn now(&amp;self) -&gt; Instant;
}

pub struct RealClock;
impl Clock for RealClock {
    fn now(&amp;self) -&gt; Instant {
        Instant::now()
    }
}

pub struct MockClock {
    pub current_time: Instant,
}

impl MockClock {
    pub fn advance(&amp;mut self, duration: Duration) {
        self.current_time += duration;
    }
}

impl Clock for MockClock {
    fn now(&amp;self) -&gt; Instant {
        self.current_time
    }
}
</code></pre>

<p>So any time I was creating a cluster for testing, I would create nodes with a <code>MockClock</code> and for actual implementations (I never created those) I would use the <code>RealClock</code>. You’ll notice that the <code>RealClock</code> cannot have its time advanced.</p>

<h3 id="network">Network</h3>

<p>Similar to the clocks, for the network layer I created a <code>MockRPCManager</code> which contained a message queue indicating what messages were sent by the node for every logical tick of the cluster. And when a node wanted to send a message, it would simply get pushed into the <code>sent_messages</code> queue.</p>

<p>There are also two additional helper methods called <code>get_messages_in_queue</code> and <code>replay_messages_in_queue</code> which allow me to either drain the messages or inspect them without mutating them. The <code>get_messages</code> method drains the messages from a node’s queue and sends them across the network and <code>replay_messages</code> keeps the messages as they are but allows for inspection.</p>

<p><em>Note: I picked up these ideas from an implementation on GitHub which you can find <a href="https://github.com/jackyzha0/miniraft">here</a></em></p>

<pre><code class="language-rust">#[derive(Debug, Clone)]
struct MessageWrapper&lt;T: RaftTypeTrait&gt; {
    from_node_id: ServerId,
    to_node_id: ServerId,
    message: RPCMessage&lt;T&gt;,
}

struct MockRPCManager&lt;T: RaftTypeTrait&gt; {
    server_id: ServerId,
    to_node_sender: mpsc::Sender&lt;MessageWrapper&lt;T&gt;&gt;,
    sent_messages: RefCell&lt;Vec&lt;MessageWrapper&lt;T&gt;&gt;&gt;,
}

impl&lt;T: RaftTypeTrait&gt; MockRPCManager&lt;T&gt; {
    fn new(server_id: ServerId, to_node_sender: mpsc::Sender&lt;MessageWrapper&lt;T&gt;&gt;) -&gt; Self {
        MockRPCManager {
            server_id,
            to_node_sender,
            sent_messages: RefCell::new(vec![]),
        }
    }
}

impl&lt;T: RaftTypeTrait&gt; MockRPCManager&lt;T&gt; {
    fn start(&amp;self) {}

    fn stop(&amp;self) {}

    fn send_message(&amp;self, _to_address: String, message: MessageWrapper&lt;T&gt;) {
        self.sent_messages.borrow_mut().push(message);
    }

    fn get_messages_in_queue(&amp;mut self) -&gt; Vec&lt;MessageWrapper&lt;T&gt;&gt; {
        let mut mock_messages_vector: Vec&lt;MessageWrapper&lt;T&gt;&gt; = Vec::new();
        for message in self.sent_messages.borrow_mut().drain(..) {
            mock_messages_vector.push(message.clone());
        }
        mock_messages_vector
    }

    fn replay_messages_in_queue(&amp;self) -&gt; Ref&lt;Vec&lt;MessageWrapper&lt;T&gt;&gt;&gt; {
        self.sent_messages.borrow()
    }
}
</code></pre>

<p>The <code>CommunicationLayer</code> trait defines what methods are available to a node to consume via its network layer.</p>

<pre><code class="language-rust">trait Communication&lt;T: RaftTypeTrait&gt; {
    fn start(&amp;self);

    fn stop(&amp;self);

    fn send_message(&amp;self, to_address: String, message: MessageWrapper&lt;T&gt;);
}

enum CommunicationLayer&lt;T: RaftTypeTrait&gt; {
    MockRPCManager(MockRPCManager&lt;T&gt;),
    RPCManager(RPCManager&lt;T&gt;),
}

impl&lt;T: RaftTypeTrait&gt; Communication&lt;T&gt; for CommunicationLayer&lt;T&gt; {
    fn start(&amp;self) {
        match self {
            CommunicationLayer::MockRPCManager(manager) =&gt; manager.start(),
            CommunicationLayer::RPCManager(manager) =&gt; manager.start(),
        }
    }

    fn stop(&amp;self) {
        match self {
            CommunicationLayer::MockRPCManager(manager) =&gt; manager.stop(),
            CommunicationLayer::RPCManager(manager) =&gt; manager.stop(),
        }
    }

    fn send_message(&amp;self, to_address: String, message: MessageWrapper&lt;T&gt;) {
        match self {
            CommunicationLayer::MockRPCManager(manager) =&gt; {
                manager.send_message(to_address, message)
            }
            CommunicationLayer::RPCManager(manager) =&gt; manager.send_message(to_address, message),
        }
    }
}

impl&lt;T: RaftTypeTrait&gt; CommunicationLayer&lt;T&gt; {
    fn get_messages(&amp;mut self) -&gt; Vec&lt;MessageWrapper&lt;T&gt;&gt; {
        match self {
            CommunicationLayer::MockRPCManager(manager) =&gt; {
                return manager.get_messages_in_queue();
            }
            CommunicationLayer::RPCManager(_) =&gt; {
                panic!("This method is not supported for the RPCManager");
            }
        }
    }

    fn replay_messages(&amp;self) -&gt; Ref&lt;Vec&lt;MessageWrapper&lt;T&gt;&gt;&gt; {
        match self {
            CommunicationLayer::MockRPCManager(manager) =&gt; {
                return manager.replay_messages_in_queue();
            }
            CommunicationLayer::RPCManager(_) =&gt; {
                panic!("This method is not supported for the RPCManager");
            }
        }
    }
}
</code></pre>

<h2 id="test-cluster">Test Cluster</h2>

<p>Okay, now that we’ve got the mocking out of the way, let’s jump into defining the test cluster itself.</p>

<p>Here’s some basic definitions for my <code>TestCluster</code>.</p>

<pre><code class="language-rust">#[derive(Clone)]
pub struct ClusterConfig {
    pub election_timeout: Duration,
    pub heartbeat_interval: Duration,
    pub ports: Vec&lt;u64&gt;,
}

pub struct TestCluster {
    pub nodes: Vec&lt;RaftNode&lt;i32, KeyValueStore&lt;i32&gt;, DirectFileOpsWriter&gt;&gt;,
    pub nodes_map:
        BTreeMap&lt;ServerId, RaftNode&lt;i32, KeyValueStore&lt;i32&gt;, DirectFileOpsWriter&gt;&gt;,
    pub message_queue: Vec&lt;MessageWrapper&lt;i32&gt;&gt;,
    pub connectivity: HashMap&lt;ServerId, HashSet&lt;ServerId&gt;&gt;,
    pub config: ClusterConfig,
}
</code></pre>

<p>I have a bunch of helper methods on this <code>TestCluster</code> but it would be too much to go into all of them so I’ll just go over the <code>tick</code> method and the time based methods here. If you want to see all the methods, check out the repo <a href="https://github.com/redixhumayun/raft">here</a>.</p>

<p>The tick method on the cluster calls the <code>get_messages</code> method we saw earlier for each node. It collects all these messages in a central queue and then dispatches them in the same logical tick. But, it dispatches the messages only after each node has gone through its own <code>tick</code> function. There’s no special reason for the ordering of events here, it’s just how I decided to do it.</p>

<pre><code class="language-rust">impl TestCluster {
pub fn tick(&amp;mut self) {
    //  Collect all messages from nodes and store them in the central queue
    self.nodes.iter_mut().for_each(|node| {
        let mut messages_from_node = node.rpc_manager.get_messages();
        self.message_queue.append(&amp;mut messages_from_node);
    });

    //  allow each node to tick
    self.nodes.iter_mut().for_each(|node| {
        node.tick();
    });

    //  deliver all messages from the central queue
    self.message_queue
        .drain(..)
        .into_iter()
        .for_each(|message| {
            let node = self
                .nodes
                .iter()
                .find(|node| node.id == message.to_node_id)
                .unwrap();
            //  check if these pair of nodes are partitioned
            if self
                .connectivity
                .get_mut(&amp;message.from_node_id)
                .unwrap()
                .contains(&amp;message.to_node_id)
            {
                match node.to_node_sender.send(message) {
                    Ok(_) =&gt; (),
                    Err(e) =&gt; {
                        panic!(
                            "There was an error while sending the message to the node: {}",
                            e
                        )
                    }
                };
            }
        });
}
</code></pre>

<p>The methods below allow me to advance logical time in my cluster for a bunch of different configurations. Either all nodes can advance by the same amount, a single node can advance by some amount, or all nodes can advance by some variable amount.</p>

<pre><code class="language-rust">pub fn tick_by(&amp;mut self, tick_interval: u64) {
    for _ in 0..tick_interval {
        self.tick();
    }
}

pub fn advance_time_by_for_node(&amp;mut self, node_id: ServerId, duration: Duration) {
    let node = self
        .nodes
        .iter_mut()
        .find(|node| node.id == node_id)
        .expect(&amp;format!("Could not find node with id: {}", node_id));
    node.advance_time_by(duration);
}

pub fn advance_time_by_variably(&amp;mut self, duration: Duration) {
    //  for each node in the cluster, advance it's mock clock by the duration + some random variation
    for node in &amp;mut self.nodes {
        let jitter = rand::thread_rng().gen_range(0..50);
        let new_duration = duration + Duration::from_millis(jitter);
        node.advance_time_by(new_duration);
    }
}

pub fn advance_time_by(&amp;mut self, duration: Duration) {
    //  for each node in the cluster, advance it's mock clock by the duration
    for node in &amp;mut self.nodes {
        node.advance_time_by(duration);
    }
}
</code></pre>

<p>There are two more methods I found super helpful while doing the testing which are below - the <code>partition</code> and <code>heal_partition</code> methods. These allow me to simulate a network partition by stopping the flow of messages from one subset of nodes to another and later fixing that.</p>

<pre><code class="language-rust">/// Separates the cluster into two smaller clusters where only the nodes within
/// each cluster can communicate between themselves
pub fn partition(&amp;mut self, group1: &amp;[ServerId], group2: &amp;[ServerId]) {
    for &amp;node_id in group1 {
        self.connectivity
            .get_mut(&amp;node_id)
            .unwrap()
            .retain(|&amp;id| group1.contains(&amp;id));
    }
    for &amp;node_id in group2 {
        self.connectivity
            .get_mut(&amp;node_id)
            .unwrap()
            .retain(|&amp;id| group2.contains(&amp;id));
    }
}

/// Removes any partition in the cluster and restores full connectivity among all nodes
pub fn heal_partition(&amp;mut self) {
    let all_node_ids = self
        .nodes
        .iter()
        .map(|node| node.id)
        .collect::&lt;HashSet&lt;ServerId&gt;&gt;();
    for node_id in all_node_ids.iter() {
        self.connectivity.insert(*node_id, all_node_ids.clone());
    }
}
</code></pre>

<p>And here’s what the code for creating, starting &amp; stopping a cluster looks like.</p>

<pre><code class="language-rust">pub fn new(number_of_nodes: u64, config: ClusterConfig) -&gt; Self {
    let mut nodes: Vec&lt;RaftNode&lt;i32, KeyValueStore&lt;i32&gt;, DirectFileOpsWriter&gt;&gt; =
        Vec::new();
    let nodes_map: BTreeMap&lt;
        ServerId,
        RaftNode&lt;i32, KeyValueStore&lt;i32&gt;, DirectFileOpsWriter&gt;,
    &gt; = BTreeMap::new();

    let node_ids: Vec&lt;ServerId&gt; = (0..number_of_nodes).collect();
    let addresses: Vec&lt;String&gt; = config
        .ports
        .iter()
        .map(|port| format!("127.0.0.1:{}", port))
        .collect();
    let mut id_to_address_mapping: HashMap&lt;ServerId, String&gt; = HashMap::new();
    for (node_id, address) in node_ids.iter().zip(addresses.iter()) {
        id_to_address_mapping.insert(*node_id, address.clone());
    }

    let mut counter = 0;
    for (node_id, address) in node_ids.iter().zip(addresses.iter()) {
        let server_config = ServerConfig {
            election_timeout: config.election_timeout,
            heartbeat_interval: config.heartbeat_interval,
            address: address.clone(),
            port: config.ports[counter],
            cluster_nodes: node_ids.clone(),
            id_to_address_mapping: id_to_address_mapping.clone(),
        };

        let state_machine = KeyValueStore::&lt;i32&gt;::new();
        let persistence_manager = DirectFileOpsWriter::new("data", *node_id).unwrap();
        let (to_node_sender, from_rpc_receiver) =
            mpsc::channel::&lt;MessageWrapper&lt;i32&gt;&gt;();
        let rpc_manager = CommunicationLayer::MockRPCManager(MockRPCManager::new(
            *node_id,
            to_node_sender.clone(),
        ));
        let mock_clock = RaftNodeClock::MockClock(MockClock {
            current_time: Instant::now(),
        });

        let node = RaftNode::new(
            *node_id,
            state_machine,
            server_config,
            node_ids.clone(),
            persistence_manager,
            rpc_manager,
            to_node_sender,
            from_rpc_receiver,
            mock_clock,
        );
        nodes.push(node);
        counter += 1;
    }
    let message_queue = Vec::new();

    let mut connectivity_hm: HashMap&lt;ServerId, HashSet&lt;ServerId&gt;&gt; = HashMap::new();
    for node_id in &amp;node_ids {
        connectivity_hm.insert(*node_id, node_ids.clone().into_iter().collect());
    }

    TestCluster {
        nodes,
        nodes_map,
        message_queue,
        connectivity: connectivity_hm,
        config,
    }
}

pub fn start(&amp;mut self) {
    for node in &amp;mut self.nodes {
        node.start();
    }
}

pub fn stop(&amp;self) {
    for node in &amp;self.nodes {
        node.stop();
    }
}
</code></pre>

<h2 id="actual-tests">Actual Tests</h2>

<p>Now that we have a reasonable test harness set up, I’m going to dive into some specific test scenarios to explain how I use the test harness to move the cluster into a specific configuration and then test out certain scenarios.</p>

<p><em>Note: After spending time doing this, I understand why people prefer property based testing or fuzz testing as a methodology. Creating scenario based tests is a very time consuming process and doesn’t give you enough coverage to justify the time spent on it.</em></p>

<p>My tests aren’t exhaustive (and might even be buggy) and I genuinely don’t think scenario based testing is the way to get exhaustive coverage anyway. I have more tests in <a href="https://github.com/redixhumayun/raft">the repo</a> if you want to look at those.</p>

<p>We’ll start with some simple tests for a single node cluster.</p>

<pre><code class="language-rust">const ELECTION_TIMEOUT: Duration = Duration::from_millis(150);
const HEARTBEAT_INTERVAL: Duration = Duration::from_millis(50);
const MAX_TICKS: u64 = 100;

/// This test checks whether a node in a single cluster will become leader as soon as the election timeout is reached
#[test]
fn leader_election() {
    let _ = env_logger::builder().is_test(true).try_init();
    //  create a cluster with a single node first
    let cluster_config = ClusterConfig {
        election_timeout: ELECTION_TIMEOUT,
        heartbeat_interval: HEARTBEAT_INTERVAL,
        ports: vec![8000],
    };
    let mut cluster = TestCluster::new(1, cluster_config);
    cluster.start();
    cluster.advance_time_by(ELECTION_TIMEOUT + Duration::from_millis(100 + 5)); //  picking 255 here because 150 + a max jitter of 100 guarantees that election has timed out
    cluster.wait_for_stable_leader(MAX_TICKS);
    cluster.stop();
    assert_eq!(cluster.has_leader(), true);
}
</code></pre>

<p>You can see that setting up the cluster earlier makes testing significantly easier here. I just call my helper methods on the cluster to move the cluster into a specific state and then assert whatever conditions I want against the cluster.</p>

<p><em>As an aside, you could convert this to a property based test using the <a href="https://altsysrq.github.io/proptest-book/">proptest crate</a>. You could change the number of nodes in the cluster to determine that a leader gets elected regardless of the number of nodes in the cluster. Something like the below code</em></p>

<pre><code class="language-rust">extern crate proptest;
use proptest::prelude::*;
use std::time::Duration;

proptest! {
    #![proptest_config(ProptestConfig::with_cases(10))]

    fn leader_election_property_based_test(node_count in 1usize..5) {
        let _ = env_logger::builder().is_test(true).try_init();
        
        let cluster_config = ClusterConfig {
            election_timeout: ELECTION_TIMEOUT,
            heartbeat_interval: HEARTBEAT_INTERVAL,
            ports: (8000..8000 + node_count as u16).collect(),
        };
        let mut cluster = TestCluster::new(node_count, cluster_config);
        cluster.start();
        
        cluster.advance_time_by(ELECTION_TIMEOUT + Duration::from_millis(100 + 5));
        cluster.wait_for_stable_leader(MAX_TICKS * node_count as u64); // Adjusted wait time
        cluster.stop();
        
        assert_eq!(cluster.has_leader(), true);
    }
}
</code></pre>

<p>Here’s a slightly more complicated test with 3 nodes that simulates a network partition in the cluster and asserts that a leader still gets elected in the majority partitioned cluster.</p>

<pre><code class="language-rust">/// This test models the scenario where the leader in a cluster is network partitioned from the
/// rest of the cluster and a new leader is elected
#[test]
fn network_partition_new_leader() {
    let _ = env_logger::builder().is_test(true).try_init();
    let cluster_config = ClusterConfig {
        election_timeout: ELECTION_TIMEOUT,
        heartbeat_interval: HEARTBEAT_INTERVAL,
        ports: vec![8000, 8001, 8002],
    };
    let mut cluster = TestCluster::new(3, cluster_config);
    cluster.start();
    cluster.advance_time_by_for_node(0, ELECTION_TIMEOUT + Duration::from_millis(50));
    cluster.wait_for_stable_leader(MAX_TICKS);

    //  partition the leader from the rest of the group
    let group1 = &amp;[cluster.get_leader().unwrap().id];
    let group2 = &amp;cluster
        .get_all_followers()
        .iter()
        .map(|node| node.id)
        .collect::&lt;Vec&lt;ServerId&gt;&gt;();
    cluster.partition(group1, group2);
    cluster.advance_time_by_for_node(1, ELECTION_TIMEOUT + Duration::from_millis(100));
    cluster.wait_for_stable_leader_partition(MAX_TICKS, group2);
    cluster.stop();
    assert_eq!(cluster.has_leader_in_partition(group2), true);
}
</code></pre>

<p>Again, you can see how useful the helper methods turn out to be because it’s much easier to separate one node from the rest by simulating a network partition and then checking certain properties on the individual partitions.</p>

<p>Now, here’s a much more complicated scenario-based test which models a network partition occurring between the leader and the rest of the cluster, the network partition healing and the old leader rejoining the rest of the cluster and having to catch up.</p>

<pre><code class="language-rust">/// The test models the following scenario
/// 1. A leader is elected
/// 2. Client requests are received and replicated
/// 3. A partition occurs and a new leader is elected
/// 4. Clients requests are processed by the new leader
/// 5. The partition heals and the old leader rejoins the cluster
/// 6. The old leader must recognize its a follower and get caught up with the new leader
#[test]
fn network_partition_log_healing() {
    let _ = env_logger::builder().is_test(true).try_init();
    let cluster_config = ClusterConfig {
        election_timeout: ELECTION_TIMEOUT,
        heartbeat_interval: HEARTBEAT_INTERVAL,
        ports: vec![8000, 8001, 8002],
    };
    let mut cluster = TestCluster::new(3, cluster_config);

    //  cluster starts and a leader is elected
    cluster.start();
    cluster.advance_time_by_for_node(0, ELECTION_TIMEOUT + Duration::from_millis(50));
    cluster.wait_for_stable_leader(MAX_TICKS);
    assert_eq!(cluster.has_leader(), true);

    //  client requests are received and replicated across cluster
    let current_leader_term = cluster
        .get_leader()
        .unwrap()
        .state
        .lock()
        .unwrap()
        .current_term;
    let mut last_log_index = cluster
        .get_leader()
        .unwrap()
        .state
        .lock()
        .unwrap()
        .log
        .last()
        .map_or(0, |entry| entry.index);
    last_log_index += 1;
    let log_entry_1 = LogEntry {
        term: current_leader_term,
        index: last_log_index,
        command: LogEntryCommand::Set,
        key: "a".to_string(),
        value: 1,
    };
    last_log_index += 1;
    let log_entry_2 = LogEntry {
        term: current_leader_term,
        index: last_log_index,
        command: LogEntryCommand::Set,
        key: "b".to_string(),
        value: 2,
    };
    cluster.apply_entries_across_cluster(vec![&amp;log_entry_1, &amp;log_entry_2], MAX_TICKS);
    cluster.verify_logs_across_cluster_for(vec![&amp;log_entry_1, &amp;log_entry_2], MAX_TICKS);

    //  partition and new leader election here
    let group1 = &amp;[cluster.get_leader().unwrap().id];
    let group2 = &amp;cluster
        .get_all_followers()
        .iter()
        .map(|node| node.id)
        .collect::&lt;Vec&lt;ServerId&gt;&gt;();
    cluster.partition(group1, group2);
    cluster.advance_time_by_for_node(1, ELECTION_TIMEOUT + Duration::from_millis(100));
    cluster.wait_for_stable_leader_partition(MAX_TICKS, group2);
    assert_eq!(cluster.has_leader_in_partition(group2), true);

    //  send requests to group2 leader
    let log_entry_3 = {
        let current_leader_term = cluster
            .get_leader_in_cluster(group2)
            .unwrap()
            .state
            .lock()
            .unwrap()
            .current_term;
        let mut last_log_index = cluster
            .get_leader()
            .unwrap()
            .state
            .lock()
            .unwrap()
            .log
            .last()
            .map_or(0, |entry| entry.index);
        last_log_index += 1;
        let log_entry_3 = LogEntry {
            term: current_leader_term,
            index: last_log_index,
            command: LogEntryCommand::Set,
            key: "c".to_string(),
            value: 3,
        };
        cluster.apply_entries_across_cluster_partition(
            vec![&amp;log_entry_3],
            group2,
            MAX_TICKS,
        );
        log_entry_3
    };

    //  partition heals and old leader rejoins the cluster
    //  cluster needs to be verified to ensure all logs are up to date
    let leader_id = cluster.get_leader_in_cluster(group2).unwrap().id;
    cluster.heal_partition();
    cluster.tick_by(MAX_TICKS);
    cluster.wait_for_stable_leader(MAX_TICKS);
    assert_eq!(cluster.get_leader().unwrap().id, leader_id);
    cluster.verify_logs_across_cluster_for(
        vec![&amp;log_entry_1, &amp;log_entry_2, &amp;log_entry_3],
        MAX_TICKS,
    );
}
</code></pre>

<p>I think this is one of those situations where property-based testing would really shine not because it would make setting up the test simpler but because it would allow me to use the same test case and test the cluster under different scenarios.</p>

<p>For instance, I could use <code>proptest</code> to generate clusters of different sizes, generate different variations of partitions and add a different number of log entries based on some generated value. This would give me more coverage.</p>

<p>So, in a sense, you could almost think of a single property test combining multiple scenario based tests.</p>

<h3 id="a-little-bit-about-dst">A Little Bit About DST</h3>

<p>I mentioned earlier that I’ve been digging into DST a little bit and with the help of some folks online, I’ve come to understand that DST is essentially about using a random seed value to generate property based tests while maintaining the ability to re-create a specific scenario using nothing but the seed value.</p>

<p>If we take the last test case we were discussing about generating clusters of different sizes, different variations of partitions and adding a different number of log entries, imagine if we did that with a randomly generated number which we can call a seed value.</p>

<p>This seed value would then be used to generate all the other values in a deterministic way. For instance, the number of nodes in the cluster could be the seed value itself, the number of log entries written could be <code>seed_value * 2</code> and the the partition configuration could be defined by <code>(seed_value % num_of_nodes) - 1</code>. This way, if the test case fails and the seed value is logged, I can re-create the test scenario using nothing but the seed value itself.</p>

<p>Since the seed value changes on every test run, I can test the system under a variety of different scenarios using property based testing. And in the case of a failure in a specific scenario, I am able to immediately re-create the scenario to debug the error.</p>

<p>I was under the impression that DST was a magic pill in the sense that if you wrote code that passed the simulator, you have bug free code. This is obviously incorrect. You’ve only reduced the probability of bugs in production by a significant factor (hopefully). In the video I linked above, Will Wilson explains how they considered writing a second DST simulator at FoundationDB because the engineers were getting really good at writing code that passed the simulator but still had bugs in it.</p>

<h2 id="conclusion">Conclusion</h2>

<p>So there you have it. Writing a test harness for a distributed system is significantly more challenging than writing the system itself I think. I can see now why FoundationDB spent years building their simulator before writing their engine, although I don’t know if that’s a realistic approach for most people.</p>

<p>Still, it’s fun to dig into this stuff.</p>]]></content><author><name></name></author><category term="databases" /><summary type="html"><![CDATA[This post is a follow up to my previous post about how to implement the Raft consensus protocol in Rust. In the previous post I went through the basics of how to set up the Raft cluster and implement the logic required for the RPC’s.]]></summary></entry></feed>