Skip to content

Commit f647430

Browse files
fix(objectql): a server boot's schema sync takes the schema-migration lock, so replicas starting together create each table once (#31747)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9XahPEEET2Vy9ZB5xcVcgHE)_ Fixes #31715 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:服务器启动时的表结构同步(含 `OS_MIGRATE_AND_EXIT=1`)现在和 `os migrate apply` 拿同一把数据库迁移锁,只在建表期间持有,同步完立即释放。 - 为什么改:多个副本同时启动会重复建同一张表,Postgres 报 `pg_type` 冲突,那个启动记下 `Schema sync FAILED` 却仍以 0 退出;实测可复现。 - 风险与回滚:单机无争用时只多一条加锁语句、输出逐字节不变;SQLite 不加锁。回滚即还原本 PR,锁模块挪回 cli。 - 先看:`packages/objectql/src/plugin.ts`(`acquireBootSchemaMigrationLock` 与 Phase 1–3 的 try/finally)、`packages/objectql/src/schema-migration-lock.ts`(从 cli 挪来)、`packages/drivers/driver-sql/src/sql-driver.ts`(新只读 getter `schemaDdlDeferred`)。 - 两分钟验证:`OS_TEST_POSTGRES_URL=postgres://… pnpm --filter @objectstack/cli exec vitest run --project integration src/utils/schema-migration-lock.integration.test.ts`,预期 8/8 通过,三个启动各打印一行等待、每张表只建一次。 - 需要你做的:无,席位落地。 ## #31598 card item 2 (quoted) > 2. The lock is harmless on a single node (uncontended); keep its output identical apart from no new lines when uncontended. Note: this changes the single-node code path by one uncontended lock call; say so in the PR. As there: this PR changes the single-node server boot by one uncontended lock call (`pg_try_advisory_lock` / `GET_LOCK` on a connection of its own, then the unlock), on Postgres and MySQL only. Nothing is printed when it is uncontended; the single-boot output was diffed byte for byte below. ## What changes - **Where the boot syncs, measured.** A server boot's DDL is `ObjectQLPlugin.start()` Phase 1 (`installRegisteredSchemas`), Phase 2 (hydrate from `sys_metadata`) and Phase 3 (sync what Phase 2 added). `OS_MIGRATE_AND_EXIT=1` is that same boot followed by an exit in `serve.ts`, so the lock goes into the engine's boot, not into `serve.ts`. **`serve.ts` is untouched.** - **The lock.** `start()` takes it right before Phase 1 and releases it in `finally` after Phase 3 (a boot that fails between the phases releases it too). It is never held while the server runs. It is taken even when nothing turns out to be pending, so the "read what is missing, then create it" race is closed. It is taken on the default datasource's driver, the database the tables live in (as #31714 locks the primary). - **No lock where this boot runs no DDL:** `skipSchemaSync` / `OS_SKIP_SCHEMA_SYNC=1`, and a default driver whose DDL `os migrate plan` / `apply` holds back. That command takes the lock itself before it reads what is pending; taking it in the boot too would make one migrator wait twice and spend a second uncontended statement. The engine reads this from a new read-only getter, `SqlDriver#schemaDdlDeferred`. - **One lock, one module (seat ruling 1).** `schema-migration-lock.ts` and its unit test moved from `packages/cli/src/utils/` to `packages/objectql/src/`: `@objectstack/objectql` cannot import `@objectstack/cli` (the CLI depends on it), and objectql is the lowest package that holds a caller of the lock (`@objectstack/core` / `types` are driver-agnostic and call nothing here). It is exported from the objectql barrel (not the lean `./core` entry). `packages/cli/src/utils/schema-migration-lock.ts` is now a re-export, so `bootSchemaStack` and the CLI tests import exactly what they did. Same name `objectstack:schema-migrate`, same Postgres key (its unit pin moved with it, unchanged). - **Dialect from the driver, before its Knex (CI fix, `ab5e5910`).** The lock reads `SqlDriver#dialectName` (the driver's one public "which SQL do I speak") and calls `getKnex()` only for `postgres` / `mysql`. At `86ea712b` it asked every driver for its Knex first, and the Turso REMOTE transport refuses `getKnex()` by throwing, so a remote Turso boot failed in `ObjectQLPlugin.start()` (CI `Test Core (3/8)`, `organization-ownership-boot-guard.turso-remote.kernel.test.ts`). A Postgres or MySQL driver whose Knex cannot be handed out still throws; nothing is swallowed. `bootSchemaStack` passes the same `SqlDriver`, so the CLI path reads the same answer. - **Wait bound.** A server boot waits at most half of `ObjectQLPlugin.startupTimeout` (60 s by default), so a boot queued behind a long migration refuses with the lock's own message (it names the lock and says nothing was changed) instead of the kernel's generic start timeout, and keeps the other half for its own sync. `os migrate apply` keeps its 10-minute bound. - **Forced edit outside the named surface:** `packages/drivers/driver-turso/src/turso-driver.ts` gains one row in `REMOTE_FACE_ANSWERS` (`schemaDdlDeferred: 'inherited'`). That ledger is typed `Record<keyof SqlDriver, …>`, so the new getter fails the Turso DTS build until it is classified. It is `inherited` because the remote face refuses arming, so it always reads `false`. ## Tests - `packages/objectql/src/plugin-boot-schema-migration-lock.test.ts` (new, 6 pins): a real `ObjectKernel` boot with a stub default driver whose knex and `syncSchema` write into one ordered log. The lock comes before the first DDL and the unlock after the last; uncontended it is one lock statement and no line logged; contended it is one wait line, then DDL; a boot failing between the phases still unlocks; no lock for `skipSchemaSync` or a deferring driver; control: SQLite and memory drivers run no statement and sync as before. - `packages/cli/src/utils/schema-migration-lock.integration.test.ts`: a second top-level describe in #31714's live-Postgres harness (ruling record 6583487846: one harness file per component). It holds the lock, starts three `os serve` boots with `OS_MIGRATE_AND_EXIT=1`, waits until Postgres shows all three queued, then lets go. Asserted: all three queued; each exits 0 and prints exactly one wait line; none logs `Schema sync FAILED`; an event trigger sees each table created once; control: one more boot prints no wait line, creates nothing, exits 0. A named skip without `OS_TEST_POSTGRES_URL`, as the apply cell; the event trigger needs a superuser URL. - Moved: `schema-migration-lock.test.ts` (header text updated for its second caller). Its fake drivers now carry `dialectName`; the dialect pins assert `getKnex()` is never called for `sqlite` / `mssql` / `oracle` / `unknown` / none; new pins: a SQLite face whose `getKnex()` throws answers `null` without calling it, and a `postgres` / `mysql` driver whose `getKnex()` throws rejects with that error. - `plugin-boot-schema-migration-lock.test.ts` gains a 7th pin: a SQLite-dialect default driver whose `getKnex()` throws (Turso remote) boots, is never asked for Knex, takes no lock and syncs. ## Acceptance notes - Postgres and MySQL only, as #31714. MySQL is NOT MEASURED live (no MySQL server in the container); the MySQL statements are the module's own, pinned in its unit test. - **The lock covers the default datasource only; DDL on any other datasource is unchanged by this PR.** Only the default datasource's database is locked (as #31714 locks the primary); objects bound to another SQL datasource on another database sync exactly as before, outside the lock. - The `kernel:ready` platform-table repairs (`metadata-protocol` identity-index migrations, `IF [NOT] EXISTS` index DDL) run after the lock is released. In the 5 barrier-aligned runs and 2 staged integration runs with the fix, no boot logged a failure from them. The runtime resyncs (`metadata:reloaded`, `ObjectQL.syncSchemas` on install) are not boot DDL and are not touched. - The red on main needs the boots aligned. Started together without help, tsx start jitter puts one boot about 100 ms ahead and it leads the whole sync: 4 unaided runs on main showed no collision. Held together at their first database round trip by a TCP barrier in front of Postgres (a measurement tool, not committed), main collided every valid time. ## Verification Concurrency measurements at `3fa9769b` (main `4013e308` merged in), Postgres 16 in the container. `86ea712b` moved a JSDoc (no code change). Head `ab5e5910` fixes the Turso remote boot: ```text runtime organization-ownership-boot-guard.turso-remote.kernel.test.ts before (objectql dist of 86ea712): 2 failed, "Handing out the Knex instance (`getKnex()`) is not supported by the Turso REMOTE transport" / after (rebuilt): 2 passed runtime turso files 2/2 (9 tests) · rest remote-tables-twin 10/10 · cli schema-migrate.remote-posture 11/11 objectql typecheck ✓ · vitest 577 files / 11736 tests passed (lock pins 26/26) driver-turso typecheck ✓ · vitest 97 files, 2522 passed | 34 skipped cli typecheck ✓ · live-PG schema-migration-lock.integration.test.ts 8/8 (OS_TEST_POSTGRES_URL set) eslint --no-inline-config on the 4 changed .ts files: exit 0 ``` <details><summary>Red on main (b126c13), green with the fix: concurrent boots on one fresh database</summary> DDL counted from the Postgres log (`log_statement = ddl`); boots aligned by the barrier. ```text main 3 x OS_MIGRATE_AND_EXIT run A CREATE TABLE 85 / distinct 84 / created twice: 1 (sys_migration) / pg_type_typname_nsp_index duplicate key x1 / all exit 0, one boot logged "Schema sync FAILED for object 'sys_migration'" main 3 x OS_MIGRATE_AND_EXIT run B CREATE TABLE 85 / distinct 84 / created twice: 1 / pg_type collision x1 / all exit 0 main 2 x ordinary boot run A created twice: 1 / pg_type collision x1 main 2 x ordinary boot run B created twice: 1 / pg_type collision x1 (a third main run was void: the barrier's own port was taken; both boots failed to connect) fix 3 x OS_MIGRATE_AND_EXIT 2 runs CREATE TABLE 84 / distinct 84 / created twice: 0 / no pg_type error / all exit 0 / 2 wait lines per run (one per waiter) fix 2 x ordinary boot 3 runs CREATE TABLE 84 / created twice: 0 / no pg_type error / both listening / 1 wait line per run ``` </details> <details><summary>Control: single-boot output unchanged</summary> Timestamps, durations, ports, temp paths and database names normalized; nothing else. ```text single OS_MIGRATE_AND_EXIT boot, main vs fix: stdout IDENTICAL (0 lines), stderr IDENTICAL (11 lines) single ordinary boot, main vs fix: stdout IDENTICAL (0 lines), stderr IDENTICAL (32 lines) ``` </details> <details><summary>Ablation: the lock call removed from ObjectQLPlugin.start()</summary> `scripts/ablation-replace.mjs` replaced `const migrationLock = await this.acquireBootSchemaMigrationLock(ctx);` with `await Promise.resolve(null)` (anchor 1 → 0, planted 1). Rebuilt objectql: the build exits 1 because the DTS step rejects the now-unused private method, but the JS was written: `ablation-dist-preflight.mjs … --absent` shows the call absent from all 8 built files. ```text unit × locks before the first DDL… × uncontended: ONE lock statement… × contended: says ONCE… × a boot that fails… (4 failed | 2 passed) live × all three boots queued behind the held lock (expected +0 to be 3) × each boot … says ONCE that it waited (2 failed | 6 passed) restore: blob == HEAD, git diff HEAD empty; rebuilt; preflight finds the call in dist/index.js and dist/index.mjs; live file 8/8 passed ``` </details> <details><summary>Packages, lint, gates</summary> ```text objectql typecheck ✓ · vitest 577 files / 11732 tests passed (pre-merge head e02afaa); after the main merge: typecheck ✓, the two lock files 22/22 driver-sql typecheck ✓ · vitest 246 passed | 13 skipped files, 4170 passed | 223 skipped tests driver-turso typecheck ✓ · vitest 97 files, 2522 passed | 34 skipped cli typecheck ✓ (both heads) · unit tier 406 files / 5755 tests · live-PG file 8/8 (both heads; OS_TEST_POSTGRES_URL set) eslint --no-inline-config on all 9 changed .ts files: exit 0 dispatch-gates --commands (79 derived on 3fa9769): 79 run, 79 exit 0; --ran reconciliation: 79 derived, 79 run, 0 NOT-MEASURED, 0 UNRUN (first round: teardown self-test, dual-build-cjs-loads and i18n-coverage exited 3 on prerequisites; green after fetching the teardown fixture and a full `pnpm build`) ``` </details> <!-- signed: domain:server#2 · os-steve · session_01ADHkmRuoKNTbmDhQPaG2vW --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 55c4e95 commit f647430

10 files changed

Lines changed: 679 additions & 189 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/objectql': patch
3+
'@objectstack/driver-sql': patch
4+
'@objectstack/cli': patch
5+
---
6+
7+
fix(objectql): a server boot's schema sync takes the same schema-migration lock as `os migrate apply`, so replicas starting together create each table once
8+
9+
Clause-②: no
10+
Metadata-protocol: none
11+
12+
Several servers booting against one Postgres or MySQL database at the same time (replicas of one deployment, or several `OS_MIGRATE_AND_EXIT=1` jobs) each read the same missing tables and each created them. Postgres then refused one of the duplicate `CREATE TABLE` statements with a `pg_type` unique-key collision, and the boot that lost logged `Schema sync FAILED` for that object while still exiting 0. The boot's schema sync now takes the schema-migration lock that `os migrate apply` takes (one name, `objectstack:schema-migrate`, and one Postgres key), on the default datasource's database, and releases it as soon as the sync is done. It is never held while the server runs. A boot that finds another migration running prints one line saying it waits, then finds the tables already there.
13+
14+
On a single node nothing changes: the lock is uncontended and prints nothing, and SQLite (with Turso and WASM SQLite) takes no lock at all: the lock reads the dialect from the driver's `dialectName` and never asks a SQLite-dialect driver for its Knex, so a remote Turso transport, which refuses to hand its Knex out, boots as before. A boot with `skipSchemaSync` / `OS_SKIP_SCHEMA_SYNC=1` takes none either, since it runs no DDL. A waiting boot gives up after half of the engine plugin's start budget (60 s by default), changes nothing and fails the boot with a message naming the lock; start it again once the other migration has finished.
15+
16+
The lock now lives in `@objectstack/objectql` (`acquireSchemaMigrationLock`, `SCHEMA_MIGRATION_LOCK_NAME`), so the engine's boot can take it; `@objectstack/cli` keeps importing it from there. `SqlDriver` gains a read-only `schemaDdlDeferred` getter, which tells the engine's boot that `os migrate plan` / `apply` is holding the driver's DDL back and will take the lock itself.

‎packages/cli/src/utils/schema-migration-lock.integration.test.ts‎

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,15 @@
2323
* the first one left. The third run is the control: uncontended, it prints
2424
* no wait line and finds the schema in sync.
2525
*
26+
* ## Server boots take the same lock (#31715)
27+
*
28+
* The second describe stages three `os serve` boots with
29+
* `OS_MIGRATE_AND_EXIT=1` the same way: the engine's boot schema sync
30+
* (`ObjectQLPlugin.start()`) takes this lock. Measured before it, with three
31+
* such boots held together at their first database round trip: one table
32+
* created twice, a `pg_type` collision, `Schema sync FAILED` in the losing
33+
* boot's output, and every boot still exiting 0.
34+
*
2635
* ## The driver axis
2736
*
2837
* Postgres only, and only when `OS_TEST_POSTGRES_URL` is set, in a database
@@ -37,6 +46,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
3746
import { execFile } from 'node:child_process';
3847
import { createHash } from 'node:crypto';
3948
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
49+
import { createServer, type AddressInfo } from 'node:net';
4050
import { tmpdir } from 'node:os';
4151
import { basename, dirname, join, resolve } from 'node:path';
4252
import { fileURLToPath } from 'node:url';
@@ -264,3 +274,156 @@ if (!PG_URL) {
264274
});
265275
});
266276
}
277+
278+
/** A port nothing listens on right now, for one `os serve` child. */
279+
function freePort(): Promise<number> {
280+
return new Promise((resolvePort, reject) => {
281+
const probe = createServer();
282+
probe.once('error', reject);
283+
probe.listen(0, '127.0.0.1', () => {
284+
const { port } = probe.address() as AddressInfo;
285+
probe.close(() => resolvePort(port));
286+
});
287+
});
288+
}
289+
290+
/**
291+
* [#31715] One `os serve` boot with `OS_MIGRATE_AND_EXIT=1` against this file's
292+
* database: the kernel bootstrap — its schema sync included — then an exit. A
293+
* home of its own, so the boot persists nothing into the runner's.
294+
*/
295+
async function migrateAndExit(cwd: string, home: string): Promise<Run> {
296+
const port = await freePort();
297+
return new Promise((resolvePromise) => {
298+
execFile(
299+
process.execPath,
300+
[CLI, 'serve', '--port', String(port), '--no-ui'],
301+
{
302+
cwd,
303+
maxBuffer: 64 * 1024 * 1024,
304+
env: {
305+
...childEnv(),
306+
NODE_ENV: 'production',
307+
OS_DATABASE_URL: liveUrl(),
308+
OS_HOME: home,
309+
OS_MIGRATE_AND_EXIT: '1',
310+
OS_AUTH_SECRET: 'a1b2'.repeat(16),
311+
},
312+
},
313+
(err, stdout, stderr) => {
314+
resolvePromise({
315+
code: err ? (typeof (err as { code?: unknown }).code === 'number' ? (err as unknown as { code: number }).code : 1) : 0,
316+
stdout: String(stdout),
317+
stderr: String(stderr),
318+
});
319+
},
320+
);
321+
});
322+
}
323+
324+
/**
325+
* Every `CREATE TABLE` that succeeds on this file's database, recorded by an
326+
* event trigger in a schema of its own (an event trigger needs a superuser,
327+
* as this cell's `CREATE DATABASE` needs `CREATEDB`). A statement that fails —
328+
* the collision this cell is about — rolls its row back with it; that half is
329+
* read from the boots' own output, `Schema sync FAILED`.
330+
*/
331+
const DDL_AUDIT = [
332+
'create schema os_ddl_audit',
333+
'create table os_ddl_audit.created (identity text not null)',
334+
'create function os_ddl_audit.record() returns event_trigger language plpgsql as $$ '
335+
+ 'declare r record; begin '
336+
+ "for r in select * from pg_event_trigger_ddl_commands() where command_tag = 'CREATE TABLE' and schema_name = 'public' loop "
337+
+ 'insert into os_ddl_audit.created values (r.object_identity); end loop; end $$',
338+
"create event trigger os_ddl_audit on ddl_command_end when tag in ('CREATE TABLE') execute function os_ddl_audit.record()",
339+
];
340+
341+
const SYNC_FAILED = /Schema sync FAILED/;
342+
const BOOT_WAIT_LINE = /^.*Another schema migration is running on the default datasource's database.*$/gm;
343+
344+
if (PG_URL) {
345+
describe('server boots (OS_MIGRATE_AND_EXIT=1) serialize on the same lock (#31715, live postgres)', () => {
346+
const BOOTS = 3;
347+
let dir: string;
348+
let runs: Run[] = [];
349+
let control: Run;
350+
let queuedBehindHolder = 0;
351+
let created: string[] = [];
352+
let createdByControl = -1;
353+
354+
beforeAll(async () => {
355+
await adminExecute(`DROP DATABASE IF EXISTS "${LIVE_DATABASE}" WITH (FORCE)`);
356+
await adminExecute(`CREATE DATABASE "${LIVE_DATABASE}"`);
357+
dir = mkdtempSync(join(tmpdir(), 'os-boot-migration-lock-'));
358+
writeFileSync(join(dir, 'objectstack.config.ts'), CONFIG);
359+
360+
const holder = new SqlDriver({ client: 'pg', connection: liveUrl() });
361+
const knex = holder.getKnex();
362+
for (const statement of DDL_AUDIT) await knex.raw(statement);
363+
const session = await knex.client.acquireRawConnection();
364+
try {
365+
// The holder first, as the apply cell above stages it: every boot then
366+
// waits by construction, and each one after the first reads the
367+
// database the one before it left.
368+
await knex.raw('select pg_advisory_lock(?::bigint)', [SCHEMA_MIGRATION_PG_LOCK_KEY]).connection(session);
369+
const pending = Array.from({ length: BOOTS }, (_, i) => migrateAndExit(dir, join(dir, `home-${i}`)));
370+
const deadline = Date.now() + CELL_TIMEOUT_MS / 2;
371+
while (Date.now() < deadline) {
372+
const r = await knex.raw(
373+
"select count(*)::int as n from pg_locks l join pg_database d on d.oid = l.database "
374+
+ "where l.locktype = 'advisory' and not l.granted and d.datname = ?",
375+
[LIVE_DATABASE],
376+
).connection(session) as { rows: Array<{ n: number }> };
377+
queuedBehindHolder = r.rows[0].n;
378+
if (queuedBehindHolder >= BOOTS) break;
379+
await new Promise((r2) => setTimeout(r2, 250));
380+
}
381+
await knex.raw('select pg_advisory_unlock(?::bigint)', [SCHEMA_MIGRATION_PG_LOCK_KEY]).connection(session);
382+
runs = await Promise.all(pending);
383+
} finally {
384+
await knex.client.destroyRawConnection(session);
385+
}
386+
const audit = async (): Promise<string[]> => {
387+
const r = await knex.raw('select identity from os_ddl_audit.created order by identity') as { rows: Array<{ identity: string }> };
388+
return r.rows.map((row) => row.identity);
389+
};
390+
try {
391+
created = await audit();
392+
control = await migrateAndExit(dir, join(dir, 'home-control'));
393+
createdByControl = (await audit()).length - created.length;
394+
} finally {
395+
await holder.disconnect();
396+
}
397+
}, CELL_TIMEOUT_MS);
398+
399+
afterAll(async () => {
400+
if (dir) rmSync(dir, { recursive: true, force: true });
401+
await adminExecute(`DROP DATABASE IF EXISTS "${LIVE_DATABASE}" WITH (FORCE)`);
402+
}, CELL_TIMEOUT_MS);
403+
404+
it('all three boots queued behind the held lock — the contention is staged, not a race that happened to miss', () => {
405+
expect(queuedBehindHolder).toBe(BOOTS);
406+
});
407+
408+
it('each boot exits 0 and says ONCE that it waited; none logs a failed schema sync', () => {
409+
for (const run of runs) {
410+
expect(run.code).toBe(0);
411+
expect(`${run.stdout}${run.stderr}`.match(BOOT_WAIT_LINE)).toHaveLength(1);
412+
expect(`${run.stdout}${run.stderr}`).not.toMatch(SYNC_FAILED);
413+
}
414+
});
415+
416+
it('⭐ each table is created ONCE across the three boots', () => {
417+
expect(created.length).toBeGreaterThan(0);
418+
expect(created).toContain('public.ml_ticket');
419+
expect(new Set(created).size).toBe(created.length);
420+
});
421+
422+
it('control — an uncontended boot prints no wait line, creates nothing, and exits 0', () => {
423+
expect(control.code).toBe(0);
424+
expect(createdByControl).toBe(0);
425+
expect(`${control.stdout}${control.stderr}`.match(BOOT_WAIT_LINE)).toBeNull();
426+
expect(`${control.stdout}${control.stderr}`).not.toMatch(SYNC_FAILED);
427+
});
428+
});
429+
}

