@dbos-inc/dbos-sdk 4.28.6-preview → 4.28.8-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 -1
- 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/client.d.ts +8 -0
- package/dist/src/client.d.ts.map +1 -1
- package/dist/src/client.js +2 -3
- package/dist/src/client.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/context.d.ts +0 -25
- package/dist/src/context.d.ts.map +1 -1
- package/dist/src/context.js.map +1 -1
- package/dist/src/dbos-executor.d.ts.map +1 -1
- package/dist/src/dbos-executor.js +10 -7
- package/dist/src/dbos-executor.js.map +1 -1
- package/dist/src/dbos.d.ts +8 -44
- package/dist/src/dbos.d.ts.map +1 -1
- package/dist/src/dbos.js +9 -75
- package/dist/src/dbos.js.map +1 -1
- package/dist/src/debouncer.d.ts.map +1 -1
- package/dist/src/debouncer.js +9 -0
- package/dist/src/debouncer.js.map +1 -1
- package/dist/src/decorators.d.ts +0 -56
- package/dist/src/decorators.d.ts.map +1 -1
- package/dist/src/decorators.js +7 -331
- package/dist/src/decorators.js.map +1 -1
- package/dist/src/enqueue_options.d.ts.map +1 -1
- package/dist/src/enqueue_options.js +0 -1
- package/dist/src/enqueue_options.js.map +1 -1
- package/dist/src/error.d.ts +0 -8
- package/dist/src/error.d.ts.map +1 -1
- package/dist/src/error.js +1 -27
- package/dist/src/error.js.map +1 -1
- package/dist/src/index.d.ts +2 -2
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +3 -7
- package/dist/src/index.js.map +1 -1
- package/dist/src/scheduler/scheduler.d.ts.map +1 -1
- package/dist/src/scheduler/scheduler.js +0 -1
- package/dist/src/scheduler/scheduler.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 +32 -3
- package/dist/src/system_database.d.ts.map +1 -1
- package/dist/src/system_database.js +448 -121
- package/dist/src/system_database.js.map +1 -1
- package/dist/src/workflow.d.ts +2 -1
- package/dist/src/workflow.d.ts.map +1 -1
- package/dist/src/workflow.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 -2
- package/dist/src/workflow_management.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -4
- package/dist/src/authdecorators.d.ts +0 -2
- package/dist/src/authdecorators.d.ts.map +0 -1
- package/dist/src/authdecorators.js +0 -48
- package/dist/src/authdecorators.js.map +0 -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 ?? '');
|
|
@@ -299,7 +309,6 @@ function mapWorkflowStatus(row) {
|
|
|
299
309
|
authenticatedUser: row.authenticated_user,
|
|
300
310
|
assumedRole: row.assumed_role,
|
|
301
311
|
authenticatedRoles: JSON.parse(row.authenticated_roles),
|
|
302
|
-
request: row.request ? JSON.parse(row.request) : {},
|
|
303
312
|
executorId: row.executor_id,
|
|
304
313
|
createdAt: Number(row.created_at),
|
|
305
314
|
updatedAt: Number(row.updated_at),
|
|
@@ -583,6 +592,11 @@ class SystemDatabase {
|
|
|
583
592
|
#batchCreatedAtCursors = new Map();
|
|
584
593
|
// Set by destroy(), so polling waits end instead of running on against a pool that outlives this handle.
|
|
585
594
|
#destroyed = false;
|
|
595
|
+
// Resolved on first use by #cockroach(), since detecting it costs a query.
|
|
596
|
+
#isCockroach = undefined;
|
|
597
|
+
// Connections a retention round holds right now. destroy() cuts them, since closing the
|
|
598
|
+
// pool would otherwise wait on the lock session and on any statement in flight.
|
|
599
|
+
#retentionClients = new Set();
|
|
586
600
|
constructor(systemDatabaseUrl, logger, serializer, sysDbPoolSize = exports.DEFAULT_POOL_SIZE, systemDatabasePool, schemaName = 'dbos', useListenNotify = true, pollingConcurrency, notificationCoalesceMs = exports.DEFAULT_NOTIFICATION_COALESCE_MS,
|
|
587
601
|
// The application this handle acts for; undefined writes unclaimed rows.
|
|
588
602
|
appName, observabilityQueryTimeoutMs = exports.DEFAULT_OBSERVABILITY_QUERY_TIMEOUT_MS) {
|
|
@@ -630,6 +644,22 @@ class SystemDatabase {
|
|
|
630
644
|
#connect() {
|
|
631
645
|
return borrowClient(this.pool, this.#onClientError);
|
|
632
646
|
}
|
|
647
|
+
/** Borrow a connection for a retention round, so destroy() can cut it. */
|
|
648
|
+
async #borrowRetentionClient() {
|
|
649
|
+
if (this.#destroyed) {
|
|
650
|
+
throw new Error('System database shutting down');
|
|
651
|
+
}
|
|
652
|
+
const client = await this.#connect();
|
|
653
|
+
this.#retentionClients.add(client);
|
|
654
|
+
return client;
|
|
655
|
+
}
|
|
656
|
+
/** Return a retention connection, unless destroy() already cut it: a second release would throw. */
|
|
657
|
+
#releaseRetentionClient(client) {
|
|
658
|
+
if (this.#retentionClients.delete(client)) {
|
|
659
|
+
// No error argument: a genuinely dead connection is still evicted by the pool's own check.
|
|
660
|
+
client.release();
|
|
661
|
+
}
|
|
662
|
+
}
|
|
633
663
|
/**
|
|
634
664
|
* Cap an introspection read with a statement timeout, so one scanning a huge table cannot hold a
|
|
635
665
|
* snapshot for minutes and stall autovacuum database-wide. Soft-private so tests can assert the cap
|
|
@@ -750,6 +780,20 @@ class SystemDatabase {
|
|
|
750
780
|
if (this.notificationsClient) {
|
|
751
781
|
this.#retireNotificationsClient(this.notificationsClient);
|
|
752
782
|
}
|
|
783
|
+
// A retention round still running is cut here rather than waited for: returned with an
|
|
784
|
+
// error, each connection is destroyed at once, the idle lock session lets go of the
|
|
785
|
+
// advisory lock, and the round fails on its next statement instead of holding shutdown.
|
|
786
|
+
for (const client of this.#retentionClients) {
|
|
787
|
+
// Cover the release() call itself, which tears the connection down and can surface a socket error.
|
|
788
|
+
client.on('error', () => { });
|
|
789
|
+
try {
|
|
790
|
+
client.release(new Error('System database shutting down'));
|
|
791
|
+
}
|
|
792
|
+
catch (e) {
|
|
793
|
+
this.logger.warn(`Error releasing a retention connection: ${String(e)}`);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
this.#retentionClients.clear();
|
|
753
797
|
// We attached nothing to the pool object itself, so there is nothing to unpick; only close one we own.
|
|
754
798
|
if (!this.customPool) {
|
|
755
799
|
await this.pool.end();
|
|
@@ -916,7 +960,6 @@ class SystemDatabase {
|
|
|
916
960
|
'authenticated_user',
|
|
917
961
|
'assumed_role',
|
|
918
962
|
'authenticated_roles',
|
|
919
|
-
'request',
|
|
920
963
|
'executor_id',
|
|
921
964
|
'application_version',
|
|
922
965
|
'application_id',
|
|
@@ -953,7 +996,9 @@ class SystemDatabase {
|
|
|
953
996
|
tuples.push(`(${columns.map(() => `$${paramIdx++}`).join(', ')})`);
|
|
954
997
|
params.push(status.workflowUUID, status.status, status.workflowName,
|
|
955
998
|
// 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),
|
|
999
|
+
status.workflowClassName === '' ? null : status.workflowClassName, status.workflowConfigName === '' ? null : status.workflowConfigName, status.queueName ?? null, status.authenticatedUser, status.assumedRole, JSON.stringify(status.authenticatedRoles), status.executorId, status.applicationVersion ?? null, status.applicationID, createdAt, 0, createdAt, status.timeoutMS ?? null, status.deadlineEpochMS ?? null,
|
|
1000
|
+
// Legacy column: the payload lives in workflow_input.
|
|
1001
|
+
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
1002
|
}
|
|
958
1003
|
const { rows } = await client.query(`INSERT INTO "${this.schemaName}".workflow_status (${columns.join(', ')})
|
|
959
1004
|
VALUES ${tuples.join(', ')}
|
|
@@ -963,6 +1008,19 @@ class SystemDatabase {
|
|
|
963
1008
|
inserted.add(row.workflow_uuid);
|
|
964
1009
|
}
|
|
965
1010
|
}
|
|
1011
|
+
for (let start = 0; start < statuses.length; start += chunkSize) {
|
|
1012
|
+
const chunk = statuses.slice(start, start + chunkSize);
|
|
1013
|
+
const tuples = [];
|
|
1014
|
+
const params = [];
|
|
1015
|
+
let paramIdx = 1;
|
|
1016
|
+
for (let i = 0; i < chunk.length; i++) {
|
|
1017
|
+
tuples.push(`($${paramIdx++}, $${paramIdx++}, $${paramIdx++})`);
|
|
1018
|
+
params.push(chunk[i].workflowUUID, chunk[i].input, createdAts[start + i]);
|
|
1019
|
+
}
|
|
1020
|
+
await client.query(`INSERT INTO "${this.schemaName}".workflow_input (workflow_uuid, inputs, retention_timestamp)
|
|
1021
|
+
VALUES ${tuples.join(', ')}
|
|
1022
|
+
ON CONFLICT (workflow_uuid) DO NOTHING`, params);
|
|
1023
|
+
}
|
|
966
1024
|
await client.query('COMMIT');
|
|
967
1025
|
}
|
|
968
1026
|
catch (e) {
|
|
@@ -1004,12 +1062,33 @@ class SystemDatabase {
|
|
|
1004
1062
|
// concurrent resume), or deleted; the caller resolves which by awaiting the
|
|
1005
1063
|
// recorded outcome.
|
|
1006
1064
|
async #recordWorkflowOutcome(client, workflowID, status, outcome) {
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1065
|
+
let committed = false;
|
|
1066
|
+
try {
|
|
1067
|
+
await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');
|
|
1068
|
+
const rowCount = await this.updateWorkflowStatus(client, workflowID, status, {
|
|
1069
|
+
update: { resetDeduplicationID: true, setCompletedAt: true },
|
|
1070
|
+
where: { status: workflow_1.StatusString.PENDING },
|
|
1071
|
+
throwOnFailure: false,
|
|
1072
|
+
});
|
|
1073
|
+
if (rowCount === 0) {
|
|
1074
|
+
// The outcome was not ours to write, so leave no orphan payload.
|
|
1075
|
+
return false;
|
|
1076
|
+
}
|
|
1077
|
+
await client.query(`INSERT INTO "${this.schemaName}".workflow_output (workflow_uuid, output, error)
|
|
1078
|
+
VALUES ($1, $2, $3)
|
|
1079
|
+
ON CONFLICT (workflow_uuid) DO UPDATE SET output = EXCLUDED.output, error = EXCLUDED.error`, [workflowID, outcome.output ?? null, outcome.error ?? null]);
|
|
1080
|
+
committed = true;
|
|
1081
|
+
return true;
|
|
1082
|
+
}
|
|
1083
|
+
finally {
|
|
1084
|
+
if (committed) {
|
|
1085
|
+
await client.query('COMMIT');
|
|
1086
|
+
}
|
|
1087
|
+
else {
|
|
1088
|
+
// Swallowed: a rollback failure must not mask why we are rolling back.
|
|
1089
|
+
await client.query('ROLLBACK').catch(() => undefined);
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1013
1092
|
}
|
|
1014
1093
|
async getPendingWorkflows(executorID, appVersion) {
|
|
1015
1094
|
const params = [workflow_1.StatusString.PENDING, executorID, appVersion];
|
|
@@ -1183,8 +1262,8 @@ class SystemDatabase {
|
|
|
1183
1262
|
// Insert a patchmarker
|
|
1184
1263
|
const dn = Date.now();
|
|
1185
1264
|
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)
|
|
1265
|
+
(workflow_uuid, function_id, output, error, function_name, child_workflow_id, started_at_epoch_ms, completed_at_epoch_ms, application_name, retention_timestamp)
|
|
1266
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, (EXTRACT(EPOCH FROM now()) * 1000)::bigint)
|
|
1188
1267
|
ON CONFLICT DO NOTHING;`, [workflowID, functionID, null, null, patchName, null, dn, dn, this.appName ?? null]);
|
|
1189
1268
|
return { isPatched: true, hasEntry: true };
|
|
1190
1269
|
}
|
|
@@ -1275,7 +1354,6 @@ class SystemDatabase {
|
|
|
1275
1354
|
const classNameOrNull = params.workflowClassName === '' ? null : params.workflowClassName;
|
|
1276
1355
|
const updateParams = [
|
|
1277
1356
|
params.delayUntilEpochMS,
|
|
1278
|
-
params.input,
|
|
1279
1357
|
params.serialization,
|
|
1280
1358
|
params.workflowName,
|
|
1281
1359
|
classNameOrNull,
|
|
@@ -1292,16 +1370,19 @@ class SystemDatabase {
|
|
|
1292
1370
|
THEN debounce_deadline_epoch_ms
|
|
1293
1371
|
ELSE $1
|
|
1294
1372
|
END,
|
|
1295
|
-
|
|
1373
|
+
serialization = $2,
|
|
1296
1374
|
updated_at = (EXTRACT(EPOCH FROM now()) * 1000)::bigint,
|
|
1297
1375
|
-- 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 = $
|
|
1376
|
+
application_name = COALESCE(application_name, $8)
|
|
1377
|
+
WHERE name = $3 AND class_name IS NOT DISTINCT FROM $4
|
|
1378
|
+
AND queue_name = $5 AND deduplication_id = $6
|
|
1379
|
+
AND status = $7 AND is_debounced = TRUE
|
|
1302
1380
|
AND ${ownScope}
|
|
1303
1381
|
RETURNING workflow_uuid`, updateParams);
|
|
1304
1382
|
if (updated.rows.length > 0) {
|
|
1383
|
+
await client.query(`INSERT INTO "${this.schemaName}".workflow_input (workflow_uuid, inputs, retention_timestamp)
|
|
1384
|
+
VALUES ($1, $2, (EXTRACT(EPOCH FROM now()) * 1000)::bigint)
|
|
1385
|
+
ON CONFLICT (workflow_uuid) DO UPDATE SET inputs = EXCLUDED.inputs`, [updated.rows[0].workflow_uuid, params.input]);
|
|
1305
1386
|
return {
|
|
1306
1387
|
bouncedWorkflowID: updated.rows[0].workflow_uuid,
|
|
1307
1388
|
holderWorkflowID: null,
|
|
@@ -1364,7 +1445,25 @@ class SystemDatabase {
|
|
|
1364
1445
|
allIds.push(...(await this.getWorkflowChildren(wfid)));
|
|
1365
1446
|
}
|
|
1366
1447
|
}
|
|
1367
|
-
|
|
1448
|
+
const client = await this.#connect();
|
|
1449
|
+
try {
|
|
1450
|
+
await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');
|
|
1451
|
+
// The payload tables carry no foreign key, so the status delete does not cascade
|
|
1452
|
+
// into them; without this they survive as orphans.
|
|
1453
|
+
for (const table of ['workflow_input', 'workflow_output', 'operation_outputs']) {
|
|
1454
|
+
await client.query(`DELETE FROM "${this.schemaName}".${table} WHERE workflow_uuid = ANY($1)`, [allIds]);
|
|
1455
|
+
}
|
|
1456
|
+
await client.query(`DELETE FROM "${this.schemaName}".workflow_status WHERE workflow_uuid = ANY($1)`, [allIds]);
|
|
1457
|
+
await client.query('COMMIT');
|
|
1458
|
+
}
|
|
1459
|
+
catch (e) {
|
|
1460
|
+
// Swallowed: a rollback failure must not mask why we are rolling back.
|
|
1461
|
+
await client.query('ROLLBACK').catch(() => undefined);
|
|
1462
|
+
throw e;
|
|
1463
|
+
}
|
|
1464
|
+
finally {
|
|
1465
|
+
client.release();
|
|
1466
|
+
}
|
|
1368
1467
|
for (const wfid of allIds) {
|
|
1369
1468
|
this.runningWorkflowMap.delete(wfid);
|
|
1370
1469
|
}
|
|
@@ -1441,11 +1540,13 @@ class SystemDatabase {
|
|
|
1441
1540
|
try {
|
|
1442
1541
|
await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');
|
|
1443
1542
|
// 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
|
-
|
|
1543
|
+
const { rows: statusRows } = await client.query(`SELECT ws.workflow_uuid, ws.name, ws.class_name, ws.config_name, ws.application_id,
|
|
1544
|
+
ws.authenticated_user, ws.authenticated_roles, ws.assumed_role,
|
|
1545
|
+
COALESCE(wi.inputs, ws.inputs) AS inputs, ws.serialization,
|
|
1546
|
+
ws.application_version, ws.attributes, ws.application_name
|
|
1547
|
+
FROM "${this.schemaName}".workflow_status ws
|
|
1548
|
+
LEFT JOIN "${this.schemaName}".workflow_input wi ON wi.workflow_uuid = ws.workflow_uuid
|
|
1549
|
+
WHERE ws.workflow_uuid = ANY($1)`, [originalWorkflowIDs]);
|
|
1449
1550
|
const statusByID = new Map(statusRows.map((r) => [r.workflow_uuid, r]));
|
|
1450
1551
|
for (const wid of originalWorkflowIDs) {
|
|
1451
1552
|
if (!statusByID.has(wid)) {
|
|
@@ -1464,7 +1565,6 @@ class SystemDatabase {
|
|
|
1464
1565
|
'authenticated_user',
|
|
1465
1566
|
'assumed_role',
|
|
1466
1567
|
'authenticated_roles',
|
|
1467
|
-
'request',
|
|
1468
1568
|
'application_version',
|
|
1469
1569
|
'application_id',
|
|
1470
1570
|
'inputs',
|
|
@@ -1491,13 +1591,24 @@ class SystemDatabase {
|
|
|
1491
1591
|
const ws = statusByID.get(origID);
|
|
1492
1592
|
const placeholders = insertCols.map(() => `$${paramIdx++}`).join(', ');
|
|
1493
1593
|
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,
|
|
1594
|
+
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, options.applicationVersion ?? ws.application_version ?? null, ws.application_id,
|
|
1595
|
+
// Legacy column: the payload lives in workflow_input.
|
|
1596
|
+
null, options.queuePartitionKey ?? null, origID, ws.serialization, ws.attributes ? JSON.stringify(ws.attributes) : null, forkOwners.get(forkID) ?? null);
|
|
1495
1597
|
if (options.timeoutMS !== undefined) {
|
|
1496
1598
|
params.push(options.timeoutMS);
|
|
1497
1599
|
}
|
|
1498
1600
|
}
|
|
1499
1601
|
await client.query(`INSERT INTO "${this.schemaName}".workflow_status (${insertCols.join(', ')})
|
|
1500
1602
|
VALUES ${valuesPlaceholders.join(', ')}`, params);
|
|
1603
|
+
const inputPlaceholders = [];
|
|
1604
|
+
const inputParams = [];
|
|
1605
|
+
let inputIdx = 1;
|
|
1606
|
+
for (let i = 0; i < originalWorkflowIDs.length; i++) {
|
|
1607
|
+
inputPlaceholders.push(`($${inputIdx++}, $${inputIdx++})`);
|
|
1608
|
+
inputParams.push(forkedWorkflowIDs[i], statusByID.get(originalWorkflowIDs[i]).inputs);
|
|
1609
|
+
}
|
|
1610
|
+
await client.query(`INSERT INTO "${this.schemaName}".workflow_input (workflow_uuid, inputs)
|
|
1611
|
+
VALUES ${inputPlaceholders.join(', ')}`, inputParams);
|
|
1501
1612
|
// Mark all original workflows as having been forked from.
|
|
1502
1613
|
await client.query(`UPDATE "${this.schemaName}".workflow_status SET was_forked_from = TRUE WHERE workflow_uuid = ANY($1)`, [originalWorkflowIDs]);
|
|
1503
1614
|
// For workflows with start_step > 0, copy checkpoints/events/streams.
|
|
@@ -1531,8 +1642,8 @@ class SystemDatabase {
|
|
|
1531
1642
|
// Copy operation outputs
|
|
1532
1643
|
await client.query(`${mappingCTE}
|
|
1533
1644
|
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
|
|
1645
|
+
(workflow_uuid, function_id, output, error, serialization, function_name, child_workflow_id, started_at_epoch_ms, completed_at_epoch_ms, application_name, retention_timestamp)
|
|
1646
|
+
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
1647
|
FROM mapping m
|
|
1537
1648
|
JOIN "${this.schemaName}".operation_outputs oo
|
|
1538
1649
|
ON oo.workflow_uuid = m.orig_id AND oo.function_id < m.start_step`, ooParams);
|
|
@@ -1592,17 +1703,22 @@ class SystemDatabase {
|
|
|
1592
1703
|
// token, not logical workflow state, and a source database's xid is
|
|
1593
1704
|
// meaningless in the target.
|
|
1594
1705
|
`SELECT
|
|
1595
|
-
workflow_uuid, status, name, authenticated_user, assumed_role,
|
|
1596
|
-
authenticated_roles,
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1706
|
+
ws.workflow_uuid, ws.status, ws.name, ws.authenticated_user, ws.assumed_role,
|
|
1707
|
+
ws.authenticated_roles,
|
|
1708
|
+
COALESCE(wo.output, ws.output) AS output, COALESCE(wo.error, ws.error) AS error,
|
|
1709
|
+
ws.executor_id,
|
|
1710
|
+
ws.created_at, ws.updated_at, ws.application_version, ws.application_id,
|
|
1711
|
+
ws.class_name, ws.config_name, ws.recovery_attempts, ws.queue_name,
|
|
1712
|
+
ws.workflow_timeout_ms, ws.workflow_deadline_epoch_ms, ws.started_at_epoch_ms,
|
|
1713
|
+
ws.deduplication_id, COALESCE(wi.inputs, ws.inputs) AS inputs,
|
|
1714
|
+
ws.priority, ws.queue_partition_key, ws.forked_from,
|
|
1715
|
+
ws.parent_workflow_id, ws.serialization, ws.delay_until_epoch_ms,
|
|
1716
|
+
ws.was_forked_from, ws.rate_limited, ws.completed_at, ws.attributes, ws.schedule_name,
|
|
1717
|
+
ws.debounce_deadline_epoch_ms, ws.is_debounced, ws.application_name
|
|
1718
|
+
FROM "${this.schemaName}".workflow_status ws
|
|
1719
|
+
LEFT JOIN "${this.schemaName}".workflow_input wi ON wi.workflow_uuid = ws.workflow_uuid
|
|
1720
|
+
LEFT JOIN "${this.schemaName}".workflow_output wo ON wo.workflow_uuid = ws.workflow_uuid
|
|
1721
|
+
WHERE ws.workflow_uuid = $1`, [wfID]);
|
|
1606
1722
|
if (statusResult.rows.length === 0) {
|
|
1607
1723
|
throw new error_1.DBOSNonExistentWorkflowError(`Workflow ${wfID} does not exist`);
|
|
1608
1724
|
}
|
|
@@ -1649,7 +1765,7 @@ class SystemDatabase {
|
|
|
1649
1765
|
// Import workflow_status
|
|
1650
1766
|
await client.query(`INSERT INTO "${this.schemaName}".workflow_status (
|
|
1651
1767
|
workflow_uuid, status, name, authenticated_user, assumed_role,
|
|
1652
|
-
authenticated_roles,
|
|
1768
|
+
authenticated_roles, output, error, executor_id,
|
|
1653
1769
|
created_at, updated_at, application_version, application_id,
|
|
1654
1770
|
class_name, config_name, recovery_attempts, queue_name,
|
|
1655
1771
|
workflow_timeout_ms, workflow_deadline_epoch_ms, started_at_epoch_ms,
|
|
@@ -1657,16 +1773,16 @@ class SystemDatabase {
|
|
|
1657
1773
|
parent_workflow_id, serialization, delay_until_epoch_ms,
|
|
1658
1774
|
was_forked_from, rate_limited, completed_at, attributes, schedule_name,
|
|
1659
1775
|
debounce_deadline_epoch_ms, is_debounced, application_name
|
|
1660
|
-
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36
|
|
1776
|
+
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36)`, [
|
|
1661
1777
|
status.workflow_uuid,
|
|
1662
1778
|
status.status,
|
|
1663
1779
|
status.name,
|
|
1664
1780
|
status.authenticated_user,
|
|
1665
1781
|
status.assumed_role,
|
|
1666
1782
|
status.authenticated_roles,
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1783
|
+
// Legacy columns: the payloads live in their own tables.
|
|
1784
|
+
null,
|
|
1785
|
+
null,
|
|
1670
1786
|
status.executor_id,
|
|
1671
1787
|
status.created_at,
|
|
1672
1788
|
status.updated_at,
|
|
@@ -1680,7 +1796,7 @@ class SystemDatabase {
|
|
|
1680
1796
|
status.workflow_deadline_epoch_ms,
|
|
1681
1797
|
status.started_at_epoch_ms,
|
|
1682
1798
|
status.deduplication_id,
|
|
1683
|
-
|
|
1799
|
+
null,
|
|
1684
1800
|
status.priority,
|
|
1685
1801
|
status.queue_partition_key,
|
|
1686
1802
|
status.forked_from,
|
|
@@ -1698,13 +1814,21 @@ class SystemDatabase {
|
|
|
1698
1814
|
status.is_debounced ?? false,
|
|
1699
1815
|
status.application_name ?? null,
|
|
1700
1816
|
]);
|
|
1817
|
+
// Retention starts at import: the original timestamps are long past the cutoff
|
|
1818
|
+
// and would be collected immediately.
|
|
1819
|
+
await client.query(`INSERT INTO "${this.schemaName}".workflow_input (workflow_uuid, inputs, retention_timestamp)
|
|
1820
|
+
VALUES ($1, $2, (EXTRACT(EPOCH FROM now()) * 1000)::bigint)`, [status.workflow_uuid, status.inputs]);
|
|
1821
|
+
if (status.output !== null || status.error !== null) {
|
|
1822
|
+
await client.query(`INSERT INTO "${this.schemaName}".workflow_output (workflow_uuid, output, error, retention_timestamp)
|
|
1823
|
+
VALUES ($1, $2, $3, (EXTRACT(EPOCH FROM now()) * 1000)::bigint)`, [status.workflow_uuid, status.output, status.error]);
|
|
1824
|
+
}
|
|
1701
1825
|
// Import operation_outputs
|
|
1702
1826
|
for (const output of workflow.operation_outputs) {
|
|
1703
1827
|
await client.query(`INSERT INTO "${this.schemaName}".operation_outputs (
|
|
1704
1828
|
workflow_uuid, function_id, function_name, output, error,
|
|
1705
1829
|
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)`, [
|
|
1830
|
+
serialization, application_name, retention_timestamp
|
|
1831
|
+
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, (EXTRACT(EPOCH FROM now()) * 1000)::bigint)`, [
|
|
1708
1832
|
output.workflow_uuid,
|
|
1709
1833
|
output.function_id,
|
|
1710
1834
|
output.function_name,
|
|
@@ -1867,8 +1991,11 @@ class SystemDatabase {
|
|
|
1867
1991
|
await this.checkIfCanceledLimited(callerID);
|
|
1868
1992
|
let rows;
|
|
1869
1993
|
try {
|
|
1870
|
-
({ rows } = await this.#pollWithLimiter(() => this.pool.query(`SELECT status, output,
|
|
1871
|
-
|
|
1994
|
+
({ rows } = await this.#pollWithLimiter(() => this.pool.query(`SELECT ws.status, COALESCE(wo.output, ws.output) AS output,
|
|
1995
|
+
COALESCE(wo.error, ws.error) AS error, ws.serialization
|
|
1996
|
+
FROM "${this.schemaName}".workflow_status ws
|
|
1997
|
+
LEFT JOIN "${this.schemaName}".workflow_output wo ON wo.workflow_uuid = ws.workflow_uuid
|
|
1998
|
+
WHERE ws.workflow_uuid=$1`, [workflowID])));
|
|
1872
1999
|
}
|
|
1873
2000
|
catch (e) {
|
|
1874
2001
|
const err = e;
|
|
@@ -2858,11 +2985,17 @@ class SystemDatabase {
|
|
|
2858
2985
|
];
|
|
2859
2986
|
input.loadInput = input.loadInput ?? true;
|
|
2860
2987
|
input.loadOutput = input.loadOutput ?? true;
|
|
2988
|
+
// Only join the payload table the caller actually asked for.
|
|
2989
|
+
const payloadColumns = [];
|
|
2990
|
+
const payloadJoins = [];
|
|
2861
2991
|
if (input.loadInput) {
|
|
2862
|
-
|
|
2992
|
+
payloadColumns.push('COALESCE(wi.inputs, workflow_status.inputs) AS inputs');
|
|
2993
|
+
payloadJoins.push(`LEFT JOIN "${schemaName}".workflow_input wi ON wi.workflow_uuid = workflow_status.workflow_uuid`);
|
|
2863
2994
|
}
|
|
2864
2995
|
if (input.loadOutput) {
|
|
2865
|
-
|
|
2996
|
+
payloadColumns.push('COALESCE(wo.output, workflow_status.output) AS output');
|
|
2997
|
+
payloadColumns.push('COALESCE(wo.error, workflow_status.error) AS error');
|
|
2998
|
+
payloadJoins.push(`LEFT JOIN "${schemaName}".workflow_output wo ON wo.workflow_uuid = workflow_status.workflow_uuid`);
|
|
2866
2999
|
}
|
|
2867
3000
|
if (input.loadInput || input.loadOutput) {
|
|
2868
3001
|
selectColumns.push('serialization');
|
|
@@ -2910,20 +3043,20 @@ class SystemDatabase {
|
|
|
2910
3043
|
paramCounter = params.length + 1;
|
|
2911
3044
|
if (input.workflow_id_prefix) {
|
|
2912
3045
|
if (Array.isArray(input.workflow_id_prefix)) {
|
|
2913
|
-
const likeClauses = input.workflow_id_prefix.map((_, i) => `workflow_uuid LIKE $${paramCounter + i}`);
|
|
3046
|
+
const likeClauses = input.workflow_id_prefix.map((_, i) => `workflow_status.workflow_uuid LIKE $${paramCounter + i}`);
|
|
2914
3047
|
whereClauses.push(`(${likeClauses.join(' OR ')})`);
|
|
2915
3048
|
params.push(...input.workflow_id_prefix.map((p) => `${p}%`));
|
|
2916
3049
|
paramCounter += input.workflow_id_prefix.length;
|
|
2917
3050
|
}
|
|
2918
3051
|
else {
|
|
2919
|
-
whereClauses.push(`workflow_uuid LIKE $${paramCounter}`);
|
|
3052
|
+
whereClauses.push(`workflow_status.workflow_uuid LIKE $${paramCounter}`);
|
|
2920
3053
|
params.push(`${input.workflow_id_prefix}%`);
|
|
2921
3054
|
paramCounter++;
|
|
2922
3055
|
}
|
|
2923
3056
|
}
|
|
2924
3057
|
if (input.workflowIDs) {
|
|
2925
3058
|
const placeholders = input.workflowIDs.map((_, i) => `$${paramCounter + i}`).join(', ');
|
|
2926
|
-
whereClauses.push(`workflow_uuid IN (${placeholders})`);
|
|
3059
|
+
whereClauses.push(`workflow_status.workflow_uuid IN (${placeholders})`);
|
|
2927
3060
|
params.push(...input.workflowIDs);
|
|
2928
3061
|
paramCounter += input.workflowIDs.length;
|
|
2929
3062
|
}
|
|
@@ -2989,9 +3122,11 @@ class SystemDatabase {
|
|
|
2989
3122
|
const orderClause = `ORDER BY created_at ${input.sortDesc ? 'DESC' : 'ASC'}`;
|
|
2990
3123
|
const limitClause = input.limit ? `LIMIT ${input.limit}` : '';
|
|
2991
3124
|
const offsetClause = input.offset ? `OFFSET ${input.offset}` : '';
|
|
3125
|
+
const projection = [...selectColumns.map((c) => `workflow_status.${c}`), ...payloadColumns].join(', ');
|
|
2992
3126
|
const query = `
|
|
2993
|
-
SELECT ${
|
|
3127
|
+
SELECT ${projection}
|
|
2994
3128
|
FROM "${schemaName}".workflow_status
|
|
3129
|
+
${payloadJoins.join('\n ')}
|
|
2995
3130
|
${whereClause}
|
|
2996
3131
|
${orderClause}
|
|
2997
3132
|
${limitClause}
|
|
@@ -3276,17 +3411,215 @@ class SystemDatabase {
|
|
|
3276
3411
|
};
|
|
3277
3412
|
});
|
|
3278
3413
|
}
|
|
3279
|
-
/**
|
|
3414
|
+
/** Whether the system database is CockroachDB. Resolved once, since detecting it costs a query. */
|
|
3415
|
+
async #cockroach() {
|
|
3416
|
+
if (this.#isCockroach === undefined) {
|
|
3417
|
+
const client = await this.#connect();
|
|
3418
|
+
try {
|
|
3419
|
+
this.#isCockroach = await isCockroachDB(client);
|
|
3420
|
+
}
|
|
3421
|
+
finally {
|
|
3422
|
+
client.release();
|
|
3423
|
+
}
|
|
3424
|
+
}
|
|
3425
|
+
return this.#isCockroach;
|
|
3426
|
+
}
|
|
3427
|
+
/**
|
|
3428
|
+
* Take a database-wide lock for one retention round, returning how to release it, or
|
|
3429
|
+
* undefined when another round already holds it. The lock is session-scoped, so a round
|
|
3430
|
+
* that crashes releases it. CockroachDB has no advisory locks and always takes it, so it
|
|
3431
|
+
* collects unprotected rather than not at all.
|
|
3432
|
+
*/
|
|
3433
|
+
async acquireRetentionLock() {
|
|
3434
|
+
if (await this.#cockroach()) {
|
|
3435
|
+
return { release: () => Promise.resolve() };
|
|
3436
|
+
}
|
|
3437
|
+
const key = retentionLockKey(this.schemaName).toString();
|
|
3438
|
+
// The round holds this connection until it ends: releasing it would drop the lock.
|
|
3439
|
+
const client = await this.#borrowRetentionClient();
|
|
3440
|
+
let acquired = false;
|
|
3441
|
+
try {
|
|
3442
|
+
const { rows } = await client.query('SELECT pg_try_advisory_lock($1) AS locked', [key]);
|
|
3443
|
+
acquired = rows[0]?.locked === true;
|
|
3444
|
+
}
|
|
3445
|
+
catch (e) {
|
|
3446
|
+
this.#releaseRetentionClient(client);
|
|
3447
|
+
throw e;
|
|
3448
|
+
}
|
|
3449
|
+
if (!acquired) {
|
|
3450
|
+
this.#releaseRetentionClient(client);
|
|
3451
|
+
return undefined;
|
|
3452
|
+
}
|
|
3453
|
+
return {
|
|
3454
|
+
release: async () => {
|
|
3455
|
+
// Already cut by destroy(): the session is gone and took the lock with it.
|
|
3456
|
+
if (!this.#retentionClients.has(client)) {
|
|
3457
|
+
return;
|
|
3458
|
+
}
|
|
3459
|
+
try {
|
|
3460
|
+
// Explicit, since releasing the client only returns the session to the pool.
|
|
3461
|
+
const { rows } = await client.query('SELECT pg_advisory_unlock($1) AS released', [
|
|
3462
|
+
key,
|
|
3463
|
+
]);
|
|
3464
|
+
if (rows[0]?.released !== true) {
|
|
3465
|
+
// False means this session no longer holds it, which a transaction-pooling proxy
|
|
3466
|
+
// causes by switching backends.
|
|
3467
|
+
this.logger.warn('Could not release the retention lock: this session no longer holds it. Retention will ' +
|
|
3468
|
+
'not proceed until the lock is released, which happens when the holding backend closes. ' +
|
|
3469
|
+
'A transaction-pooling proxy in front of Postgres causes this; run DBOS through a ' +
|
|
3470
|
+
'session-pooled or direct connection.');
|
|
3471
|
+
}
|
|
3472
|
+
}
|
|
3473
|
+
finally {
|
|
3474
|
+
this.#releaseRetentionClient(client);
|
|
3475
|
+
}
|
|
3476
|
+
},
|
|
3477
|
+
};
|
|
3478
|
+
}
|
|
3479
|
+
/** VACUUM the tables a sweep dirtied. No-op where there is no autovacuum to outrun. */
|
|
3480
|
+
async #vacuumTables(tables) {
|
|
3481
|
+
if (await this.#cockroach()) {
|
|
3482
|
+
return;
|
|
3483
|
+
}
|
|
3484
|
+
const client = await this.#borrowRetentionClient();
|
|
3485
|
+
let notices = [];
|
|
3486
|
+
const onNotice = (notice) => notices.push(notice.message ?? '');
|
|
3487
|
+
client.on('notice', onNotice);
|
|
3488
|
+
try {
|
|
3489
|
+
for (const table of tables) {
|
|
3490
|
+
// Per table, so one refusal does not skip the rest.
|
|
3491
|
+
notices = [];
|
|
3492
|
+
try {
|
|
3493
|
+
await client.query(`VACUUM (INDEX_CLEANUP ON, TRUNCATE OFF, ANALYZE) "${this.schemaName}"."${table}"`);
|
|
3494
|
+
}
|
|
3495
|
+
catch (e) {
|
|
3496
|
+
if (!this.#retentionClients.has(client)) {
|
|
3497
|
+
throw e;
|
|
3498
|
+
}
|
|
3499
|
+
this.logger.warn(`Payload retention could not vacuum ${table}: ${e.message}`);
|
|
3500
|
+
continue;
|
|
3501
|
+
}
|
|
3502
|
+
// A refused or stalled VACUUM does not raise, it says so in a notice; a successful
|
|
3503
|
+
// one is silent, so anything here is worth surfacing.
|
|
3504
|
+
for (const notice of notices) {
|
|
3505
|
+
this.logger.warn(`Payload retention vacuuming ${table}: ${notice}`);
|
|
3506
|
+
}
|
|
3507
|
+
}
|
|
3508
|
+
}
|
|
3509
|
+
finally {
|
|
3510
|
+
client.removeListener('notice', onNotice);
|
|
3511
|
+
this.#releaseRetentionClient(client);
|
|
3512
|
+
}
|
|
3513
|
+
}
|
|
3514
|
+
/** Delete one payload table's orphans below the cutoff, one batch per transaction. */
|
|
3515
|
+
async #garbageCollectTable(table, cutoff, batchSize) {
|
|
3516
|
+
// A payload below the cutoff belongs to a workflow created before it, so the status side
|
|
3517
|
+
// is the few such rows still present, not the whole table.
|
|
3518
|
+
const orphaned = `NOT EXISTS (
|
|
3519
|
+
SELECT 1 FROM "${this.schemaName}".workflow_status ws
|
|
3520
|
+
WHERE ws.workflow_uuid = t.workflow_uuid AND ws.created_at < $1
|
|
3521
|
+
)`;
|
|
3522
|
+
// Seed from the oldest row in range.
|
|
3523
|
+
const oldest = await retryOnSerializationError(async () => {
|
|
3524
|
+
const { rows } = await this.pool.query(`SELECT retention_timestamp
|
|
3525
|
+
FROM "${this.schemaName}".${table}
|
|
3526
|
+
WHERE retention_timestamp < $1
|
|
3527
|
+
ORDER BY retention_timestamp
|
|
3528
|
+
LIMIT 1`, [cutoff]);
|
|
3529
|
+
// retention_timestamp is a bigint, so node-postgres hands it back as a string.
|
|
3530
|
+
return rows.length > 0 ? Number(rows[0].retention_timestamp) : undefined;
|
|
3531
|
+
});
|
|
3532
|
+
if (oldest === undefined) {
|
|
3533
|
+
return 0;
|
|
3534
|
+
}
|
|
3535
|
+
let total = 0;
|
|
3536
|
+
let watermark = oldest - 1;
|
|
3537
|
+
for (;;) {
|
|
3538
|
+
const batch = await retryOnSerializationError(async () => {
|
|
3539
|
+
// Borrowed rather than pool.query'd: that releases with the error, which discards the
|
|
3540
|
+
// connection on a deadlock, so the retry wrapping this would churn the pool per batch.
|
|
3541
|
+
const client = await this.#borrowRetentionClient();
|
|
3542
|
+
try {
|
|
3543
|
+
// Batches are cut by candidate count, so rows spared by the anti-join only thin one
|
|
3544
|
+
// out; they are re-checked next round.
|
|
3545
|
+
const { rows } = await client.query(`SELECT retention_timestamp
|
|
3546
|
+
FROM "${this.schemaName}".${table}
|
|
3547
|
+
WHERE retention_timestamp < $1 AND retention_timestamp > $2
|
|
3548
|
+
ORDER BY retention_timestamp
|
|
3549
|
+
LIMIT 1 OFFSET ${batchSize - 1}`, [cutoff, watermark]);
|
|
3550
|
+
const step = rows.length > 0 ? Number(rows[0].retention_timestamp) : undefined;
|
|
3551
|
+
const params = [cutoff, watermark];
|
|
3552
|
+
// Timestamp ties may push the batch slightly over batchSize, but never split across two.
|
|
3553
|
+
const upperBound = step === undefined ? '' : `AND t.retention_timestamp <= $${params.push(step)} `;
|
|
3554
|
+
const result = await client.query(`DELETE FROM "${this.schemaName}".${table} t
|
|
3555
|
+
WHERE t.retention_timestamp < $1 AND t.retention_timestamp > $2 ${upperBound}AND ${orphaned}`, params);
|
|
3556
|
+
return { step, deleted: result.rowCount ?? 0 };
|
|
3557
|
+
}
|
|
3558
|
+
finally {
|
|
3559
|
+
this.#releaseRetentionClient(client);
|
|
3560
|
+
}
|
|
3561
|
+
});
|
|
3562
|
+
total += batch.deleted;
|
|
3563
|
+
if (batch.step === undefined) {
|
|
3564
|
+
return total;
|
|
3565
|
+
}
|
|
3566
|
+
watermark = batch.step;
|
|
3567
|
+
}
|
|
3568
|
+
}
|
|
3569
|
+
/**
|
|
3570
|
+
* Delete payload and step rows below the cutoff whose workflow is gone, returning the count
|
|
3571
|
+
* removed from each table. Runs after the status sweep, whose orphans all fall in range:
|
|
3572
|
+
* every payload is stamped no later than the completion that made the row collectable.
|
|
3573
|
+
*/
|
|
3574
|
+
async garbageCollectPayloads(cutoff, batchSize = exports.DEFAULT_GC_BATCH_SIZE) {
|
|
3575
|
+
// A NaN survives a bare `< 1` test and would only fail once it reached SQL.
|
|
3576
|
+
if (!Number.isInteger(batchSize) || batchSize < 1) {
|
|
3577
|
+
throw new error_1.DBOSError(`batchSize must be a positive integer, got ${batchSize}`);
|
|
3578
|
+
}
|
|
3579
|
+
const tables = ['workflow_input', 'workflow_output', 'operation_outputs'];
|
|
3580
|
+
// To optimize performance, vacuum payload tables both before and after collecting them.
|
|
3581
|
+
await this.#vacuumTables(['workflow_status', ...tables]);
|
|
3582
|
+
// One connection per concurrent sweep, on top of the one the retention lock holds for the
|
|
3583
|
+
// whole round. A pool too small for all three runs them sequentially rather than leaving
|
|
3584
|
+
// sweeps waiting on a connection that only the round itself would free.
|
|
3585
|
+
const poolMax = this.pool.options.max ?? exports.DEFAULT_POOL_SIZE;
|
|
3586
|
+
const concurrency = Math.max(1, Math.min(tables.length, poolMax - 1));
|
|
3587
|
+
const deleted = new Array(tables.length).fill(0);
|
|
3588
|
+
const failures = [];
|
|
3589
|
+
let next = 0;
|
|
3590
|
+
const sweep = async () => {
|
|
3591
|
+
for (;;) {
|
|
3592
|
+
const i = next++;
|
|
3593
|
+
if (i >= tables.length)
|
|
3594
|
+
return;
|
|
3595
|
+
try {
|
|
3596
|
+
deleted[i] = await this.#garbageCollectTable(tables[i], cutoff, batchSize);
|
|
3597
|
+
}
|
|
3598
|
+
catch (e) {
|
|
3599
|
+
failures.push(e);
|
|
3600
|
+
}
|
|
3601
|
+
}
|
|
3602
|
+
};
|
|
3603
|
+
await Promise.all(Array.from({ length: concurrency }, () => sweep()));
|
|
3604
|
+
// Only the first can be thrown, so the rest would otherwise be lost.
|
|
3605
|
+
for (const extra of failures.slice(1)) {
|
|
3606
|
+
this.logger.warn(`Payload retention sweep also failed: ${extra instanceof Error ? extra.message : String(extra)}`);
|
|
3607
|
+
}
|
|
3608
|
+
if (failures.length > 0) {
|
|
3609
|
+
throw failures[0];
|
|
3610
|
+
}
|
|
3611
|
+
await this.#vacuumTables(tables);
|
|
3612
|
+
this.logger.debug(`Payload retention deleted ${deleted[0]} inputs, ${deleted[1]} outputs, and ${deleted[2]} steps`);
|
|
3613
|
+
return deleted;
|
|
3614
|
+
}
|
|
3615
|
+
/**
|
|
3616
|
+
* Rows garbage collection may delete. completed_at is set on every terminal transition and
|
|
3617
|
+
* cleared on resume, so one predicate covers eligibility: in-flight rows hold NULL and never
|
|
3618
|
+
* compare true. Unscoped by application: retention is system-wide.
|
|
3619
|
+
*/
|
|
3280
3620
|
#gcFilter(cutoffEpochTimestampMs, params) {
|
|
3281
3621
|
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}`;
|
|
3622
|
+
return `completed_at < $${params.length}`;
|
|
3290
3623
|
}
|
|
3291
3624
|
/**
|
|
3292
3625
|
* Delete one batch, returning the watermark to resume from, or undefined once the last one ran.
|
|
@@ -3296,53 +3629,56 @@ class SystemDatabase {
|
|
|
3296
3629
|
async #garbageCollectBatch(cutoffEpochTimestampMs, batchSize, watermark) {
|
|
3297
3630
|
// Borrowed rather than pool.query'd: that releases with the error, which discards the
|
|
3298
3631
|
// connection on a deadlock, so the retry wrapping this would churn the pool per batch.
|
|
3299
|
-
const client = await this.#
|
|
3632
|
+
const client = await this.#borrowRetentionClient();
|
|
3300
3633
|
try {
|
|
3301
3634
|
// The batchSize-th oldest eligible row above the watermark bounds this range
|
|
3302
3635
|
const stepParams = [];
|
|
3303
3636
|
const stepScope = this.#gcFilter(cutoffEpochTimestampMs, stepParams);
|
|
3304
3637
|
stepParams.push(watermark);
|
|
3305
|
-
const stepResult = await client.query(`SELECT
|
|
3638
|
+
const stepResult = await client.query(`SELECT completed_at
|
|
3306
3639
|
FROM "${this.schemaName}".workflow_status
|
|
3307
|
-
WHERE ${stepScope} AND
|
|
3308
|
-
ORDER BY
|
|
3640
|
+
WHERE ${stepScope} AND completed_at > $${stepParams.length}
|
|
3641
|
+
ORDER BY completed_at
|
|
3309
3642
|
LIMIT 1 OFFSET ${batchSize - 1}`, stepParams);
|
|
3310
|
-
//
|
|
3311
|
-
const step = stepResult.rows.length > 0 ? Number(stepResult.rows[0].
|
|
3643
|
+
// completed_at is a bigint, so node-postgres hands it back as a string.
|
|
3644
|
+
const step = stepResult.rows.length > 0 ? Number(stepResult.rows[0].completed_at) : undefined;
|
|
3312
3645
|
const deleteParams = [];
|
|
3313
3646
|
let deleteScope = this.#gcFilter(cutoffEpochTimestampMs, deleteParams);
|
|
3314
3647
|
if (step !== undefined) {
|
|
3315
|
-
// Inclusive upper bound:
|
|
3648
|
+
// Inclusive upper bound: completed_at ties may push a batch over batchSize, but never split across two.
|
|
3316
3649
|
deleteParams.push(watermark, step);
|
|
3317
|
-
deleteScope = `${deleteScope} AND
|
|
3650
|
+
deleteScope = `${deleteScope} AND completed_at > $${deleteParams.length - 1} AND completed_at <= $${deleteParams.length}`;
|
|
3318
3651
|
}
|
|
3319
|
-
// The final batch drops the watermark,
|
|
3652
|
+
// The final batch drops the watermark: unbounded, since an import can land a
|
|
3653
|
+
// completed_at below it mid-pass.
|
|
3320
3654
|
await client.query(`DELETE FROM "${this.schemaName}".workflow_status WHERE ${deleteScope}`, deleteParams);
|
|
3321
3655
|
return step;
|
|
3322
3656
|
}
|
|
3323
3657
|
finally {
|
|
3324
|
-
|
|
3325
|
-
client.release();
|
|
3658
|
+
this.#releaseRetentionClient(client);
|
|
3326
3659
|
}
|
|
3327
3660
|
}
|
|
3328
|
-
|
|
3661
|
+
/**
|
|
3662
|
+
* Delete old terminal workflows throughout the system database, returning the cutoff
|
|
3663
|
+
* actually used, or undefined when there is nothing to collect.
|
|
3664
|
+
*
|
|
3665
|
+
* Conductor sends cleared retention thresholds as JSON null, so every param is nullish.
|
|
3666
|
+
*/
|
|
3329
3667
|
async garbageCollect(cutoffEpochTimestampMs, rowsThreshold, options = {}) {
|
|
3330
|
-
const batchSize = options.batchSize
|
|
3668
|
+
const batchSize = options.batchSize ?? exports.DEFAULT_GC_BATCH_SIZE;
|
|
3331
3669
|
// A NaN survives a bare `< 1` test and would only fail once it reached SQL, leaving GC half-applied.
|
|
3332
|
-
if (
|
|
3670
|
+
if (!Number.isInteger(batchSize) || batchSize < 1) {
|
|
3333
3671
|
throw new error_1.DBOSError(`batchSize must be a positive integer, got ${batchSize}`);
|
|
3334
3672
|
}
|
|
3335
3673
|
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
|
|
3674
|
+
// The completed_at of the rowsThreshold newest completed row
|
|
3675
|
+
const result = await retryOnSerializationError(() => this.pool.query(`SELECT completed_at
|
|
3340
3676
|
FROM "${this.schemaName}".workflow_status
|
|
3341
|
-
WHERE
|
|
3342
|
-
ORDER BY
|
|
3343
|
-
LIMIT 1 OFFSET $1`,
|
|
3677
|
+
WHERE completed_at IS NOT NULL
|
|
3678
|
+
ORDER BY completed_at DESC
|
|
3679
|
+
LIMIT 1 OFFSET $1`, [rowsThreshold - 1]));
|
|
3344
3680
|
if (result.rows.length > 0) {
|
|
3345
|
-
const rowsBasedCutoff = result.rows[0].
|
|
3681
|
+
const rowsBasedCutoff = Number(result.rows[0].completed_at);
|
|
3346
3682
|
// Use the more restrictive cutoff (higher timestamp = more recent = more deletion)
|
|
3347
3683
|
if (cutoffEpochTimestampMs === undefined ||
|
|
3348
3684
|
cutoffEpochTimestampMs === null ||
|
|
@@ -3352,32 +3688,28 @@ class SystemDatabase {
|
|
|
3352
3688
|
}
|
|
3353
3689
|
}
|
|
3354
3690
|
if (cutoffEpochTimestampMs === undefined || cutoffEpochTimestampMs === null) {
|
|
3355
|
-
return;
|
|
3691
|
+
return undefined;
|
|
3356
3692
|
}
|
|
3357
3693
|
// Narrowed to a constant so the closures below keep it.
|
|
3358
3694
|
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
|
|
3695
|
+
// Advance a completed_at watermark, one committed transaction per batch, so a long
|
|
3374
3696
|
// history neither deletes in one transaction nor rescans what it already deleted.
|
|
3375
|
-
|
|
3697
|
+
const oldest = await retryOnSerializationError(async () => {
|
|
3698
|
+
const params = [];
|
|
3699
|
+
const scope = this.#gcFilter(cutoff, params);
|
|
3700
|
+
const { rows } = await this.pool.query(`SELECT completed_at
|
|
3701
|
+
FROM "${this.schemaName}".workflow_status
|
|
3702
|
+
WHERE ${scope}
|
|
3703
|
+
ORDER BY completed_at
|
|
3704
|
+
LIMIT 1`, params);
|
|
3705
|
+
return rows.length > 0 ? Number(rows[0].completed_at) : undefined;
|
|
3706
|
+
});
|
|
3707
|
+
let watermark = oldest === undefined ? 0 : oldest - 1;
|
|
3376
3708
|
for (;;) {
|
|
3377
3709
|
const next = await retryOnSerializationError(() => this.#garbageCollectBatch(cutoff, batchSize, watermark));
|
|
3378
3710
|
// Fewer than a full batch remained, so that delete took the rest.
|
|
3379
3711
|
if (next === undefined)
|
|
3380
|
-
return;
|
|
3712
|
+
return cutoff;
|
|
3381
3713
|
watermark = next;
|
|
3382
3714
|
}
|
|
3383
3715
|
}
|
|
@@ -3930,7 +4262,6 @@ class SystemDatabase {
|
|
|
3930
4262
|
authenticated_user,
|
|
3931
4263
|
assumed_role,
|
|
3932
4264
|
authenticated_roles,
|
|
3933
|
-
request,
|
|
3934
4265
|
executor_id,
|
|
3935
4266
|
application_version,
|
|
3936
4267
|
application_id,
|
|
@@ -3951,7 +4282,7 @@ class SystemDatabase {
|
|
|
3951
4282
|
debounce_deadline_epoch_ms,
|
|
3952
4283
|
is_debounced,
|
|
3953
4284
|
application_name
|
|
3954
|
-
) VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29
|
|
4285
|
+
) VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29)
|
|
3955
4286
|
ON CONFLICT (workflow_uuid)
|
|
3956
4287
|
DO UPDATE SET
|
|
3957
4288
|
updated_at = (EXTRACT(EPOCH FROM now()) * 1000)::bigint,
|
|
@@ -3971,14 +4302,14 @@ class SystemDatabase {
|
|
|
3971
4302
|
initStatus.authenticatedUser,
|
|
3972
4303
|
initStatus.assumedRole,
|
|
3973
4304
|
JSON.stringify(initStatus.authenticatedRoles),
|
|
3974
|
-
JSON.stringify(initStatus.request),
|
|
3975
4305
|
initStatus.executorId,
|
|
3976
4306
|
initStatus.applicationVersion ?? null,
|
|
3977
4307
|
initStatus.applicationID,
|
|
3978
4308
|
initStatus.status === workflow_1.StatusString.ENQUEUED || initStatus.status === workflow_1.StatusString.DELAYED ? 0 : 1,
|
|
3979
4309
|
initStatus.timeoutMS ?? null,
|
|
3980
4310
|
initStatus.deadlineEpochMS ?? null,
|
|
3981
|
-
|
|
4311
|
+
// Legacy column: the payload lives in workflow_input.
|
|
4312
|
+
null,
|
|
3982
4313
|
initStatus.deduplicationID ?? null,
|
|
3983
4314
|
initStatus.priority,
|
|
3984
4315
|
initStatus.queuePartitionKey ?? null,
|
|
@@ -3997,6 +4328,10 @@ class SystemDatabase {
|
|
|
3997
4328
|
if (rows.length === 0) {
|
|
3998
4329
|
throw new Error(`Attempt to insert workflow ${initStatus.workflowUUID} failed`);
|
|
3999
4330
|
}
|
|
4331
|
+
// Two statements, not a data-modifying CTE: at scale the CTE costs more than the round trip it saves.
|
|
4332
|
+
await client.query(`INSERT INTO "${this.schemaName}".workflow_input (workflow_uuid, inputs)
|
|
4333
|
+
VALUES ($1, $2)
|
|
4334
|
+
ON CONFLICT (workflow_uuid) DO NOTHING`, [initStatus.workflowUUID, initStatus.input ?? null]);
|
|
4000
4335
|
const ret = rows[0];
|
|
4001
4336
|
ret.class_name = ret.class_name ?? '';
|
|
4002
4337
|
ret.config_name = ret.config_name ?? '';
|
|
@@ -4023,14 +4358,6 @@ class SystemDatabase {
|
|
|
4023
4358
|
let whereClause = `WHERE workflow_uuid=$1`;
|
|
4024
4359
|
const args = [workflowID, status];
|
|
4025
4360
|
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
4361
|
if (update.resetRecoveryAttempts) {
|
|
4035
4362
|
setClause += `, recovery_attempts = 0`;
|
|
4036
4363
|
}
|
|
@@ -4080,8 +4407,8 @@ class SystemDatabase {
|
|
|
4080
4407
|
async recordOperationResultInternal(client, workflowID, functionID, functionName, checkConflict, startTimeEpochMs, endTimeEpochMs, options = {}) {
|
|
4081
4408
|
try {
|
|
4082
4409
|
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)
|
|
4410
|
+
(workflow_uuid, function_id, output, error, function_name, child_workflow_id, started_at_epoch_ms, completed_at_epoch_ms, serialization, application_name, retention_timestamp)
|
|
4411
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, (EXTRACT(EPOCH FROM now()) * 1000)::bigint)
|
|
4085
4412
|
ON CONFLICT (workflow_uuid, function_id) DO UPDATE
|
|
4086
4413
|
SET completed_at_epoch_ms = operation_outputs.completed_at_epoch_ms
|
|
4087
4414
|
RETURNING completed_at_epoch_ms;`, [
|