Algorithm Search⌘K

Log-structured merge tree in Python

Buffers writes in memory and merges sorted runs on disk, trading read cost for high write throughput.

File
log_structured_merge_tree.py, 171 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
log_structured_merge_tree.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/log-structured-merge-tree/. The authors are listed in prov/T0A3TV.jsonl.

from bisect import bisect_left, bisect_right
from math import ceil
from typing import Any, Generic, Iterator, Optional, Protocol, TypeVar


class Ordered(Protocol):
    def __lt__(self, other: Any, /) -> bool: ...


K = TypeVar("K", bound=Ordered)
V = TypeVar("V")

TOMB: Any = object()  # the value stored for a deleted key


class Run(Generic[K, V]):
    """A sorted run on disk: distinct keys in increasing order, with values
    or tombstones, cut into blocks of B entries. The first key of each block
    (the fence keys) is kept in memory."""

    def __init__(self, keys: list[K], vals: list[Any], block_size: int) -> None:
        self.keys = keys
        self.vals = vals
        self.fences = keys[::block_size]

    def __len__(self) -> int:
        return len(self.keys)


class LSMTree(Generic[K, V]):
    """A leveled log-structured merge tree.

    Updates go into a memtable of at most C0 entries in memory. When it is
    full, it is written out and merged into level 1. Level i holds one sorted
    run of at most C0 * T**i entries; a level that overflows is merged into
    the next one. A newer entry hides an older one on the same key, and a
    delete writes a tombstone, dropped when it reaches the last level.
    `ios` counts block reads and writes.
    """

    def __init__(self, block_size: int = 4, memtable_size: int = 4, ratio: int = 2) -> None:
        if block_size < 1 or memtable_size < 1 or ratio < 2:
            raise ValueError("need block_size >= 1, memtable_size >= 1, ratio >= 2")
        self.B = block_size
        self.C0 = memtable_size
        self.T = ratio
        self.mem: dict[K, Any] = {}
        self.levels: list[Run[K, V]] = []  # levels[0] is level 1
        self.ios = 0

    def capacity(self, i: int) -> int:
        """The capacity of level i + 1."""
        return self.C0 * self.T ** (i + 1)

    # updates -------------------------------------------------------------

    def put(self, k: K, v: V) -> None:
        """Insert k with value v, or replace its value."""
        self._update(k, v)

    def delete(self, k: K) -> None:
        """Delete k by writing a tombstone."""
        self._update(k, TOMB)

    def _update(self, k: K, v: Any) -> None:
        self.mem[k] = v  # @line L1
        if len(self.mem) >= self.C0:  # @line L2
            self._flush()

    def _flush(self) -> None:
        keys = sorted(self.mem)  # @line L3
        run: Run[K, V] = Run(keys, [self.mem[k] for k in keys], self.B)
        self.mem = {}
        i = 0
        while True:  # @line L4
            if i == len(self.levels):
                self.levels.append(Run([], [], self.B))
            last = all(len(r) == 0 for r in self.levels[i + 1 :])
            run = self._merge(run, self.levels[i], drop_tombs=last)  # @line L5
            if len(run) <= self.capacity(i):  # @line L6
                self.levels[i] = run
                return
            self.levels[i] = Run([], [], self.B)  # @line L7
            i += 1

    def _merge(self, new: Run[K, V], old: Run[K, V], drop_tombs: bool) -> Run[K, V]:
        """Merge two runs; on equal keys the entry of `new` wins."""
        self.ios += ceil(len(old) / self.B)  # `new` was just written or is in memory
        keys: list[K] = []
        vals: list[Any] = []
        a = b = 0
        while a < len(new) or b < len(old):
            if b == len(old) or (a < len(new) and new.keys[a] < old.keys[b]):
                k, v = new.keys[a], new.vals[a]
                a += 1
            elif a == len(new) or old.keys[b] < new.keys[a]:
                k, v = old.keys[b], old.vals[b]
                b += 1
            else:
                k, v = new.keys[a], new.vals[a]
                a += 1
                b += 1
            if drop_tombs and v is TOMB:
                continue
            keys.append(k)
            vals.append(v)
        self.ios += ceil(len(keys) / self.B)
        return Run(keys, vals, self.B)

    # queries -------------------------------------------------------------

    def get(self, k: K, default: Optional[V] = None) -> Optional[V]:
        """Return the value of k, or default if k is absent."""
        if k in self.mem:  # @line L8
            v = self.mem[k]
            return default if v is TOMB else v
        for run in self.levels:  # @line L9
            if not run.keys or k < run.keys[0]:
                continue
            blk = bisect_right(run.fences, k) - 1  # the fence keys are in memory
            self.ios += 1
            lo = blk * self.B
            hi = min(lo + self.B, len(run))
            j = bisect_left(run.keys, k, lo, hi)
            if j < hi and not (k < run.keys[j]):  # @line L10
                v = run.vals[j]
                return default if v is TOMB else v
        return default  # @line L11

    def range(self, lo: K, hi: K) -> list[tuple[K, V]]:
        """All live pairs with lo <= key <= hi, in key order."""
        seen: dict[K, Any] = {}
        for run in reversed(self.levels):  # oldest first, newer ones overwrite
            a = bisect_left(run.keys, lo)
            b = bisect_right(run.keys, hi)
            self.ios += ceil((b - a) / self.B) + (1 if a < b else 0)
            for j in range(a, b):
                seen[run.keys[j]] = run.vals[j]
        for k, v in self.mem.items():
            if not (k < lo) and not (hi < k):
                seen[k] = v
        return [(k, seen[k]) for k in sorted(seen) if seen[k] is not TOMB]

    def items(self) -> list[tuple[K, V]]:
        out: dict[K, Any] = {}
        for run in reversed(self.levels):
            out.update(zip(run.keys, run.vals))
        out.update(self.mem)
        return [(k, out[k]) for k in sorted(out) if out[k] is not TOMB]

    def __iter__(self) -> Iterator[K]:
        return iter([k for k, _ in self.items()])

    def __len__(self) -> int:
        return len(self.items())

    # checking -----------------------------------------------------------

    def check_invariant(self) -> None:
        """Raise AssertionError if a property of the tree fails."""
        assert len(self.mem) < self.C0
        for i, run in enumerate(self.levels):
            assert len(run.keys) == len(run.vals) <= self.capacity(i)
            assert all(a < b for a, b in zip(run.keys, run.keys[1:]))
            assert run.fences == run.keys[:: self.B]
        nonempty = [i for i, r in enumerate(self.levels) if len(r)]
        if nonempty:
            assert all(v is not TOMB for v in self.levels[nonempty[-1]].vals)
