Skip to content

fix(database): skip idempotent schema statements and honour a lock timeout at init - #87

Merged
onamfc merged 2 commits into
mainfrom
fix/init-schema-guard
Oct 26, 2026
Merged

onamfc merged 2 commits into
mainfrom
fix/init-schema-guard

Conversation

@onamfc

@onamfc onamfc commented Oct 26, 2026

Copy link
Copy Markdown
Member

Why

initializeDatabase() runs its schema statements on every start. Postgres acquires a table's lock before it evaluates IF NOT EXISTS, so ALTER TABLE links ADD COLUMN IF NOT EXISTS … for a column that has existed for a year still requests ACCESS EXCLUSIVE on links, and CREATE INDEX IF NOT EXISTS for an existing index still requests SHARE. If a long statement holds the table at that moment (a retention job, a large export), the request waits, and Postgres queues every later request on the table behind the waiting one. Redirects read links, so a routine start of a second instance can stall every redirect for as long as the long statement runs.

What changes

  • src/lib/schema-guard.ts (new): guardSchemaClient(client) answers idempotent statements from the catalog when their effect already exists, so they are never sent and never ask for a lock: ALTER TABLE … ADD COLUMN IF NOT EXISTS (every column of a multi-action ALTER must exist; commas inside types and defaults do not split actions; a mixed ALTER passes through), CREATE [UNIQUE] INDEX [CONCURRENTLY] IF NOT EXISTS, DROP CONSTRAINT IF EXISTS, ALTER COLUMN … SET/DROP NOT NULL. Anything else, any multi-statement string and any parameterised query pass through untouched.
  • initializeDatabase() runs on a guarded client and accepts a new lockTimeoutMs option. It is applied after the schema advisory lock is held (waiting for another instance's initialisation is wanted; waiting for a table lock is not) and reset before the client returns to the pool. A statement that still waits longer fails the start with SQLSTATE 55P03 and an operator-readable message, so a host can retry with backoff or let its orchestrator restart. Default unset: behaviour unchanged for anyone not opting in.
  • The success log line says how many statements were already applied and not re-sent.
  • README: the option, why it exists, and the rule for new schema statements.

Tests

  • schema-guard.test.ts: the guard's decisions for each shape, multi-column and mixed ALTERs, parameterised queries untouched, SQLSTATE recognition.
  • database.test.ts (Postgres, DATABASE_URL-gated like the existing cases): with a transaction holding ACCESS SHARE on links, a second initializeDatabase({ lockTimeoutMs: 2000 }) completes in ~2 s, the reader is never blocked, and SHOW lock_timeout on a fresh pool client is 0; a bare ALTER that does need the table fails with 55P03 after the timeout instead of queueing it.
  • Full suite green, npm run build clean.

…meout at init

Postgres takes a table's lock before it evaluates IF NOT EXISTS, so every
start's ALTER TABLE ... ADD COLUMN IF NOT EXISTS for a column that already
exists still asks for ACCESS EXCLUSIVE on the table, and CREATE INDEX IF NOT
EXISTS still asks for SHARE. When a long statement holds the table, the
request waits and every later query on the table queues behind it: a routine
start of a second instance stalls redirects until the long statement ends.

- schema-guard.ts: guardSchemaClient answers idempotent statements whose
  effect already exists (a column, an index, an absent constraint, a settled
  NOT NULL) from the catalog, so they are never sent and never ask for a lock;
  anything else passes through untouched
- initializeDatabase runs on a guarded client and accepts lockTimeoutMs: set
  after the schema advisory lock is held, reset before the client returns to
  the pool; a statement that still waits longer fails the start with SQLSTATE
  55P03 and an operator-readable message so the host can retry
- README: the option and the rule for new schema statements
- tests: the guard's decisions; a second start completes while a transaction
  holds ACCESS SHARE on links and the setting does not leak; a bare ALTER that
  does need the table fails fast with 55P03
…ymlinked node_modules

The ignore rule was `node_modules/`, which matches a directory but not a
symlink of that name, so a worktree set up with a linked node_modules put the
link itself into the previous commit. Ignore the name whatever it points at.
@codecov

codecov Bot commented Oct 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.90741% with 11 lines in your changes missing coverage. Please review.

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

📢 Thoughts on this report? Let us know!

@onamfc
onamfc merged commit 1ee0f63 into main Oct 26, 2026
14 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Oct 26, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant