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
# 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