Last updated on

The cleanup job that deleted nothing


Part 7 of 8 in the series Running a production database, carefully. Full series ↓

I added a scheduled job to delete stale database branches. Its first real test was a dry run, and it reported that it would delete nothing. For about thirty seconds I read that as good news. Then it landed that this was the actual bug: five stale branches were sitting right there, and my job had looked straight at them and decided to keep every one.

That near-miss is the interesting part, not the cron. This is a follow-up to The preview database that was still production, where I stood up per-preview databases and the cleanup that was supposed to keep them tidy. The short version: every preview deployment gets its own Neon branch named preview/<git-branch>, and a GitHub Action deletes a PR's branch the moment it closes, on pull_request: closed. It worked. Mostly.

This post is about the "mostly": why that cleanup leaks, and the two small mistakes that nearly made the better fix a no-op.

Where it leaks

I checked the real numbers: 57 PRs had closed since that action went in, and exactly two of their branches were still around. Call it 96%. Good, but not zero, and the leftovers told a story.

Some branches had never had a pull request at all. A branch gets pushed, Vercel builds a preview, Neon makes the database branch, and then nothing. Maybe it was a spike; maybe the work moved elsewhere. The pull_request: closed event never fires, because there was never a pull request to close. The cleanup can't catch these. It's waiting on an event that will never arrive.

The other two were ordinary closed PRs whose branch survived anyway: a delete that lost a race on a busy merge day, or a preview that redeployed and recreated the branch right after the close event ran.

Two failure modes, one root cause. The cleanup is edge-triggered: it reacts to an event. Anything that doesn't produce the event, or produces it at the wrong instant, slips past.

The fix is a different shape, not a bigger hook

You can't patch an edge-triggered job into covering the cases where the edge never happens. The fix is a second job with the opposite design.

Instead of reacting to closes, it runs on a timer, looks at the actual state of the world, and reconciles it toward what it should be:

  • list every preview/* branch that exists right now,
  • list every open pull request,
  • delete any preview branch with no matching open PR that's older than a few days.

No events. It doesn't care how a branch got orphaned or when. If a branch is sitting there with no PR behind it and it's had time to prove that, it goes.

That's the difference between edge-triggered and level-triggered: the first responds to changes, the second continuously drives current state toward desired state. It's the idea at the center of Kubernetes controllers, and it holds up just as well in a small scheduled job. Edge cleanup handles the fast common path; the level sweep is the backstop that makes a leak temporary instead of permanent.

Then I ran it in dry-run against the real project, and it wanted to delete nothing.

A TTL on the wrong clock

The "older than a few days" guard exists so a branch whose PR hasn't been opened yet gets a grace period. I'd written the age check against the branch's updated_at timestamp. It sounds right: how long since anything touched this branch?

Except Neon bumps a child branch's updated_at whenever its parent advances. Our main moves many times a day, and every one of those commits refreshed updated_at on every preview branch hanging off it. So updated_at was never more than a few hours old, on branches nobody had touched in days. My "days since last activity" was really "hours since main last moved." Every branch looked fresh, so the sweep spared every branch.

The fix was a one-word change, to a timestamp that means what I thought the other one meant:

const ageDays = (now - new Date(b.updated_at).getTime()) / 86400000
const ageDays = (now - new Date(b.created_at).getTime()) / 86400000

The lesson outlives the bug. Before you build a TTL on a timestamp, find out what actually updates it. Plenty of clocks tick for reasons that have nothing to do with the thing you're trying to measure: updated_at, mtime, last_seen.

The boolean that was a string

I gave the job a dry_run toggle for manual runs. A review bot caught the second bug before I did: a GitHub Actions workflow_dispatch boolean input doesn't arrive as a boolean. It arrives as the string "true" or "false". And "false" is a non-empty string, which is truthy. My guard amounted to "if dry_run is truthy, pass --dry-run," so unchecking the box still passed the flag. Every manual run would have been a dry run, indefinitely.

The fix is to stop treating it as a boolean and compare the string:

env:
  DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}
run: |
  if [ "$DRY_RUN" = "true" ]; then
    node scripts/prune.mjs --dry-run
  else
    node scripts/prune.mjs
  fi

What I took away

Both gotchas share a through-line: a cleanup job's worst failure isn't a crash. A crash is loud. The worst failure is doing nothing while reporting success, because nobody notices a delete that didn't happen. Both of my mistakes had that exact signature, and both would have sailed through a casual "did the workflow go green?" check.

What saved it was the dry run. Not the code, the dry run against real state. Reading the actual branches and printing what it would touch is the single step that turned two silent no-ops into two obvious ones. If you write something that deletes, give it a mode that shows its work against production reality before you trust it, and read that output like you expect it to be wrong.

And keep the shape in mind. Edge-triggered handlers are good at the common case and blind to everything that doesn't ring the bell. When something actually has to stay clean, put a level-triggered sweep behind it.

Comments

No comments yet. Be the first to comment.

Leave a comment