DEV Community

Taylor Wang
Taylor Wang

Posted on

48-Hour Field Notes: My Batch Job Trusted Local Disk, and a Restart Re-Bought Every Token

48-Hour Field Notes: My Batch Job Trusted Local Disk, and a Restart Re-Bought Every Token

I had a batch job that walked a queue of documents, called a model for each one, and wrote a summary into an output table. It ran fine for six hours on a small worker, and I felt clever about the whole arrangement. Then the worker restarted, the queue replayed from the top, and every item got processed a second time. Did I lose data? No. I lost something I had not been measuring at all: repeated work, repeated latency, and a warm fuzzy feeling that turned out to be unfounded.

This is the write-up of the next 42 hours: what I tried, what broke, and the small receipts table I now build before I build anything else.

The setup, and the assumption I never wrote down

I was running a summarization batch on MonkeyCode's free server option and calling models through its free model access, because I wanted to spend my effort on the pipeline instead of on a bill. So far so good. The problem was not the platform and not the model; the problem was that I had quietly assumed three things and verified none of them.

  • That local disk on the worker survives a restart long enough for my checkpoint file to matter.
  • That "the job already ran" is something the pipeline can figure out from its own state.
  • That a restart is roughly the same event as a crash, so the same recovery logic covers both.

None of those are platform facts, and none of them are model facts. They are assumptions about my code, and I had written zero lines to test them. Disclosure: This article was prepared as part of MonkeyCode's product outreach.

Hour 0–6: the smoke test that lied by being too small

My smoke test processed five items serially, printed five lines of output, and exited cleanly. Excellent. But a serial run has no lease, no contention, and nothing to resume, so it exercises roughly none of the failure modes that matter at batch scale. Fair question: why did I trust it? Because it was fast and green, and green output is a terrible proxy for correct recovery behaviour.

The run itself was simple: claim an item, build a prompt, call the model, write the result, mark the item finished. Notice the ordering. The "mark finished" step happens after the call returns, which means there is a window where the model has done the work and my process has not recorded it. That window is fine if it is rare and cheap. It is not fine if recovery replays the entire queue.

Hour 6–18: the restart, and the replay

The worker restarted mid-run, and the resume path found no checkpoint it recognised. So it did what I told it to do: it started over. Every completed item got re-sent, and because nothing downstream was keyed on content, every duplicate looked like brand-new work.

Here is what I would tell anyone in that moment, and what I had to tell myself slowly: a resume mechanism that depends on the worker's own filesystem is a resume mechanism that only works while the worker is the same worker. If you cannot state, in one sentence, where the durable truth lives, you do not have one.

Numbers in this post are illustrative, because the useful part is the shape of the failure rather than my exact volume. The shape was: work in, restart, work in again, no record that the work had ever happened.

Hour 18–30: a receipts table instead of a checkpoint file

I replaced the checkpoint file with a table whose primary key is derived from the content of the work rather than from a timestamp, a sequence number, or a worker id. Every call has a key, every key has a row, and the row is written before the call goes out.

import hashlib, json, sqlite3, time

SCHEMA = """
PRAGMA journal_mode=WAL;
CREATE TABLE IF NOT EXISTS receipts (
  idem_key    TEXT PRIMARY KEY,
  item_id     TEXT NOT NULL,
  stage       TEXT NOT NULL,
  state       TEXT NOT NULL CHECK (state IN ('claimed','done','failed')),
  owner       TEXT NOT NULL,
  claimed_at  INTEGER NOT NULL,
  finished_at INTEGER,
  output_ref  TEXT
);
CREATE INDEX IF NOT EXISTS receipts_state_idx ON receipts(state, claimed_at);
"""

def idem_key(item_id, stage, template_version, model_alias):
    payload = {"item": item_id, "stage": stage,
               "tpl": template_version, "model": model_alias}
    blob = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
    return hashlib.sha256(blob).hexdigest()

def connect(path):
    conn = sqlite3.connect(path, isolation_level=None, timeout=10)
    conn.executescript(SCHEMA)
    conn.execute("PRAGMA busy_timeout = 10000")
    return conn

def claim(conn, key, item_id, stage, owner, lease_s=900):
    now = int(time.time())
    conn.execute("BEGIN IMMEDIATE")
    try:
        row = conn.execute(
            "SELECT state, claimed_at FROM receipts WHERE idem_key = ?", (key,)
        ).fetchone()
        if row is None:
            conn.execute(
                "INSERT INTO receipts (idem_key, item_id, stage, state, owner, claimed_at)"
                " VALUES (?, ?, ?, 'claimed', ?, ?)",
                (key, item_id, stage, owner, now))
            conn.execute("COMMIT")
            return True
        state, claimed_at = row
        if state == "done" or (state == "claimed" and now - claimed_at < lease_s):
            conn.execute("COMMIT")
            return False
        conn.execute(
            "UPDATE receipts SET state='claimed', owner=?, claimed_at=?"
            " WHERE idem_key=? AND state IN ('claimed','failed')",
            (owner, now, key))
        conn.execute("COMMIT")
        return True
    except Exception:
        conn.execute("ROLLBACK")
        raise

def complete(conn, key, owner, output_ref):
    conn.execute(
        "UPDATE receipts SET state='done', finished_at=?, output_ref=?"
        " WHERE idem_key=? AND owner=?",
        (int(time.time()), output_ref, key, owner))
Enter fullscreen mode Exit fullscreen mode

Three details earn their keep. BEGIN IMMEDIATE takes the write lock up front instead of upgrading mid-transaction. The owner=? guard means a worker whose lease was reassigned cannot quietly mark the row done. And output_ref points at the artifact instead of storing it, so the receipts table stays small enough to query while the job runs.

Hour 30–42: the probe I should have run first, and the queries I now keep

Before the next run, I wrote a five-minute durability probe. It is not clever, and that is the point: it produces a fact where I previously had a vibe.

mkdir -p state && date -u +%FT%TZ > state/boot.marker
hostname >> state/boot.marker
# now restart the server the way your plan actually restarts it
cat state/boot.marker || echo "marker gone: treat local disk as ephemeral"
hostname
Enter fullscreen mode Exit fullscreen mode

Run it twice and write down four results: whether the marker file survived, whether the hostname changed, whether the working directory contents are the same, and whether the clock jumped. Whatever you did not test, treat as not surviving. That single rule removed an entire class of surprises for me.

Two queries also became part of the run report, because I do not trust a pipeline I cannot interrogate:

-- did anything finish twice? usually means the key inputs drifted
SELECT item_id, stage, COUNT(*) AS done_rows
FROM receipts WHERE state = 'done'
GROUP BY item_id, stage HAVING COUNT(*) > 1;

-- how much work is currently in flight, and is any of it stale?
SELECT state, COUNT(*) FROM receipts
WHERE state = 'claimed' AND claimed_at < strftime('%s','now') - 900
GROUP BY state;
Enter fullscreen mode Exit fullscreen mode

The first query is my favourite kind of alarm: it fires when I bump a template version or switch model aliases without thinking about the key, which is exactly the moment a duplicate would otherwise slip past silently.

What I would repeat, and what I would not

  • Repeat: writing the durability probe before the pipeline, because it is cheaper than the replay.
  • Repeat: keying on content plus template version plus model alias, so an intentional change produces new work and an accidental retry does not.
  • Repeat: logging the idempotency key on every call, next to the latency and the output reference.
  • Not repeat: storing the only copy of progress on the worker that produces the progress.
  • Not repeat: letting a wall-clock value or a random UUID into the key material.
  • Not repeat: treating lease expiry as an exception instead of a normal, expected event.

Is a free server the right home for your batch job?

Workload shape Reasonable fit? Guardrail you need first
One-shot enrichment, duplicates only cost time Yes Content-derived idempotency keys
Writes to external side effects (mail, tickets, payments) Only with care Pass the key to the downstream API's own dedupe
Single job outliving one lease Split it Stage boundaries with per-stage receipts
Depends on local files between steps No, unless probed External store, or verified persistence
Latency-sensitive request path No Use a warm, dedicated runtime
Data you cannot freely relocate Depends on your policy Decide before the first call, not after

Who should not follow this workflow

If you need a p99 latency guarantee, do not build on a worker whose lifecycle you do not control. If your work unit is one long stateful session, the claim-and-complete pattern will fight you, and you should shard by stage instead. If you cannot yet name where your durable state lives, stop designing the pipeline and answer that question first, because everything else is decoration.

Closing note

Forty-eight hours bought me a table with four columns and a probe script with three lines, and both of them are now the first thing I write for any batch job. The restart was not the failure; the failure was that I had never asked what a restart would do to my assumptions.

If you are wiring something similar and want to compare notes on receipt tables or restart probes, the free model access and free server option in MonkeyCode are a low-stakes place to run the experiment — bring your own durable store and your own probe.

Top comments (0)