@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.
Files changed (64) hide show
  1. package/dist/schemas/system_db_schema.d.ts +12 -1
  2. package/dist/schemas/system_db_schema.d.ts.map +1 -1
  3. package/dist/schemas/system_db_schema.js.map +1 -1
  4. package/dist/src/adminserver.d.ts.map +1 -1
  5. package/dist/src/adminserver.js +2 -1
  6. package/dist/src/adminserver.js.map +1 -1
  7. package/dist/src/client.d.ts +8 -0
  8. package/dist/src/client.d.ts.map +1 -1
  9. package/dist/src/client.js +2 -3
  10. package/dist/src/client.js.map +1 -1
  11. package/dist/src/conductor/conductor.d.ts +6 -0
  12. package/dist/src/conductor/conductor.d.ts.map +1 -1
  13. package/dist/src/conductor/conductor.js +47 -12
  14. package/dist/src/conductor/conductor.js.map +1 -1
  15. package/dist/src/context.d.ts +0 -25
  16. package/dist/src/context.d.ts.map +1 -1
  17. package/dist/src/context.js.map +1 -1
  18. package/dist/src/dbos-executor.d.ts.map +1 -1
  19. package/dist/src/dbos-executor.js +10 -7
  20. package/dist/src/dbos-executor.js.map +1 -1
  21. package/dist/src/dbos.d.ts +8 -44
  22. package/dist/src/dbos.d.ts.map +1 -1
  23. package/dist/src/dbos.js +9 -75
  24. package/dist/src/dbos.js.map +1 -1
  25. package/dist/src/debouncer.d.ts.map +1 -1
  26. package/dist/src/debouncer.js +9 -0
  27. package/dist/src/debouncer.js.map +1 -1
  28. package/dist/src/decorators.d.ts +0 -56
  29. package/dist/src/decorators.d.ts.map +1 -1
  30. package/dist/src/decorators.js +7 -331
  31. package/dist/src/decorators.js.map +1 -1
  32. package/dist/src/enqueue_options.d.ts.map +1 -1
  33. package/dist/src/enqueue_options.js +0 -1
  34. package/dist/src/enqueue_options.js.map +1 -1
  35. package/dist/src/error.d.ts +0 -8
  36. package/dist/src/error.d.ts.map +1 -1
  37. package/dist/src/error.js +1 -27
  38. package/dist/src/error.js.map +1 -1
  39. package/dist/src/index.d.ts +2 -2
  40. package/dist/src/index.d.ts.map +1 -1
  41. package/dist/src/index.js +3 -7
  42. package/dist/src/index.js.map +1 -1
  43. package/dist/src/scheduler/scheduler.d.ts.map +1 -1
  44. package/dist/src/scheduler/scheduler.js +0 -1
  45. package/dist/src/scheduler/scheduler.js.map +1 -1
  46. package/dist/src/sysdb_migrations/internal/migrations.js +158 -0
  47. package/dist/src/sysdb_migrations/internal/migrations.js.map +1 -1
  48. package/dist/src/system_database.d.ts +32 -3
  49. package/dist/src/system_database.d.ts.map +1 -1
  50. package/dist/src/system_database.js +448 -121
  51. package/dist/src/system_database.js.map +1 -1
  52. package/dist/src/workflow.d.ts +2 -1
  53. package/dist/src/workflow.d.ts.map +1 -1
  54. package/dist/src/workflow.js.map +1 -1
  55. package/dist/src/workflow_management.d.ts +4 -0
  56. package/dist/src/workflow_management.d.ts.map +1 -1
  57. package/dist/src/workflow_management.js +28 -2
  58. package/dist/src/workflow_management.js.map +1 -1
  59. package/dist/tsconfig.tsbuildinfo +1 -1
  60. package/package.json +1 -4
  61. package/dist/src/authdecorators.d.ts +0 -2
  62. package/dist/src/authdecorators.d.ts.map +0 -1
  63. package/dist/src/authdecorators.js +0 -48
  64. 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. Matches the GC default.
70
+ // Workflows re-owned per transaction by a rename.
71
71
  exports.DEFAULT_RENAME_BATCH_SIZE = 10_000;
