Skip to content
About
Main content

Check that your dead-man's switch really fires

Break it on purposeNeeds Python

Problem

An 's own alerts run inside the orchestrator. When the itself stops (crashes, hangs, loses its database), nothing runs, including the checks that would report that nothing is running: the silence looks like health. A dead man's switch inverts the signal: the job sends a when it finishes, and a checker outside the orchestrator alerts when heartbeats stop.

The script runs a small scheduler (its own process) that runs a job every 2 seconds; the job's final step sends a heartbeat over HTTP to a checker outside the scheduler, which alerts if no heartbeat arrives within 5 seconds. The scheduler also runs its own check. After 10 seconds the scheduler is stopped (paused, as a hung scheduler would be), and both are watched for 20 seconds.

The question. When the scheduler stops, which alert fires, and how quickly?


Predict

When the scheduler process stops, does its own SLA alert fire?

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.

    check-that-your-dead-mans-switch.py63 linesDownload
    View the code
    check-that-your-dead-mans-switch.py
    # /// script
    # requires-python = ">=3.10"
    # ///
    # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT
    # https://thedataengineering.com/lab-code/LICENSE.txt
    """Check that your dead man's switch works.
    
    A scheduler (its own process) runs a job every few seconds; the job's final
    step sends a heartbeat over HTTP to a checker outside the scheduler, and the
    scheduler also runs its own SLA check on the job. After a while the scheduler
    is stopped (SIGSTOP: it hangs, as a stuck scheduler does). The checker alerts
    when no heartbeat arrives within the deadline; the scheduler's own SLA alert
    runs inside the scheduler, so it cannot fire. Times both from the stop.
    """
    import argparse, json, os, signal, subprocess, sys, threading, time
    from http.server import BaseHTTPRequestHandler, HTTPServer
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--every", type=float, default=2.0, help="Job interval (s).")
    p.add_argument("--deadline", type=float, default=5.0, help="Heartbeat deadline (s).")
    p.add_argument("--stop-after", type=float, default=10.0, help="Seconds before the scheduler is stopped.")
    p.add_argument("--watch", type=float, default=20.0, help="Seconds to watch after the stop.")
    p.add_argument("--port", type=int, default=58301, help="Checker port.")
    p.add_argument("--json", help="Also write the results to this JSON file.")
    args = p.parse_args()
    
    SCHEDULER = r"""
    import sys, time, urllib.request
    every, deadline, port = float(sys.argv[1]), float(sys.argv[2]), int(sys.argv[3])
    last_ok = time.time()
    while True:
        time.sleep(every)
        time.sleep(0.1)                                              # the job
        last_ok = time.time()
        urllib.request.urlopen(f"http://127.0.0.1:{port}/beat").read()   # final step: heartbeat
        if time.time() - last_ok > deadline:                         # the scheduler's own SLA check
            print("SLA ALERT", flush=True)
    """
    beats = []
    class H(BaseHTTPRequestHandler):
        def do_GET(self): beats.append(time.time()); self.send_response(200); self.end_headers(); self.wfile.write(b"ok")
        def log_message(self, *a): pass
    srv = HTTPServer(("127.0.0.1", args.port), H); threading.Thread(target=srv.serve_forever, daemon=True).start()
    sched = subprocess.Popen([sys.executable, "-u", "-c", SCHEDULER, str(args.every), str(args.deadline), str(args.port)], stdout=subprocess.PIPE, text=True)
    sla = []; threading.Thread(target=lambda: [sla.append(time.time()) for line in sched.stdout if "SLA ALERT" in line], daemon=True).start()
    t0 = time.time(); alert = None; stopped = None
    while time.time() - t0 < args.stop_after + args.watch:
        now = time.time()
        if stopped is None and now - t0 >= args.stop_after: os.kill(sched.pid, signal.SIGSTOP); stopped = now
        last = beats[-1] if beats else t0
        if stopped and alert is None and now - last > args.deadline: alert = now
        time.sleep(0.05)
    os.kill(sched.pid, signal.SIGKILL); sched.wait(); srv.shutdown()
    rows = [{"monitor": "heartbeat checker (outside the scheduler)", "fired": "yes" if alert else "no", "seconds_after_stop": alert - stopped if alert else None},
            {"monitor": "scheduler's own SLA alert", "fired": "yes" if any(t > stopped for t in sla) else "no", "seconds_after_stop": None}]
    summary = {"beats_before_stop": sum(1 for b in beats if b < stopped), "beats_after_stop": sum(1 for b in beats if b >= stopped), "deadline_s": args.deadline}
    print(f"{summary['beats_before_stop']} heartbeats before the stop, {summary['beats_after_stop']} after")
    for r in rows: print(f"{r['monitor']:<44} " + (f"alerted {r['seconds_after_stop']:.1f} s after the scheduler stopped" if r["seconds_after_stop"] else f"fired: {r['fired']} in {args.watch:g} s"))
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"summary": summary, "tables": {"monitors": 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 check-that-your-dead-mans-switch.py
    uv run check-that-your-dead-mans-switch.py --help     # every knob
Knobs
Knob · defaultWhat it changes
--every2 Job interval (s).
--deadline5 Heartbeat deadline (s).
--stop-after10 Seconds before the scheduler is stopped.
--watch20 Seconds to watch after the stop.
--port58,301 Checker port.

Our run

After the scheduler stopped

After the scheduler stopped
MonitorFiredSeconds after the stop
heartbeat checker (outside the scheduler)yes3.47
scheduler's own SLA alertno—
Reference runApple M2 · 8 cores · 8 GB
Output · uv run check-that-your-dead-mans-switch.py
4 heartbeats before the stop, 0 after
heartbeat checker (outside the scheduler)    alerted 3.5 s after the scheduler stopped
scheduler's own SLA alert                    fired: no in 20 s
macOS 26.6.2 · Python 3.13.3 · run 2026-10-20

What we found

After 4 heartbeats the scheduler stopped and sent no more. The outside checker alerted 3.47 s after the stop, as soon as the last heartbeat was older than the 5 s deadline. The scheduler's own SLA alert fired: no.


Read it

A monitor must not share fate with what it monitors. Heartbeats from the work, checked by something that runs elsewhere, catch the failures where the system goes quiet, which are the ones its internal alerts cannot report.