Skip to content
About
Main content

Run an Elle-style check on your own database

Run a real systemBreak it on purposeNeeds Docker, Python

Problem

You cannot see an isolation from inside the database; it exists only in the combined history of many transactions. Jepsen's checks it from outside with a list-append workload: each transaction appends a unique number to one list and reads others. Because every element is unique, each list's final order reveals the order in which the appends were applied, and every read reveals exactly which appends it saw. From that, the checker builds the (ww, wr, rw edges) without trusting the database, and any cycle is an anomaly, classified by its edges.

Write a list-append workload: each transaction appends a unique integer to one of a few list rows and reads another list. Run it against your database at READ COMMITTED, and with 8 concurrent clients for a few minutes. Reconstruct the dependency graph offline (each list's final order gives the write order) and search for cycles. If you can run the real tool, use Jepsen's Elle on the recorded history.

The script runs 30 seconds per level against PostgreSQL, with each transaction appending to one of four lists and reading two others, and classifies each cyclic group by its weakest cycle: G0/G1c (no rw edge), G-single (one rw edge, ) or G2 (more, ).

The question. Which anomalies does each let through on PostgreSQL, and does Serializable produce none?


Predict

At Read Committed on PostgreSQL, how many write-skew (G2) cycles does the checker find in 30 seconds?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you. This lab also runs a real system, so you need Docker.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    run-an-elle-style-check-on.py130 linesDownload
    View the code
    run-an-elle-style-check-on.py
    # /// script
    # requires-python = ">=3.10"
    # dependencies = ["psycopg[binary]"]
    # ///
    # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT
    # https://thedataengineering.com/lab-code/LICENSE.txt
    """Run an Elle-style check on your database.
    
    A list-append workload: each transaction appends a unique integer to one
    list and reads two others. Run at Read Committed, Repeatable Read and
    Serializable with 8 clients, then rebuild the dependency graph from the
    history (each list's final order is its write order) and look for cycles.
    Needs the PostgreSQL service from the compose file (port 55462).
    """
    import argparse, itertools, json, random, threading, time
    from collections import defaultdict
    import psycopg
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--dsn", default="postgresql://postgres:lab@localhost:55462/postgres", help="Connection string.")
    p.add_argument("--clients", type=int, default=8, help="Concurrent clients.")
    p.add_argument("--seconds", type=float, default=30, help="Seconds of workload per isolation level.")
    p.add_argument("--lists", type=int, default=4, help="Number of list rows.")
    p.add_argument("--seed", type=int, default=0, help="Random seed.")
    p.add_argument("--json", help="Also write the results to this JSON file.")
    args = p.parse_args()
    
    counter = itertools.count(1)
    
    
    def workload(level):
        admin = psycopg.connect(args.dsn, autocommit=True)
        admin.execute("DROP TABLE IF EXISTS lists")
        admin.execute("CREATE TABLE lists (k int PRIMARY KEY, vals int[] NOT NULL DEFAULT '{}')")
        admin.execute(f"INSERT INTO lists SELECT k, '{{}}' FROM generate_series(1, {args.lists}) k")
        history, aborted, stop = [], [0], threading.Event()
        lock = threading.Lock()
        def client(cid):
            rnd = random.Random(args.seed * 100 + cid)
            con = psycopg.connect(args.dsn)
            while not stop.is_set():
                k = rnd.randint(1, args.lists); v = next(counter)
                reads = rnd.sample([x for x in range(1, args.lists + 1) if x != k], 2)
                try:
                    con.execute(f"BEGIN ISOLATION LEVEL {level}")
                    con.execute("UPDATE lists SET vals = array_append(vals, %s) WHERE k = %s", (v, k))
                    got = {r: con.execute("SELECT vals FROM lists WHERE k = %s", (r,)).fetchone()[0] for r in reads}
                    con.commit()
                    with lock: history.append({"append": (k, v), "reads": got})
                except psycopg.errors.SerializationFailure:
                    con.rollback()
                    with lock: aborted[0] += 1
            con.close()
        ts = [threading.Thread(target=client, args=(c,)) for c in range(args.clients)]
        for t in ts: t.start()
        time.sleep(args.seconds); stop.set()
        for t in ts: t.join()
        final = {k: vals for k, vals in admin.execute("SELECT k, vals FROM lists").fetchall()}
        admin.close()
        return history, aborted[0], final
    
    
    def check(history, final):
        """Edges from the list-append history (Elle's inference), then cycles classified by their rw edges."""
        writer = {h["append"][1]: i for i, h in enumerate(history)}
        succ = defaultdict(set); kind = {}
        def edge(a, b, k):
            if a != b and a is not None and b is not None:
                succ[a].add(b); kind.setdefault((a, b), set()).add(k)
        for k, order in final.items():
            for x, y in zip(order, order[1:]): edge(writer.get(x), writer.get(y), "ww")
        bad_reads = 0
        for i, h in enumerate(history):
            for k, seen in h["reads"].items():
                order = final[k]
                if seen != order[: len(seen)]: bad_reads += 1; continue          # not a prefix: an impossible read
                if seen: edge(writer.get(seen[-1]), i, "wr")
                if len(seen) < len(order): edge(i, writer.get(order[len(seen)]), "rw")
        # strongly connected components (iterative Tarjan)
        index, low, on, stack, sccs, n = {}, {}, set(), [], [], [0]
        for root in range(len(history)):
            if root in index: continue
            work = [(root, iter(succ[root]))]; index[root] = low[root] = n[0]; n[0] += 1; stack.append(root); on.add(root)
            while work:
                v, it = work[-1]
                w = next(it, None)
                if w is None:
                    work.pop()
                    if work: low[work[-1][0]] = min(low[work[-1][0]], low[v])
                    if low[v] == index[v]:
                        comp = []
                        while True:
                            x = stack.pop(); on.discard(x); comp.append(x)
                            if x == v: break
                        if len(comp) > 1: sccs.append(set(comp))
                elif w not in index:
                    index[w] = low[w] = n[0]; n[0] += 1; stack.append(w); on.add(w); work.append((w, iter(succ[w])))
                elif w in on:
                    low[v] = min(low[v], index[w])
        # classify: a cycle with no rw edge (G0/G1c), exactly one (G-single, read skew), or more (G2, write skew)
        counts = {"G0/G1c": 0, "G-single": 0, "G2": 0}
        for comp in sccs:
            dep = {a: {b for b in succ[a] if b in comp and kind[(a, b)] - {"rw"}} for a in comp}
            def reach(src, dst):
                seen, todo = {src}, [src]
                while todo:
                    x = todo.pop()
                    if x == dst: return True
                    for y in dep[x]:
                        if y not in seen: seen.add(y); todo.append(y)
                return False
            if any(reach(b, a) for a in comp for b in dep[a]): counts["G0/G1c"] += 1
            elif any(reach(b, a) for a in comp for b in succ[a] if b in comp and "rw" in kind[(a, b)]): counts["G-single"] += 1
            else: counts["G2"] += 1
        return counts, len(sccs), bad_reads
    
    
    rows = []
    for level in ("READ COMMITTED", "REPEATABLE READ", "SERIALIZABLE"):
        history, aborted, final = workload(level)
        counts, n_scc, bad = check(history, final)
        rows.append({"level": level, "committed": len(history), "aborted": aborted, "cyclic_components": n_scc, **counts,
                     "non_prefix_reads": bad})
        print(f"{level:<16} {len(history):>6} committed, {aborted:>5} aborted   cycles: G-single {counts['G-single']:>4}, "
              f"G2 {counts['G2']:>4}, G0/G1c {counts['G0/G1c']}   impossible reads {bad}")
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"tables": {"levels": rows}}, f)
    
  3. 3Get the services it runs against, pinned to the versions we measured. Save it next to the script.

    run-an-elle-style-check-on.compose.yml12 linesDownload
    View the code
    run-an-elle-style-check-on.compose.yml
    # PostgreSQL for this lab.
    services:
      postgres:
        image: postgres:17.6
        environment:
          POSTGRES_PASSWORD: lab
        ports: ["55462:5432"]
        healthcheck:
          test: ["CMD", "pg_isready", "-U", "postgres"]
          interval: 1s
          retries: 30
    
  4. 4Start the services, run the script with the defaults first, then change a knob from the table below and run it again. Remove the services when you are done.

    Terminal
    docker compose -f run-an-elle-style-check-on.compose.yml up -d --wait
    uv run run-an-elle-style-check-on.py
    uv run run-an-elle-style-check-on.py --help     # every knob
    docker compose -f run-an-elle-style-check-on.compose.yml down -v
