DEV Community

Alex Chen
Alex Chen

Posted on

Build a Resume-Safe Batch Summarizer: A Case Study on a Free Server

It is 1 a.m., the fan is loud, and the lid is closed. Somewhere in that folder of lecture notes, a job is either still moving or already dead, and you have no way to tell which until morning.

That gap is the whole reason this article exists. I wanted one small project — summarize every file in a folder — and I wanted a crash to cost me exactly one file. Not the whole night, not the whole budget. So the learning question here is narrow: when a batch job dies in the middle, what is the smallest piece of state that lets you restart without paying twice? If you can answer that in your head before reading on, you already own this idea.

The project, end to end

I called it nightshift. One folder of note files goes in, one append-only ledger.jsonl comes out, and every line in that ledger is a claim about a single file: "I was processed, here is the result, here is the model and prompt version that produced it." That's it. No database, no queue service, no framework.

The reason I reached for MonkeyCode here is boring and practical: the free model access gives me an endpoint I don't have to pay for to run a batch of a few hundred small calls, and the free server option means the job can keep running after my laptop sleeps. Both are availability claims from the operator rather than things I've audited myself. Disclosure: This article was prepared as part of MonkeyCode's product outreach.

The allowance I was told to expect is on the order of ten million free tokens, and there is a free server option alongside it. Treat those numbers as a starting point to confirm on the project page before you plan a semester around them — I have not measured the quota, and you shouldn't assume it is permanent either.

Prerequisites, exactly

Python 3.11 or newer (I ran this on 3.11.9), standard library only, and a folder of text files. That's the whole list. I deliberately keep the fixture .txt files so nothing in this article depends on network access — if you have PDFs, extract them to text first with pdftotext and let the runner stay ignorant of file formats.

Here is the naive version I started from, and I want you to see it fail before I fix it:

for p in Path("corpus").rglob("*.txt"):
    summary = call_model(p.read_text())
    open("out.jsonl", "a").write(json.dumps({"file": str(p), "summary": summary}) + "\n")
Enter fullscreen mode Exit fullscreen mode

Three things go wrong. Kill it at file 211 and the next run re-sends files 1 through 210 — you paid twice for the same text. Key your output on the path and a renamed copy becomes a brand new call. And a plain write without a flush can leave a half-written line that poisons every later read. None of these are exotic; they are just invisible until the second run.

The artifact

Save this as nightshift.py. It is the entire runner.

#!/usr/bin/env python3
"""nightshift.py - resumable batch summarizer. Python 3.11+, stdlib only."""
import hashlib
import json
import os
import sys
from pathlib import Path

PROMPT_VERSION = "sum-v1"
LEDGER = Path("ledger.jsonl")
SUFFIXES = {".txt", ".pdf"}


def fingerprint(path: Path, model: str) -> str:
    h = hashlib.sha256()
    h.update(path.read_bytes())
    h.update(model.encode("utf-8"))
    h.update(PROMPT_VERSION.encode("utf-8"))
    return h.hexdigest()[:16]


def load_done(ledger: Path) -> dict:
    done = {}
    if not ledger.exists():
        return done
    for lineno, line in enumerate(ledger.read_text(encoding="utf-8").splitlines(), 1):
        if not line.strip():
            continue
        try:
            rec = json.loads(line)
        except json.JSONDecodeError:
            print(f"warning: torn line {lineno} ignored", file=sys.stderr)
            continue
        done[rec["key"]] = rec
    return done


def append(ledger: Path, rec: dict) -> None:
    with ledger.open("a", encoding="utf-8") as fh:
        fh.write(json.dumps(rec, sort_keys=True) + "\n")
        fh.flush()
        os.fsync(fh.fileno())


def stub_model(text: str, model: str) -> str:
    if not text.strip():
        raise ValueError("empty input: no text to summarize")
    words = text.split()
    return f"[{model}] {len(words)} words; first: {' '.join(words[:3])}"


def summarize(path: Path, model: str) -> str:
    text = path.read_text(encoding="utf-8", errors="replace")
    if os.environ.get("NIGHTSHIFT_STUB") == "1":
        return stub_model(text, model)
    raise SystemExit("no client wired; see the endpoint section")