‎packages/cli/src/utils/schema-migration-lock.ts‎

Lines changed: 11 additions & 145 deletions
Original file line numberDiff line numberDiff line change
@@ -2,149 +2,15 @@
22

33
/**
44
* The schema-migration lock `bootSchemaStack` (`./schema-migrate.ts`) takes on
5-
* a boot that can apply (#31598): one migrator per database at a time.
6-
*
7-
* Its own module, importing nothing but `node:crypto`, so the pins on it load
8-
* the lock and not the migration stack around it.
5+
* a boot that can apply (#31598). [#31715] The module moved to
6+
* `@objectstack/objectql`, whose `ObjectQLPlugin.start()` takes the same lock
7+
* around every other boot's schema sync: re-exported here, never re-declared,
8+
* so `os migrate apply` and a server boot lock on one name and one key.
99
*/
10-
import { createHash } from 'node:crypto';
11-
12-
/**
13-
* The one name every migrator locks on (#31598): two `os migrate apply` runs
14-
* against one database — one per pod, from an init container on each replica —
15-
* otherwise both read the same pending work and both perform it. Measured on
16-
* Postgres before this lock: three concurrent applies against one fresh
17-
* database each reported creating tables (82 + 86 + 37 for 86 tables), and the
18-
* third applied drift on tables the others were still creating.
19-
*
20-
* ⛔ Never derived from the database name, the project or the release: every
21-
* CLI version that can migrate this database must take the SAME lock, or a
22-
* rolling upgrade runs an old and a new migrator side by side. The unit pin
23-
* holds the derived Postgres key to its value for that reason.
24-
*/
25-
export const SCHEMA_MIGRATION_LOCK_NAME = 'objectstack:schema-migrate';
26-
27-
/**
28-
* The Postgres advisory-lock key: the first 8 bytes of SHA-256 of
29-
* {@link SCHEMA_MIGRATION_LOCK_NAME}, as the signed 64-bit integer
30-
* `pg_advisory_lock(bigint)` takes. In `pg_locks` it appears with `locktype =
31-
* 'advisory'`, its high 32 bits as `classid` and its low 32 as `objid`.
32-
* A string, so no JavaScript number rounds it on the way to the bind.
33-
*/
34-
export const SCHEMA_MIGRATION_PG_LOCK_KEY = BigInt.asIntN(
35-
64,
36-
BigInt(`0x${createHash('sha256').update(SCHEMA_MIGRATION_LOCK_NAME).digest('hex').slice(0, 16)}`),
37-
).toString();
38-
39-
/**
40-
* How long a migrator waits for another to finish before it refuses. No
41-
* environment override: none exists for migration timeouts to follow.
42-
*/
43-
export const SCHEMA_MIGRATION_LOCK_WAIT_MS = 10 * 60_000;
44-
45-
/** The slice of knex the lock uses, typed structurally. */
46-
interface LockKnex {
47-
client: {
48-
dialect?: string;
49-
acquireRawConnection(): Promise<unknown>;
50-
destroyRawConnection(connection: unknown): Promise<void>;
51-
};
52-
raw(sql: string, bindings?: readonly unknown[]): { connection(connection: unknown): Promise<unknown> };
53-
}
54-
55-
/** A held schema-migration lock. `release()` is idempotent and never throws. */
56-
export interface SchemaMigrationLock {
57-
release(): Promise<void>;
58-
}
59-
60-
/** The single row a lock statement answers, whichever client shaped the result. */
61-
function lockRow(result: unknown): Record<string, unknown> | undefined {
62-
// pg answers `{ rows }`; mysql2 answers `[rows, fields]`.
63-
const rows = Array.isArray(result) ? result[0] : (result as { rows?: unknown } | null)?.rows;
64-
return Array.isArray(rows) ? (rows[0] as Record<string, unknown> | undefined) : undefined;
65-
}
66-
67-
/**
68-
* Take the schema-migration lock on `driver`'s database (#31598): Postgres
69-
* `pg_advisory_lock`, MySQL `GET_LOCK`. Every other dialect answers `null` and
70-
* runs no statement — SQLite (and the Turso / WASM drivers on it) serializes
71-
* writers in the database file itself, and the CLI's primary database is never
72-
* MSSQL or Oracle (`resolveStandaloneDatabase` refuses their URL schemes).
73-
*
74-
* Uncontended, it is silent: one statement, no output. Contended, it prints
75-
* ONE line through `warn`, then waits up to `waitMs` and refuses on timeout —
76-
* this run then migrates nothing, and says so.
77-
*
78-
* The lock lives on a connection of its own, opened through the driver's own
79-
* client (its configuration, credentials and TLS) but OUTSIDE its pool: both
80-
* locks are session-scoped, so the lock and its release must run on one
81-
* session, which a pool does not promise; a pooled connection idle past the
82-
* pool's reaper would be closed and the lock released with it; and an
83-
* `OS_DATABASE_POOL_MAX=1` pool would have none left for the migration. A
84-
* session lock excludes every other session on the database, so the
85-
* migration's own pooled connections are serialized by it all the same.
86-
*/
87-
export async function acquireSchemaMigrationLock(
88-
driver: unknown,
89-
opts: { database: string; warn: (line: string) => void; waitMs?: number },
90-
): Promise<SchemaMigrationLock | null> {
91-
const knex = (driver as { getKnex?: () => LockKnex } | null)?.getKnex?.();
92-
const dialect = knex?.client?.dialect;
93-
if (!knex || (dialect !== 'postgresql' && dialect !== 'mysql')) return null;
94-
const pg = dialect === 'postgresql';
95-
const waitMs = opts.waitMs ?? SCHEMA_MIGRATION_LOCK_WAIT_MS;
96-
const connection = await knex.client.acquireRawConnection();
97-
const run = async (sql: string, bindings: readonly unknown[]): Promise<Record<string, unknown> | undefined> =>
98-
lockRow(await knex.raw(sql, bindings).connection(connection));
99-
const close = async (): Promise<void> => {
100-
try { await knex.client.destroyRawConnection(connection); } catch { /* closing releases the lock anyway */ }
101-
};
102-
const refuse = (): Error => new Error(
103-
`Refusing to migrate ${opts.database}: another schema migration still held the migration lock `
104-
+ `('${SCHEMA_MIGRATION_LOCK_NAME}') after ${waitMs} ms, so this run changed nothing. `
105-
+ 'Run it again once that migration has finished.',
106-
);
107-
try {
108-
const tried = pg
109-
? await run('select pg_try_advisory_lock(?::bigint) as locked', [SCHEMA_MIGRATION_PG_LOCK_KEY])
110-
: await run('select GET_LOCK(?, 0) as locked', [SCHEMA_MIGRATION_LOCK_NAME]);
111-
if (!(tried?.locked === true || Number(tried?.locked) === 1)) {
112-
opts.warn(
113-
`Another schema migration is running on ${opts.database}; waiting up to `
114-
+ `${Math.ceil(waitMs / 1000)} s for it to finish…`,
115-
);
116-
if (pg) {
117-
await run(`set lock_timeout = ${Math.max(1, Math.trunc(waitMs))}`, []);
118-
try {
119-
await run('select pg_advisory_lock(?::bigint)', [SCHEMA_MIGRATION_PG_LOCK_KEY]);
120-
} catch (error) {
121-
// 55P03 lock_not_available: `lock_timeout` ran out.
122-
if ((error as { code?: unknown } | null)?.code === '55P03') throw refuse();
123-
throw error;
124-
}
125-
} else {
126-
// GET_LOCK answers 1 (taken), 0 (timed out) or NULL (an error).
127-
const waited = await run('select GET_LOCK(?, ?) as locked', [
128-
SCHEMA_MIGRATION_LOCK_NAME,
129-
Math.max(1, Math.ceil(waitMs / 1000)),
130-
]);
131-
if (Number(waited?.locked) !== 1) throw refuse();
132-
}
133-
}
134-
} catch (error) {
135-
await close();
136-
throw error;
137-
}
138-
let held = true;
139-
return {
140-
release: async () => {
141-
if (!held) return;
142-
held = false;
143-
try {
144-
if (pg) await run('select pg_advisory_unlock(?::bigint)', [SCHEMA_MIGRATION_PG_LOCK_KEY]);
145-
else await run('select RELEASE_LOCK(?)', [SCHEMA_MIGRATION_LOCK_NAME]);
146-
} catch { /* closing the session below releases it */ }
147-
await close();
148-
},
149-
};
150-
}
10+
export {
11+
acquireSchemaMigrationLock,
12+
SCHEMA_MIGRATION_LOCK_NAME,
13+
SCHEMA_MIGRATION_LOCK_WAIT_MS,
14+
SCHEMA_MIGRATION_PG_LOCK_KEY,
15+
} from '@objectstack/objectql';
16+
export type { SchemaMigrationLock } from '@objectstack/objectql';

