8000 SIGKILL during an active transact has a ~26% chance of permanently corrupting the entire database (header checksum, no recovery path) · Issue #308 · project-minigraf/minigraf · GitHub
[go: up one dir, main page]

Skip to content

SIGKILL during an active transact has a ~26% chance of permanently corrupting the entire database (header checksum, no recovery path) #308

Closed

Description

@adityamukho

Summary

Killing a writer process (SIGKILL) while it is actively transacting has a substantial, repeatable chance of leaving the entire graph file permanently and totally corrupted — not just losing the in-flight write, but making the whole database unopenable forever afterward:

msg='Header checksum mismatch: possible file corruption. Database may be damaged.'

There is no recovery path via the public API. A .wal sidecar file exists (present in every corrupted case observed), but removing it entirely and retrying MiniGrafDb.open() does not help — the header checksum check fails identically with or without the WAL present, meaning either the WAL isn't used for header recovery at all, or whatever recovery logic exists never gets a chance to run because the checksum check aborts first.

This is a very realistic trigger: OOM-killer, a crashed/stopped container, a host reboot, or an operator running kill -9 on a stuck process (which is exactly what we did to a real, live temporal_reasoning MCP server process during unrelated troubleshooting, with no idea at the time that it carried this risk).

Reproduction (fully isolated, scratch files only, deterministic harness)

Driven directly against minigraf.MiniGrafDb (bypassing temporal_reasoning entirely, to isolate this to the core storage engine):

  1. Spawn a writer subprocess that opens a fresh .graph file and loops: transact a ~3000-byte fact, checkpoint(), repeat — no other process touches the file.
  2. After a short randomized delay (20ms–200ms into the run), send SIGKILL to the writer.
  3. Immediately probe the file from a fresh process: MiniGrafDb.open(path) + one trivial query.
  4. Repeat with a fresh graph file every round (so one corruption can't poison later rounds' results).

Result over 50 independent rounds:

Outcome Count
Clean recovery (open + query succeeded) 37
Permanent corruption (Header checksum mismatch) 13 (26%)
Other error 0

Corrupted rounds and the file size at the moment of the kill (all at small scale — tens to hundreds of KB, so this is not a large-file-only phenomenon):

round 4:  size_before_kill=53248   wal_size_before_kill=3189
round 5:  size_before_kill=81920   wal_size_before_kill=3189
round 6:  size_before_kill=114688  wal_size_before_kill=3189
round 7:  size_before_kill=131072  wal_size_before_kill=3189
round 14: size_before_kill=40960   wal_size_before_kill=3189
round 15: size_before_kill=81920   wal_size_before_kill=3189
round 16: size_before_kill=98304   wal_size_before_kill=3189
round 19: size_before_kill=196608  wal_size_before_kill=3189
round 24: size_before_kill=45056   wal_size_before_kill=3189
round 25: size_before_kill=86016   wal_size_before_kill=3189
round 26: size_before_kill=102400  wal_size_before_kill=3189
round 44: size_before_kill=57344   wal_size_before_kill=3189
round 45: size_before_kill=77824   wal_size_before_kill=3189

Note the .wal file size is identical (3189 bytes) across every single case, both corrupted and clean — it never grows during the writer's tight loop. That's suspicious on its own: either the WAL isn't actually being used for the kind of protection you'd expect from a write-ahead log, or it's specifically not covering whatever the header update path is doing.

Evidence pointing at the root cause: non-atomic header update

Compared the raw header bytes (first 64 bytes) of a clean file vs. two independently-corrupted files:

clean (kill2_0, 4096 bytes, 1 page):
4d47 5246 0700 0000 0100 0000 0000 0000
0000 0000 0000 0000 0000 0000 0000 0000
...

corrupted (kill2_4, 53248 bytes, 13 pages):
4d47 5246 0700 0000 0d00 0000 0000 0000
0400 0000 0000 0000 0200 0000 0000 0000
0300 0000 0000 0000 0400 0000 0000 0000
0a00 0000 0000 0000 0b00 0000 0000 0000

corrupted (kill2_14, 40960 bytes, 10 pages):
4d47 5246 0700 0000 0a00 0000 0000 0000
0200 0000 0000 0000 0100 0000 0000 0000
0200 0000 0000 0000 0300 0000 0000 0000
0600 0000 0000 0000 0700 0000 0000 0000

The magic (MGRF), version, and the visible little-endian counters all look internally plausible — nothing looks visibly torn/truncated in this window. That's consistent with the checksum itself (presumably stored elsewhere in the header, or computed over this region) being updated in a separate write from the page-count/metadata fields it covers — i.e. the header is likely updated via something like "write new counts, then separately (re)compute and write a checksum over the header" rather than as one atomic unit (e.g. write-new-page-then-atomic-rename, or a checksum-then-commit ordering with the counts written last). A kill landing between those two steps leaves a header whose declared checksum doesn't match its own content, with no shadow/backup copy to fall back on — even though a .wal exists for (presumably) data pages.

Impact

This is significantly more severe than #304 (the transient multi-writer page-bounds bug), which is at least recoverable — retrying eventually succeeds and no data is lost. Here, a single ungraceful termination during any write has a ~1-in-4 chance (in our sample; likely varies with exact timing relative to internal write phases) of destroying the entire database permanently, including everything ever successfully committed and checkpointed before that point — not just the in-flight transaction. Given how commonly processes get killed ungracefully in real deployments (container orchestration SIGKILL after a grace period, OOM killer, kill -9 during operator troubleshooting — see project-minigraf/temporal_reasoning#108 and #109 for a real incident this session where we killed live server processes multiple times), this seems likely to bite real users.

Suggested investigation focus

  • Confirm whether the header (page 0) is updated via separate writes for its data fields vs. its checksum, and if so, make that update atomic — e.g. compute the checksum first and write the whole header page in one pwrite, or write a new header to a fresh location and atomically rename/swap a pointer to it.
  • Investigate why the .wal file's size never changes across any of these runs (constant 3189 bytes) — confirm whether it's actually participating in crash recovery for the header, or only for data pages (or not being used for recovery at all despite being written to disk).
  • Consider whether MiniGrafDb.open() should attempt any repair/replay pass when it detects a header checksum mismatch, rather than failing hard with no path forward — at minimum, exposing a repair/recovery API (even a manual, explicit one) would let callers attempt to salvage a damaged file instead of losing it outright.

Environment

  • Reproduced via direct minigraf.MiniGrafDb Python FFI bindings (minigraf PyPI package, bundled with temporal_reasoning), not through temporal_reasoning's mcp_server.py wrapper — this is a core storage-engine issue, independent of the MCP layer.
  • Page size 4096 bytes (confirmed via file-size arithmetic in all cases).
  • Reproducible at very small scale (tens of KB) — not dependent on large files or long-running processes.
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

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions

      0