def main(argv: list[str]) -> int:
    root = Path(argv[1] if len(argv) > 1 else "corpus")
    model = os.environ.get("NIGHTSHIFT_MODEL", "stub-model")
    files = sorted(p for p in root.rglob("*") if p.is_file() and p.suffix in SUFFIXES)
    done = load_done(LEDGER)

    todo, seen, skipped = [], set(), 0
    for p in files:
        key = fingerprint(p, model)
        if key in done or key in seen:
            skipped += 1
            continue
        seen.add(key)
        todo.append((key, p))

    print(f"corpus={len(files)} todo={len(todo)} skipped={skipped}")
    for key, p in todo:
        try:
            summary = summarize(p, model)
        except Exception as exc:
            print(f"FAIL {p.name} {type(exc).__name__}: {exc}")
            append(LEDGER, {"key": key, "file": p.name, "status": "error",
                            "error": type(exc).__name__})
            continue
        append(LEDGER, {"key": key, "file": p.name, "status": "ok",
                        "model": model, "prompt": PROMPT_VERSION,
                        "summary": summary})
        print(f"OK   {p.name}")

    poison = sum(1 for r in load_done(LEDGER).values() if r["status"] == "error")
    print(f"poison={poison}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main(sys.argv))
Enter fullscreen mode Exit fullscreen mode

The fingerprint function is the load-bearing part. It hashes the file bytes, the model name, and the prompt version together, so the same text under a different model is a different job — which is what you want when you swap endpoints, and mildly annoying the first time it silently re-runs everything. That annoyance is the point. A cache that ignores the model is a cache that lies.

The failing input, and the run you can reproduce

Build a fixture with one deliberately broken file. The empty one is my poison file.

mkdir -p corpus
printf 'attention is a lookup table\n' > corpus/a.txt
printf 'a softmax turns scores into weights\n' > corpus/b.txt
: > corpus/c.txt
cp corpus/a.txt corpus/dup_of_a.txt
Enter fullscreen mode Exit fullscreen mode

Now run it offline. The stub keeps the whole exercise network-free.

NIGHTSHIFT_STUB=1 NIGHTSHIFT_MODEL=stub-model python3 nightshift.py corpus
Enter fullscreen mode Exit fullscreen mode

Expected output on the first run:

corpus=4 todo=3 skipped=1
OK   a.txt
OK   b.txt
FAIL c.txt ValueError: empty input: no text to summarize
poison=1
Enter fullscreen mode Exit fullscreen mode

Read the counters slowly, because each one is a decision. skipped=1 is dup_of_a.txt: identical bytes plus identical model plus identical prompt version produced a key already seen in this pass, so it never left the machine. That deduplication is free, and I did not write a single line of code for it. FAIL c.txt is the empty file — most model endpoints reject a blank prompt, so I raise the same way the stub does, and the failure path deliberately writes an error record instead of a summary.

And poison=1 is the trap I want you to notice. The error record uses the same key, so load_done counts it as handled and the next run will not retry it. That is the right default for a batch you are paying for — one broken file shouldn't burn retries forever — but it means a poisoned key sits there quietly until you delete its line by hand. Delete the line, and only that file re-runs.

Run it a second time:

corpus=4 todo=0 skipped=4
poison=1
Enter fullscreen mode Exit fullscreen mode

Zero todo. Nothing resent, nothing re-billed, and the crash boundary is now the only file that was in flight when the process died. That is the property I wanted at 1 a.m.

The ledger itself is the last thing worth inspecting:

{"file": "a.txt", "key": "…", "model": "stub-model", "prompt": "sum-v1", "status": "ok", "summary": "[stub-model] 5 words; first: attention is a"}
Enter fullscreen mode Exit fullscreen mode

The key is a 16-hex-character digest in the real file; I elided it because yours will differ from mine and the counts will not.

Moving it to the free server

Here is where the free server option actually earns its place. My laptop sleeps, and a sleeping laptop is a job that stops without telling you. A box that stays awake turns a 40-minute batch into something you check over breakfast.

Two commands do the trip. Ship the corpus and the script, then start the run detached so an SSH hiccup doesn't kill it:

rsync -av corpus/ box:~/nightshift/corpus/
rsync -av nightshift.py box:~/nightshift/
ssh box 'cd ~/nightshift && NIGHTSHIFT_MODEL=<model-id> nohup python3 nightshift.py corpus > run.log 2>&1 &'
ssh box 'tail -f ~/nightshift/run.log'
rsync -av box:~/nightshift/ledger.jsonl ./ledger.jsonl
Enter fullscreen mode Exit fullscreen mode

Notice what I did not do: I did not try to make the remote box reliable. I made it disposable. The ledger is the only state that matters, it is append-only, and it travels back over rsync in a second. If the free server is reclaimed tomorrow, I lose the run in progress and nothing else — which is exactly the trade I want from something I am not paying for.

When should you run where? I use three rules:

Situation Run it on Why
Under ~50 files, one-off Your laptop Setup cost outweighs the benefit
Long batch, sleeps overnight Free server Keeps running when your lid closes
Deadline or SLA attached Paid endpoint Free tiers carry no promises
Anything confidential Neither You cannot send the text to a third party at all

That last row is not rhetorical. Lecture notes you wrote are fine. A friend's medical records, an employer's internal docs, or someone else's copyrighted book are not — no free tier makes that decision for you.

Common mistakes I hit while writing this

Using the file path as the key instead of the content hash. It works perfectly until you reorganize your folders, and then every file is new.

Forgetting to version the prompt. Change one word in your instruction, rerun, and now your ledger holds two incompatible generations of summaries with no way to tell them apart. PROMPT_VERSION costs you one line.

Retrying on a timer instead of on a key. Time-based retry logic guesses. A ledger does not guess; it knows whether the key is present.

And the one I still catch myself doing: writing to the ledger before the call succeeds. Order matters here. Call, then append, never the reverse.

What I actually learned

A batch job is not a loop. It is a loop plus a durable claim about what already happened, and the claim is worth more than the loop. Once the key exists, retries, model swaps, machine swaps, and crashes all collapse into the same boring question: is this key in the set?

I also stopped treating the free server as an environment and started treating it as a spot to stand. Something you can lose without losing work is a much better fit for a free tier than hoping it behaves like a paid one.

How to swap in a real endpoint: replace the body of summarize with an HTTP call, set NIGHTSHIFT_STUB to nothing, and keep the guard that raises when neither is configured. The exact request shape belongs to whichever endpoint you were given, and I have not run this particular client, so treat it as a sketch rather than tested code:

# SKETCH - unexecuted. Adapt the payload and the response path to your endpoint.
import urllib.request

def call_endpoint(text: str, model: str) -> str:
    req = urllib.request.Request(
        os.environ["NIGHTSHIFT_URL"],
        data=json.dumps({"model": model, "messages": [
            {"role": "user", "content": f"Summarize in one sentence:\n{text}"}]}).encode(),
        headers={"Content-Type": "application/json",
                 "Authorization": f"Bearer {os.environ['NIGHTSHIFT_KEY']}"},
    )
    with urllib.request.urlopen(req, timeout=60) as resp:
        return json.load(resp)["choices"][0]["message"]["content"]
Enter fullscreen mode Exit fullscreen mode

Who should not use this approach? Anyone whose batch has a deadline attached, anyone processing text they are not allowed to send off-device, and anyone who needs to cite an exact model version months later in published work. A free tier plus a hand-rolled ledger is a good fit for a student's overnight notes job, and a poor fit for a pipeline with a contract behind it.

Your turn, and I am more interested in the diagnosis than the applause. Suppose c.txt is the second file in a 400-file run and the process dies at file 300. On restart, which counters change, and which file never gets a second chance no matter how many times you rerun? If you can predict that from the code above, you have the pattern — and a good extension exercise is splitting skipped into two counters, one for keys already in the ledger and one for duplicates caught inside the current pass, so the log stops conflating them.

Top comments (0)