Knobs
Knob · defaultWhat it changes
--dsnpostgresql://postgres:lab@localhost:55462/postgres Connection string.
--clients8 Concurrent clients.
--seconds30 Seconds of workload per isolation level.
--lists4 Number of list rows.
--seed0 Random seed.

Our run

List-append workload, 8 clients

List-append workload, 8 clients
LevelCommittedAbortedG-singleG2G0/G1cImpossible reads
READ COMMITTED27.5K004,71000
REPEATABLE READ16.6K22.1K02,54000
SERIALIZABLE12.5K44.3K0000
Reference runApple M2 · 8 cores · 8 GB
Output · uv run run-an-elle-style-check-on.py
READ COMMITTED    27521 committed,     0 aborted   cycles: G-single    0, G2 4708, G0/G1c 0   impossible reads 0
REPEATABLE READ   16574 committed, 22146 aborted   cycles: G-single    0, G2 2543, G0/G1c 0   impossible reads 0
SERIALIZABLE      12502 committed, 44334 aborted   cycles: G-single    0, G2    0, G0/G1c 0   impossible reads 0
macOS 26.6.2 · Python 3.13.3 · psycopg 3.3.6 · postgres:17.6 · Docker 28.5.1 · run 2026-10-20

What we found

At Read Committed the checker found 4,710 groups of transactions forming G2 cycles, write skew: two transactions each appended to one list and read the other's list before the other's append was visible. At Repeatable Read it found 2,540, fewer only because PostgreSQL aborted 22.1K transactions whose appends conflicted on the same row; does not prevent write skew. At Serializable it found none (0), as expected, and aborted 44.3K transactions instead, more than it committed. No cycle had exactly one (G-single, read skew) at any level: with only two reads per transaction, issued right after the append, this workload rarely gives read skew a chance, so its absence here is not evidence that Read Committed prevents it.


Read it

The point: the vendor's level name is a hypothesis you can test cheaply.

The classification follows the dependency-graph vocabulary of the count-the-shapes lab: a cycle with no anti-dependency is G0/G1c, with exactly one is G-single, with more is G2. This checker is a small version of Elle's inference; the real tool also checks for aborted reads, intermediate reads and impossible orders, and it finds anomalies in databases whose documentation promises they cannot happen.