Reconciling forgotten database branches against open pull requests
Teardown hooks get missed. A reconciler that treats the repository as the source of truth, with the delete semantics and dry-run discipline that make it safe to automate.
Every team that creates an Infrai database branch per pull request eventually finds branches for PRs that closed months ago. The teardown hook isn’t unreliable so much as conditional — it runs when a workflow completes normally, and workflows get cancelled, runners die, and someone closes a PR from the phone app while CI is still queued.
The fix isn’t a better hook. It’s a reconciler that compares GET /v1/db/branch/list against the repository and deletes what shouldn’t exist.
Delete is the easy half
curl -sS -X DELETE \
"https://api.infrai.cc/v1/db/branch/delete/dbp_7Uu2kQxWvR4mBn8d?branch_name=pr-1482" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"db_id": "db_9wQ1zV6pLkS3dHyB",
"branch_name": "pr-1482",
"parent": "main",
"created_at": "2026-09-21T02:58:11Z",
"state": "deleted"
}
}
The branch name goes in the query string; the project id is the path segment. Deleting a branch that’s already gone answers 404, which is what makes the reconciler safe to run repeatedly — a second pass is a no-op rather than an error you have to special-case.
The inventory
curl -sS "https://api.infrai.cc/v1/db/branch/list?project_id=dbp_7Uu2kQxWvR4mBn8d" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"items": [
{"db_id": "db_9wQ1zV6pLkS3dHyB", "branch_name": "main", "parent": null, "created_at": "2026-09-01T10:00:00Z", "state": "ready"},
{"db_id": "db_2fVc8nRqLmT4xBzY", "branch_name": "pr-1482", "parent": "main", "created_at": "2026-09-21T02:58:11Z", "state": "ready"},
{"db_id": "db_6hJk1pWsQnD9rGtU", "branch_name": "pr-1109", "parent": "main", "created_at": "2026-07-04T08:12:44Z", "state": "ready"}
],
"next_cursor": null
}
}
There’s the shape of the problem: pr-1109, created in July, still ready. Nobody deleted it and nobody noticed, because nothing looks at this list.
The reconciler
import os
import re
from datetime import datetime, timedelta, timezone
import requests
API = "https://api.infrai.cc"
KEY = os.environ["INFRAI_API_KEY"]
PROJECT_ID = os.environ["INFRAI_DB_PROJECT_ID"]
PROTECTED = {"main", "staging"}
PR_NAME = re.compile(r"^pr-(\d+)$")
MIN_AGE = timedelta(hours=2)
SESSION = requests.Session()
SESSION.headers.update({"Authorization": f"Bearer {KEY}"})
def inventory() -> list[dict]:
items, cursor = [], None
while True:
params = {"project_id": PROJECT_ID}
if cursor:
params["cursor"] = cursor
resp = SESSION.get(f"{API}/v1/db/branch/list", params=params, timeout=25)
resp.raise_for_status()
page = resp.json()["data"]
items += page.get("items", [])
cursor = page.get("next_cursor")
if not cursor:
return items
def reconcile(open_prs: set[int], apply: bool = False) -> dict:
"""Repository first, platform second. Anything whose PR isn't open goes — with
three safety rules: named branches are protected, unparseable names are never
touched, and a branch younger than MIN_AGE is left alone so a race with a
still-starting CI job can't delete the database out from under it."""
now = datetime.now(timezone.utc)
deleted, kept, skipped = [], [], []
for branch in inventory():
name = branch.get("branch_name") or ""
if name in PROTECTED:
kept.append(name)
continue
match = PR_NAME.match(name)
if not match:
skipped.append(name) # not ours to reason about
continue
if int(match.group(1)) in open_prs:
kept.append(name)
continue
created = datetime.fromisoformat((branch.get("created_at") or "").replace("Z", "+00:00"))
if now - created < MIN_AGE:
kept.append(name) # too young; a job may still be starting
continue
if apply:
resp = SESSION.delete(f"{API}/v1/db/branch/delete/{PROJECT_ID}",
params={"branch_name": name}, timeout=25)
if resp.status_code not in (200, 404):
resp.raise_for_status()
deleted.append(name)
return {"deleted": deleted, "kept": kept, "skipped_unparseable": skipped, "applied": apply}
if __name__ == "__main__":
report = reconcile({1482, 1490}, apply=os.environ.get("APPLY") == "1")
print(report)
Three rules in there are what make it safe to schedule. Protected names can’t be deleted by a naming accident. Branches whose names don’t match the convention are reported, never removed — a human made those on purpose. And the age floor stops a reconciler racing a CI job that created its branch thirty seconds ago.
Run it with APPLY unset for a week and read the output before you let it delete anything.
Schedule it on the same key
curl -sS -X POST "https://api.infrai.cc/v1/cron/create" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"name": "db-branch-reconcile",
"cron_expr": "0 3 * * *",
"task": "https://ci.example.com/hooks/db-reconcile",
"timeout_seconds": 300,
"on_failure_webhook": "https://ops.example.com/hooks/cron-failed"
}'
task is the URL the schedule fires against and cron_expr is a standard five-field expression. GET /v1/cron/runs/list/{id} then shows whether it actually ran, which matters more than it sounds: a cleanup job that silently stopped firing is indistinguishable from having no cleanup job, and the symptom shows up a month later on your bill.
What the reconciler can’t know
| Case | Reconciler behaviour | Why |
|---|---|---|
pr-1482, PR open | kept | matches an open PR |
pr-1109, PR merged in July | deleted | no longer open |
main, staging | kept | explicitly protected |
spike-cache-idea | reported, not deleted | no convention to check it against |
| Branch created 5 minutes ago | kept | age floor, avoids a race |
That fourth row is the honest limitation: naming is the whole contract. A branch whose name doesn’t encode something checkable can’t be reconciled by anything, and the answer is to make the convention mandatory in CI rather than to make the reconciler cleverer.
There’s also no server-side TTL on a branch — nothing expires by itself, so if you don’t run a reconciler, nothing cleans up. That’s a design choice rather than an oversight, since a database that vanishes on a timer is its own kind of incident, but it does mean the sweep is not optional.
Worth knowing what you’d get elsewhere: Neon’s and Supabase’s Git integrations create and destroy the branch from the pull-request event itself, so the hook and the repository can’t drift apart in the first place. If preview databases are the whole of what you’re buying, that integration is a real advantage and the reconciler above is work you wouldn’t have to do.
Why this stays cheap to operate
The branch, the schedule that reconciles it, the log lines the job emits via POST /v1/logs/ingest and the notification when it deletes something unexpected via POST /v1/email/send are all on the same credential — so the cleanup system needs no second account, and its own cost shows up in the same GET /v1/account/usage as the branches it’s cleaning. Branch creation and deletion rates are live in GET /v1/discovery/db.branch.create (verified 2026-09-21) and drift downward as vendor contracts improve; the standing GB-month rent on a branch you never delete is the cost this whole page exists to remove.