@rvoh/dream 2.11.1 → 2.11.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/src/db/DreamDbConnection.js +45 -1
- package/dist/cjs/src/db/dbConnectionLeakDiagnostics.js +117 -0
- package/dist/cjs/src/dream/QueryDriver/Kysely.js +17 -4
- package/dist/esm/src/db/DreamDbConnection.js +45 -1
- package/dist/esm/src/db/dbConnectionLeakDiagnostics.js +117 -0
- package/dist/esm/src/dream/QueryDriver/Kysely.js +17 -4
- package/dist/types/src/db/dbConnectionLeakDiagnostics.d.ts +10 -0
- package/dist/types/src/dream-app/index.d.ts +21 -0
- package/docs/classes/db.DreamMigrationHelpers.html +9 -9
- package/docs/classes/db.KyselyQueryDriver.html +32 -32
- package/docs/classes/db.PostgresQueryDriver.html +33 -33
- package/docs/classes/db.QueryDriverBase.html +31 -31
- package/docs/classes/errors.CheckConstraintViolation.html +3 -3
- package/docs/classes/errors.ColumnOverflow.html +3 -3
- package/docs/classes/errors.CreateOrFindByFailedToCreateAndFind.html +3 -3
- package/docs/classes/errors.DataIncompatibleWithDatabaseField.html +3 -3
- package/docs/classes/errors.DataTypeColumnTypeMismatch.html +3 -3
- package/docs/classes/errors.DecryptionError.html +2 -2
- package/docs/classes/errors.DecryptionParseError.html +2 -2
- package/docs/classes/errors.DecryptionRotationError.html +3 -3
- package/docs/classes/errors.GlobalNameNotSet.html +3 -3
- package/docs/classes/errors.InvalidCalendarDate.html +2 -2
- package/docs/classes/errors.InvalidClockTime.html +2 -2
- package/docs/classes/errors.InvalidClockTimeTz.html +2 -2
- package/docs/classes/errors.InvalidDateTime.html +2 -2
- package/docs/classes/errors.MissingSerializersDefinition.html +3 -3
- package/docs/classes/errors.NonLoadedAssociation.html +3 -3
- package/docs/classes/errors.NotNullViolation.html +3 -3
- package/docs/classes/errors.RecordNotFound.html +3 -3
- package/docs/classes/errors.ValidationError.html +3 -3
- package/docs/classes/index.CalendarDate.html +33 -33
- package/docs/classes/index.ClockTime.html +32 -32
- package/docs/classes/index.ClockTimeTz.html +35 -35
- package/docs/classes/index.DateTime.html +86 -86
- package/docs/classes/index.Decorators.html +19 -19
- package/docs/classes/index.Dream.html +118 -118
- package/docs/classes/index.DreamApp.html +5 -5
- package/docs/classes/index.DreamTransaction.html +2 -2
- package/docs/classes/index.Env.html +2 -2
- package/docs/classes/index.Query.html +56 -56
- package/docs/classes/system.CliFileWriter.html +4 -4
- package/docs/classes/system.DreamBin.html +2 -2
- package/docs/classes/system.DreamCLI.html +7 -7
- package/docs/classes/system.DreamImporter.html +2 -2
- package/docs/classes/system.DreamLogos.html +2 -2
- package/docs/classes/system.DreamSerializerBuilder.html +11 -11
- package/docs/classes/system.ObjectSerializerBuilder.html +8 -8
- package/docs/classes/system.PathHelpers.html +3 -3
- package/docs/classes/utils.Encrypt.html +3 -3
- package/docs/classes/utils.Range.html +2 -2
- package/docs/functions/db.closeAllDbConnections.html +1 -1
- package/docs/functions/db.dreamDbConnections.html +1 -1
- package/docs/functions/db.untypedDb.html +1 -1
- package/docs/functions/db.validateColumn.html +1 -1
- package/docs/functions/db.validateTable.html +1 -1
- package/docs/functions/errors.pgErrorType.html +1 -1
- package/docs/functions/index.DreamSerializer.html +1 -1
- package/docs/functions/index.ObjectSerializer.html +1 -1
- package/docs/functions/index.ReplicaSafe.html +1 -1
- package/docs/functions/index.STI.html +1 -1
- package/docs/functions/index.SoftDelete.html +1 -1
- package/docs/functions/utils.camelize.html +1 -1
- package/docs/functions/utils.capitalize.html +1 -1
- package/docs/functions/utils.cloneDeepSafe.html +1 -1
- package/docs/functions/utils.compact.html +1 -1
- package/docs/functions/utils.groupBy.html +1 -1
- package/docs/functions/utils.hyphenize.html +1 -1
- package/docs/functions/utils.intersection.html +1 -1
- package/docs/functions/utils.isEmpty.html +1 -1
- package/docs/functions/utils.normalizeUnicode.html +1 -1
- package/docs/functions/utils.pascalize.html +1 -1
- package/docs/functions/utils.percent.html +1 -1
- package/docs/functions/utils.range.html +1 -1
- package/docs/functions/utils.round.html +1 -1
- package/docs/functions/utils.sanitizeString.html +1 -1
- package/docs/functions/utils.snakeify.html +1 -1
- package/docs/functions/utils.sort.html +1 -1
- package/docs/functions/utils.sortBy.html +1 -1
- package/docs/functions/utils.sortObjectByKey.html +1 -1
- package/docs/functions/utils.sortObjectByValue.html +1 -1
- package/docs/functions/utils.uncapitalize.html +1 -1
- package/docs/functions/utils.uniq.html +1 -1
- package/docs/interfaces/openapi.OpenapiDescription.html +2 -2
- package/docs/interfaces/openapi.OpenapiSchemaProperties.html +1 -1
- package/docs/interfaces/openapi.OpenapiSchemaPropertiesShorthand.html +1 -1
- package/docs/interfaces/openapi.OpenapiTypeFieldObject.html +1 -1
- package/docs/interfaces/types.BelongsToStatement.html +2 -2
- package/docs/interfaces/types.DecoratorContext.html +2 -2
- package/docs/interfaces/types.DreamAppInitOptions.html +2 -2
- package/docs/interfaces/types.DreamAppOpts.html +2 -2
- package/docs/interfaces/types.DurationObject.html +2 -2
- package/docs/interfaces/types.EncryptOptions.html +2 -2
- package/docs/interfaces/types.InternalAnyTypedSerializerRendersMany.html +2 -2
- package/docs/interfaces/types.InternalAnyTypedSerializerRendersOne.html +2 -2
- package/docs/interfaces/types.SerializerRendererOpts.html +2 -2
- package/docs/types/openapi.CommonOpenapiSchemaObjectFields.html +1 -1
- package/docs/types/openapi.OpenapiAllTypes.html +1 -1
- package/docs/types/openapi.OpenapiFormats.html +1 -1
- package/docs/types/openapi.OpenapiNumberFormats.html +1 -1
- package/docs/types/openapi.OpenapiPrimitiveBaseTypes.html +1 -1
- package/docs/types/openapi.OpenapiPrimitiveTypes.html +1 -1
- package/docs/types/openapi.OpenapiSchemaArray.html +1 -1
- package/docs/types/openapi.OpenapiSchemaArrayShorthand.html +1 -1
- package/docs/types/openapi.OpenapiSchemaBase.html +1 -1
- package/docs/types/openapi.OpenapiSchemaBody.html +1 -1
- package/docs/types/openapi.OpenapiSchemaBodyShorthand.html +1 -1
- package/docs/types/openapi.OpenapiSchemaCommonFields.html +1 -1
- package/docs/types/openapi.OpenapiSchemaExpressionAllOf.html +2 -2
- package/docs/types/openapi.OpenapiSchemaExpressionAnyOf.html +2 -2
- package/docs/types/openapi.OpenapiSchemaExpressionOneOf.html +2 -2
- package/docs/types/openapi.OpenapiSchemaExpressionRef.html +2 -2
- package/docs/types/openapi.OpenapiSchemaExpressionRefSchemaShorthand.html +2 -2
- package/docs/types/openapi.OpenapiSchemaInteger.html +1 -1
- package/docs/types/openapi.OpenapiSchemaNull.html +2 -2
- package/docs/types/openapi.OpenapiSchemaNumber.html +1 -1
- package/docs/types/openapi.OpenapiSchemaObject.html +1 -1
- package/docs/types/openapi.OpenapiSchemaObjectAllOf.html +1 -1
- package/docs/types/openapi.OpenapiSchemaObjectAllOfShorthand.html +1 -1
- package/docs/types/openapi.OpenapiSchemaObjectAnyOf.html +1 -1
- package/docs/types/openapi.OpenapiSchemaObjectAnyOfShorthand.html +1 -1
- package/docs/types/openapi.OpenapiSchemaObjectBase.html +1 -1
- package/docs/types/openapi.OpenapiSchemaObjectBaseShorthand.html +1 -1
- package/docs/types/openapi.OpenapiSchemaObjectOneOf.html +1 -1
- package/docs/types/openapi.OpenapiSchemaObjectOneOfShorthand.html +1 -1
- package/docs/types/openapi.OpenapiSchemaObjectShorthand.html +1 -1
- package/docs/types/openapi.OpenapiSchemaPrimitiveGeneric.html +1 -1
- package/docs/types/openapi.OpenapiSchemaShorthandExpressionAllOf.html +2 -2
- package/docs/types/openapi.OpenapiSchemaShorthandExpressionAnyOf.html +2 -2
- package/docs/types/openapi.OpenapiSchemaShorthandExpressionOneOf.html +2 -2
- package/docs/types/openapi.OpenapiSchemaShorthandExpressionSerializableRef.html +2 -2
- package/docs/types/openapi.OpenapiSchemaShorthandExpressionSerializerRef.html +2 -2
- package/docs/types/openapi.OpenapiSchemaShorthandPrimitiveGeneric.html +1 -1
- package/docs/types/openapi.OpenapiSchemaString.html +1 -1
- package/docs/types/openapi.OpenapiShorthandAllTypes.html +1 -1
- package/docs/types/openapi.OpenapiShorthandPrimitiveBaseTypes.html +1 -1
- package/docs/types/openapi.OpenapiShorthandPrimitiveTypes.html +1 -1
- package/docs/types/openapi.OpenapiTypeField.html +1 -1
- package/docs/types/system.DreamAppAllowedPackageManagersEnum.html +1 -1
- package/docs/types/types.CalendarDateDurationUnit.html +1 -1
- package/docs/types/types.CalendarDateObject.html +1 -1
- package/docs/types/types.Camelized.html +1 -1
- package/docs/types/types.ClockTimeObject.html +1 -1
- package/docs/types/types.DbConnectionType.html +1 -1
- package/docs/types/types.DbTypes.html +1 -1
- package/docs/types/types.DreamAssociationMetadata.html +1 -1
- package/docs/types/types.DreamAttributes.html +1 -1
- package/docs/types/types.DreamClassAssociationAndStatement.html +1 -1
- package/docs/types/types.DreamClassColumn.html +1 -1
- package/docs/types/types.DreamColumn.html +1 -1
- package/docs/types/types.DreamColumnNames.html +1 -1
- package/docs/types/types.DreamLogLevel.html +1 -1
- package/docs/types/types.DreamLogger.html +2 -2
- package/docs/types/types.DreamModelSerializerType.html +1 -1
- package/docs/types/types.DreamOrViewModelClassSerializerKey.html +1 -1
- package/docs/types/types.DreamOrViewModelSerializerKey.html +1 -1
- package/docs/types/types.DreamParamSafeAttributes.html +1 -1
- package/docs/types/types.DreamParamSafeColumnNames.html +1 -1
- package/docs/types/types.DreamSerializable.html +1 -1
- package/docs/types/types.DreamSerializableArray.html +1 -1
- package/docs/types/types.DreamSerializerKey.html +1 -1
- package/docs/types/types.DreamSerializers.html +1 -1
- package/docs/types/types.DreamVirtualColumns.html +1 -1
- package/docs/types/types.DurationUnit.html +1 -1
- package/docs/types/types.EncryptAlgorithm.html +1 -1
- package/docs/types/types.HasManyStatement.html +1 -1
- package/docs/types/types.HasOneStatement.html +1 -1
- package/docs/types/types.Hyphenized.html +1 -1
- package/docs/types/types.Pascalized.html +1 -1
- package/docs/types/types.PrimaryKeyType.html +1 -1
- package/docs/types/types.RoundingPrecision.html +1 -1
- package/docs/types/types.SerializerCasing.html +1 -1
- package/docs/types/types.SimpleObjectSerializerType.html +1 -1
- package/docs/types/types.Snakeified.html +1 -1
- package/docs/types/types.StrictInterface.html +1 -1
- package/docs/types/types.UpdateableAssociationProperties.html +1 -1
- package/docs/types/types.UpdateableProperties.html +1 -1
- package/docs/types/types.ValidationType.html +1 -1
- package/docs/types/types.ViewModel.html +2 -2
- package/docs/types/types.ViewModelClass.html +1 -1
- package/docs/types/types.WeekdayName.html +1 -1
- package/docs/types/types.WhereStatementForDream.html +1 -1
- package/docs/types/types.WhereStatementForDreamClass.html +1 -1
- package/docs/variables/index.DreamConst.html +1 -1
- package/docs/variables/index.ops.html +1 -1
- package/docs/variables/openapi.openapiPrimitiveTypes.html +1 -1
- package/docs/variables/openapi.openapiShorthandPrimitiveTypes.html +1 -1
- package/docs/variables/system.DreamAppAllowedPackageManagersEnumValues.html +1 -1
- package/docs/variables/system.primaryKeyTypes.html +1 -1
- package/package.json +1 -1
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { CamelCasePlugin, Kysely } from 'kysely';
|
|
10
10
|
import DreamApp from '../dream-app/index.js';
|
|
11
11
|
import protectAgainstPollutingAssignment from '../helpers/protectAgainstPollutingAssignment.js';
|
|
12
|
+
import { installDbConnectionLeakDiagnosticsIfEnabled, reportLeakedDbConnections, } from './dbConnectionLeakDiagnostics.js';
|
|
12
13
|
let connections = {};
|
|
13
14
|
export default class DreamDbConnection {
|
|
14
15
|
static getConnection(connectionName, connectionType, dialectProvider) {
|
|
@@ -19,6 +20,9 @@ export default class DreamDbConnection {
|
|
|
19
20
|
return connection;
|
|
20
21
|
}
|
|
21
22
|
const connectionConf = dreamApp.dbConnectionConfig(connectionName, connectionType);
|
|
23
|
+
// Must run before dialectProvider() constructs the pg.Pool. Idempotent and
|
|
24
|
+
// a no-op unless DREAM_DB_LEAK_DIAGNOSTICS is set.
|
|
25
|
+
installDbConnectionLeakDiagnosticsIfEnabled();
|
|
22
26
|
const dbConn = new Kysely({
|
|
23
27
|
log(event) {
|
|
24
28
|
const dreamApp = DreamApp.getOrFail();
|
|
@@ -50,11 +54,51 @@ export async function closeAllDbConnections() {
|
|
|
50
54
|
}
|
|
51
55
|
connections = {};
|
|
52
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* Upper bound (ms) on how long {@link closeAllConnectionsForConnectionName}
|
|
59
|
+
* waits for a single Kysely connection's underlying pool to drain.
|
|
60
|
+
*
|
|
61
|
+
* `conn.destroy()` resolves to `pg`'s `pool.end()`, which only settles once
|
|
62
|
+
* every checked-out client has been released back to the pool. A client that
|
|
63
|
+
* was leased by a query still in flight when shutdown began — an aborted HTTP
|
|
64
|
+
* request during a SIGTERM drain, a feature-spec whose page is torn down
|
|
65
|
+
* mid-request — is never released, so `pool.end()` blocks forever and takes
|
|
66
|
+
* the whole shutdown with it. Bounding the wait keeps shutdown deterministic;
|
|
67
|
+
* the leaked socket is reaped by the OS when the process exits.
|
|
68
|
+
*/
|
|
69
|
+
const DB_CONNECTION_CLOSE_TIMEOUT_MS = 10_000;
|
|
53
70
|
export async function closeAllConnectionsForConnectionName(connectionName) {
|
|
54
71
|
const protectedName = protectAgainstPollutingAssignment(connectionName);
|
|
55
72
|
return await Promise.allSettled(Object.keys(connections[protectedName]).map(async (key) => {
|
|
56
73
|
const conn = connections[protectedName][key];
|
|
57
|
-
|
|
74
|
+
// Remove from the registry first so a subsequent getConnection() builds a
|
|
75
|
+
// fresh pool even if this drain times out and the old pool is abandoned.
|
|
58
76
|
delete connections[protectedName][key];
|
|
77
|
+
await destroyConnectionWithinTimeout(conn, `${connectionName}:${key}`);
|
|
59
78
|
}));
|
|
60
79
|
}
|
|
80
|
+
async function destroyConnectionWithinTimeout(conn, label) {
|
|
81
|
+
let timer;
|
|
82
|
+
const timedOut = Symbol('timedOut');
|
|
83
|
+
const timeout = new Promise(resolve => {
|
|
84
|
+
timer = setTimeout(() => resolve(timedOut), DB_CONNECTION_CLOSE_TIMEOUT_MS);
|
|
85
|
+
// Don't let the timer itself keep the event loop (or `process.exit`) alive.
|
|
86
|
+
timer.unref?.();
|
|
87
|
+
});
|
|
88
|
+
try {
|
|
89
|
+
const result = await Promise.race([conn.destroy(), timeout]);
|
|
90
|
+
if (result === timedOut) {
|
|
91
|
+
DreamApp.logWithLevel('warn', `[dream] timed out after ${DB_CONNECTION_CLOSE_TIMEOUT_MS}ms waiting for db connection "${label}" ` +
|
|
92
|
+
`to close; abandoning the drain so shutdown can proceed. A pooled client was most likely held ` +
|
|
93
|
+
`past shutdown by an in-flight or aborted query. ` +
|
|
94
|
+
`Re-run with NODE_DEBUG=dream to log the acquire stack of the offending query.`);
|
|
95
|
+
// No-op unless NODE_DEBUG=dream; when set, this prints the acquire
|
|
96
|
+
// stack(s) of the client(s) that never got released.
|
|
97
|
+
reportLeakedDbConnections(label);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
if (timer)
|
|
102
|
+
clearTimeout(timer);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// after building for esm, importing pg using the following:
|
|
2
|
+
//
|
|
3
|
+
// import * as pg from 'pg'
|
|
4
|
+
//
|
|
5
|
+
// will crash. This is difficult to discover, since it only happens
|
|
6
|
+
// when being imported from our esm build.
|
|
7
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
8
|
+
// @ts-ignore
|
|
9
|
+
import pg from 'pg';
|
|
10
|
+
import { debuglog } from 'node:util';
|
|
11
|
+
import DreamApp from '../dream-app/index.js';
|
|
12
|
+
/**
|
|
13
|
+
* Opt-in connection-leak diagnostics.
|
|
14
|
+
*
|
|
15
|
+
* `closeAllConnectionsForConnectionName` bounds the shutdown drain, but when it
|
|
16
|
+
* times out it can only say *that* a pooled client was held past shutdown, not
|
|
17
|
+
* *where* it was acquired. Finding the offending query then means hand-patching
|
|
18
|
+
* `pg` — exactly the multi-hour spelunk this is meant to delete.
|
|
19
|
+
*
|
|
20
|
+
* Enable with `NODE_DEBUG=dream` (the same `node:util` debug-channel
|
|
21
|
+
* convention Psychic uses for `NODE_DEBUG=psychic`). Dev/CI only — it patches
|
|
22
|
+
* `pg.Pool.prototype.connect` process-wide and retains references to
|
|
23
|
+
* checked-out clients, so it is never installed unless explicitly asked for.
|
|
24
|
+
* When the close timeout fires, the acquire stack of every still-checked-out
|
|
25
|
+
* client is logged.
|
|
26
|
+
*
|
|
27
|
+
* Like the rest of the ecosystem's `debuglog` usage, the channel is resolved
|
|
28
|
+
* once by Node on first call and cached for the process lifetime — this is a
|
|
29
|
+
* process-level switch, not a runtime toggle.
|
|
30
|
+
*/
|
|
31
|
+
// Resolved once at module load (Node caches the channel), mirroring
|
|
32
|
+
// `const debugEnabled = debuglog('psychic').enabled` in Psychic.
|
|
33
|
+
const dreamDebugEnabled = debuglog('dream').enabled;
|
|
34
|
+
// Map (not WeakMap) so entries can be enumerated at report time. Entries are
|
|
35
|
+
// deleted on release, so only genuinely-leaked clients accumulate, and only
|
|
36
|
+
// when diagnostics are explicitly enabled.
|
|
37
|
+
const checkedOutClients = new Map();
|
|
38
|
+
const INSTALLED = Symbol.for('dream:dbLeakDiagnosticsInstalled');
|
|
39
|
+
let enabled = false;
|
|
40
|
+
function track(client, stack) {
|
|
41
|
+
if (!client)
|
|
42
|
+
return;
|
|
43
|
+
checkedOutClients.set(client, { stack: stack ?? '(no stack captured)', since: Date.now() });
|
|
44
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
45
|
+
const c = client;
|
|
46
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
|
47
|
+
if (c.__dreamReleasePatched)
|
|
48
|
+
return;
|
|
49
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
|
50
|
+
const originalRelease = c.release;
|
|
51
|
+
if (typeof originalRelease !== 'function')
|
|
52
|
+
return;
|
|
53
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
|
54
|
+
c.__dreamReleasePatched = true;
|
|
55
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
|
56
|
+
c.release = function patchedRelease(...args) {
|
|
57
|
+
checkedOutClients.delete(client);
|
|
58
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call
|
|
59
|
+
return originalRelease.apply(this, args);
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Idempotent. Patches `pg.Pool.prototype.connect` to stamp the caller's stack
|
|
64
|
+
* onto each leased client. No-op unless `DREAM_DB_LEAK_DIAGNOSTICS` is set.
|
|
65
|
+
*/
|
|
66
|
+
export function installDbConnectionLeakDiagnosticsIfEnabled() {
|
|
67
|
+
if (!dreamDebugEnabled)
|
|
68
|
+
return;
|
|
69
|
+
enabled = true;
|
|
70
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
|
|
71
|
+
const Pool = pg.Pool;
|
|
72
|
+
if (!Pool?.prototype)
|
|
73
|
+
return;
|
|
74
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
|
75
|
+
if (Pool.prototype[INSTALLED])
|
|
76
|
+
return;
|
|
77
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
|
78
|
+
Pool.prototype[INSTALLED] = true;
|
|
79
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment
|
|
80
|
+
const originalConnect = Pool.prototype.connect;
|
|
81
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
|
82
|
+
Pool.prototype.connect = function patchedConnect(cb) {
|
|
83
|
+
const acquireStack = new Error('db connection acquired here').stack;
|
|
84
|
+
if (typeof cb === 'function') {
|
|
85
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call
|
|
86
|
+
return originalConnect.call(this, (err, client, release) => {
|
|
87
|
+
track(client, acquireStack);
|
|
88
|
+
// `track()` replaces `client.release` with an untracking wrapper.
|
|
89
|
+
// Callback-style users call the 3rd `release` arg, which pg captured
|
|
90
|
+
// *before* that swap — so hand them the wrapped one instead, or a
|
|
91
|
+
// correctly-released client would be reported as a false leak.
|
|
92
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
|
|
93
|
+
const wrappedRelease = client ? client.release : release;
|
|
94
|
+
cb(err, client, wrappedRelease);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
|
98
|
+
return originalConnect.call(this).then((client) => {
|
|
99
|
+
track(client, acquireStack);
|
|
100
|
+
return client;
|
|
101
|
+
});
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* If diagnostics are enabled, log the acquire stack of every client still
|
|
106
|
+
* checked out. Called from the shutdown-drain timeout path.
|
|
107
|
+
*/
|
|
108
|
+
export function reportLeakedDbConnections(label) {
|
|
109
|
+
if (!enabled || checkedOutClients.size === 0)
|
|
110
|
+
return;
|
|
111
|
+
const now = Date.now();
|
|
112
|
+
const report = Array.from(checkedOutClients.values())
|
|
113
|
+
.map((entry, i) => ` [#${i}] held ${now - entry.since}ms\n${entry.stack}`)
|
|
114
|
+
.join('\n\n');
|
|
115
|
+
DreamApp.logWithLevel('warn', `[dream] connection-leak diagnostics: ${checkedOutClients.size} pg client(s) still checked out ` +
|
|
116
|
+
`when "${label}" hit the close timeout. Acquired at:\n\n${report}`);
|
|
117
|
+
}
|
|
@@ -92,16 +92,29 @@ export default class KyselyQueryDriver extends QueryDriverBase {
|
|
|
92
92
|
return _db(connectionName || 'default', dbConnectionType, this.dialectProvider(connectionName, dbConnectionType));
|
|
93
93
|
}
|
|
94
94
|
static dialectProvider(connectionName, dbConnectionType) {
|
|
95
|
-
return (connectionConf) =>
|
|
96
|
-
pool
|
|
95
|
+
return (connectionConf) => {
|
|
96
|
+
const pool = new pg.Pool({
|
|
97
|
+
// Spread pg passthrough first; Dream's resolved fields follow and
|
|
98
|
+
// always win (per-connection database name, TLS directive).
|
|
99
|
+
...(connectionConf.pg ?? {}),
|
|
97
100
|
user: connectionConf.user || '',
|
|
98
101
|
password: connectionConf.password || '',
|
|
99
102
|
database: DreamApp.getOrFail().dbName(connectionName, dbConnectionType),
|
|
100
103
|
host: connectionConf.host || 'localhost',
|
|
101
104
|
port: connectionConf.port || 5432,
|
|
102
105
|
ssl: resolvePostgresSsl(connectionConf),
|
|
103
|
-
})
|
|
104
|
-
|
|
106
|
+
});
|
|
107
|
+
// node-postgres explicitly warns: a Pool that emits 'error' with no
|
|
108
|
+
// listener crashes the Node process. Idle pooled clients emit 'error'
|
|
109
|
+
// on backend restart / failover / a load balancer reaping idle TCP.
|
|
110
|
+
// Log via Dream's logger instead of taking the process down; the pool
|
|
111
|
+
// discards the dead client and recovers on the next acquire.
|
|
112
|
+
pool.on('error', err => {
|
|
113
|
+
DreamApp.getOrFail().logger.error(`[dream] idle pg client error on connection "${connectionName}:${dbConnectionType}" ` +
|
|
114
|
+
`(handled — process kept alive): ${err.stack ?? String(err)}`);
|
|
115
|
+
});
|
|
116
|
+
return new PostgresDialect({ pool });
|
|
117
|
+
};
|
|
105
118
|
}
|
|
106
119
|
static async ensureAllMigrationsHaveBeenRun(connectionName) {
|
|
107
120
|
const migrationsNeedToBeRun = await checkForNeedToBeRunMigrations({
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { CamelCasePlugin, Kysely } from 'kysely';
|
|
10
10
|
import DreamApp from '../dream-app/index.js';
|
|
11
11
|
import protectAgainstPollutingAssignment from '../helpers/protectAgainstPollutingAssignment.js';
|
|
12
|
+
import { installDbConnectionLeakDiagnosticsIfEnabled, reportLeakedDbConnections, } from './dbConnectionLeakDiagnostics.js';
|
|
12
13
|
let connections = {};
|
|
13
14
|
export default class DreamDbConnection {
|
|
14
15
|
static getConnection(connectionName, connectionType, dialectProvider) {
|
|
@@ -19,6 +20,9 @@ export default class DreamDbConnection {
|
|
|
19
20
|
return connection;
|
|
20
21
|
}
|
|
21
22
|
const connectionConf = dreamApp.dbConnectionConfig(connectionName, connectionType);
|
|
23
|
+
// Must run before dialectProvider() constructs the pg.Pool. Idempotent and
|
|
24
|
+
// a no-op unless DREAM_DB_LEAK_DIAGNOSTICS is set.
|
|
25
|
+
installDbConnectionLeakDiagnosticsIfEnabled();
|
|
22
26
|
const dbConn = new Kysely({
|
|
23
27
|
log(event) {
|
|
24
28
|
const dreamApp = DreamApp.getOrFail();
|
|
@@ -50,11 +54,51 @@ export async function closeAllDbConnections() {
|
|
|
50
54
|
}
|
|
51
55
|
connections = {};
|
|
52
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* Upper bound (ms) on how long {@link closeAllConnectionsForConnectionName}
|
|
59
|
+
* waits for a single Kysely connection's underlying pool to drain.
|
|
60
|
+
*
|
|
61
|
+
* `conn.destroy()` resolves to `pg`'s `pool.end()`, which only settles once
|
|
62
|
+
* every checked-out client has been released back to the pool. A client that
|
|
63
|
+
* was leased by a query still in flight when shutdown began — an aborted HTTP
|
|
64
|
+
* request during a SIGTERM drain, a feature-spec whose page is torn down
|
|
65
|
+
* mid-request — is never released, so `pool.end()` blocks forever and takes
|
|
66
|
+
* the whole shutdown with it. Bounding the wait keeps shutdown deterministic;
|
|
67
|
+
* the leaked socket is reaped by the OS when the process exits.
|
|
68
|
+
*/
|
|
69
|
+
const DB_CONNECTION_CLOSE_TIMEOUT_MS = 10_000;
|
|
53
70
|
export async function closeAllConnectionsForConnectionName(connectionName) {
|
|
54
71
|
const protectedName = protectAgainstPollutingAssignment(connectionName);
|
|
55
72
|
return await Promise.allSettled(Object.keys(connections[protectedName]).map(async (key) => {
|
|
56
73
|
const conn = connections[protectedName][key];
|
|
57
|
-
|
|
74
|
+
// Remove from the registry first so a subsequent getConnection() builds a
|
|
75
|
+
// fresh pool even if this drain times out and the old pool is abandoned.
|
|
58
76
|
delete connections[protectedName][key];
|
|
77
|
+
await destroyConnectionWithinTimeout(conn, `${connectionName}:${key}`);
|
|
59
78
|
}));
|
|
60
79
|
}
|
|
80
|
+
async function destroyConnectionWithinTimeout(conn, label) {
|
|
81
|
+
let timer;
|
|
82
|
+
const timedOut = Symbol('timedOut');
|
|
83
|
+
const timeout = new Promise(resolve => {
|
|
84
|
+
timer = setTimeout(() => resolve(timedOut), DB_CONNECTION_CLOSE_TIMEOUT_MS);
|
|
85
|
+
// Don't let the timer itself keep the event loop (or `process.exit`) alive.
|
|
86
|
+
timer.unref?.();
|
|
87
|
+
});
|
|
88
|
+
try {
|
|
89
|
+
const result = await Promise.race([conn.destroy(), timeout]);
|
|
90
|
+
if (result === timedOut) {
|
|
91
|
+
DreamApp.logWithLevel('warn', `[dream] timed out after ${DB_CONNECTION_CLOSE_TIMEOUT_MS}ms waiting for db connection "${label}" ` +
|
|
92
|
+
`to close; abandoning the drain so shutdown can proceed. A pooled client was most likely held ` +
|
|
93
|
+
`past shutdown by an in-flight or aborted query. ` +
|
|
94
|
+
`Re-run with NODE_DEBUG=dream to log the acquire stack of the offending query.`);
|
|
95
|
+
// No-op unless NODE_DEBUG=dream; when set, this prints the acquire
|
|
96
|
+
// stack(s) of the client(s) that never got released.
|
|
97
|
+
reportLeakedDbConnections(label);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
if (timer)
|
|
102
|
+
clearTimeout(timer);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// after building for esm, importing pg using the following:
|
|
2
|
+
//
|
|
3
|
+
// import * as pg from 'pg'
|
|
4
|
+
//
|
|
5
|
+
// will crash. This is difficult to discover, since it only happens
|
|
6
|
+
// when being imported from our esm build.
|
|
7
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
8
|
+
// @ts-ignore
|
|
9
|
+
import pg from 'pg';
|
|
10
|
+
import { debuglog } from 'node:util';
|
|
11
|
+
import DreamApp from '../dream-app/index.js';
|
|
12
|
+
/**
|
|
13
|
+
* Opt-in connection-leak diagnostics.
|
|
14
|
+
*
|
|
15
|
+
* `closeAllConnectionsForConnectionName` bounds the shutdown drain, but when it
|
|
16
|
+
* times out it can only say *that* a pooled client was held past shutdown, not
|
|
17
|
+
* *where* it was acquired. Finding the offending query then means hand-patching
|
|
18
|
+
* `pg` — exactly the multi-hour spelunk this is meant to delete.
|
|
19
|
+
*
|
|
20
|
+
* Enable with `NODE_DEBUG=dream` (the same `node:util` debug-channel
|
|
21
|
+
* convention Psychic uses for `NODE_DEBUG=psychic`). Dev/CI only — it patches
|
|
22
|
+
* `pg.Pool.prototype.connect` process-wide and retains references to
|
|
23
|
+
* checked-out clients, so it is never installed unless explicitly asked for.
|
|
24
|
+
* When the close timeout fires, the acquire stack of every still-checked-out
|
|
25
|
+
* client is logged.
|
|
26
|
+
*
|
|
27
|
+
* Like the rest of the ecosystem's `debuglog` usage, the channel is resolved
|
|
28
|
+
* once by Node on first call and cached for the process lifetime — this is a
|
|
29
|
+
* process-level switch, not a runtime toggle.
|
|
30
|
+
*/
|
|
31
|
+
// Resolved once at module load (Node caches the channel), mirroring
|
|
32
|
+
// `const debugEnabled = debuglog('psychic').enabled` in Psychic.
|
|
33
|
+
const dreamDebugEnabled = debuglog('dream').enabled;
|
|
34
|
+
// Map (not WeakMap) so entries can be enumerated at report time. Entries are
|
|
35
|
+
// deleted on release, so only genuinely-leaked clients accumulate, and only
|
|
36
|
+
// when diagnostics are explicitly enabled.
|
|
37
|
+
const checkedOutClients = new Map();
|
|
38
|
+
const INSTALLED = Symbol.for('dream:dbLeakDiagnosticsInstalled');
|
|
39
|
+
let enabled = false;
|
|
40
|
+
function track(client, stack) {
|
|
41
|
+
if (!client)
|
|
42
|
+
return;
|
|
43
|
+
checkedOutClients.set(client, { stack: stack ?? '(no stack captured)', since: Date.now() });
|
|
44
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
45
|
+
const c = client;
|
|
46
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
|
47
|
+
if (c.__dreamReleasePatched)
|
|
48
|
+
return;
|
|
49
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
|
50
|
+
const originalRelease = c.release;
|
|
51
|
+
if (typeof originalRelease !== 'function')
|
|
52
|
+
return;
|
|
53
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
|
54
|
+
c.__dreamReleasePatched = true;
|
|
55
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
|
56
|
+
c.release = function patchedRelease(...args) {
|
|
57
|
+
checkedOutClients.delete(client);
|
|
58
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call
|
|
59
|
+
return originalRelease.apply(this, args);
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Idempotent. Patches `pg.Pool.prototype.connect` to stamp the caller's stack
|
|
64
|
+
* onto each leased client. No-op unless `DREAM_DB_LEAK_DIAGNOSTICS` is set.
|
|
65
|
+
*/
|
|
66
|
+
export function installDbConnectionLeakDiagnosticsIfEnabled() {
|
|
67
|
+
if (!dreamDebugEnabled)
|
|
68
|
+
return;
|
|
69
|
+
enabled = true;
|
|
70
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
|
|
71
|
+
const Pool = pg.Pool;
|
|
72
|
+
if (!Pool?.prototype)
|
|
73
|
+
return;
|
|
74
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
|
75
|
+
if (Pool.prototype[INSTALLED])
|
|
76
|
+
return;
|
|
77
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
|
78
|
+
Pool.prototype[INSTALLED] = true;
|
|
79
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment
|
|
80
|
+
const originalConnect = Pool.prototype.connect;
|
|
81
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
|
82
|
+
Pool.prototype.connect = function patchedConnect(cb) {
|
|
83
|
+
const acquireStack = new Error('db connection acquired here').stack;
|
|
84
|
+
if (typeof cb === 'function') {
|
|
85
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call
|
|
86
|
+
return originalConnect.call(this, (err, client, release) => {
|
|
87
|
+
track(client, acquireStack);
|
|
88
|
+
// `track()` replaces `client.release` with an untracking wrapper.
|
|
89
|
+
// Callback-style users call the 3rd `release` arg, which pg captured
|
|
90
|
+
// *before* that swap — so hand them the wrapped one instead, or a
|
|
91
|
+
// correctly-released client would be reported as a false leak.
|
|
92
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
|
|
93
|
+
const wrappedRelease = client ? client.release : release;
|
|
94
|
+
cb(err, client, wrappedRelease);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
|
98
|
+
return originalConnect.call(this).then((client) => {
|
|
99
|
+
track(client, acquireStack);
|
|
100
|
+
return client;
|
|
101
|
+
});
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* If diagnostics are enabled, log the acquire stack of every client still
|
|
106
|
+
* checked out. Called from the shutdown-drain timeout path.
|
|
107
|
+
*/
|
|
108
|
+
export function reportLeakedDbConnections(label) {
|
|
109
|
+
if (!enabled || checkedOutClients.size === 0)
|
|
110
|
+
return;
|
|
111
|
+
const now = Date.now();
|
|
112
|
+
const report = Array.from(checkedOutClients.values())
|
|
113
|
+
.map((entry, i) => ` [#${i}] held ${now - entry.since}ms\n${entry.stack}`)
|
|
114
|
+
.join('\n\n');
|
|
115
|
+
DreamApp.logWithLevel('warn', `[dream] connection-leak diagnostics: ${checkedOutClients.size} pg client(s) still checked out ` +
|
|
116
|
+
`when "${label}" hit the close timeout. Acquired at:\n\n${report}`);
|
|
117
|
+
}
|
|
@@ -92,16 +92,29 @@ export default class KyselyQueryDriver extends QueryDriverBase {
|
|
|
92
92
|
return _db(connectionName || 'default', dbConnectionType, this.dialectProvider(connectionName, dbConnectionType));
|
|
93
93
|
}
|
|
94
94
|
static dialectProvider(connectionName, dbConnectionType) {
|
|
95
|
-
return (connectionConf) =>
|
|
96
|
-
pool
|
|
95
|
+
return (connectionConf) => {
|
|
96
|
+
const pool = new pg.Pool({
|
|
97
|
+
// Spread pg passthrough first; Dream's resolved fields follow and
|
|
98
|
+
// always win (per-connection database name, TLS directive).
|
|
99
|
+
...(connectionConf.pg ?? {}),
|
|
97
100
|
user: connectionConf.user || '',
|
|
98
101
|
password: connectionConf.password || '',
|
|
99
102
|
database: DreamApp.getOrFail().dbName(connectionName, dbConnectionType),
|
|
100
103
|
host: connectionConf.host || 'localhost',
|
|
101
104
|
port: connectionConf.port || 5432,
|
|
102
105
|
ssl: resolvePostgresSsl(connectionConf),
|
|
103
|
-
})
|
|
104
|
-
|
|
106
|
+
});
|
|
107
|
+
// node-postgres explicitly warns: a Pool that emits 'error' with no
|
|
108
|
+
// listener crashes the Node process. Idle pooled clients emit 'error'
|
|
109
|
+
// on backend restart / failover / a load balancer reaping idle TCP.
|
|
110
|
+
// Log via Dream's logger instead of taking the process down; the pool
|
|
111
|
+
// discards the dead client and recovers on the next acquire.
|
|
112
|
+
pool.on('error', err => {
|
|
113
|
+
DreamApp.getOrFail().logger.error(`[dream] idle pg client error on connection "${connectionName}:${dbConnectionType}" ` +
|
|
114
|
+
`(handled — process kept alive): ${err.stack ?? String(err)}`);
|
|
115
|
+
});
|
|
116
|
+
return new PostgresDialect({ pool });
|
|
117
|
+
};
|
|
105
118
|
}
|
|
106
119
|
static async ensureAllMigrationsHaveBeenRun(connectionName) {
|
|
107
120
|
const migrationsNeedToBeRun = await checkForNeedToBeRunMigrations({
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Idempotent. Patches `pg.Pool.prototype.connect` to stamp the caller's stack
|
|
3
|
+
* onto each leased client. No-op unless `DREAM_DB_LEAK_DIAGNOSTICS` is set.
|
|
4
|
+
*/
|
|
5
|
+
export declare function installDbConnectionLeakDiagnosticsIfEnabled(): void;
|
|
6
|
+
/**
|
|
7
|
+
* If diagnostics are enabled, log the acquire stack of every client still
|
|
8
|
+
* checked out. Called from the shutdown-drain timeout path.
|
|
9
|
+
*/
|
|
10
|
+
export declare function reportLeakedDbConnections(label: string): void;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { CompiledQuery } from 'kysely';
|
|
2
2
|
import type { ConnectionOptions as TlsConnectionOptions } from 'node:tls';
|
|
3
|
+
import type { PoolConfig as PgPoolConfig } from 'pg';
|
|
3
4
|
import { Context } from 'node:vm';
|
|
4
5
|
import Dream from '../Dream.js';
|
|
5
6
|
import QueryDriverBase from '../dream/QueryDriver/Base.js';
|
|
@@ -242,6 +243,26 @@ export interface SingleDbCredential {
|
|
|
242
243
|
* decision at the call site rather than a silent default.
|
|
243
244
|
*/
|
|
244
245
|
ssl?: TlsConnectionOptions | false;
|
|
246
|
+
/**
|
|
247
|
+
* pg pool/client options passed straight through to `new pg.Pool(...)`.
|
|
248
|
+
* Dream knows nothing about these fields — pg's own types carry the
|
|
249
|
+
* documentation. Unset ⇒ pg applies its own defaults (backward compatible).
|
|
250
|
+
*
|
|
251
|
+
* Omitted from the passthrough:
|
|
252
|
+
* - `user / password / database / host / port / ssl`: Dream manages these
|
|
253
|
+
* (per-connection name, TLS directive) — hard invariants.
|
|
254
|
+
* - `connectionString`: `pg`'s `ConnectionParameters` re-parses the URL
|
|
255
|
+
* and lets its fields take precedence, bypassing Dream's per-connection
|
|
256
|
+
* database name and TLS directive. Parse `DATABASE_URL` into the discrete
|
|
257
|
+
* `host/port/user/password/name/ssl` fields in `conf/dream.ts` instead.
|
|
258
|
+
* - `min`: node-pg's `pg-pool` does not honor it (silent no-op).
|
|
259
|
+
* - `types / Client / Promise / log / stream`: programmatic, not
|
|
260
|
+
* credential config (`types` is already wired via Dream's parsers).
|
|
261
|
+
*
|
|
262
|
+
* When another database adapter is added, a parallel `mysql?: ...` key
|
|
263
|
+
* (or similar) will appear here — this key is intentionally pg-specific.
|
|
264
|
+
*/
|
|
265
|
+
pg?: Omit<PgPoolConfig, 'user' | 'password' | 'database' | 'host' | 'port' | 'ssl' | 'connectionString' | 'min' | 'types' | 'Client' | 'Promise' | 'log' | 'stream'>;
|
|
245
266
|
}
|
|
246
267
|
export type DreamLogger = {
|
|
247
268
|
info: (...args: any[]) => void;
|