Repository navigation
Bug: a crash after a CHECKPOINT with a reader open can lose commits or block reopening #1304
Description
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:
- 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 isgraph shadow file db.g.shadow is missing.Plain node and rel tables reopen. - A crash during the next checkpoint. After one such checkpoint has completed, a crash while the next one writes its shadow file leaves
db.shadowbehind, and opening fails withCannot replay shadow file db.shadow: file is too small.(orpage 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.shadowand a trailingCHECKPOINT:replayPinnedActiveCheckpointtakes the checkpoint as applied, but callsreadCheckpoint(true, ...)andrecoverGraphCheckpoints(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.shadowand a trailingCHECKPOINT:replayCommittedCheckpointreplays 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 hasdb.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.shadowand no trailingCHECKPOINT: the prefix before the firstCHECKPOINTrecord 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
zahariash commentedon Oct 23, 2026
The third case does more than replay the prefix twice: it loses committed transactions. Recovery replays the WAL up to the first
CHECKPOINTrecord and truncates it there, so every transaction committed after the pinned checkpoint is dropped, and the open succeeds without an error. Onmain319bb92 this script loses a committedDELETEand a committed 149,999-rowSETin 5 of 5 runs; v0.21.2 keeps both:Expected (v0.21.2), five times:
rows: 299999, id 2 present: False, rows with v = 1: 149999. Actual onmain, five times:rows: 300000, id 2 present: True, rows with v = 1: 0.A kill in that window leaves
db.pinned.tmp, a partialdb.shadow, and a WAL whose last records are commits after the pinnedCHECKPOINTrecord; a kill after the new checkpoint writes its ownCHECKPOINTrecord recovers correctly. With DDL in the WAL before the pinned record, the same kill makes every later open fail withCatalog 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.
[-]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[/+]zahariash commentedon Oct 24, 2026
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
CHECKPOINTrecord 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. Onmainc81ec66 this script keeps 2 of 100 rows in 3 of 3 runs:Expected (v0.21.2, or with
--no-reader):rows: 100, distinct n: 100. Actual onmain:rows: 2, distinct n: 1, that isn = 0twice andn = 1to99lost.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 oneclose()runs; the WAL writes still fit. Before #1361 each of those 99 commits raisedTransaction 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.zahariash commentedon Oct 24, 2026
working on it
zahariash commentedon Oct 24, 2026
Two more crash points block reopening, both inside the cleanup of the pinned checkpoint itself, after it has logged its
CHECKPOINTrecord and applied its shadow pages. Both are deterministic withstracefault injection, onmain7bcc47d:unlinkofdb.shadow, whichShadowFile::clearhas just truncateddb.walending in theCHECKPOINTrecord, an emptydb.shadowCannot replay shadow file db.shadow: file is too small.db.pinned.tmp, after the shadow is gonedb.walending in theCHECKPOINTrecord, no shadow, no markerCannot 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
unlinkfails for every checkpoint, reader or not (Database ID for temporary file 'db.shadow' does not match the current database);mainfixed that for unpinned checkpoints only.For a pinned checkpoint,
postCheckpointCleanupkeeps the WAL, clears the shadow and then writes the marker (checkpointer.cpp#L438-L484).ShadowFile::cleartruncates the file before it unlinks it (shadow_file.cpp#L422-L424). When the last WAL record is aCHECKPOINT, 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
CHECKPOINTrecord only after its shadow, header included, is synced.python repro.pycrashes at theunlink,python repro.py markerbefore the marker. Expected:reopened, 1000 rows.