Skip to content

Bug: a crash after a CHECKPOINT with a reader open can lose commits or block reopening #1304

Open

Description

@zahariash

This work was produced with the help of language models.

Ladybug version

main at a7e923e (includes #1230). v0.21.2 (PyPI ladybug) opens the same databases.

What operating system are you using?

Ubuntu 24.04 (Linux x86_64)

What happened?

When a CHECKPOINT runs while another connection has a read-only transaction open, the WAL is kept for that reader. A crash after such a checkpoint can leave a database that every later open rejects, in two ways:

  1. Partitioned tables and named graphs. After a completed checkpoint with the reader open and a crash before the next commit, opening fails with Cannot recover committed checkpoint: partition shadow file db.L_p0.shadow is missing. With a named graph (CREATE GRAPH g) instead it is graph shadow file db.g.shadow is missing. Plain node and rel tables reopen.
  2. A crash during the next checkpoint. After one such checkpoint has completed, a crash while the next one writes its shadow file leaves db.shadow behind, and opening fails with Cannot replay shadow file db.shadow: file is too small. (or page records are truncated.). It happened in 10 of 10 trials of the script below. A large commit while the reader is open can be the first checkpoint, since it forces one.

Committing the reader before the checkpoint avoids both; committing it afterwards does not, and closing cleanly instead of crashing reopens. Our workload simulator also hit a third outcome of the same recovery rule: reopening failed with Found duplicated primary key value 25 for a row committed and checkpointed long before the crash.

Are there known steps to reproduce?

Partitioned table, crash after the checkpoint:

import os, subprocess, sys, tempfile
import ladybug as lb

CHILD = """
import os, sys
import ladybug as lb
db = lb.Database(sys.argv[1], checkpoint_threshold=-1)
w, r = lb.Connection(db), lb.Connection(db)
w.execute("CREATE NODE TABLE L(id INT64, cluster INT64, PRIMARY KEY(id)) PARTITION BY LIST (cluster)")
w.execute("CREATE (:L {id: 5, cluster: 1})")
w.execute("CHECKPOINT")
r.execute("BEGIN TRANSACTION READ ONLY")
w.execute("CHECKPOINT")
os._exit(0)  # crash
"""
path = os.path.join(tempfile.mkdtemp(), "db")
subprocess.run([sys.executable, "-c", CHILD, path], check=True)
try:
    lb.Database(path)
    print("reopened")
except RuntimeError as e:
    print(e)

Crash during the following checkpoint:

import os, signal, subprocess, sys, tempfile
import ladybug as lb

CHILD = """
import sys
import ladybug as lb
db = lb.Database(sys.argv[1], checkpoint_threshold=-1)
w, r = lb.Connection(db), lb.Connection(db)
w.execute("CREATE NODE TABLE P(id INT64 PRIMARY KEY, v STRING)")
w.execute("CHECKPOINT")
r.execute("BEGIN TRANSACTION READ ONLY")
w.execute("UNWIND range(1, 400000) AS i CREATE (:P {id: i, v: 'x'})")  # forces a checkpoint
print("ready", flush=True)
w.execute("CHECKPOINT")
"""
failures = 0
for _ in range(10):
    path = os.path.join(tempfile.mkdtemp(), "db")
    child = subprocess.Popen([sys.executable, "-c", CHILD, path], stdout=subprocess.PIPE, text=True)
    child.stdout.readline()
    while not os.path.exists(path + ".shadow"):  # the checkpoint has started
        pass
    os.kill(child.pid, signal.SIGKILL)  # crash
    child.wait()
    try:
        lb.Database(path)
    except RuntimeError as e:
        failures += 1
        last = e
print(f"{failures} of 10 databases cannot be opened:", last if failures else "")

Expected (v0.21.2): reopened, and 0 of 10 databases cannot be opened.

Actual on main:

Runtime exception: Cannot recover committed checkpoint: partition shadow file .../db.L_p0.shadow is missing.
10 of 10 databases cannot be opened: Runtime exception: Cannot replay shadow file .../db.shadow: file is too small.

Cause

A checkpoint taken while a reader is open keeps the active WAL, which then ends in its CHECKPOINT record (postCheckpointCleanup, checkpointer.cpp#L425-L444). It still removes the applied shadow files of the main database, the partition children and named graphs (#L446-L457), and writes the db.pinned.tmp marker for the main database only (#L465-L479). Recovery decides from the files (wal_replayer.cpp#L730-L737):

  • No db.shadow and a trailing CHECKPOINT: replayPinnedActiveCheckpoint takes the checkpoint as applied, but calls readCheckpoint(true, ...) and recoverGraphCheckpoints(true, ...), which require each partition and graph shadow file and throw when it is gone (partition_storage_registry.cpp#L225-L232, wal_replayer.cpp#L359-L362). That is case 1.
  • A db.shadow and a trailing CHECKPOINT: replayCommittedCheckpoint replays the shadow as that checkpoint's. After a completed pinned checkpoint, the shadow present is the next checkpoint's, still being written, so the replay fails. That is case 2. A database killed this way has db.pinned.tmp, the first 8 KB of a checkpoint-bundle shadow and both checkpoint lock files, so the next checkpoint had started and the trailing record belongs to the completed one.
  • A db.shadow and no trailing CHECKPOINT: the prefix before the first CHECKPOINT record is replayed again onto data that already holds it, the duplicated-key reopen.

The marker from #1230 is consulted only when the main shadow is missing. Treating it as "the trailing CHECKPOINT is applied" for the main, partition and graph shadows alike, and a shadow found with the marker as a newer, unfinished checkpoint, would cover all three.

Activity

  1. zahariash commented on Oct 23, 2026

    @zahariash
    ContributorAuthor

    The third case does more than replay the prefix twice: it loses committed transactions. Recovery replays the WAL up to the first CHECKPOINT record and truncates it there, so every transaction committed after the pinned checkpoint is dropped, and the open succeeds without an error. On main 319bb92 this script loses a committed DELETE and a committed 149,999-row SET in 5 of 5 runs; v0.21.2 keeps both:

    import os
    import signal
    import subprocess
    import sys
    import tempfile
    
    import ladybug as lb
    
    CHILD = """
    import sys
    import ladybug as lb
    db = lb.Database(sys.argv[1], checkpoint_threshold=-1)
    w, r = lb.Connection(db), lb.Connection(db)
    w.execute("CREATE NODE TABLE T(id INT64 PRIMARY KEY, v INT64)")
    w.execute("UNWIND range(1, 300000) AS i CREATE (:T {id: i, v: 0})")
    w.execute("CHECKPOINT")
    r.execute("BEGIN TRANSACTION READ ONLY")
    r.execute("MATCH (t:T) RETURN count(*)")
    w.execute("MATCH (t:T {id: 1}) SET t.v = 7")
    w.execute("CHECKPOINT")  # runs with the reader open
    r.execute("COMMIT")
    w.execute("MATCH (t:T {id: 2}) DELETE t")
    w.execute("MATCH (t:T) WHERE t.id % 2 = 0 SET t.v = 1")
    print("committed", flush=True)
    w.execute("CHECKPOINT")
    """
    
    lost = 0
    for _ in range(5):
        path = os.path.join(tempfile.mkdtemp(), "db")
        child = subprocess.Popen([sys.executable, "-c", CHILD, path], stdout=subprocess.PIPE, text=True)
        child.stdout.readline()
        while not os.path.exists(path + ".shadow"):  # the last checkpoint has started
            pass
        os.kill(child.pid, signal.SIGKILL)  # crash
        child.wait()
        conn = lb.Connection(lb.Database(path))
        rows, has_2, updated = conn.execute(
            "MATCH (t:T) RETURN count(*), count(CASE WHEN t.id = 2 THEN 1 END), count(CASE WHEN t.v = 1 THEN 1 END)"
        ).get_all()[0]
        print(f"rows: {rows}, id 2 present: {bool(has_2)}, rows with v = 1: {updated}")
        lost += rows != 299999 or has_2 or updated != 149999
    print(f"{lost} of 5 reopened databases lost committed transactions")

    Expected (v0.21.2), five times: rows: 299999, id 2 present: False, rows with v = 1: 149999. Actual on main, five times: rows: 300000, id 2 present: True, rows with v = 1: 0.

    A kill in that window leaves db.pinned.tmp, a partial db.shadow, and a WAL whose last records are commits after the pinned CHECKPOINT record; a kill after the new checkpoint writes its own CHECKPOINT record recovers correctly. With DDL in the WAL before the pinned record, the same kill makes every later open fail with Catalog exception: T already exists in catalog. instead. Our workload simulator found the loss as committed rel deletes that came back after a crash during an auto-checkpoint.

    Pinned checkpoints came after v0.21.2, so this would ship in 0.22.

  2. changed the title [-]Bug: a crash after a CHECKPOINT that ran with a read-only transaction open can leave the database unopenable[/-] [+]Bug: a crash after a CHECKPOINT with a reader open can lose commits or block reopening[/+] on Oct 23, 2026
  3. zahariash commented on Oct 24, 2026

    @zahariash
    ContributorAuthor

    A crash isn't needed. When the checkpoints after a pinned one fail, for example on a full disk, and the database is then closed normally, the reopen applies the same rule: it replays the WAL up to the first CHECKPOINT record and truncates it there. Every transaction committed after the pinned checkpoint is gone, and the one the pinned checkpoint already holds is replayed on top of it, so it comes back twice. On main c81ec66 this script keeps 2 of 100 rows in 3 of 3 runs:

    import os
    import resource
    import signal
    import sys
    import tempfile
    
    import ladybug as lb
    
    reader_open = "--no-reader" not in sys.argv
    signal.signal(signal.SIGXFSZ, signal.SIG_IGN)
    path = os.path.join(tempfile.mkdtemp(), "db")
    db = lb.Database(path, checkpoint_threshold=0)  # a checkpoint after every commit
    w, r = lb.Connection(db), lb.Connection(db)
    w.execute("CREATE NODE TABLE P(id INT64 PRIMARY KEY)")
    w.execute("CREATE REL TABLE K(FROM P TO P, n INT64)")
    w.execute("CREATE (:P {id: 1}), (:P {id: 2})")
    if reader_open:
        r.execute("BEGIN TRANSACTION READ ONLY")  # every checkpoint from here on is pinned
        r.execute("MATCH ()-[k:K]->() RETURN count(*)")
    add = "MATCH (a:P {id: 1}), (b:P {id: 2}) CREATE (a)-[:K {n: $n}]->(b)"
    w.execute(add, {"n": 0})
    soft, hard = resource.getrlimit(resource.RLIMIT_FSIZE)
    resource.setrlimit(resource.RLIMIT_FSIZE, (os.path.getsize(path) + 128 * 1024, hard))  # the disk fills up
    for n in range(1, 100):
        try:
            w.execute(add, {"n": n})
        except RuntimeError:
            pass  # before #1361: "Transaction committed successfully, but the post-commit checkpoint failed ..."
    r.close()
    w.close()
    db.close()  # its checkpoint fails too
    resource.setrlimit(resource.RLIMIT_FSIZE, (soft, hard))  # space is freed
    
    conn = lb.Connection(lb.Database(path))
    rows, distinct = conn.execute("MATCH ()-[k:K]->() RETURN count(*), count(DISTINCT k.n)").get_all()[0]
    print(f"rows: {rows}, distinct n: {distinct}")

    Expected (v0.21.2, or with --no-reader): rows: 100, distinct n: 100. Actual on main: rows: 2, distinct n: 1, that is n = 0 twice and n = 1 to 99 lost.

    The file-size limit stands in for the full disk: from the moment it is set, data-file writes fail with File too large, so every checkpoint after it fails, including the one close() runs; the WAL writes still fit. Before #1361 each of those 99 commits raised Transaction committed successfully, but the post-commit checkpoint failed. The committed data is durable and will be recovered on restart, so each lost commit had been reported durable. A stress loop for #1361 found it as committed writes missing after a clean close while checkpoints kept failing; it loses them at the same rate with and without #1361.

  4. zahariash commented on Oct 24, 2026

    @zahariash
    ContributorAuthor

    working on it

  5. zahariash commented on Oct 24, 2026

    @zahariash
    ContributorAuthor

    Two more crash points block reopening, both inside the cleanup of the pinned checkpoint itself, after it has logged its CHECKPOINT record and applied its shadow pages. Both are deterministic with strace fault injection, on main 7bcc47d:

    Crash at Left on disk Reopen
    unlink of db.shadow, which ShadowFile::clear has just truncated db.wal ending in the CHECKPOINT record, an empty db.shadow Cannot replay shadow file db.shadow: file is too small.
    opening db.pinned.tmp, after the shadow is gone db.wal ending in the CHECKPOINT record, no shadow, no marker Cannot recover checkpoint WAL db.wal: shadow file db.shadow is missing with no pinned-checkpoint marker. ...

    Without the reader, both crash points reopen with every row: the WAL is retired before the shadow is removed, so a leftover empty shadow is ignored. On v0.21.2, a crash at the same unlink fails for every checkpoint, reader or not (Database ID for temporary file 'db.shadow' does not match the current database); main fixed that for unpinned checkpoints only.

    For a pinned checkpoint, postCheckpointCleanup keeps the WAL, clears the shadow and then writes the marker (checkpointer.cpp#L438-L484). ShadowFile::clear truncates the file before it unlinks it (shadow_file.cpp#L422-L424). When the last WAL record is a CHECKPOINT, recovery replays any shadow it finds and needs the marker when there is none (wal_replayer.cpp#L730-L737).

    Writing the marker before removing the shadow closes the second window. The first needs the shadow unlinked before it is truncated, or recovery to treat an empty shadow as absent: a checkpoint logs its CHECKPOINT record only after its shadow, header included, is synced.

    import os, subprocess, sys, tempfile
    import ladybug as lb
    
    CHILD = """
    import sys
    import ladybug as lb
    db = lb.Database(sys.argv[1], checkpoint_threshold=-1)
    w, r = lb.Connection(db), lb.Connection(db)
    w.execute("CREATE NODE TABLE P(id INT64 PRIMARY KEY)")
    w.execute("UNWIND range(1, 1000) AS i CREATE (:P {id: i})")
    r.execute("BEGIN TRANSACTION READ ONLY")
    w.execute("CHECKPOINT")
    """
    target, syscall = (".pinned.tmp", "openat") if sys.argv[1:] == ["marker"] else (".shadow", "unlink")
    path = os.path.join(tempfile.mkdtemp(), "db")
    # SIGKILL the child at its first unlink of db.shadow (already truncated) or open of db.pinned.tmp
    subprocess.run(["strace", "-f", "-qq", "-o", os.devnull, "-P", path + target, "-e", f"trace={syscall}",
                    "-e", f"inject={syscall}:signal=KILL", sys.executable, "-c", CHILD, path])
    try:
        conn = lb.Connection(lb.Database(path))
        print("reopened,", conn.execute("MATCH (p:P) RETURN count(p)").get_all()[0][0], "rows")
    except RuntimeError as e:
        print(e)

    python repro.py crashes at the unlink, python repro.py marker before the marker. Expected: reopened, 1000 rows.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions