← All notes

What not to migrate

We put the databases on one cluster this week. The migration itself was unremarkable, which is the goal. The decisions worth writing down were mostly about what stayed where it was.

First, count what you actually have

Six projects had a backing store. The assumption going in was that they were all MongoDB and this was a matter of changing connection strings.

Four were. Two were PostgreSQL.

That is the sort of thing worth checking before planning rather than during. A Postgres project does not move to MongoDB by pointing it somewhere new. That is a rewrite of the data layer wearing a migration's clothes, and the estimate is different by an order of magnitude.

The queue is the hard part

One of the Postgres projects uses Postgres as its job queue, not just its store. That distinction decided the whole question.

Claiming a job in Postgres is usually SELECT ... FOR UPDATE SKIP LOCKED: take the next row nobody else has locked, skip anything already claimed, do it atomically so two workers never grab the same job. It is one statement and the database does the hard part.

MongoDB can do the same job, but not the same way. You reach for findOneAndUpdate to flip a status field atomically, plus a lease with a heartbeat so a worker that dies mid-job releases its claim instead of holding it forever, plus something that sweeps up jobs whose lease expired.

That is a different concurrency design. It has to be written and tested, not translated. And the service is live with green tests.

So it stayed on Postgres. One fewer vendor is not worth a rewrite of the part of the system most likely to lose work when it goes wrong.

A comment that described code that was not there

The auth API had this:

// Extract database name from connection string
const dbName = 'resetkit';

The comment describes behaviour the line does not implement. Whatever database the connection string named, the service talked to that one.

Nobody had noticed, because until you try to point a service somewhere else, hardcoded and configured look identical. It only became visible at the moment it became a problem.

It now resolves in order: the path on the connection string, then an environment variable, then the old default. The connection string wins because that is what every Mongo tool already honours, so a URI that points somewhere in Compass points to the same place in the app.

The important part of that change is a test asserting that a deployment passing neither still lands exactly where it always did. A migration fix that silently relocates an existing service is worse than the bug.

Two databases that looked like duplication

Two databases had names differing by a single underscore and shared three collection names. The obvious read was that somebody had rebuilt something and left the old one lying around, and the obvious action was to pick one.

The obvious read was wrong. One holds the live store's real records: orders, invoices, expenses, tax documents. The other belongs to an in-progress rewrite of that store, with about a week of development data in it.

They are not two generations of the same thing. They are production and a sandbox, and the fact that they were nearly colliding by name was the actual problem, not the fact that both existed.

Consolidating them would have meant pointing half-finished code at live order history. The safe move is the opposite of the tidy one: keep them apart, and migrate when the rewrite is finished rather than because the names look similar.

There is a lesson in there about naming. If two things must never be confused, they should not differ by one character.

Indexes are not free and do not travel

A dump and restore carries indexes. Copying data any other way does not, and a missing unique index is worse than a missing one for speed, because it fails silently. Duplicates simply start being allowed and nobody finds out until something downstream cannot tell two records apart.

While writing the provisioning script we went looking for what each project's queries actually needed. One worker polls its job collection every five seconds, filtering on status and sorting by priority then age. There was no index on it anywhere in the codebase. That is a full collection scan plus an in-memory sort, twelve times a minute, forever. Invisible at zero rows. Not invisible later.

Two others were correctness rather than performance. A billing record gets upserted by customer ID, and an upsert without a unique index can insert twice under concurrent webhooks: both calls check, both miss, both write. You end up with two billing accounts for one customer and no error anywhere. Customer-facing order numbers have the same shape of problem, and there the duplicate is the one support cannot untangle.

Indexes like those are not optimisations. They are the only thing enforcing a rule the application assumes is already true.

Testing a script you cannot run

The provisioning script had to be verified without access to the cluster it targets.

So it ran against a fake: a stub standing in for the database driver, recording every call instead of executing it. That proves the script asks for the right indexes, with the right keys in the right order, the right uniqueness, the right expiry values, and no duplicate names. Then a second pass with every index reporting as already present, to prove a re-run changes nothing.

Worth being precise about what that does not prove. The stub records calls, it does not validate them. Whether the real server accepts each specification is still unverified until it runs for real.

That distinction matters more than it sounds. "Tested" and "tested against the thing it will actually talk to" are different claims, and quietly conflating them is how people end up confident about something nobody checked.

The rule underneath all of it

Consolidate what is cheap to consolidate. Leave alone what works and would be expensive to move.

Most of the value in this week's work was in the second half.