@dbos-inc/dbos-sdk 4.28.4-preview → 4.28.7-preview
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/schemas/system_db_schema.d.ts +12 -0
- package/dist/schemas/system_db_schema.d.ts.map +1 -1
- package/dist/schemas/system_db_schema.js.map +1 -1
- package/dist/src/adminserver.d.ts.map +1 -1
- package/dist/src/adminserver.js +2 -1
- package/dist/src/adminserver.js.map +1 -1
- package/dist/src/conductor/conductor.d.ts +6 -0
- package/dist/src/conductor/conductor.d.ts.map +1 -1
- package/dist/src/conductor/conductor.js +47 -12
- package/dist/src/conductor/conductor.js.map +1 -1
- package/dist/src/dbos.d.ts.map +1 -1
- package/dist/src/dbos.js +3 -0
- package/dist/src/dbos.js.map +1 -1
- package/dist/src/sysdb_migrations/internal/migrations.js +158 -0
- package/dist/src/sysdb_migrations/internal/migrations.js.map +1 -1
- package/dist/src/system_database.d.ts +30 -2
- package/dist/src/system_database.d.ts.map +1 -1
- package/dist/src/system_database.js +446 -112
- package/dist/src/system_database.js.map +1 -1
- package/dist/src/workflow_management.d.ts +4 -0
- package/dist/src/workflow_management.d.ts.map +1 -1
- package/dist/src/workflow_management.js +28 -1
- package/dist/src/workflow_management.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
|
@@ -9,7 +9,7 @@ var __metadata = (this && this.__metadata) || function (k, v) {
|
|
|
9
9
|
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
10
10
|
};
|
|
11
11
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
-
exports.SystemDatabase = exports.verifySystemDatabase = exports.ensureSystemDatabase = exports.grantDbosSchemaPermissions = exports.getDbosSchemaPermissionsSql = exports.DEFAULT_GC_BATCH_SIZE = exports.DEFAULT_RENAME_BATCH_SIZE = exports.validateObservabilityQueryTimeoutMs = exports.DEFAULT_OBSERVABILITY_QUERY_TIMEOUT_MS = exports.DEFAULT_NOTIFICATION_COALESCE_MS = exports.DBOS_STREAMS_CHANNEL = exports.DBOS_WORKFLOW_EVENTS_CHANNEL = exports.DBOS_NOTIFICATIONS_CHANNEL = exports.isLegacyClosedSentinel = exports.isStreamClosedSentinel = exports.DBOS_STREAM_CLOSED_SENTINEL_SERIALIZED = exports.DBOS_STREAM_CLOSED_SENTINEL = exports.DEFAULT_POOL_SIZE = exports.DBOS_FUNCNAME_READSTREAMOFFSET = exports.DBOS_FUNCNAME_READSTREAM = exports.DBOS_FUNCNAME_CLOSESTREAM = exports.DBOS_FUNCNAME_WRITESTREAM = exports.DBOS_FUNCNAME_GETSTATUS = exports.DBOS_FUNCNAME_SLEEP = exports.DBOS_FUNCNAME_GETEVENT = exports.DBOS_FUNCNAME_SETEVENT = exports.DBOS_FUNCNAME_RECV = exports.DBOS_FUNCNAME_SEND = void 0;
|
|
12
|
+
exports.SystemDatabase = exports.verifySystemDatabase = exports.ensureSystemDatabase = exports.retentionLockKey = exports.grantDbosSchemaPermissions = exports.getDbosSchemaPermissionsSql = exports.DEFAULT_GC_BATCH_SIZE = exports.DEFAULT_RENAME_BATCH_SIZE = exports.validateObservabilityQueryTimeoutMs = exports.DEFAULT_OBSERVABILITY_QUERY_TIMEOUT_MS = exports.DEFAULT_NOTIFICATION_COALESCE_MS = exports.DBOS_STREAMS_CHANNEL = exports.DBOS_WORKFLOW_EVENTS_CHANNEL = exports.DBOS_NOTIFICATIONS_CHANNEL = exports.isLegacyClosedSentinel = exports.isStreamClosedSentinel = exports.DBOS_STREAM_CLOSED_SENTINEL_SERIALIZED = exports.DBOS_STREAM_CLOSED_SENTINEL = exports.DEFAULT_POOL_SIZE = exports.DBOS_FUNCNAME_READSTREAMOFFSET = exports.DBOS_FUNCNAME_READSTREAM = exports.DBOS_FUNCNAME_CLOSESTREAM = exports.DBOS_FUNCNAME_WRITESTREAM = exports.DBOS_FUNCNAME_GETSTATUS = exports.DBOS_FUNCNAME_SLEEP = exports.DBOS_FUNCNAME_GETEVENT = exports.DBOS_FUNCNAME_SETEVENT = exports.DBOS_FUNCNAME_RECV = exports.DBOS_FUNCNAME_SEND = void 0;
|
|
13
13
|
const dbos_executor_1 = require("./dbos-executor");
|
|
14
14
|
const pg_1 = require("pg");
|
|
15
15
|
const error_1 = require("./error");
|
|
@@ -67,10 +67,10 @@ function validateObservabilityQueryTimeoutMs(value) {
|
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
exports.validateObservabilityQueryTimeoutMs = validateObservabilityQueryTimeoutMs;
|
|
70
|
-
// Workflows re-owned per transaction by a rename.
|
|
70
|
+
// Workflows re-owned per transaction by a rename.
|
|
71
71
|
exports.DEFAULT_RENAME_BATCH_SIZE = 10_000;
|
|
72
|
-
//
|
|
73
|
-
exports.DEFAULT_GC_BATCH_SIZE =
|
|
72
|
+
// Rows deleted per transaction by garbage collection.
|
|
73
|
+
exports.DEFAULT_GC_BATCH_SIZE = 50_000;
|
|
74
74
|
const QUEUE_COLUMN_BY_FIELD = {
|
|
75
75
|
concurrency: 'concurrency',
|
|
76
76
|
workerConcurrency: 'worker_concurrency',
|
|
@@ -186,6 +186,16 @@ async function releaseSystemDatabaseClient(client, customPool) {
|
|
|
186
186
|
}
|
|
187
187
|
catch (e) { }
|
|
188
188
|
}
|
|
189
|
+
/**
|
|
190
|
+
* The pg_try_advisory_lock argument for one schema's retention round: the leading 8 bytes of
|
|
191
|
+
* SHA-256 over a fixed string, read as a signed big-endian 64-bit integer. Separate locks for
|
|
192
|
+
* separate schemas. Every DBOS SDK derives the key this way, so rounds in different languages
|
|
193
|
+
* against one system database contend for the same lock; changing it here changes it everywhere.
|
|
194
|
+
*/
|
|
195
|
+
function retentionLockKey(schemaName) {
|
|
196
|
+
return (0, crypto_1.createHash)('sha256').update(`dbos.retention.${schemaName}`).digest().readBigInt64BE(0);
|
|
197
|
+
}
|
|
198
|
+
exports.retentionLockKey = retentionLockKey;
|
|
189
199
|
async function isCockroachDB(client) {
|
|
190
200
|
const versionRes = await client.query('SELECT version() AS version');
|
|
191
201
|
return /cockroachdb/i.test(versionRes.rows[0]?.version ?? '');
|
|
@@ -583,6 +593,11 @@ class SystemDatabase {
|
|
|
583
593
|
#batchCreatedAtCursors = new Map();
|
|
584
594
|
// Set by destroy(), so polling waits end instead of running on against a pool that outlives this handle.
|
|
585
595
|
#destroyed = false;
|
|
596
|
+
// Resolved on first use by #cockroach(), since detecting it costs a query.
|
|
597
|
+
#isCockroach = undefined;
|
|
598
|
+
// Connections a retention round holds right now. destroy() cuts them, since closing the
|
|
599
|
+
// pool would otherwise wait on the lock session and on any statement in flight.
|
|
600
|
+
#retentionClients = new Set();
|
|
586
601
|
constructor(systemDatabaseUrl, logger, serializer, sysDbPoolSize = exports.DEFAULT_POOL_SIZE, systemDatabasePool, schemaName = 'dbos', useListenNotify = true, pollingConcurrency, notificationCoalesceMs = exports.DEFAULT_NOTIFICATION_COALESCE_MS,
|
|
587
602
|
// The application this handle acts for; undefined writes unclaimed rows.
|
|
588
603
|
appName, observabilityQueryTimeoutMs = exports.DEFAULT_OBSERVABILITY_QUERY_TIMEOUT_MS) {
|
|
@@ -630,6 +645,22 @@ class SystemDatabase {
|
|
|
630
645
|
#connect() {
|
|
631
646
|
return borrowClient(this.pool, this.#onClientError);
|
|
632
647
|
}
|
|
648
|
+
/** Borrow a connection for a retention round, so destroy() can cut it. */
|
|
649
|
+
async #borrowRetentionClient() {
|
|
650
|
+
if (this.#destroyed) {
|
|
651
|
+
throw new Error('System database shutting down');
|
|
652
|
+
}
|
|
653
|
+
const client = await this.#connect();
|
|
654
|
+
this.#retentionClients.add(client);
|
|
655
|
+
return client;
|
|
656
|
+
}
|
|
657
|
+
/** Return a retention connection, unless destroy() already cut it: a second release would throw. */
|
|
658
|
+
#releaseRetentionClient(client) {
|
|
659
|
+
if (this.#retentionClients.delete(client)) {
|
|
660
|
+
// No error argument: a genuinely dead connection is still evicted by the pool's own check.
|
|
661
|
+
client.release();
|
|
662
|
+
}
|
|
663
|
+
}
|
|
633
664
|
/**
|
|
634
665
|
* Cap an introspection read with a statement timeout, so one scanning a huge table cannot hold a
|
|
635
666
|
* snapshot for minutes and stall autovacuum database-wide. Soft-private so tests can assert the cap
|
|
@@ -750,6 +781,20 @@ class SystemDatabase {
|
|
|
750
781
|
if (this.notificationsClient) {
|
|
751
782
|
this.#retireNotificationsClient(this.notificationsClient);
|
|
752
783
|
}
|
|
784
|
+
// A retention round still running is cut here rather than waited for: returned with an
|
|
785
|
+
// error, each connection is destroyed at once, the idle lock session lets go of the
|
|
786
|
+
// advisory lock, and the round fails on its next statement instead of holding shutdown.
|
|
787
|
+
for (const client of this.#retentionClients) {
|
|
788
|
+
// Cover the release() call itself, which tears the connection down and can surface a socket error.
|
|
789
|
+
client.on('error', () => { });
|
|
790
|
+
try {
|
|
791
|
+
client.release(new Error('System database shutting down'));
|
|
792
|
+
}
|
|
793
|
+
catch (e) {
|
|
794
|
+
this.logger.warn(`Error releasing a retention connection: ${String(e)}`);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
this.#retentionClients.clear();
|
|
753
798
|
// We attached nothing to the pool object itself, so there is nothing to unpick; only close one we own.
|
|
754
799
|
if (!this.customPool) {
|
|
755
800
|
await this.pool.end();
|
|
@@ -953,7 +998,9 @@ class SystemDatabase {
|
|
|
953
998
|
tuples.push(`(${columns.map(() => `$${paramIdx++}`).join(', ')})`);
|
|
954
999
|
params.push(status.workflowUUID, status.status, status.workflowName,
|
|
955
1000
|
// For cross-language compatibility, these MUST be NULL in the database when not set
|
|
956
|
-
status.workflowClassName === '' ? null : status.workflowClassName, status.workflowConfigName === '' ? null : status.workflowConfigName, status.queueName ?? null, status.authenticatedUser, status.assumedRole, JSON.stringify(status.authenticatedRoles), JSON.stringify(status.request), status.executorId, status.applicationVersion ?? null, status.applicationID, createdAt, 0, createdAt, status.timeoutMS ?? null, status.deadlineEpochMS ?? null,
|
|
1001
|
+
status.workflowClassName === '' ? null : status.workflowClassName, status.workflowConfigName === '' ? null : status.workflowConfigName, status.queueName ?? null, status.authenticatedUser, status.assumedRole, JSON.stringify(status.authenticatedRoles), JSON.stringify(status.request), status.executorId, status.applicationVersion ?? null, status.applicationID, createdAt, 0, createdAt, status.timeoutMS ?? null, status.deadlineEpochMS ?? null,
|
|
1002
|
+
// Legacy column: the payload lives in workflow_input.
|
|
1003
|
+
null, null, status.priority, status.queuePartitionKey ?? null, status.parentWorkflowID ?? null, status.serialization, null, status.delayUntilEpochMS ?? null, status.attributes ? JSON.stringify(status.attributes) : null, status.scheduleName ?? null, status.applicationName ?? null);
|
|
957
1004
|
}
|
|
958
1005
|
const { rows } = await client.query(`INSERT INTO "${this.schemaName}".workflow_status (${columns.join(', ')})
|
|
959
1006
|
VALUES ${tuples.join(', ')}
|
|
@@ -963,6 +1010,19 @@ class SystemDatabase {
|
|
|
963
1010
|
inserted.add(row.workflow_uuid);
|
|
964
1011
|
}
|
|
965
1012
|
}
|
|
1013
|
+
for (let start = 0; start < statuses.length; start += chunkSize) {
|
|
1014
|
+
const chunk = statuses.slice(start, start + chunkSize);
|
|
1015
|
+
const tuples = [];
|
|
1016
|
+
const params = [];
|
|
1017
|
+
let paramIdx = 1;
|
|
1018
|
+
for (let i = 0; i < chunk.length; i++) {
|
|
1019
|
+
tuples.push(`($${paramIdx++}, $${paramIdx++}, $${paramIdx++})`);
|
|
1020
|
+
params.push(chunk[i].workflowUUID, chunk[i].input, createdAts[start + i]);
|
|
1021
|
+
}
|
|
1022
|
+
await client.query(`INSERT INTO "${this.schemaName}".workflow_input (workflow_uuid, inputs, retention_timestamp)
|
|
1023
|
+
VALUES ${tuples.join(', ')}
|
|
1024
|
+
ON CONFLICT (workflow_uuid) DO NOTHING`, params);
|
|
1025
|
+
}
|
|
966
1026
|
await client.query('COMMIT');
|
|
967
1027
|
}
|
|
968
1028
|
catch (e) {
|
|
@@ -1004,12 +1064,33 @@ class SystemDatabase {
|
|
|
1004
1064
|
// concurrent resume), or deleted; the caller resolves which by awaiting the
|
|
1005
1065
|
// recorded outcome.
|
|
1006
1066
|
async #recordWorkflowOutcome(client, workflowID, status, outcome) {
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1067
|
+
let committed = false;
|
|
1068
|
+
try {
|
|
1069
|
+
await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');
|
|
1070
|
+
const rowCount = await this.updateWorkflowStatus(client, workflowID, status, {
|
|
1071
|
+
update: { resetDeduplicationID: true, setCompletedAt: true },
|
|
1072
|
+
where: { status: workflow_1.StatusString.PENDING },
|
|
1073
|
+
throwOnFailure: false,
|
|
1074
|
+
});
|
|
1075
|
+
if (rowCount === 0) {
|
|
1076
|
+
// The outcome was not ours to write, so leave no orphan payload.
|
|
1077
|
+
return false;
|
|
1078
|
+
}
|
|
1079
|
+
await client.query(`INSERT INTO "${this.schemaName}".workflow_output (workflow_uuid, output, error)
|
|
1080
|
+
VALUES ($1, $2, $3)
|
|
1081
|
+
ON CONFLICT (workflow_uuid) DO UPDATE SET output = EXCLUDED.output, error = EXCLUDED.error`, [workflowID, outcome.output ?? null, outcome.error ?? null]);
|
|
1082
|
+
committed = true;
|
|
1083
|
+
return true;
|
|
1084
|
+
}
|
|
1085
|
+
finally {
|
|
1086
|
+
if (committed) {
|
|
1087
|
+
await client.query('COMMIT');
|
|
1088
|
+
}
|
|
1089
|
+
else {
|
|
1090
|
+
// Swallowed: a rollback failure must not mask why we are rolling back.
|
|
1091
|
+
await client.query('ROLLBACK').catch(() => undefined);
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1013
1094
|
}
|
|
1014
1095
|
async getPendingWorkflows(executorID, appVersion) {
|
|
1015
1096
|
const params = [workflow_1.StatusString.PENDING, executorID, appVersion];
|
|
@@ -1183,8 +1264,8 @@ class SystemDatabase {
|
|
|
1183
1264
|
// Insert a patchmarker
|
|
1184
1265
|
const dn = Date.now();
|
|
1185
1266
|
await this.pool.query(`INSERT INTO ${this.schemaName}.operation_outputs
|
|
1186
|
-
(workflow_uuid, function_id, output, error, function_name, child_workflow_id, started_at_epoch_ms, completed_at_epoch_ms, application_name)
|
|
1187
|
-
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
1267
|
+
(workflow_uuid, function_id, output, error, function_name, child_workflow_id, started_at_epoch_ms, completed_at_epoch_ms, application_name, retention_timestamp)
|
|
1268
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, (EXTRACT(EPOCH FROM now()) * 1000)::bigint)
|
|
1188
1269
|
ON CONFLICT DO NOTHING;`, [workflowID, functionID, null, null, patchName, null, dn, dn, this.appName ?? null]);
|
|
1189
1270
|
return { isPatched: true, hasEntry: true };
|
|
1190
1271
|
}
|
|
@@ -1275,7 +1356,6 @@ class SystemDatabase {
|
|
|
1275
1356
|
const classNameOrNull = params.workflowClassName === '' ? null : params.workflowClassName;
|
|
1276
1357
|
const updateParams = [
|
|
1277
1358
|
params.delayUntilEpochMS,
|
|
1278
|
-
params.input,
|
|
1279
1359
|
params.serialization,
|
|
1280
1360
|
params.workflowName,
|
|
1281
1361
|
classNameOrNull,
|
|
@@ -1292,16 +1372,19 @@ class SystemDatabase {
|
|
|
1292
1372
|
THEN debounce_deadline_epoch_ms
|
|
1293
1373
|
ELSE $1
|
|
1294
1374
|
END,
|
|
1295
|
-
|
|
1375
|
+
serialization = $2,
|
|
1296
1376
|
updated_at = (EXTRACT(EPOCH FROM now()) * 1000)::bigint,
|
|
1297
1377
|
-- Claim it for the target, as its dequeue would: left unclaimed, every peer coalesces onto the one workflow and the last inputs win.
|
|
1298
|
-
application_name = COALESCE(application_name, $
|
|
1299
|
-
WHERE name = $
|
|
1300
|
-
AND queue_name = $
|
|
1301
|
-
AND status = $
|
|
1378
|
+
application_name = COALESCE(application_name, $8)
|
|
1379
|
+
WHERE name = $3 AND class_name IS NOT DISTINCT FROM $4
|
|
1380
|
+
AND queue_name = $5 AND deduplication_id = $6
|
|
1381
|
+
AND status = $7 AND is_debounced = TRUE
|
|
1302
1382
|
AND ${ownScope}
|
|
1303
1383
|
RETURNING workflow_uuid`, updateParams);
|
|
1304
1384
|
if (updated.rows.length > 0) {
|
|
1385
|
+
await client.query(`INSERT INTO "${this.schemaName}".workflow_input (workflow_uuid, inputs, retention_timestamp)
|
|
1386
|
+
VALUES ($1, $2, (EXTRACT(EPOCH FROM now()) * 1000)::bigint)
|
|
1387
|
+
ON CONFLICT (workflow_uuid) DO UPDATE SET inputs = EXCLUDED.inputs`, [updated.rows[0].workflow_uuid, params.input]);
|
|
1305
1388
|
return {
|
|
1306
1389
|
bouncedWorkflowID: updated.rows[0].workflow_uuid,
|
|
1307
1390
|
holderWorkflowID: null,
|
|
@@ -1364,7 +1447,25 @@ class SystemDatabase {
|
|
|
1364
1447
|
allIds.push(...(await this.getWorkflowChildren(wfid)));
|
|
1365
1448
|
}
|
|
1366
1449
|
}
|
|
1367
|
-
|
|
1450
|
+
const client = await this.#connect();
|
|
1451
|
+
try {
|
|
1452
|
+
await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');
|
|
1453
|
+
// The payload tables carry no foreign key, so the status delete does not cascade
|
|
1454
|
+
// into them; without this they survive as orphans.
|
|
1455
|
+
for (const table of ['workflow_input', 'workflow_output', 'operation_outputs']) {
|
|
1456
|
+
await client.query(`DELETE FROM "${this.schemaName}".${table} WHERE workflow_uuid = ANY($1)`, [allIds]);
|
|
1457
|
+
}
|
|
1458
|
+
await client.query(`DELETE FROM "${this.schemaName}".workflow_status WHERE workflow_uuid = ANY($1)`, [allIds]);
|
|
1459
|
+
await client.query('COMMIT');
|
|
1460
|
+
}
|
|
1461
|
+
catch (e) {
|
|
1462
|
+
// Swallowed: a rollback failure must not mask why we are rolling back.
|
|
1463
|
+
await client.query('ROLLBACK').catch(() => undefined);
|
|
1464
|
+
throw e;
|
|
1465
|
+
}
|
|
1466
|
+
finally {
|
|
1467
|
+
client.release();
|
|
1468
|
+
}
|
|
1368
1469
|
for (const wfid of allIds) {
|
|
1369
1470
|
this.runningWorkflowMap.delete(wfid);
|
|
1370
1471
|
}
|
|
@@ -1441,11 +1542,13 @@ class SystemDatabase {
|
|
|
1441
1542
|
try {
|
|
1442
1543
|
await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');
|
|
1443
1544
|
// Fetch the status of all original workflows inside the transaction.
|
|
1444
|
-
const { rows: statusRows } = await client.query(`SELECT workflow_uuid, name, class_name, config_name, application_id,
|
|
1445
|
-
authenticated_user, authenticated_roles, assumed_role,
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1545
|
+
const { rows: statusRows } = await client.query(`SELECT ws.workflow_uuid, ws.name, ws.class_name, ws.config_name, ws.application_id,
|
|
1546
|
+
ws.authenticated_user, ws.authenticated_roles, ws.assumed_role,
|
|
1547
|
+
COALESCE(wi.inputs, ws.inputs) AS inputs, ws.serialization,
|
|
1548
|
+
ws.request, ws.application_version, ws.attributes, ws.application_name
|
|
1549
|
+
FROM "${this.schemaName}".workflow_status ws
|
|
1550
|
+
LEFT JOIN "${this.schemaName}".workflow_input wi ON wi.workflow_uuid = ws.workflow_uuid
|
|
1551
|
+
WHERE ws.workflow_uuid = ANY($1)`, [originalWorkflowIDs]);
|
|
1449
1552
|
const statusByID = new Map(statusRows.map((r) => [r.workflow_uuid, r]));
|
|
1450
1553
|
for (const wid of originalWorkflowIDs) {
|
|
1451
1554
|
if (!statusByID.has(wid)) {
|
|
@@ -1491,13 +1594,24 @@ class SystemDatabase {
|
|
|
1491
1594
|
const ws = statusByID.get(origID);
|
|
1492
1595
|
const placeholders = insertCols.map(() => `$${paramIdx++}`).join(', ');
|
|
1493
1596
|
valuesPlaceholders.push(`(${placeholders})`);
|
|
1494
|
-
params.push(forkID, workflow_1.StatusString.ENQUEUED, ws.name, ws.class_name ?? null, ws.config_name ?? null, queueName, ws.authenticated_user, ws.assumed_role, ws.authenticated_roles, ws.request, options.applicationVersion ?? ws.application_version ?? null, ws.application_id,
|
|
1597
|
+
params.push(forkID, workflow_1.StatusString.ENQUEUED, ws.name, ws.class_name ?? null, ws.config_name ?? null, queueName, ws.authenticated_user, ws.assumed_role, ws.authenticated_roles, ws.request, options.applicationVersion ?? ws.application_version ?? null, ws.application_id,
|
|
1598
|
+
// Legacy column: the payload lives in workflow_input.
|
|
1599
|
+
null, options.queuePartitionKey ?? null, origID, ws.serialization, ws.attributes ? JSON.stringify(ws.attributes) : null, forkOwners.get(forkID) ?? null);
|
|
1495
1600
|
if (options.timeoutMS !== undefined) {
|
|
1496
1601
|
params.push(options.timeoutMS);
|
|
1497
1602
|
}
|
|
1498
1603
|
}
|
|
1499
1604
|
await client.query(`INSERT INTO "${this.schemaName}".workflow_status (${insertCols.join(', ')})
|
|
1500
1605
|
VALUES ${valuesPlaceholders.join(', ')}`, params);
|
|
1606
|
+
const inputPlaceholders = [];
|
|
1607
|
+
const inputParams = [];
|
|
1608
|
+
let inputIdx = 1;
|
|
1609
|
+
for (let i = 0; i < originalWorkflowIDs.length; i++) {
|
|
1610
|
+
inputPlaceholders.push(`($${inputIdx++}, $${inputIdx++})`);
|
|
1611
|
+
inputParams.push(forkedWorkflowIDs[i], statusByID.get(originalWorkflowIDs[i]).inputs);
|
|
1612
|
+
}
|
|
1613
|
+
await client.query(`INSERT INTO "${this.schemaName}".workflow_input (workflow_uuid, inputs)
|
|
1614
|
+
VALUES ${inputPlaceholders.join(', ')}`, inputParams);
|
|
1501
1615
|
// Mark all original workflows as having been forked from.
|
|
1502
1616
|
await client.query(`UPDATE "${this.schemaName}".workflow_status SET was_forked_from = TRUE WHERE workflow_uuid = ANY($1)`, [originalWorkflowIDs]);
|
|
1503
1617
|
// For workflows with start_step > 0, copy checkpoints/events/streams.
|
|
@@ -1531,8 +1645,8 @@ class SystemDatabase {
|
|
|
1531
1645
|
// Copy operation outputs
|
|
1532
1646
|
await client.query(`${mappingCTE}
|
|
1533
1647
|
INSERT INTO "${this.schemaName}".operation_outputs
|
|
1534
|
-
(workflow_uuid, function_id, output, error, serialization, function_name, child_workflow_id, started_at_epoch_ms, completed_at_epoch_ms, application_name)
|
|
1535
|
-
SELECT m.fork_id, oo.function_id, oo.output, oo.error, oo.serialization, oo.function_name, ${childWfExpr}, oo.started_at_epoch_ms, oo.completed_at_epoch_ms, m.owner
|
|
1648
|
+
(workflow_uuid, function_id, output, error, serialization, function_name, child_workflow_id, started_at_epoch_ms, completed_at_epoch_ms, application_name, retention_timestamp)
|
|
1649
|
+
SELECT m.fork_id, oo.function_id, oo.output, oo.error, oo.serialization, oo.function_name, ${childWfExpr}, oo.started_at_epoch_ms, oo.completed_at_epoch_ms, m.owner, (EXTRACT(EPOCH FROM now()) * 1000)::bigint
|
|
1536
1650
|
FROM mapping m
|
|
1537
1651
|
JOIN "${this.schemaName}".operation_outputs oo
|
|
1538
1652
|
ON oo.workflow_uuid = m.orig_id AND oo.function_id < m.start_step`, ooParams);
|
|
@@ -1592,17 +1706,22 @@ class SystemDatabase {
|
|
|
1592
1706
|
// token, not logical workflow state, and a source database's xid is
|
|
1593
1707
|
// meaningless in the target.
|
|
1594
1708
|
`SELECT
|
|
1595
|
-
workflow_uuid, status, name, authenticated_user, assumed_role,
|
|
1596
|
-
authenticated_roles, request,
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1709
|
+
ws.workflow_uuid, ws.status, ws.name, ws.authenticated_user, ws.assumed_role,
|
|
1710
|
+
ws.authenticated_roles, ws.request,
|
|
1711
|
+
COALESCE(wo.output, ws.output) AS output, COALESCE(wo.error, ws.error) AS error,
|
|
1712
|
+
ws.executor_id,
|
|
1713
|
+
ws.created_at, ws.updated_at, ws.application_version, ws.application_id,
|
|
1714
|
+
ws.class_name, ws.config_name, ws.recovery_attempts, ws.queue_name,
|
|
1715
|
+
ws.workflow_timeout_ms, ws.workflow_deadline_epoch_ms, ws.started_at_epoch_ms,
|
|
1716
|
+
ws.deduplication_id, COALESCE(wi.inputs, ws.inputs) AS inputs,
|
|
1717
|
+
ws.priority, ws.queue_partition_key, ws.forked_from,
|
|
1718
|
+
ws.parent_workflow_id, ws.serialization, ws.delay_until_epoch_ms,
|
|
1719
|
+
ws.was_forked_from, ws.rate_limited, ws.completed_at, ws.attributes, ws.schedule_name,
|
|
1720
|
+
ws.debounce_deadline_epoch_ms, ws.is_debounced, ws.application_name
|
|
1721
|
+
FROM "${this.schemaName}".workflow_status ws
|
|
1722
|
+
LEFT JOIN "${this.schemaName}".workflow_input wi ON wi.workflow_uuid = ws.workflow_uuid
|
|
1723
|
+
LEFT JOIN "${this.schemaName}".workflow_output wo ON wo.workflow_uuid = ws.workflow_uuid
|
|
1724
|
+
WHERE ws.workflow_uuid = $1`, [wfID]);
|
|
1606
1725
|
if (statusResult.rows.length === 0) {
|
|
1607
1726
|
throw new error_1.DBOSNonExistentWorkflowError(`Workflow ${wfID} does not exist`);
|
|
1608
1727
|
}
|
|
@@ -1665,8 +1784,9 @@ class SystemDatabase {
|
|
|
1665
1784
|
status.assumed_role,
|
|
1666
1785
|
status.authenticated_roles,
|
|
1667
1786
|
status.request,
|
|
1668
|
-
|
|
1669
|
-
|
|
1787
|
+
// Legacy columns: the payloads live in their own tables.
|
|
1788
|
+
null,
|
|
1789
|
+
null,
|
|
1670
1790
|
status.executor_id,
|
|
1671
1791
|
status.created_at,
|
|
1672
1792
|
status.updated_at,
|
|
@@ -1680,7 +1800,7 @@ class SystemDatabase {
|
|
|
1680
1800
|
status.workflow_deadline_epoch_ms,
|
|
1681
1801
|
status.started_at_epoch_ms,
|
|
1682
1802
|
status.deduplication_id,
|
|
1683
|
-
|
|
1803
|
+
null,
|
|
1684
1804
|
status.priority,
|
|
1685
1805
|
status.queue_partition_key,
|
|
1686
1806
|
status.forked_from,
|
|
@@ -1698,13 +1818,21 @@ class SystemDatabase {
|
|
|
1698
1818
|
status.is_debounced ?? false,
|
|
1699
1819
|
status.application_name ?? null,
|
|
1700
1820
|
]);
|
|
1821
|
+
// Retention starts at import: the original timestamps are long past the cutoff
|
|
1822
|
+
// and would be collected immediately.
|
|
1823
|
+
await client.query(`INSERT INTO "${this.schemaName}".workflow_input (workflow_uuid, inputs, retention_timestamp)
|
|
1824
|
+
VALUES ($1, $2, (EXTRACT(EPOCH FROM now()) * 1000)::bigint)`, [status.workflow_uuid, status.inputs]);
|
|
1825
|
+
if (status.output !== null || status.error !== null) {
|
|
1826
|
+
await client.query(`INSERT INTO "${this.schemaName}".workflow_output (workflow_uuid, output, error, retention_timestamp)
|
|
1827
|
+
VALUES ($1, $2, $3, (EXTRACT(EPOCH FROM now()) * 1000)::bigint)`, [status.workflow_uuid, status.output, status.error]);
|
|
1828
|
+
}
|
|
1701
1829
|
// Import operation_outputs
|
|
1702
1830
|
for (const output of workflow.operation_outputs) {
|
|
1703
1831
|
await client.query(`INSERT INTO "${this.schemaName}".operation_outputs (
|
|
1704
1832
|
workflow_uuid, function_id, function_name, output, error,
|
|
1705
1833
|
child_workflow_id, started_at_epoch_ms, completed_at_epoch_ms,
|
|
1706
|
-
serialization, application_name
|
|
1707
|
-
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, [
|
|
1834
|
+
serialization, application_name, retention_timestamp
|
|
1835
|
+
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, (EXTRACT(EPOCH FROM now()) * 1000)::bigint)`, [
|
|
1708
1836
|
output.workflow_uuid,
|
|
1709
1837
|
output.function_id,
|
|
1710
1838
|
output.function_name,
|
|
@@ -1867,8 +1995,11 @@ class SystemDatabase {
|
|
|
1867
1995
|
await this.checkIfCanceledLimited(callerID);
|
|
1868
1996
|
let rows;
|
|
1869
1997
|
try {
|
|
1870
|
-
({ rows } = await this.#pollWithLimiter(() => this.pool.query(`SELECT status, output,
|
|
1871
|
-
|
|
1998
|
+
({ rows } = await this.#pollWithLimiter(() => this.pool.query(`SELECT ws.status, COALESCE(wo.output, ws.output) AS output,
|
|
1999
|
+
COALESCE(wo.error, ws.error) AS error, ws.serialization
|
|
2000
|
+
FROM "${this.schemaName}".workflow_status ws
|
|
2001
|
+
LEFT JOIN "${this.schemaName}".workflow_output wo ON wo.workflow_uuid = ws.workflow_uuid
|
|
2002
|
+
WHERE ws.workflow_uuid=$1`, [workflowID])));
|
|
1872
2003
|
}
|
|
1873
2004
|
catch (e) {
|
|
1874
2005
|
const err = e;
|
|
@@ -2858,11 +2989,18 @@ class SystemDatabase {
|
|
|
2858
2989
|
];
|
|
2859
2990
|
input.loadInput = input.loadInput ?? true;
|
|
2860
2991
|
input.loadOutput = input.loadOutput ?? true;
|
|
2992
|
+
// Only join the payload table the caller actually asked for.
|
|
2993
|
+
const payloadColumns = [];
|
|
2994
|
+
const payloadJoins = [];
|
|
2861
2995
|
if (input.loadInput) {
|
|
2862
|
-
selectColumns.push('
|
|
2996
|
+
selectColumns.push('request');
|
|
2997
|
+
payloadColumns.push('COALESCE(wi.inputs, workflow_status.inputs) AS inputs');
|
|
2998
|
+
payloadJoins.push(`LEFT JOIN "${schemaName}".workflow_input wi ON wi.workflow_uuid = workflow_status.workflow_uuid`);
|
|
2863
2999
|
}
|
|
2864
3000
|
if (input.loadOutput) {
|
|
2865
|
-
|
|
3001
|
+
payloadColumns.push('COALESCE(wo.output, workflow_status.output) AS output');
|
|
3002
|
+
payloadColumns.push('COALESCE(wo.error, workflow_status.error) AS error');
|
|
3003
|
+
payloadJoins.push(`LEFT JOIN "${schemaName}".workflow_output wo ON wo.workflow_uuid = workflow_status.workflow_uuid`);
|
|
2866
3004
|
}
|
|
2867
3005
|
if (input.loadInput || input.loadOutput) {
|
|
2868
3006
|
selectColumns.push('serialization');
|
|
@@ -2910,20 +3048,20 @@ class SystemDatabase {
|
|
|
2910
3048
|
paramCounter = params.length + 1;
|
|
2911
3049
|
if (input.workflow_id_prefix) {
|
|
2912
3050
|
if (Array.isArray(input.workflow_id_prefix)) {
|
|
2913
|
-
const likeClauses = input.workflow_id_prefix.map((_, i) => `workflow_uuid LIKE $${paramCounter + i}`);
|
|
3051
|
+
const likeClauses = input.workflow_id_prefix.map((_, i) => `workflow_status.workflow_uuid LIKE $${paramCounter + i}`);
|
|
2914
3052
|
whereClauses.push(`(${likeClauses.join(' OR ')})`);
|
|
2915
3053
|
params.push(...input.workflow_id_prefix.map((p) => `${p}%`));
|
|
2916
3054
|
paramCounter += input.workflow_id_prefix.length;
|
|
2917
3055
|
}
|
|
2918
3056
|
else {
|
|
2919
|
-
whereClauses.push(`workflow_uuid LIKE $${paramCounter}`);
|
|
3057
|
+
whereClauses.push(`workflow_status.workflow_uuid LIKE $${paramCounter}`);
|
|
2920
3058
|
params.push(`${input.workflow_id_prefix}%`);
|
|
2921
3059
|
paramCounter++;
|
|
2922
3060
|
}
|
|
2923
3061
|
}
|
|
2924
3062
|
if (input.workflowIDs) {
|
|
2925
3063
|
const placeholders = input.workflowIDs.map((_, i) => `$${paramCounter + i}`).join(', ');
|
|
2926
|
-
whereClauses.push(`workflow_uuid IN (${placeholders})`);
|
|
3064
|
+
whereClauses.push(`workflow_status.workflow_uuid IN (${placeholders})`);
|
|
2927
3065
|
params.push(...input.workflowIDs);
|
|
2928
3066
|
paramCounter += input.workflowIDs.length;
|
|
2929
3067
|
}
|
|
@@ -2989,9 +3127,11 @@ class SystemDatabase {
|
|
|
2989
3127
|
const orderClause = `ORDER BY created_at ${input.sortDesc ? 'DESC' : 'ASC'}`;
|
|
2990
3128
|
const limitClause = input.limit ? `LIMIT ${input.limit}` : '';
|
|
2991
3129
|
const offsetClause = input.offset ? `OFFSET ${input.offset}` : '';
|
|
3130
|
+
const projection = [...selectColumns.map((c) => `workflow_status.${c}`), ...payloadColumns].join(', ');
|
|
2992
3131
|
const query = `
|
|
2993
|
-
SELECT ${
|
|
3132
|
+
SELECT ${projection}
|
|
2994
3133
|
FROM "${schemaName}".workflow_status
|
|
3134
|
+
${payloadJoins.join('\n ')}
|
|
2995
3135
|
${whereClause}
|
|
2996
3136
|
${orderClause}
|
|
2997
3137
|
${limitClause}
|
|
@@ -3276,17 +3416,215 @@ class SystemDatabase {
|
|
|
3276
3416
|
};
|
|
3277
3417
|
});
|
|
3278
3418
|
}
|
|
3279
|
-
/**
|
|
3419
|
+
/** Whether the system database is CockroachDB. Resolved once, since detecting it costs a query. */
|
|
3420
|
+
async #cockroach() {
|
|
3421
|
+
if (this.#isCockroach === undefined) {
|
|
3422
|
+
const client = await this.#connect();
|
|
3423
|
+
try {
|
|
3424
|
+
this.#isCockroach = await isCockroachDB(client);
|
|
3425
|
+
}
|
|
3426
|
+
finally {
|
|
3427
|
+
client.release();
|
|
3428
|
+
}
|
|
3429
|
+
}
|
|
3430
|
+
return this.#isCockroach;
|
|
3431
|
+
}
|
|
3432
|
+
/**
|
|
3433
|
+
* Take a database-wide lock for one retention round, returning how to release it, or
|
|
3434
|
+
* undefined when another round already holds it. The lock is session-scoped, so a round
|
|
3435
|
+
* that crashes releases it. CockroachDB has no advisory locks and always takes it, so it
|
|
3436
|
+
* collects unprotected rather than not at all.
|
|
3437
|
+
*/
|
|
3438
|
+
async acquireRetentionLock() {
|
|
3439
|
+
if (await this.#cockroach()) {
|
|
3440
|
+
return { release: () => Promise.resolve() };
|
|
3441
|
+
}
|
|
3442
|
+
const key = retentionLockKey(this.schemaName).toString();
|
|
3443
|
+
// The round holds this connection until it ends: releasing it would drop the lock.
|
|
3444
|
+
const client = await this.#borrowRetentionClient();
|
|
3445
|
+
let acquired = false;
|
|
3446
|
+
try {
|
|
3447
|
+
const { rows } = await client.query('SELECT pg_try_advisory_lock($1) AS locked', [key]);
|
|
3448
|
+
acquired = rows[0]?.locked === true;
|
|
3449
|
+
}
|
|
3450
|
+
catch (e) {
|
|
3451
|
+
this.#releaseRetentionClient(client);
|
|
3452
|
+
throw e;
|
|
3453
|
+
}
|
|
3454
|
+
if (!acquired) {
|
|
3455
|
+
this.#releaseRetentionClient(client);
|
|
3456
|
+
return undefined;
|
|
3457
|
+
}
|
|
3458
|
+
return {
|
|
3459
|
+
release: async () => {
|
|
3460
|
+
// Already cut by destroy(): the session is gone and took the lock with it.
|
|
3461
|
+
if (!this.#retentionClients.has(client)) {
|
|
3462
|
+
return;
|
|
3463
|
+
}
|
|
3464
|
+
try {
|
|
3465
|
+
// Explicit, since releasing the client only returns the session to the pool.
|
|
3466
|
+
const { rows } = await client.query('SELECT pg_advisory_unlock($1) AS released', [
|
|
3467
|
+
key,
|
|
3468
|
+
]);
|
|
3469
|
+
if (rows[0]?.released !== true) {
|
|
3470
|
+
// False means this session no longer holds it, which a transaction-pooling proxy
|
|
3471
|
+
// causes by switching backends.
|
|
3472
|
+
this.logger.warn('Could not release the retention lock: this session no longer holds it. Retention will ' +
|
|
3473
|
+
'not proceed until the lock is released, which happens when the holding backend closes. ' +
|
|
3474
|
+
'A transaction-pooling proxy in front of Postgres causes this; run DBOS through a ' +
|
|
3475
|
+
'session-pooled or direct connection.');
|
|
3476
|
+
}
|
|
3477
|
+
}
|
|
3478
|
+
finally {
|
|
3479
|
+
this.#releaseRetentionClient(client);
|
|
3480
|
+
}
|
|
3481
|
+
},
|
|
3482
|
+
};
|
|
3483
|
+
}
|
|
3484
|
+
/** VACUUM the tables a sweep dirtied. No-op where there is no autovacuum to outrun. */
|
|
3485
|
+
async #vacuumTables(tables) {
|
|
3486
|
+
if (await this.#cockroach()) {
|
|
3487
|
+
return;
|
|
3488
|
+
}
|
|
3489
|
+
const client = await this.#borrowRetentionClient();
|
|
3490
|
+
let notices = [];
|
|
3491
|
+
const onNotice = (notice) => notices.push(notice.message ?? '');
|
|
3492
|
+
client.on('notice', onNotice);
|
|
3493
|
+
try {
|
|
3494
|
+
for (const table of tables) {
|
|
3495
|
+
// Per table, so one refusal does not skip the rest.
|
|
3496
|
+
notices = [];
|
|
3497
|
+
try {
|
|
3498
|
+
await client.query(`VACUUM (INDEX_CLEANUP ON, TRUNCATE OFF, ANALYZE) "${this.schemaName}"."${table}"`);
|
|
3499
|
+
}
|
|
3500
|
+
catch (e) {
|
|
3501
|
+
if (!this.#retentionClients.has(client)) {
|
|
3502
|
+
throw e;
|
|
3503
|
+
}
|
|
3504
|
+
this.logger.warn(`Payload retention could not vacuum ${table}: ${e.message}`);
|
|
3505
|
+
continue;
|
|
3506
|
+
}
|
|
3507
|
+
// A refused or stalled VACUUM does not raise, it says so in a notice; a successful
|
|
3508
|
+
// one is silent, so anything here is worth surfacing.
|
|
3509
|
+
for (const notice of notices) {
|
|
3510
|
+
this.logger.warn(`Payload retention vacuuming ${table}: ${notice}`);
|
|
3511
|
+
}
|
|
3512
|
+
}
|
|
3513
|
+
}
|
|
3514
|
+
finally {
|
|
3515
|
+
client.removeListener('notice', onNotice);
|
|
3516
|
+
this.#releaseRetentionClient(client);
|
|
3517
|
+
}
|
|
3518
|
+
}
|
|
3519
|
+
/** Delete one payload table's orphans below the cutoff, one batch per transaction. */
|
|
3520
|
+
async #garbageCollectTable(table, cutoff, batchSize) {
|
|
3521
|
+
// A payload below the cutoff belongs to a workflow created before it, so the status side
|
|
3522
|
+
// is the few such rows still present, not the whole table.
|
|
3523
|
+
const orphaned = `NOT EXISTS (
|
|
3524
|
+
SELECT 1 FROM "${this.schemaName}".workflow_status ws
|
|
3525
|
+
WHERE ws.workflow_uuid = t.workflow_uuid AND ws.created_at < $1
|
|
3526
|
+
)`;
|
|
3527
|
+
// Seed from the oldest row in range.
|
|
3528
|
+
const oldest = await retryOnSerializationError(async () => {
|
|
3529
|
+
const { rows } = await this.pool.query(`SELECT retention_timestamp
|
|
3530
|
+
FROM "${this.schemaName}".${table}
|
|
3531
|
+
WHERE retention_timestamp < $1
|
|
3532
|
+
ORDER BY retention_timestamp
|
|
3533
|
+
LIMIT 1`, [cutoff]);
|
|
3534
|
+
// retention_timestamp is a bigint, so node-postgres hands it back as a string.
|
|
3535
|
+
return rows.length > 0 ? Number(rows[0].retention_timestamp) : undefined;
|
|
3536
|
+
});
|
|
3537
|
+
if (oldest === undefined) {
|
|
3538
|
+
return 0;
|
|
3539
|
+
}
|
|
3540
|
+
let total = 0;
|
|
3541
|
+
let watermark = oldest - 1;
|
|
3542
|
+
for (;;) {
|
|
3543
|
+
const batch = await retryOnSerializationError(async () => {
|
|
3544
|
+
// Borrowed rather than pool.query'd: that releases with the error, which discards the
|
|
3545
|
+
// connection on a deadlock, so the retry wrapping this would churn the pool per batch.
|
|
3546
|
+
const client = await this.#borrowRetentionClient();
|
|
3547
|
+
try {
|
|
3548
|
+
// Batches are cut by candidate count, so rows spared by the anti-join only thin one
|
|
3549
|
+
// out; they are re-checked next round.
|
|
3550
|
+
const { rows } = await client.query(`SELECT retention_timestamp
|
|
3551
|
+
FROM "${this.schemaName}".${table}
|
|
3552
|
+
WHERE retention_timestamp < $1 AND retention_timestamp > $2
|
|
3553
|
+
ORDER BY retention_timestamp
|
|
3554
|
+
LIMIT 1 OFFSET ${batchSize - 1}`, [cutoff, watermark]);
|
|
3555
|
+
const step = rows.length > 0 ? Number(rows[0].retention_timestamp) : undefined;
|
|
3556
|
+
const params = [cutoff, watermark];
|
|
3557
|
+
// Timestamp ties may push the batch slightly over batchSize, but never split across two.
|
|
3558
|
+
const upperBound = step === undefined ? '' : `AND t.retention_timestamp <= $${params.push(step)} `;
|
|
3559
|
+
const result = await client.query(`DELETE FROM "${this.schemaName}".${table} t
|
|
3560
|
+
WHERE t.retention_timestamp < $1 AND t.retention_timestamp > $2 ${upperBound}AND ${orphaned}`, params);
|
|
3561
|
+
return { step, deleted: result.rowCount ?? 0 };
|
|
3562
|
+
}
|
|
3563
|
+
finally {
|
|
3564
|
+
this.#releaseRetentionClient(client);
|
|
3565
|
+
}
|
|
3566
|
+
});
|
|
3567
|
+
total += batch.deleted;
|
|
3568
|
+
if (batch.step === undefined) {
|
|
3569
|
+
return total;
|
|
3570
|
+
}
|
|
3571
|
+
watermark = batch.step;
|
|
3572
|
+
}
|
|
3573
|
+
}
|
|
3574
|
+
/**
|
|
3575
|
+
* Delete payload and step rows below the cutoff whose workflow is gone, returning the count
|
|
3576
|
+
* removed from each table. Runs after the status sweep, whose orphans all fall in range:
|
|
3577
|
+
* every payload is stamped no later than the completion that made the row collectable.
|
|
3578
|
+
*/
|
|
3579
|
+
async garbageCollectPayloads(cutoff, batchSize = exports.DEFAULT_GC_BATCH_SIZE) {
|
|
3580
|
+
// A NaN survives a bare `< 1` test and would only fail once it reached SQL.
|
|
3581
|
+
if (!Number.isInteger(batchSize) || batchSize < 1) {
|
|
3582
|
+
throw new error_1.DBOSError(`batchSize must be a positive integer, got ${batchSize}`);
|
|
3583
|
+
}
|
|
3584
|
+
const tables = ['workflow_input', 'workflow_output', 'operation_outputs'];
|
|
3585
|
+
// To optimize performance, vacuum payload tables both before and after collecting them.
|
|
3586
|
+
await this.#vacuumTables(['workflow_status', ...tables]);
|
|
3587
|
+
// One connection per concurrent sweep, on top of the one the retention lock holds for the
|
|
3588
|
+
// whole round. A pool too small for all three runs them sequentially rather than leaving
|
|
3589
|
+
// sweeps waiting on a connection that only the round itself would free.
|
|
3590
|
+
const poolMax = this.pool.options.max ?? exports.DEFAULT_POOL_SIZE;
|
|
3591
|
+
const concurrency = Math.max(1, Math.min(tables.length, poolMax - 1));
|
|
3592
|
+
const deleted = new Array(tables.length).fill(0);
|
|
3593
|
+
const failures = [];
|
|
3594
|
+
let next = 0;
|
|
3595
|
+
const sweep = async () => {
|
|
3596
|
+
for (;;) {
|
|
3597
|
+
const i = next++;
|
|
3598
|
+
if (i >= tables.length)
|
|
3599
|
+
return;
|
|
3600
|
+
try {
|
|
3601
|
+
deleted[i] = await this.#garbageCollectTable(tables[i], cutoff, batchSize);
|
|
3602
|
+
}
|
|
3603
|
+
catch (e) {
|
|
3604
|
+
failures.push(e);
|
|
3605
|
+
}
|
|
3606
|
+
}
|
|
3607
|
+
};
|
|
3608
|
+
await Promise.all(Array.from({ length: concurrency }, () => sweep()));
|
|
3609
|
+
// Only the first can be thrown, so the rest would otherwise be lost.
|
|
3610
|
+
for (const extra of failures.slice(1)) {
|
|
3611
|
+
this.logger.warn(`Payload retention sweep also failed: ${extra instanceof Error ? extra.message : String(extra)}`);
|
|
3612
|
+
}
|
|
3613
|
+
if (failures.length > 0) {
|
|
3614
|
+
throw failures[0];
|
|
3615
|
+
}
|
|
3616
|
+
await this.#vacuumTables(tables);
|
|
3617
|
+
this.logger.debug(`Payload retention deleted ${deleted[0]} inputs, ${deleted[1]} outputs, and ${deleted[2]} steps`);
|
|
3618
|
+
return deleted;
|
|
3619
|
+
}
|
|
3620
|
+
/**
|
|
3621
|
+
* Rows garbage collection may delete. completed_at is set on every terminal transition and
|
|
3622
|
+
* cleared on resume, so one predicate covers eligibility: in-flight rows hold NULL and never
|
|
3623
|
+
* compare true. Unscoped by application: retention is system-wide.
|
|
3624
|
+
*/
|
|
3280
3625
|
#gcFilter(cutoffEpochTimestampMs, params) {
|
|
3281
3626
|
params.push(cutoffEpochTimestampMs);
|
|
3282
|
-
|
|
3283
|
-
const statuses = [workflow_1.StatusString.PENDING, workflow_1.StatusString.ENQUEUED, workflow_1.StatusString.DELAYED].map((status) => {
|
|
3284
|
-
params.push(status);
|
|
3285
|
-
return `$${params.length}`;
|
|
3286
|
-
});
|
|
3287
|
-
// Unclaimed rows included: excluding them would leak pre-upgrade rows forever.
|
|
3288
|
-
const scope = this.#appNameFilter('application_name', this.appName, params);
|
|
3289
|
-
return `${cutoffClause} AND status NOT IN (${statuses.join(', ')}) AND ${scope}`;
|
|
3627
|
+
return `completed_at < $${params.length}`;
|
|
3290
3628
|
}
|
|
3291
3629
|
/**
|
|
3292
3630
|
* Delete one batch, returning the watermark to resume from, or undefined once the last one ran.
|
|
@@ -3296,53 +3634,56 @@ class SystemDatabase {
|
|
|
3296
3634
|
async #garbageCollectBatch(cutoffEpochTimestampMs, batchSize, watermark) {
|
|
3297
3635
|
// Borrowed rather than pool.query'd: that releases with the error, which discards the
|
|
3298
3636
|
// connection on a deadlock, so the retry wrapping this would churn the pool per batch.
|
|
3299
|
-
const client = await this.#
|
|
3637
|
+
const client = await this.#borrowRetentionClient();
|
|
3300
3638
|
try {
|
|
3301
3639
|
// The batchSize-th oldest eligible row above the watermark bounds this range
|
|
3302
3640
|
const stepParams = [];
|
|
3303
3641
|
const stepScope = this.#gcFilter(cutoffEpochTimestampMs, stepParams);
|
|
3304
3642
|
stepParams.push(watermark);
|
|
3305
|
-
const stepResult = await client.query(`SELECT
|
|
3643
|
+
const stepResult = await client.query(`SELECT completed_at
|
|
3306
3644
|
FROM "${this.schemaName}".workflow_status
|
|
3307
|
-
WHERE ${stepScope} AND
|
|
3308
|
-
ORDER BY
|
|
3645
|
+
WHERE ${stepScope} AND completed_at > $${stepParams.length}
|
|
3646
|
+
ORDER BY completed_at
|
|
3309
3647
|
LIMIT 1 OFFSET ${batchSize - 1}`, stepParams);
|
|
3310
|
-
//
|
|
3311
|
-
const step = stepResult.rows.length > 0 ? Number(stepResult.rows[0].
|
|
3648
|
+
// completed_at is a bigint, so node-postgres hands it back as a string.
|
|
3649
|
+
const step = stepResult.rows.length > 0 ? Number(stepResult.rows[0].completed_at) : undefined;
|
|
3312
3650
|
const deleteParams = [];
|
|
3313
3651
|
let deleteScope = this.#gcFilter(cutoffEpochTimestampMs, deleteParams);
|
|
3314
3652
|
if (step !== undefined) {
|
|
3315
|
-
// Inclusive upper bound:
|
|
3653
|
+
// Inclusive upper bound: completed_at ties may push a batch over batchSize, but never split across two.
|
|
3316
3654
|
deleteParams.push(watermark, step);
|
|
3317
|
-
deleteScope = `${deleteScope} AND
|
|
3655
|
+
deleteScope = `${deleteScope} AND completed_at > $${deleteParams.length - 1} AND completed_at <= $${deleteParams.length}`;
|
|
3318
3656
|
}
|
|
3319
|
-
// The final batch drops the watermark,
|
|
3657
|
+
// The final batch drops the watermark: unbounded, since an import can land a
|
|
3658
|
+
// completed_at below it mid-pass.
|
|
3320
3659
|
await client.query(`DELETE FROM "${this.schemaName}".workflow_status WHERE ${deleteScope}`, deleteParams);
|
|
3321
3660
|
return step;
|
|
3322
3661
|
}
|
|
3323
3662
|
finally {
|
|
3324
|
-
|
|
3325
|
-
client.release();
|
|
3663
|
+
this.#releaseRetentionClient(client);
|
|
3326
3664
|
}
|
|
3327
3665
|
}
|
|
3328
|
-
|
|
3666
|
+
/**
|
|
3667
|
+
* Delete old terminal workflows throughout the system database, returning the cutoff
|
|
3668
|
+
* actually used, or undefined when there is nothing to collect.
|
|
3669
|
+
*
|
|
3670
|
+
* Conductor sends cleared retention thresholds as JSON null, so every param is nullish.
|
|
3671
|
+
*/
|
|
3329
3672
|
async garbageCollect(cutoffEpochTimestampMs, rowsThreshold, options = {}) {
|
|
3330
|
-
const batchSize = options.batchSize
|
|
3673
|
+
const batchSize = options.batchSize ?? exports.DEFAULT_GC_BATCH_SIZE;
|
|
3331
3674
|
// A NaN survives a bare `< 1` test and would only fail once it reached SQL, leaving GC half-applied.
|
|
3332
|
-
if (
|
|
3675
|
+
if (!Number.isInteger(batchSize) || batchSize < 1) {
|
|
3333
3676
|
throw new error_1.DBOSError(`batchSize must be a positive integer, got ${batchSize}`);
|
|
3334
3677
|
}
|
|
3335
3678
|
if (rowsThreshold !== undefined && rowsThreshold !== null) {
|
|
3336
|
-
//
|
|
3337
|
-
const
|
|
3338
|
-
const scope = this.#appNameFilter('application_name', this.appName, params);
|
|
3339
|
-
const result = await this.pool.query(`SELECT created_at
|
|
3679
|
+
// The completed_at of the rowsThreshold newest completed row
|
|
3680
|
+
const result = await retryOnSerializationError(() => this.pool.query(`SELECT completed_at
|
|
3340
3681
|
FROM "${this.schemaName}".workflow_status
|
|
3341
|
-
WHERE
|
|
3342
|
-
ORDER BY
|
|
3343
|
-
LIMIT 1 OFFSET $1`,
|
|
3682
|
+
WHERE completed_at IS NOT NULL
|
|
3683
|
+
ORDER BY completed_at DESC
|
|
3684
|
+
LIMIT 1 OFFSET $1`, [rowsThreshold - 1]));
|
|
3344
3685
|
if (result.rows.length > 0) {
|
|
3345
|
-
const rowsBasedCutoff = result.rows[0].
|
|
3686
|
+
const rowsBasedCutoff = Number(result.rows[0].completed_at);
|
|
3346
3687
|
// Use the more restrictive cutoff (higher timestamp = more recent = more deletion)
|
|
3347
3688
|
if (cutoffEpochTimestampMs === undefined ||
|
|
3348
3689
|
cutoffEpochTimestampMs === null ||
|
|
@@ -3352,32 +3693,28 @@ class SystemDatabase {
|
|
|
3352
3693
|
}
|
|
3353
3694
|
}
|
|
3354
3695
|
if (cutoffEpochTimestampMs === undefined || cutoffEpochTimestampMs === null) {
|
|
3355
|
-
return;
|
|
3696
|
+
return undefined;
|
|
3356
3697
|
}
|
|
3357
3698
|
// Narrowed to a constant so the closures below keep it.
|
|
3358
3699
|
const cutoff = cutoffEpochTimestampMs;
|
|
3359
|
-
|
|
3360
|
-
await retryOnSerializationError(async () => {
|
|
3361
|
-
const deleteParams = [];
|
|
3362
|
-
const deleteScope = this.#gcFilter(cutoff, deleteParams);
|
|
3363
|
-
const client = await this.#connect();
|
|
3364
|
-
try {
|
|
3365
|
-
await client.query(`DELETE FROM "${this.schemaName}".workflow_status WHERE ${deleteScope}`, deleteParams);
|
|
3366
|
-
}
|
|
3367
|
-
finally {
|
|
3368
|
-
client.release();
|
|
3369
|
-
}
|
|
3370
|
-
});
|
|
3371
|
-
return;
|
|
3372
|
-
}
|
|
3373
|
-
// Advance a created_at watermark, one committed transaction per batch, so a long
|
|
3700
|
+
// Advance a completed_at watermark, one committed transaction per batch, so a long
|
|
3374
3701
|
// history neither deletes in one transaction nor rescans what it already deleted.
|
|
3375
|
-
|
|
3702
|
+
const oldest = await retryOnSerializationError(async () => {
|
|
3703
|
+
const params = [];
|
|
3704
|
+
const scope = this.#gcFilter(cutoff, params);
|
|
3705
|
+
const { rows } = await this.pool.query(`SELECT completed_at
|
|
3706
|
+
FROM "${this.schemaName}".workflow_status
|
|
3707
|
+
WHERE ${scope}
|
|
3708
|
+
ORDER BY completed_at
|
|
3709
|
+
LIMIT 1`, params);
|
|
3710
|
+
return rows.length > 0 ? Number(rows[0].completed_at) : undefined;
|
|
3711
|
+
});
|
|
3712
|
+
let watermark = oldest === undefined ? 0 : oldest - 1;
|
|
3376
3713
|
for (;;) {
|
|
3377
3714
|
const next = await retryOnSerializationError(() => this.#garbageCollectBatch(cutoff, batchSize, watermark));
|
|
3378
3715
|
// Fewer than a full batch remained, so that delete took the rest.
|
|
3379
3716
|
if (next === undefined)
|
|
3380
|
-
return;
|
|
3717
|
+
return cutoff;
|
|
3381
3718
|
watermark = next;
|
|
3382
3719
|
}
|
|
3383
3720
|
}
|
|
@@ -3978,7 +4315,8 @@ class SystemDatabase {
|
|
|
3978
4315
|
initStatus.status === workflow_1.StatusString.ENQUEUED || initStatus.status === workflow_1.StatusString.DELAYED ? 0 : 1,
|
|
3979
4316
|
initStatus.timeoutMS ?? null,
|
|
3980
4317
|
initStatus.deadlineEpochMS ?? null,
|
|
3981
|
-
|
|
4318
|
+
// Legacy column: the payload lives in workflow_input.
|
|
4319
|
+
null,
|
|
3982
4320
|
initStatus.deduplicationID ?? null,
|
|
3983
4321
|
initStatus.priority,
|
|
3984
4322
|
initStatus.queuePartitionKey ?? null,
|
|
@@ -3997,6 +4335,10 @@ class SystemDatabase {
|
|
|
3997
4335
|
if (rows.length === 0) {
|
|
3998
4336
|
throw new Error(`Attempt to insert workflow ${initStatus.workflowUUID} failed`);
|
|
3999
4337
|
}
|
|
4338
|
+
// Two statements, not a data-modifying CTE: at scale the CTE costs more than the round trip it saves.
|
|
4339
|
+
await client.query(`INSERT INTO "${this.schemaName}".workflow_input (workflow_uuid, inputs)
|
|
4340
|
+
VALUES ($1, $2)
|
|
4341
|
+
ON CONFLICT (workflow_uuid) DO NOTHING`, [initStatus.workflowUUID, initStatus.input ?? null]);
|
|
4000
4342
|
const ret = rows[0];
|
|
4001
4343
|
ret.class_name = ret.class_name ?? '';
|
|
4002
4344
|
ret.config_name = ret.config_name ?? '';
|
|
@@ -4023,14 +4365,6 @@ class SystemDatabase {
|
|
|
4023
4365
|
let whereClause = `WHERE workflow_uuid=$1`;
|
|
4024
4366
|
const args = [workflowID, status];
|
|
4025
4367
|
const update = options.update ?? {};
|
|
4026
|
-
if (update.output) {
|
|
4027
|
-
const param = args.push(update.output);
|
|
4028
|
-
setClause += `, output=$${param}`;
|
|
4029
|
-
}
|
|
4030
|
-
if (update.error) {
|
|
4031
|
-
const param = args.push(update.error);
|
|
4032
|
-
setClause += `, error=$${param}`;
|
|
4033
|
-
}
|
|
4034
4368
|
if (update.resetRecoveryAttempts) {
|
|
4035
4369
|
setClause += `, recovery_attempts = 0`;
|
|
4036
4370
|
}
|
|
@@ -4080,8 +4414,8 @@ class SystemDatabase {
|
|
|
4080
4414
|
async recordOperationResultInternal(client, workflowID, functionID, functionName, checkConflict, startTimeEpochMs, endTimeEpochMs, options = {}) {
|
|
4081
4415
|
try {
|
|
4082
4416
|
const out = await client.query(`INSERT INTO ${this.schemaName}.operation_outputs
|
|
4083
|
-
(workflow_uuid, function_id, output, error, function_name, child_workflow_id, started_at_epoch_ms, completed_at_epoch_ms, serialization, application_name)
|
|
4084
|
-
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
|
4417
|
+
(workflow_uuid, function_id, output, error, function_name, child_workflow_id, started_at_epoch_ms, completed_at_epoch_ms, serialization, application_name, retention_timestamp)
|
|
4418
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, (EXTRACT(EPOCH FROM now()) * 1000)::bigint)
|
|
4085
4419
|
ON CONFLICT (workflow_uuid, function_id) DO UPDATE
|
|
4086
4420
|
SET completed_at_epoch_ms = operation_outputs.completed_at_epoch_ms
|
|
4087
4421
|
RETURNING completed_at_epoch_ms;`, [
|