Skip to content
About
Main content

Kill vs. power loss: what survives?

Break it on purposeNeeds Python

Problem

Durability is a ladder of what a write survives. Once write() returns, the bytes are in the kernel's : they survive the process crashing or being killed, because the kernel still holds them and will write them out. They do not survive the kernel or the machine going down, because the page cache is memory. Only after fsync() (and, on macOS, F_FULLFSYNC) do they survive a power loss. A test that only kills processes cannot tell these rungs apart.

Write 1,000 numbered lines, once with only write() and once with write() + fsync(). Run each in a loop and (a) kill -9 the process, (b) if you have a virtual machine, kill the VM without a clean shutdown. Count the lines after restart.

The reference run does (a) only, 50 kills per version. It does not do (b): there is no VM on the testbed that can be powered off safely, so the power-loss half of this lab has not been run.

The question. After a kill -9, do acknowledged lines survive with and without ?


Predict

After 50 kill -9s, how many acknowledged lines does the write-only version (no fsync) lose?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you.

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

    kill-vs-power-loss-what-survives.py52 linesDownload
    View the code
    kill-vs-power-loss-what-survives.py
    # /// script
    # requires-python = ">=3.10"
    # ///
    # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT
    # https://thedataengineering.com/lab-code/LICENSE.txt
    """kill -9 against power loss: what survives?
    
    1,000 numbered lines written with write() only and with write() + fsync().
    The process is killed with SIGKILL at a random moment, again and again; after
    each kill the file is checked against what the process had reported written.
    (The power-loss half of the lab needs a virtual machine you can switch off;
    this script runs the kill half.)
    """
    import argparse, json, os, random, signal, subprocess, sys, tempfile, time
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--lines", type=int, default=1000, help="Lines to write.")
    p.add_argument("--kills", type=int, default=50, help="Kills per version.")
    p.add_argument("--seed", type=int, default=0, help="Random seed.")
    p.add_argument("--worker", help=argparse.SUPPRESS)
    p.add_argument("--out", help=argparse.SUPPRESS)
    p.add_argument("--json", help="Also write the results to this JSON file.")
    args = p.parse_args()
    
    if args.worker:
        fd = os.open(args.out, os.O_WRONLY | os.O_CREAT | os.O_TRUNC)
        for i in range(args.lines):
            os.write(fd, f"{i}\n".encode())
            if args.worker == "fsync": os.fsync(fd)
            print(i, flush=True)                              # "line i is written": what the process believes
            time.sleep(0.001)
        sys.exit(0)
    
    random.seed(args.seed)
    rows = []
    for mode in ("write only", "write + fsync"):
        lost_total = 0
        for _ in range(args.kills):
            out = os.path.join(tempfile.mkdtemp(), "lines.txt")
            proc = subprocess.Popen([sys.executable, __file__, "--worker", "fsync" if "fsync" in mode else "plain", "--out", out,
                                     "--lines", str(args.lines)], stdout=subprocess.PIPE, text=True)
            time.sleep(random.uniform(0.1, 1.0)); os.kill(proc.pid, signal.SIGKILL); proc.wait()
            acked = [int(x) for x in proc.stdout.read().split()]
            on_disk = set(int(x) for x in open(out).read().split())
            lost_total += sum(1 for i in acked if i not in on_disk)
        rows.append({"version": mode, "kills": args.kills, "acknowledged_lines_lost": lost_total})
        print(f"{mode:<14} {args.kills} kills: {lost_total} acknowledged lines missing after the kill")
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"tables": {"versions": rows}}, f)
    
  3. 3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.

    Terminal
    uv run kill-vs-power-loss-what-survives.py
    uv run kill-vs-power-loss-what-survives.py --help     # every knob
Knobs
Knob · defaultWhat it changes
--lines1000 Lines to write.
--kills50 Kills per version.
--seed0 Random seed.

Our run

Acknowledged lines missing after kill -9

Acknowledged lines missing after kill -9
VersionKillsAcknowledged lines lost
write only500
write + fsync500
Reference runApple M2 · 8 cores · 8 GB
Output · uv run kill-vs-power-loss-what-survives.py
write only     50 kills: 0 acknowledged lines missing after the kill
write + fsync  50 kills: 0 acknowledged lines missing after the kill
macOS 26.6.2 · Python 3.13.3 · run 2026-10-20

What we found

After 50 kills, the write-only version lost 0 acknowledged lines and the fsync version 0: a process kill cannot tell them apart, as expected. That is the point of the lab, and also its limit here: the half that separates them, the power-off, was not run, so this page shows only that the page cache survives a process kill.


Read it

A crash test that kills the process proves the second rung of the ladder (the data reached the kernel) and nothing above it. Many "durability tests" are this test, which is why they pass for systems that are not durable. To test the rung that fsync buys you need to take the page cache away: power off a VM, or use a fault-injecting block device that drops unflushed writes.

This makes the difference between rung 2 and rung 4 visible. Do not do this on hardware you care about.