fix(apply-migrations): unblock PGLite chain (closes #1100)

`gbrain apply-migrations --yes` was wedging on the v0.11.0 (Minions)
schema phase for PGLite installs. Two compounding bugs:

1. `apply-migrations` pre-flight schema-version warning connects to
   PGLite to read config.version, then disconnects. The brief lock
   hold races with downstream subprocess spawns that try to re-acquire
   it; the 30s lock timeout fires before the parent fully releases.
   Pre-flight is a *warning*; on PGLite it adds no information the
   orchestrators don't already handle. Skip the probe for PGLite.

2. v0.11.0 phase A spawned `gbrain init --migrate-only` as an execSync
   subprocess to apply schema migrations. PGLite is single-writer;
   the subprocess inherits HOME and tries to lock the same DB. On
   Postgres this works (concurrent connections OK); on PGLite it
   deadlocks. Route in-process for PGLite — create + connect +
   initSchema + disconnect directly, skipping the subprocess hop.
   Postgres keeps the legacy execSync path.

Verified: fresh PGLite install now walks the full migration chain
through v0.32.2 (Facts SoR) and lands "All migrations up to date" on
re-run.

Closes #1100.
This commit is contained in:
Garry Tan
2026-05-18 13:58:35 -07:00
parent 80d309379a
commit d93fa81dec
2 changed files with 44 additions and 13 deletions
+21 -11
View File
@@ -364,17 +364,27 @@ export async function runApplyMigrations(args: string[]): Promise<void> {
const { createEngine } = await import('../core/engine-factory.ts'); const { createEngine } = await import('../core/engine-factory.ts');
const cfg = lc(); const cfg = lc();
if (cfg) { if (cfg) {
const eng = await createEngine(toEngineConfig(cfg)); // v0.36.x #1100: skip the pre-flight warning on PGLite. The probe
await eng.connect(toEngineConfig(cfg)); // briefly holds the single-writer lock; if a downstream orchestrator
const verStr = await eng.getConfig('version'); // phase spawns `gbrain init --migrate-only` as a subprocess (the
const schemaVer = parseInt(verStr || '1', 10); // legacy v0.11.0 phase A path), the child can race the parent's
await eng.disconnect(); // lock release and hit a 30s timeout. The orchestrators handle
if (schemaVer < LATEST_VERSION) { // schema lifecycle internally on PGLite (phase A routes in-process),
console.warn( // so the warning here adds no information for PGLite users.
`\n⚠️ Schema version ${schemaVer} is behind latest ${LATEST_VERSION}.\n` + const skipPreflight = cfg.engine === 'pglite';
` Schema migrations run automatically on next connectEngine() / initSchema().\n` + if (!skipPreflight) {
` To run them now: gbrain init --migrate-only\n`, const eng = await createEngine(toEngineConfig(cfg));
); await eng.connect(toEngineConfig(cfg));
const verStr = await eng.getConfig('version');
const schemaVer = parseInt(verStr || '1', 10);
await eng.disconnect();
if (schemaVer < LATEST_VERSION) {
console.warn(
`\n⚠️ Schema version ${schemaVer} is behind latest ${LATEST_VERSION}.\n` +
` Schema migrations run automatically on next connectEngine() / initSchema().\n` +
` To run them now: gbrain init --migrate-only\n`,
);
}
} }
} }
} catch { } catch {
+23 -2
View File
@@ -58,9 +58,30 @@ export interface PendingHostWorkEntry {
// Phase A — Schema // Phase A — Schema
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
function phaseASchema(opts: OrchestratorOpts): OrchestratorPhaseResult { async function phaseASchema(opts: OrchestratorOpts): Promise<OrchestratorPhaseResult> {
if (opts.dryRun) return { name: 'schema', status: 'skipped', detail: 'dry-run' }; if (opts.dryRun) return { name: 'schema', status: 'skipped', detail: 'dry-run' };
try { try {
// v0.36.x #1100: route PGLite through an in-process schema apply rather
// than `execSync('gbrain init --migrate-only')`. The subprocess inherits
// HOME and tries to acquire the same file lock the parent process is
// holding (or briefly released and the on-disk artifact has not finished
// settling), which deadlocks until the 30s lock timeout fires. The
// structural fix is to not spawn a subprocess for work the parent can
// do directly — Postgres tolerates concurrent connections, so the
// legacy execSync path stays for Postgres callers.
const { loadConfig, toEngineConfig } = await import('../../core/config.ts');
const cfg = loadConfig();
if (cfg?.engine === 'pglite') {
const { createEngine } = await import('../../core/engine-factory.ts');
const eng = await createEngine(toEngineConfig(cfg));
try {
await eng.connect(toEngineConfig(cfg));
await eng.initSchema();
} finally {
try { await eng.disconnect(); } catch { /* best-effort */ }
}
return { name: 'schema', status: 'complete' };
}
execSync('gbrain init --migrate-only' + childGlobalFlags(), { stdio: 'inherit', timeout: 60_000, env: process.env }); execSync('gbrain init --migrate-only' + childGlobalFlags(), { stdio: 'inherit', timeout: 60_000, env: process.env });
return { name: 'schema', status: 'complete' }; return { name: 'schema', status: 'complete' };
} catch (e) { } catch (e) {
@@ -413,7 +434,7 @@ function phaseFInstall(opts: OrchestratorOpts): OrchestratorPhaseResult {
async function orchestrator(opts: OrchestratorOpts): Promise<OrchestratorResult> { async function orchestrator(opts: OrchestratorOpts): Promise<OrchestratorResult> {
const phases: OrchestratorPhaseResult[] = []; const phases: OrchestratorPhaseResult[] = [];
const a = phaseASchema(opts); const a = await phaseASchema(opts);
phases.push(a); phases.push(a);
if (a.status === 'failed') { if (a.status === 'failed') {
console.error(`Phase A (schema) failed: ${a.detail}. Aborting; re-run after fixing.`); console.error(`Phase A (schema) failed: ${a.detail}. Aborting; re-run after fixing.`);