‎packages/drivers/driver-sql/src/sql-driver.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5063,6 +5063,15 @@ export class SqlDriver implements IDataDriver {
50635063
return DeferredDdlFacet.setDeferredDdl.call(deferredDdlHost.of(this), deferred);
50645064
}
50655065

5066+
/**
5067+
* [#31715] Whether {@link setDeferredDdl} holds this driver's schema DDL back
5068+
* right now. The engine's boot reads it to leave the schema-migration lock to
5069+
* the command that deferred the DDL, which takes it before the flush.
5070+
*/
5071+
get schemaDdlDeferred(): boolean {
5072+
return this.deferredDdl;
5073+
}
5074+
50665075
/** How many objects are waiting for {@link flushDeferredSchemaDdl}. */
50675076
get deferredSchemaObjectCount(): number {
50685077
return this.deferredSchemaObjects.size;

‎packages/drivers/driver-turso/src/turso-driver.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1107,8 +1107,13 @@ export const REMOTE_FACE_ANSWERS = {
11071107
// declares, the input its drift detector diffs against.
11081108
registerObjectMetadata: 'remote',
11091109
// Deferred DDL: arming is refused, so nothing is ever deferred here, and the
1110-
// three inherited members answer that truthfully without reaching Knex.
1110+
// four inherited members answer that truthfully without reaching Knex
1111+
// (`schemaDdlDeferred` reads `false`, so the engine's boot asks for the
1112+
// schema-migration lock, #31715; the lock reads `dialectName`, `'sqlite'`
1113+
// here, and answers `null` WITHOUT calling `getKnex()`, which this face
1114+
// refuses by throwing).
11111115
setDeferredDdl: 'refused',
1116+
schemaDdlDeferred: 'inherited',
11121117
deferredSchemaObjectCount: 'inherited',
11131118
previewDeferredSchemaWork: 'inherited',
11141119
flushDeferredSchemaDdl: 'inherited',

0 commit comments

Comments
 (0)