Repository navigation
fix(postgres-storage): serialize shared schema creation on its own lock - #723
Merged
Merged
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
blove
enabled auto-merge (squash)
September 18, 2026 01:02
blove
temporarily deployed
to
vercel-preview
September 18, 2026 01:02 — with
GitHub Actions
Inactive
blove
temporarily deployed
to
vercel-preview
September 18, 2026 01:26 — with
GitHub Actions
Inactive
The first request to a schema that does not exist yet failed with 23505 on pg_namespace_nspname_index. runMigrations takes an advisory lock keyed on schema, prefix and component so the three stores version independently, but it ran CREATE SCHEMA IF NOT EXISTS inside that lock. The schema is the one object all three share, so three components held three different locks and raced on it, and IF NOT EXISTS is not concurrency-safe: the loser raised a duplicate-key error rather than a benign no-op, leaving the schema partly populated so only a second run succeeded. Schema creation moves to its own short transaction under a lock keyed on the schema alone. The shared DDL is serialized; the per-component migrations are not, which is what the component key was for. Committing it separately is safe because creating a schema is idempotent and additive. Proven against real Postgres: three stores calling ready() concurrently on a virgin schema reproduced the 23505 before the change and pass after it. The statement-level test pins the ordering the fix depends on, so a future edit cannot fold the DDL back under the component lock unnoticed. Closes #709 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The guard added a second advisory lock to every cold start, which reds hono-node-roundtrip's assertion that one cold start costs exactly one lock per store component. That assertion is worth keeping: it is what proves migrations run once per isolate rather than once per request. Read the catalog first. A schema that already exists — and the default `public` always does — needs no lock, no transaction and no DDL, so an existing deployment pays one cheap catalog probe per component and no extra lock. The probe is an optimization, not the correctness mechanism: two cold starts can both read "missing" and both proceed, and the lock plus IF NOT EXISTS is what makes that safe. Also fixes assume-migrated's statement-sequence assertion, which pinned BEGIN as the first statement. It now asserts on the component's own transaction, which is what that test meant. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The api-reference behavior contract for postgres-storage.migration.instance-scoped fingerprints the assertions in assume-migrated.test.ts, so that weakening them cannot silently leave a documented claim unbacked. Changing those assertions for the schema guard tripped it, which is the gate doing its job. Repinned rather than relaxed. The claim it backs — an unflagged instance begins a locked transaction — is still exactly what the test proves; only the statement index moved, because the shared-schema guard opens a transaction ahead of the component's own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
blove
force-pushed
the
blove/pg-schema-create-race
branch
from
September 18, 2026 02:03
f6bb82c to
5bbd2a9
Compare
blove
temporarily deployed
to
vercel-preview
September 18, 2026 02:04 — with
GitHub Actions
Inactive
This was referenced Sep 18, 2026
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #709.
The bug
The first request to a freshly provisioned schema fails, deterministically:
Root cause
runMigrationstakespg_advisory_xact_lockkeyed onschema.prefix.component, and the comment above it says why: the three stores get their own migrations table and their own lock so they version independently and do not serialize against each other. That is right for migrations.But
CREATE SCHEMA IF NOT EXISTSran inside that same lock, and the schema is the one object all three components share. Three components hold three different locks, so nothing serialized the shared DDL, andIF NOT EXISTSis no more concurrency-safe than theCREATE TABLE IF NOT EXISTSon the next line: the loser raises a duplicate-key error rather than a benign no-op. The partly-created schema is why the reporter saw the checkpointer tables present, the threads and permissions tables missing, and a second run succeed.This is the cold start after a deploy to a new environment, which is precisely what
B4_PG_SCHEMAwas added for.The fix
Schema creation moves into its own short transaction under a lock keyed on the schema alone (
schema:<name>). The shared DDL is serialized; the per-component migrations are not, so the independence the component key exists for is preserved.Kept to a separate transaction deliberately. Holding a schema-wide lock for the length of a migration pass would serialize all three stores against each other, undoing the design. Committing the schema separately is safe because creating one is idempotent and additive, so a later failure leaves an empty schema rather than a wrong one.
Tests
B4_TEST_PGSTORAGE=1: the three stores the generatedstores.mjsbuilds callready()concurrently against a schema that does not exist. This reproduced the exact23505onpg_namespace_nspname_indexbefore the change and passes after it, and it then writes and reads a thread to prove the schema is not left half-populated.Validation
@b4run/postgres-storagefull suite with the real-Postgres lane enabled: 9 files, 94 passed@b4run/postgres-storagetypecheck and lintnode scripts/check-changesets.mjsWorth landing before the pending release:
postgres-per-environment-schemais one of the changesets waiting in #673, so releasing without this ships a known first-run failure in the feature it belongs to.🤖 Generated with Claude Code