v0.42.21.0 fix(postgres): module-singleton ownership — canonical landing for the dream-cycle "connect() has not been called" class (#1404/#1471/#1619) (#1805)

* fix(postgres): module-singleton ownership — borrower disconnect no longer nulls the cycle's connection (#1404/#1471/#1619)

gbrain dream on Postgres failed every DB phase with "No database connection:
connect() has not been called": a short-lived borrower probe engine (lint/doctor
config-lift, no poolSize) called db.disconnect() in its own disconnect(), nulling
the shared module singleton the long-lived cycle owner was still using. The module
`sql` is only ever nulled by db.disconnect() (postgres.js auto-reconnects its own
pool), so the failure was always a borrower-disconnect, never an idle-pooler drop.

Fix: db.connect() returns whether THIS call created the singleton (atomic — no
await between the null-check and the sql=postgres() assignment), PostgresEngine
stores it as _ownsModuleSingleton, and disconnect() only calls db.disconnect()
when it owns the connection. Borrowers no-op. Hardening: db.disconnect() snapshots
+nulls sql before awaiting end(); reconnect() shares one in-flight _reconnectPromise.

Tests: new postgres-engine-singleton-ownership.test.ts; expanded DB-gated e2e
matrix (owner/borrower, creation-not-role, symmetric CLI-exit, owner-reconnect-
with-live-borrower); module-style getter asymmetry; #1570 shared-recovery
regression updated to assert the fixed contract.

Co-Authored-By: nullhex-io <noreply@github.com>
Co-Authored-By: joelwp <noreply@github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: bump version and changelog (v0.42.15.0)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: nullhex-io <noreply@github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-06-03 08:59:39 -07:00
committed by GitHub
parent ec5fed2921
commit f3ade6c0c3
14 changed files with 454 additions and 98 deletions

View File

@@ -96,6 +96,19 @@ export class PostgresEngine implements BrainEngine {
private _savedConfig: (EngineConfig & { poolSize?: number; parentConnectionManager?: ConnectionManager }) | null = null;
/** Whether a reconnect is in progress (prevents concurrent reconnects). */
private _reconnecting = false;
/**
* #1471: module-singleton OWNERSHIP token. `true` only for the engine whose
* connect() actually created the shared db.ts `sql` singleton (returned
* atomically by db.connect()). Borrowers — probe engines constructed while the
* singleton already exists (resolveLintContentSanity config-lift, doctor,
* integrity) — get `false` and must NOT db.disconnect() it, or they null the
* `sql` the long-lived owner (the cycle engine) still uses and every later
* phase throws "connect() has not been called". `_connectionStyle` alone can't
* separate owner from borrower: both are 'module'. Correct because the
* creator's lifetime dominates all borrowers — the CLI engine is created first
* and disconnected last (cli.ts), and borrowers are strictly nested.
*/
private _ownsModuleSingleton = false;
/**
* Tracks which connection path this engine is using so disconnect() is
* idempotent. 'instance' = own _sql pool (poolSize was set);
@@ -194,8 +207,12 @@ export class PostgresEngine implements BrainEngine {
});
this.connectionManager.setReadPool(this._sql);
} else {
// Module-level singleton (backward compat for CLI main engine)
await db.connect(config);
// Module-level singleton (backward compat for CLI main engine).
// #1471: db.connect() returns whether THIS call created the singleton —
// decided atomically inside connect() (no await between its null-check and
// pool assignment), so two concurrent module connects can't both claim
// ownership. Store the token; only the owner tears the singleton down.
this._ownsModuleSingleton = await db.connect(config);
this._connectionStyle = 'module';
// v0.30.1: connection-manager wraps the module singleton.
@@ -237,7 +254,13 @@ export class PostgresEngine implements BrainEngine {
return;
}
if (this._connectionStyle === 'module') {
await db.disconnect();
// #1471: only the engine that created the shared singleton may tear it
// down. A borrower clears its own markers WITHOUT calling db.disconnect(),
// so a probe engine's teardown can't clobber the owner's live connection.
if (this._ownsModuleSingleton) {
await db.disconnect();
this._ownsModuleSingleton = false;
}
this._connectionStyle = null;
}
// else: nothing to disconnect (already done or never connected)
@@ -1984,7 +2007,7 @@ export class PostgresEngine implements BrainEngine {
},
// v0.41.25.0 (#1570): on null-singleton retryable errors, rebuild
// the connection BEFORE the inter-attempt sleep so the next attempt
// sees a live pool. `this.reconnect()` is race-safe via
// sees a live pool. `this.reconnect()` is race-safe via the
// `_reconnecting` guard, handles both module and instance pools,
// and is a fast no-op when the underlying client is still healthy
// (postgres.js's own connection-replacement covers that case).