Skip to content

fix(database): serialise schema initialisation with an advisory lock - #45

Open
onamfc wants to merge 2 commits into
mainfrom
fix/concurrent-schema-init
Open

fix(database): serialise schema initialisation with an advisory lock#45
onamfc wants to merge 2 commits into
mainfrom
fix/concurrent-schema-init

Conversation

@onamfc

@onamfc onamfc commented Sep 8, 2026

Copy link
Copy Markdown
Member

Two commits: the schema-initialisation fix, and a comment cleanup.


1. Serialise schema initialisation with an advisory lock

initializeDatabase() is safe to call from one process and unsafe to call from several at once against an empty database.

CREATE TABLE IF NOT EXISTS is not atomic. The existence check and the creation are separate steps, so two connections can both find a table absent and both attempt to create it. The loser does not quietly do nothing — it fails:

duplicate key value violates unique constraint "pg_type_typname_nsp_index"

The CREATE INDEX IF NOT EXISTS and ADD COLUMN IF NOT EXISTS statements have the same property.

Reproduction

Four initialisations run concurrently against one empty database:

Before After
Succeeded 1 of 4 4 of 4
Failed 3 of 4 0

Against an already-initialised database all four succeed even without the fix, because every statement short-circuits. That is why this only surfaces on the first boot of a new environment that starts more than one instance — or a test harness initialising in parallel — and why it stays invisible in ordinary use.

The fix

A session-level advisory lock around the initialisation, so the second caller waits instead of racing.

  • Postgres releases the lock automatically when the connection ends, so a process that dies mid-initialisation cannot wedge the others.
  • Blocking is the desired behaviour here: another instance is building the schema this one is about to use.
  • No transaction is introduced, so failure semantics are unchanged — partial progress still persists exactly as before.
  • SCHEMA_INIT_LOCK_KEY is exported so an embedding application taking its own advisory locks on the same database can avoid colliding with it.

Testing

A database-backed test covers both halves of the contract: that initialisation blocks while another session holds the lock, and that the lock is released once it completes. A leaked lock would block every subsequent boot, so the second half matters as much as the first.

The behaviour under test is a property of Postgres locking — a mocked client would accept any sequence of queries and prove nothing — so it needs a real server. The test skips itself when DATABASE_URL is absent, keeping npm test green without one, and a new CI job supplies a Postgres service so it actually runs rather than silently skipping.

Verified by mutation: with the lock removed, both cases fail.


2. Remove internal tracker ids and downstream references from comments

Code comments carried issue ids from a private tracker, and one test header named a file path in a downstream private repository. Neither means anything to a reader of this repository, and the file path described private structure.

Every comment keeps its explanation; only the identifiers are gone. Two needed rewording rather than deletion, because the id was carrying the sentence:

  • a regression test described itself by ticket — it now describes the behaviour it reproduces
  • a note about which consumer must not misread a null column named that consumer by ticket — it now names it by what it does

The preview-scraper suite no longer points at a downstream file. It states what is actually true here: a host application may register its own preview handler ahead of these routes, so this list governs standalone deployments.

Generated changelog entries are stripped of the same ids. The underlying commit subjects still contain them and are not practically rewritable; this only stops the rendered changelog repeating them.

Two references to the hosted product remain and look deliberate rather than leaked — the README link to it, and a comment marking an endpoint as a premium feature. Left alone; happy to change either if you would rather they went.


Full suite: 254 passed / 2 skipped without a database, 256 passed with one. Type check and build clean.

`initializeDatabase()` is safe to call from one process and unsafe to call from
several at once against an empty database.

`CREATE TABLE IF NOT EXISTS` is not atomic: the existence check and the creation
are separate steps, so two connections can both find a table absent and both try
to create it. The loser does not quietly do nothing — it fails with
`duplicate key value violates unique constraint "pg_type_typname_nsp_index"`.
The `CREATE INDEX IF NOT EXISTS` and `ADD COLUMN IF NOT EXISTS` statements have
the same property.

Reproduced by running four initialisations concurrently against one empty
database: three of the four crashed, and the survivor produced the correct
schema. Against an already-initialised database all four succeed, because every
statement short-circuits — which is why this only shows up on the first boot of
a new environment that starts more than one instance, and why it is invisible
in ordinary use.

A session-level advisory lock around the initialisation makes the second caller
wait rather than race. Postgres releases it automatically when the connection
ends, so a process that dies mid-initialisation cannot wedge the others, and
waiting is the desired behaviour anyway: another instance is building the schema
this one is about to use. The key is exported so a host application taking its
own advisory locks on the same database can avoid colliding with it.

Adds a database-backed test covering both halves of the contract — that
initialisation blocks while another session holds the lock, and that the lock is
released once it completes. The behaviour under test is a property of Postgres
locking, so a mocked client would accept any sequence of queries and prove
nothing; it needs a real server. The test skips itself when DATABASE_URL is
absent, so `npm test` stays green without one, and a CI job now supplies a
Postgres service so it actually runs.
…ents

Code comments carried issue ids from a private tracker, and one test header
named a file path in a downstream private repository. Neither means anything to
a reader of this repository, and the file path described private structure.

Every comment keeps its explanation; only the identifiers are gone. Two needed
rewording rather than deletion, because the id was carrying the sentence:

  - a regression test described itself by ticket; it now describes the
    behaviour it reproduces
  - a note about which consumer must not misread a null column named that
    consumer by ticket; it now names it by what it does

The preview-scraper suite no longer points at a downstream file. It says what
is actually true for this repository: a host application may register its own
preview handler ahead of these routes, so this list governs standalone
deployments.

Generated changelog entries are stripped of the same ids. The underlying commit
subjects still contain them and are not practically rewritable; this only stops
the rendered changelog repeating them.

Two references to the hosted product remain and look deliberate rather than
leaked — the README link to it, and a comment marking an endpoint as a premium
feature. Left alone.
@codecov

codecov Bot commented Sep 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 29.41176% with 36 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/lib/database.ts 18.18% 36 Missing ⚠️

📢 Thoughts on this report? Let us know!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant