@dbos-inc/dbos-sdk 4.28.4-preview → 4.28.7-preview

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