test_log_structured_merge_tree.py 104 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/log-structured-merge-tree/. The authors are listed in prov/T0A3TV.jsonl.

import math
import random

import pytest

from log_structured_merge_tree import TOMB, LSMTree


def show(t: LSMTree[int, str]) -> str:
    def f(k: int, v: object) -> str:
        return ("-" if v is TOMB else "") + str(k)

    mem = "mem{" + " ".join(f(k, v) for k, v in sorted(t.mem.items())) + "}"
    lv = [
        "L%d[%s]" % (i + 1, " ".join(f(k, v) for k, v in zip(r.keys, r.vals)))
        for i, r in enumerate(t.levels)
    ]
    return " ".join([mem, *lv])


def test_example() -> None:
    t: LSMTree[int, str] = LSMTree(block_size=2, memtable_size=2, ratio=2)
    for k in [50, 20, 80, 10]:
        t.put(k, str(k))
    assert show(t) == "mem{} L1[10 20 50 80]"
    t.put(60, "60")
    t.delete(20)
    # the tombstone reaches the last level and is dropped with the key
    assert show(t) == "mem{} L1[10 50 60 80]"
    t.put(30, "30")
    t.put(70, "70")
    # level 1 would hold 6 > 4 entries, so the merge goes on into level 2
    assert show(t) == "mem{} L1[] L2[10 30 50 60 70 80]"
    t.put(40, "40")
    t.delete(80)
    t.put(90, "90")
    t.put(25, "25")
    assert show(t) == "mem{} L1[25 40 -80 90] L2[10 30 50 60 70 80]"
    assert t.ios == 20
    # 80 is still in level 2, but the newer tombstone in level 1 hides it
    assert t.get(80) is None and t.get(20) is None
    assert t.get(60) == "60" and t.get(25) == "25"
    assert t.range(20, 60) == [(25, "25"), (30, "30"), (40, "40"), (50, "50"), (60, "60")]
    t.check_invariant()


def test_edges() -> None:
    with pytest.raises(ValueError):
        LSMTree(2, 2, 1)
    t: LSMTree[int, int] = LSMTree(1, 1, 2)
    assert t.get(3) is None and len(t) == 0
    t.delete(3)
    t.put(3, 1)
    t.put(3, 2)
    assert t.get(3) == 2 and list(t) == [3]
    t.delete(3)
    assert t.get(3) is None and t.items() == []
    t.check_invariant()


def test_against_dict() -> None:
    rng = random.Random(13)
    for _ in range(500):
        t: LSMTree[int, int] = LSMTree(rng.randrange(1, 4), rng.randrange(1, 6), rng.randrange(2, 5))
        d: dict[int, int] = {}
        for i in range(rng.randrange(1, 300)):
            k = rng.randrange(100)
            r = rng.random()
            if r < 0.25:
                t.delete(k)
                d.pop(k, None)
            elif r < 0.75:
                t.put(k, i)
                d[k] = i
            elif r < 0.9:
                assert t.get(k) == d.get(k)
            else:
                lo, hi = sorted((k, rng.randrange(100)))
                assert t.range(lo, hi) == sorted((a, b) for a, b in d.items() if lo <= a <= hi)
            if i % 23 == 0:
                t.check_invariant()
        t.check_invariant()
        assert t.items() == sorted(d.items())


def test_io_bound() -> None:
    B, C0, T = 64, 256, 4
    t: LSMTree[int, int] = LSMTree(B, C0, T)
    rng = random.Random(2)
    N = 40000
    for k in rng.sample(range(10 * N), N):
        t.put(k, k)
    levels = len(t.levels)
    assert levels <= 1 + math.log(N / C0, T)
    # each entry is rewritten at most about T + 1 times per level
    assert t.ios <= (T + 1) * levels * N / B * 1.5
    assert t.ios < N / 4
    t.ios = 0
    for k in rng.sample(range(10 * N), 500):
        t.get(k)
    assert t.ios <= 500 * levels

What the next level needs

  • the tests did not run in this build

Read the full page about Log-structured merge tree