72
- // Workflows deleted per transaction by garbage collection.
73
- exports.DEFAULT_GC_BATCH_SIZE = 10_000;
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), JSON.stringify(status.request), status.executorId, status.applicationVersion ?? null, status.applicationID, createdAt, 0, createdAt, status.timeoutMS ?? null, status.deadlineEpochMS ?? null, status.input, 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);
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
- const rowCount = await this.updateWorkflowStatus(client, workflowID, status, {
1008
- update: { ...outcome, resetDeduplicationID: true, setCompletedAt: true },
1009
- where: { status: workflow_1.StatusString.PENDING },
1010
- throwOnFailure: false,
1011
- });
1012
- return rowCount > 0;
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
- inputs = $2, serialization = $3,
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, $9)
1299
- WHERE name = $4 AND class_name IS NOT DISTINCT FROM $5
1300
- AND queue_name = $6 AND deduplication_id = $7
1301
- AND status = $8 AND is_debounced = TRUE
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
- await this.pool.query(`DELETE FROM "${this.schemaName}".workflow_status WHERE workflow_uuid = ANY($1)`, [allIds]);
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, inputs, serialization,
1446
- request, application_version, attributes, application_name
1447
- FROM "${this.schemaName}".workflow_status
1448
- WHERE workflow_uuid = ANY($1)`, [originalWorkflowIDs]);
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, ws.request, options.applicationVersion ?? ws.application_version ?? null, ws.application_id, ws.inputs, options.queuePartitionKey ?? null, origID, ws.serialization, ws.attributes ? JSON.stringify(ws.attributes) : null, forkOwners.get(forkID) ?? null);
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, request, output, error, executor_id,
1597
- created_at, updated_at, application_version, application_id,
1598
- class_name, config_name, recovery_attempts, queue_name,
1599
- workflow_timeout_ms, workflow_deadline_epoch_ms, started_at_epoch_ms,
1600
- deduplication_id, inputs, priority, queue_partition_key, forked_from,
1601
- parent_workflow_id, serialization, delay_until_epoch_ms,
1602
- was_forked_from, rate_limited, completed_at, attributes, schedule_name,
1603
- debounce_deadline_epoch_ms, is_debounced, application_name
1604
- FROM "${this.schemaName}".workflow_status
1605
- WHERE workflow_uuid = $1`, [wfID]);
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, request, output, error, executor_id,
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, $37)`, [
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
- status.request,
1668
- status.output,
1669
- status.error,
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
- status.inputs,
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, error, serialization FROM "${this.schemaName}".workflow_status
1871
- WHERE workflow_uuid=$1`, [workflowID])));
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
- selectColumns.push('inputs', 'request');
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
- selectColumns.push('output', 'error');
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 ${selectColumns.join(', ')}
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
- /** Rows garbage collection may delete: terminal, older than the cutoff, and ours. */
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
- const cutoffClause = `created_at < $${params.length}`;
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.#connect();
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 created_at
3638
+ const stepResult = await client.query(`SELECT completed_at
3306
3639
  FROM "${this.schemaName}".workflow_status
3307
- WHERE ${stepScope} AND created_at > $${stepParams.length}
3308
- ORDER BY created_at
3640
+ WHERE ${stepScope} AND completed_at > $${stepParams.length}
3641
+ ORDER BY completed_at
3309
3642
  LIMIT 1 OFFSET ${batchSize - 1}`, stepParams);
3310
- // created_at is a bigint, so node-postgres hands it back as a string.
3311
- const step = stepResult.rows.length > 0 ? Number(stepResult.rows[0].created_at) : undefined;
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: created_at ties may push a batch over batchSize, but never split across two.
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 created_at > $${deleteParams.length - 1} AND created_at <= $${deleteParams.length}`;
3650
+ deleteScope = `${deleteScope} AND completed_at > $${deleteParams.length - 1} AND completed_at <= $${deleteParams.length}`;
3318
3651
  }
3319
- // The final batch drops the watermark, so rows that appeared below it are still deleted.
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
- // No error argument: a genuinely dead connection is still evicted by the pool's own check.
3325
- client.release();
3658
+ this.#releaseRetentionClient(client);
3326
3659
  }
3327
3660
  }
3328
- // Conductor sends cleared retention thresholds as JSON null, so both params must be treated as nullish
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 === null ? undefined : (options.batchSize ?? exports.DEFAULT_GC_BATCH_SIZE);
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 (batchSize !== undefined && (!Number.isInteger(batchSize) || batchSize < 1)) {
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
- // Get the created_at timestamp of the rows_threshold newest row
3337
- const params = [rowsThreshold - 1];
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 ${scope}
3342
- ORDER BY created_at DESC
3343
- LIMIT 1 OFFSET $1`, params);
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].created_at;
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
- if (batchSize === undefined) {
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
- let watermark = 0;
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, $30)
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
- initStatus.input ?? null,
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;`, [