Postmortem: My SQLite-to-Postgres migration put our controller in a crash loop
On September 29 our site began returning 503s. The cause was code I wrote. The controller behind it is stuck in a crash loop, and it can’t start because the one-time job that moves its data from SQLite to Postgres fails on every attempt.
Your data is safe. The SQLite database is untouched, because my import only deletes it after a successful commit. Postgres is empty, because every failed attempt rolled back completely. Nothing is lost or half-written. The service will stay down until a fixed import runs. Below is what I got wrong and what I’m changing.
What happened
The Postgres migration merged in the morning (UTC). For most of the day, every deploy failed a check that runs before anything is applied. The new setup names the controller image twice: once for the import step and once for the controller itself. The check only allowed one. So nothing was deployed, and the old SQLite-based controller kept running.
Late that evening a fix for the check landed and a deploy went through. Postgres and the new controller came up. Then the pipeline said “Nothing was applied,” which was wrong. Only its wait for the rollout had timed out. By then the old controller had been replaced, and the new one’s import step had started failing on every attempt.
Why the import fails
I wrote the import to copy the full history (about 2 GB) into Postgres in one transaction, so it’s all-or-nothing. That part worked as intended and is why the data is safe. The problem was how I handled rows that Postgres rejects:
- Rows go in 500 at a time, each batch under a savepoint (a checkpoint inside the transaction that you can roll back to).
- If Postgres rejects a batch, the import retries it one row at a time, each row under its own savepoint, so one bad row can’t sink the rest.
I assumed rejected rows would be rare. They weren’t. The power-sample table has about 500k rows, and nearly 300k of them are exact re-sends: agents sending the same report again, identical except for their ID and received time. Postgres’s primary key (machine plus timestamp) rejects those as duplicates, so almost every batch fell back to row-at-a-time.
That triggered something I didn’t know about. The table is a TimescaleDB hypertable. On a hypertable, every row rejected as a duplicate under a savepoint keeps one lock until the whole transaction ends, even after the savepoint is rolled back. I confirmed this on the production database inside a transaction that I rolled back: 300 duplicates left 300 extra locks, and 600 left 600. New rows, ordinary tables and PL/pgSQL exception blocks don’t leak this way.
After about 14,000 duplicates the lock table fills up and Postgres fails with out of shared memory. The transaction rolls back, the container restarts, and the same thing happens again every time.
I checked and ruled out three other explanations:
- Savepoints that succeed and are released don’t hold locks.
- The number of TimescaleDB chunks isn’t the problem. The data spans six days, about 27 chunks and roughly 500 locks.
- Raising
max_locks_per_transactionisn’t a real fix. With ~300k duplicates the setting would have to be absurdly high, and it would only hide the design flaw.
What I did wrong
- I didn’t look at the real data before designing the import. A simple query for key collisions would have shown that most power samples were repeats. I designed for a clean dataset that doesn’t exist.
- I used error handling for something that isn’t an error. Agents re-sending reports is normal behavior. Treating each repeat as a failure to catch and retry was the wrong model, and it’s what led into the lock leak.
- I didn’t test at production scale or on the same kind of table. A test on a few thousand clean rows in an ordinary table would never show this. A test on a copy of the real data in a real hypertable would have failed within seconds.
- The old system was replaced before the new one proved it could start. Once the deploy went through, there was no working controller left. The import is a one-off and the riskiest part of the change, and it should have been proven before cutover.
- I didn’t catch the tooling problems that hid the failure. The broken pre-deploy check meant the change sat unexercised for hours and then went out late at night. The misleading “Nothing was applied” message made the state of production harder to read during the incident.
The fix
A fix is written. It hasn’t been compiled or tested yet, and I won’t ship it until it has run successfully against a copy of the real data.
- Rows that no other rows depend on are inserted with
ON CONFLICT DO NOTHING. A repeat is skipped quietly instead of raising an error, so no savepoints roll back and no locks leak. - Skipped rows are counted in a new
repeatedfield and shown in the per-table summary logged after the import. Dropped rows will be visible, not hidden. - Rows that have child rows still use a plain insert. That way a skipped repeat can never leave its children attached to a different row.
What I’m changing
- Profile the source data first. Before any migration I’ll check row counts, key collisions and duplicates, and design around what’s actually there.
- Rehearse migrations on production-sized copies, using the same database extensions and table types as production.
- Keep the old system serving until the new one is ready. One-off data moves should be proven before cutover, not run for the first time during it.
- Handle expected cases in the normal code path. If the data will contain duplicates, the insert should handle them directly and not through exceptions.
- Fix the deploy tooling. The image check is already fixed. The “Nothing was applied” message will also be corrected: when the command has already started and only the wait times out, the pipeline shouldn’t say nothing changed.
I’m sorry for the downtime. The data is safe, the cause is understood, and I’ll post an update once the tested fix is deployed.