Case study · Our own product
A nightly batch pipeline that cannot report an empty run as success
We rebuilt our own nightly Docker pipeline on one small VM so that a run that delivers nothing fails with a written reason, and cut its runtime from 2h43m to under an hour.
At a glance
- Sector
- Our own product
- Duration
- since February 2026; the work on this page ran from August to October 2026.
- Our role
- design, build and operation, end to end.
- Team
- TQM Invest, one engineer.
- Python
- Docker Compose
- systemd
- PostgreSQL (managed)
- Prisma
- Next.js
- GitHub Actions (self-hosted runners)
- gitleaks
Headline results
- Nightly runtime fell from 2h43m45s (91% of a three-hour cap) on September 2, 2026 to 54m59s (17% of a five-hour cap) on September 5, and all eleven nights from September 20 to 30 exited clean.
- A run that fetches items and keeps none now records why, in the database, and fails its stage.
- Five releases, v3.0 to v3.4, reached production between October 1 and 7, 2026 through a fourteen-line gate, with a restored backup and per-table counts around every migration.
The situation
The pipeline collects and scores sales leads for our consulting work. It runs every night as a series of one-shot Docker containers under a systemd timer, on a small VM, and writes to a managed Postgres database that also sits behind our web app.
By August 2026 nothing was crashing, but the run summary printed "8 scrapers, 0 failed" on nights when two sources had returned nothing, and runtime was climbing toward the cap on the systemd unit. We had also decided to develop the web app against production data, backed up and restore-verified first and guarded by count checks, so every schema change was a change to live rows.
What we did
Empty runs that looked healthy
Scrapers returned zero rows for nights at a time while the run reported success. We fixed it three times, and each fix named one more specific cause: a feed that changed shape, a URL rule that collapsed rows, a source that had been dead for months. Then a fourth empty run appeared: an ingest step fetched 5 items, mapped 0, and recorded no error.
The mapping function had three ways to drop an item, and the caller that explained an empty result knew about one. So we put an invariant where the rows are dropped: the mapper counts each drop by reason, and if items came in and none survived, the run record in the database gets the breakdown. A partial drop stays unflagged, because some attrition is normal. An illustration of the shape:
def map_batch(items, mapper, run):
drops: dict[str, int] = {}
kept = []
for item in items:
row = mapper(item, drops) # the mapper records why it dropped
if row is not None:
kept.append(row)
# Items arrived and none survived: this must never pass as a success.
if items and not kept:
reasons = " ".join(f"{k}={v}" for k, v in sorted(drops.items())) or "unknown"
run.zero_reason = f"fetched={len(items)} kept=0 {reasons}"
run.failed = True # stored on the run row, read by the summary
return kept
The summary had a second gap. It counted a scraper that raised an exception as failed, but one that caught its own error and reported it in a structured list as healthy. From August 27, two sources returned zero every night, and for about a week each night printed "0 failed". Both shapes now fail the stage, while the systemd unit's own failure stays reserved for "the pipeline did not run".
A runtime creeping toward its cap
On September 2 the run took 2h43m45s, and 1h50m of it was one stage: revalidation, which re-checks whether older postings are still open. When a source blocks the check, the answer is "unknown", and by design an unknown answer does not close a posting. But nothing limited how often a blocked source was asked. One source had 1,758 attempts and no verdict, ever. Another had started serving a managed bot challenge, so each failed check cost about five minutes; with 187 checks pending, that was 15.6 hours of work that could not succeed.
We added a per-source circuit breaker with two arms. One counts consecutive unknowns; the other adds up the seconds they cost, because the count alone tripped in about 5 seconds on one source and after 31m54s on another. Any real verdict resets both, so a source that recovers is back the next night.
The same week, unattended upgrades restarted the service 1h47m into a healthy run. It started again from stage one and hit the cap, and the killed run left job containers holding most of the VM's memory. Each stage now has its own wall-clock budget, summing to less than a raised five-hour cap, and the runner cleans up after itself:
reap() { docker compose --profile jobs rm -fsv >/dev/null 2>&1 || true; }
on_signal() { trap - EXIT TERM INT; reap; kill -s "$1" "$$"; } # re-raise for systemd
trap reap EXIT
trap 'on_signal TERM' TERM
trap 'on_signal INT' INT
for stage in $STAGES; do
if ! timeout -k 60s "$(budget_for "$stage")" \
docker compose --profile jobs run --rm "$stage"; then
failed=$((failed + 1)) # exit 124 or 137: the stage ran out of budget
fi
done
The upgrade helper got a narrow exemption for this one unit, so SSH and Docker still restart after security updates.
Schema changes on production data
The scraper deploy applies migrations when a schema change reaches the production branch, so on September 21 a merge applied one that we had described as held back. Four days later a documented "local" setup turned out to read the production connection string: it applied three migrations early, and a seed script nearly wrote ten test rows to production before a row-count diff caught it. Both were harmless only because every migration was additive and nullable.
So the unattended path now accepts additive migrations only. A verify job validates the schema with fake connection strings, runs the tests, and scans each new migration file for destructive SQL. The first version blocked the one historical DROP TABLE we tried. A separate review pass the same day fed it crafted SQL, and renames, type changes, five other kinds of DROP, UPDATE ... SET and a DROP split across two lines all got through. After the fix it blocks 16 of 16 destructive cases and passes 6 of 6 additive ones. Local tooling now runs through a wrapper that refuses to start if the environment points at the managed database.
A release gate with counts
Every merge to production now goes through a fourteen-line checklist, G1 to G14, carried in the pull request template, and a line is ticked only with a link to its evidence. The data lines are the ones that carry weight:
- A restored backup. Before the first gated release we restored a full dump into a throwaway Postgres and matched exact row counts on every table.
- Counts with a verdict. A script snapshots per-table counts before and after and fails if a protected table shrank. It runs one
SELECTinside a read-only transaction, and we tested it against eight failure scenarios first. - A rehearsal. Migrations run on a branch of the production database first; for v3.4, three migrations took 4 seconds there with counts equal and no drift.
- Checksums. On September 30 two migrations were applied with md5 checksums over every existing column of the protected tables, before and after: identical.
- A rollback point. An annotated tag on the commit production is running, set before the merge.
On October 7, 2026 we also verified the database's point-in-time restore: a test branch taken from a timestamp before an edit reproduced the row as it had been.
Results
| Measure | Before | After |
|---|---|---|
| Nightly runtime | 2h43m45s on September 2, 91% of a 3 h cap | 54m59s on September 5, 17% of a 5 h cap |
| Clean nights | "0 failed" while 2 sources returned nothing | 11 of 11, September 20 to 30, 2026 |
| Empty runs | could pass as success | fail their stage with a stored reason |
| Destructive-SQL scanner | 7 statement classes and 2 parsing tricks passed | 16 of 16 blocked, 6 of 6 additive passed |
| Releases through the gate | none | v3.0 to v3.4, October 1 to 7, 2026 |
The runtime figures are three nights apart: the September 3 run never finished (the restart above) and the September 4 run failed on a database pooler outage. Most of the drop came from the breaker.
Related reading
Go deeper
Standalone write-ups of the methods in this study.
Related case studies
Our own tooling
A validated security sweep for production web apps, without a scanner bill
We built a repeatable security sweep for the web apps we run and maintain, and it reports only what it can prove: every finding comes with a file and line or a reproduced request.
Three production web apps swept, code and live, each in under ten minutes of collection.
- gitleaks
- trivy
- semgrep
- nuclei
- Docker
A festival bar operator
Offline upgrades for a festival bar's inventory system
We built a festival bar operator's stock and cash-flow system to upgrade its Docker-hosted database on a box with no internet, with every upgrade path rehearsed first on a restore of real event data.
Upgrades rehearsed on a restore of the newest production backup before a bundle ships, with an invariant battery that runs again on the box after every install.
- Docker Compose
- PostgreSQL
- Alembic
- FastAPI
- Next.js
An events and entertainment brand
Moving an events brand off WordPress, with one gated path to production
We moved an events and entertainment brand off WordPress onto one application whose only road to production is a test-and-audit gate, and resolved every legacy URL that search engines reported as missing.
One gated path to production since July 2026. Every production deploy passes a dependency audit and the unit suite; lint joined the gate on September 6, 2026.
- Next.js
- React
- TypeScript
- Prisma
- Postgres (Neon)
Running into something like this?
Thirty minutes, no commitment. Bring the problem; we will tell you what we would check first and whether we are the right people for it.