@dbos-inc/dbos-sdk 5.2.10-preview → 5.3.0

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 (56) hide show
  1. package/dist/src/context.d.ts +6 -0
  2. package/dist/src/context.d.ts.map +1 -1
  3. package/dist/src/context.js +25 -1
  4. package/dist/src/context.js.map +1 -1
  5. package/dist/src/datasource.d.ts +3 -0
  6. package/dist/src/datasource.d.ts.map +1 -1
  7. package/dist/src/datasource.js +17 -2
  8. package/dist/src/datasource.js.map +1 -1
  9. package/dist/src/dbos-executor.d.ts +2 -2
  10. package/dist/src/dbos-executor.d.ts.map +1 -1
  11. package/dist/src/dbos-executor.js +9 -8
  12. package/dist/src/dbos-executor.js.map +1 -1
  13. package/dist/src/dbos.d.ts.map +1 -1
  14. package/dist/src/dbos.js +54 -20
  15. package/dist/src/dbos.js.map +1 -1
  16. package/dist/src/enqueue_workflow.d.ts.map +1 -1
  17. package/dist/src/enqueue_workflow.js +3 -0
  18. package/dist/src/enqueue_workflow.js.map +1 -1
  19. package/dist/src/enterprise.d.ts +26 -0
  20. package/dist/src/enterprise.d.ts.map +1 -0
  21. package/dist/src/enterprise.js +36 -0
  22. package/dist/src/enterprise.js.map +1 -0
  23. package/dist/src/scheduler/scheduler.d.ts.map +1 -1
  24. package/dist/src/scheduler/scheduler.js +8 -2
  25. package/dist/src/scheduler/scheduler.js.map +1 -1
  26. package/dist/src/sysdb_migrations/internal/migrations.js +1 -1
  27. package/dist/src/sysdb_migrations/internal/migrations.js.map +1 -1
  28. package/dist/src/system_database.d.ts +41 -133
  29. package/dist/src/system_database.d.ts.map +1 -1
  30. package/dist/src/system_database.js +215 -1103
  31. package/dist/src/system_database.js.map +1 -1
  32. package/dist/src/telemetry/logs.d.ts +1 -0
  33. package/dist/src/telemetry/logs.d.ts.map +1 -1
  34. package/dist/src/telemetry/logs.js +2 -1
  35. package/dist/src/telemetry/logs.js.map +1 -1
  36. package/dist/src/wfqueue.d.ts +1 -0
  37. package/dist/src/wfqueue.d.ts.map +1 -1
  38. package/dist/src/wfqueue.js +5 -0
  39. package/dist/src/wfqueue.js.map +1 -1
  40. package/dist/src/workflow.d.ts +1 -3
  41. package/dist/src/workflow.d.ts.map +1 -1
  42. package/dist/src/workflow.js.map +1 -1
  43. package/dist/src/workflow_management.d.ts +0 -5
  44. package/dist/src/workflow_management.d.ts.map +1 -1
  45. package/dist/src/workflow_management.js +2 -36
  46. package/dist/src/workflow_management.js.map +1 -1
  47. package/dist/tsconfig.tsbuildinfo +1 -1
  48. package/package.json +17 -3
  49. package/dist/src/conductor/conductor.d.ts +0 -38
  50. package/dist/src/conductor/conductor.d.ts.map +0 -1
  51. package/dist/src/conductor/conductor.js +0 -941
  52. package/dist/src/conductor/conductor.js.map +0 -1
  53. package/dist/src/conductor/protocol.d.ts +0 -591
  54. package/dist/src/conductor/protocol.d.ts.map +0 -1
  55. package/dist/src/conductor/protocol.js +0 -447
  56. package/dist/src/conductor/protocol.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.retriablePostgresException = exports.verifySystemDatabase = exports.migrateSystemDatabase = exports.ensureSystemDatabase = exports.advisoryLockKey = exports.retentionLockKey = exports.getDbosSchemaPermissionsSql = exports.DEFAULT_GC_BATCH_SIZE = exports.DEFAULT_RENAME_BATCH_SIZE = exports.validateIdleTransactionTimeoutMs = exports.DEFAULT_IDLE_TRANSACTION_TIMEOUT_MS = 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.withDbRetry = exports.retriablePostgresException = exports.unwrapErrors = exports.isPgDatabaseError = exports.verifySystemDatabase = exports.migrateSystemDatabase = exports.ensureSystemDatabase = exports.isCockroachDB = exports.advisoryLockKey = exports.getDbosSchemaPermissionsSql = exports.DEFAULT_RENAME_BATCH_SIZE = exports.validateIdleTransactionTimeoutMs = exports.DEFAULT_IDLE_TRANSACTION_TIMEOUT_MS = 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");
@@ -88,8 +88,6 @@ function setsIdleTransactionTimeout(databaseUrl) {
88
88
  }
89
89
  // Workflows re-owned per transaction by a rename.
90
90
  exports.DEFAULT_RENAME_BATCH_SIZE = 10_000;
91
- // Rows deleted per transaction by garbage collection.
92
- exports.DEFAULT_GC_BATCH_SIZE = 50_000;
93
91
  const QUEUE_COLUMN_BY_FIELD = {
94
92
  concurrency: 'concurrency',
95
93
  workerConcurrency: 'worker_concurrency',
@@ -204,16 +202,6 @@ async function releaseSystemDatabaseClient(client, customPool) {
204
202
  }
205
203
  catch (e) { }
206
204
  }
207
- /**
208
- * The pg_try_advisory_lock argument for one schema's retention round: the leading 8 bytes of
209
- * SHA-256 over a fixed string, read as a signed big-endian 64-bit integer. Separate locks for
210
- * separate schemas. Every DBOS SDK derives the key this way, so rounds in different languages
211
- * against one system database contend for the same lock; changing it here changes it everywhere.
212
- */
213
- function retentionLockKey(schemaName) {
214
- return advisoryLockKey(`dbos.retention.${schemaName}`);
215
- }
216
- exports.retentionLockKey = retentionLockKey;
217
205
  /** A Postgres advisory lock key: the leading 8 bytes of SHA-256 over `lockName`, as a signed big-endian integer. */
218
206
  function advisoryLockKey(lockName) {
219
207
  return (0, crypto_1.createHash)('sha256').update(lockName).digest().readBigInt64BE(0);
@@ -223,6 +211,7 @@ async function isCockroachDB(client) {
223
211
  const versionRes = await client.query('SELECT version() AS version');
224
212
  return /cockroachdb/i.test(versionRes.rows[0]?.version ?? '');
225
213
  }
214
+ exports.isCockroachDB = isCockroachDB;
226
215
  async function ensureSystemDatabase(sysDbUrl, logger, customPool, schemaName = 'dbos', useListenNotify = true) {
227
216
  if (!customPool) {
228
217
  // A custom pool means the database already exists; otherwise, create it if it does not.
@@ -378,14 +367,6 @@ const RETRY_SQLSTATE_CODES = new Set([
378
367
  '25P03', // idle_in_transaction_session_timeout, when the kill lands on an in-flight query; an idle kill surfaces as a message match below
379
368
  '40P01', // deadlock_detected: the victim's transaction rolled back, so rerunning it is safe
380
369
  ]);
381
- /**
382
- * Bulk maintenance retries these a bounded number of times. 40001 is kept out of the sets above,
383
- * which feed `dbRetry` and so retry forever; every other path runs READ COMMITTED and never sees it.
384
- */
385
- const SERIALIZATION_SQLSTATE_CODES = new Set([
386
- '40001', // serialization_failure (MVCC conflict)
387
- '40P01', // deadlock_detected
388
- ]);
389
370
  // Node.js transient network error codes (system call level)
390
371
  const RETRY_NODE_ERRNOS = new Set([
391
372
  'ECONNRESET',
@@ -394,16 +375,21 @@ const RETRY_NODE_ERRNOS = new Set([
394
375
  'ENETUNREACH',
395
376
  'ETIMEDOUT',
396
377
  'ECONNABORTED',
378
+ 'EAI_AGAIN', // DNS lookup failed temporarily, e.g. while a container's hostname is briefly unresolvable
397
379
  ]);
398
380
  function isPgDatabaseError(e) {
399
381
  // Matched by shape, not instanceof: a user-supplied pool may throw another pg copy's DatabaseError.
400
382
  return !!e && typeof e === 'object' && typeof e.code === 'string' && e.code.length === 5;
401
383
  }
384
+ exports.isPgDatabaseError = isPgDatabaseError;
402
385
  function sqlStateLooksRetryable(sqlstate) {
403
386
  if (!sqlstate)
404
387
  return false;
405
388
  if (RETRY_SQLSTATE_CODES.has(sqlstate))
406
389
  return true;
390
+ // query_canceled (a statement timeout or an explicit cancel); retrying would undo it
391
+ if (sqlstate === '57014')
392
+ return false;
407
393
  const prefix = sqlstate.toString().slice(0, 2);
408
394
  return RETRY_SQLSTATE_PREFIXES.has(prefix);
409
395
  }
@@ -448,6 +434,7 @@ function* unwrapErrors(e) {
448
434
  yield cur;
449
435
  }
450
436
  }
437
+ exports.unwrapErrors = unwrapErrors;
451
438
  // "What could possibly go wrong?"
452
439
  function retriablePostgresException(err) {
453
440
  // Dig into AggregateErrors of various types
@@ -480,80 +467,59 @@ exports.retriablePostgresException = retriablePostgresException;
480
467
  function isStatementTimeout(err) {
481
468
  return !!err && typeof err === 'object' && err.code === '57014';
482
469
  }
483
- function isSerializationError(err) {
484
- for (const e of unwrapErrors(err)) {
485
- const anyErr = e;
486
- if (isPgDatabaseError(anyErr) && !!anyErr.code && SERIALIZATION_SQLSTATE_CODES.has(anyErr.code)) {
487
- return true;
488
- }
489
- }
490
- return false;
491
- }
492
- /**
493
- * Re-run a batch that lost a deadlock or serialization race. The database already rolled it
494
- * back, so replaying it is safe.
495
- */
496
- async function retryOnSerializationError(operation) {
497
- const maxAttempts = 10;
498
- const maxBackoff = 2.0;
499
- let backoff = 0.05;
500
- for (let attempt = 1;; attempt++) {
470
+ /** Run `operation` under the {@link dbRetry} policy, for code that cannot take the decorator. */
471
+ async function withDbRetry(operation, options = {}) {
472
+ // Read the defaults per call so the backoff stays tunable after the decorator is applied.
473
+ const maxBackoff = options.maxBackoff ?? utils_1.dbRetryConfig.maxBackoffSec;
474
+ let retries = 0;
475
+ let backoff = options.initialBackoff ?? utils_1.dbRetryConfig.initialBackoffSec;
476
+ const abortSignal = (0, context_1.currentDbRetryAbortSignal)();
477
+ while (true) {
501
478
  try {
502
479
  return await operation();
503
480
  }
504
481
  catch (e) {
505
- if (!isSerializationError(e)) {
506
- throw e;
482
+ if (retriablePostgresException(e)) {
483
+ if (abortSignal?.aborted)
484
+ throw e;
485
+ retries++;
486
+ // Calculate backoff with jitter
487
+ const actualBackoff = backoff * (0.5 + Math.random());
488
+ dbos_executor_1.DBOSExecutor.globalInstance?.logger.warn(`Database connection failed: ${e instanceof Error ? e.message : String(e)}. ` +
489
+ `Retrying in ${actualBackoff.toFixed(2)}s (attempt ${retries})`);
490
+ // Sleep with backoff
491
+ if (abortSignal) {
492
+ await (0, utils_1.interruptibleSleep)(actualBackoff * 1000, abortSignal);
493
+ if (abortSignal.aborted)
494
+ throw e;
495
+ }
496
+ else {
497
+ await (0, utils_1.sleepms)(actualBackoff * 1000); // Convert to milliseconds
498
+ }
499
+ // Increase backoff for next attempt (exponential)
500
+ backoff = Math.min(backoff * 2, maxBackoff);
507
501
  }
508
- const message = e instanceof Error ? e.message : String(e);
509
- if (attempt === maxAttempts) {
510
- dbos_executor_1.DBOSExecutor.globalInstance?.logger.warn(`Garbage collection failed after ${maxAttempts} attempts: ${message}`);
502
+ else {
511
503
  throw e;
512
504
  }
513
- // Jittered backoff, so peers that collided do not collide again
514
- const actualBackoff = backoff * (0.5 + Math.random());
515
- dbos_executor_1.DBOSExecutor.globalInstance?.logger.warn(`Contention or deadlock detected in workflow garbage collection: ${message}. ` +
516
- `Retrying in ${actualBackoff.toFixed(2)}s (attempt ${attempt})`);
517
- await (0, utils_1.sleepms)(actualBackoff * 1000);
518
- backoff = Math.min(backoff * 2, maxBackoff);
519
505
  }
520
506
  }
521
507
  }
508
+ exports.withDbRetry = withDbRetry;
522
509
  /**
523
510
  * If a workflow encounters a database connection issue while performing an operation,
524
511
  * block the workflow and retry the operation until it reconnects and succeeds.
525
512
  * In other words, if DBOS loses its database connection, everything pauses until the connection is recovered,
526
513
  * trading off availability for correctness.
514
+ *
515
+ * The exception is a background DBOS loop run under `runWithAbortableDbRetries`: once its stop signal
516
+ * aborts, the last connection error is rethrown, so stopping the loop (e.g. at shutdown) cannot hang.
527
517
  */
528
518
  function dbRetry(options = {}) {
529
519
  return function (target, propertyName, descriptor) {
530
520
  const method = descriptor.value;
531
521
  descriptor.value = async function (...args) {
532
- // Read the defaults per call so the backoff stays tunable after the decorator is applied.
533
- const maxBackoff = options.maxBackoff ?? utils_1.dbRetryConfig.maxBackoffSec;
534
- let retries = 0;
535
- let backoff = options.initialBackoff ?? utils_1.dbRetryConfig.initialBackoffSec;
536
- while (true) {
537
- try {
538
- return await method.apply(this, args);
539
- }
540
- catch (e) {
541
- if (retriablePostgresException(e)) {
542
- retries++;
543
- // Calculate backoff with jitter
544
- const actualBackoff = backoff * (0.5 + Math.random());
545
- dbos_executor_1.DBOSExecutor.globalInstance?.logger.warn(`Database connection failed: ${e instanceof Error ? e.message : String(e)}. ` +
546
- `Retrying in ${actualBackoff.toFixed(2)}s (attempt ${retries})`);
547
- // Sleep with backoff
548
- await (0, utils_1.sleepms)(actualBackoff * 1000); // Convert to milliseconds
549
- // Increase backoff for next attempt (exponential)
550
- backoff = Math.min(backoff * 2, maxBackoff);
551
- }
552
- else {
553
- throw e;
554
- }
555
- }
556
- }
522
+ return withDbRetry(() => method.apply(this, args), options);
557
523
  };
558
524
  return descriptor;
559
525
  };
@@ -635,11 +601,12 @@ class SystemDatabase {
635
601
  #batchCreatedAtCursors = new Map();
636
602
  // Set by destroy(), so polling waits end instead of running on against a pool that outlives this handle.
637
603
  #destroyed = false;
638
- // Resolved on first use by #cockroach(), since detecting it costs a query.
639
- #isCockroach = undefined;
604
+ get destroyed() {
605
+ return this.#destroyed;
606
+ }
640
607
  // Connections a retention round holds right now. destroy() cuts them, since closing the
641
608
  // pool would otherwise wait on the lock session and on any statement in flight.
642
- #retentionClients = new Set();
609
+ retentionClients = new Set();
643
610
  constructor(systemDatabaseUrl, logger, serializer, sysDbPoolSize = exports.DEFAULT_POOL_SIZE, systemDatabasePool, schemaName = 'dbos', useListenNotify = true, pollingConcurrency, notificationCoalesceMs = exports.DEFAULT_NOTIFICATION_COALESCE_MS,
644
611
  // The application this handle acts for; undefined writes unclaimed rows.
645
612
  appName, observabilityQueryTimeoutMs = exports.DEFAULT_OBSERVABILITY_QUERY_TIMEOUT_MS, idleTransactionTimeoutMs = exports.DEFAULT_IDLE_TRANSACTION_TIMEOUT_MS) {
@@ -699,25 +666,9 @@ class SystemDatabase {
699
666
  this.logger.warn(`Unexpected error on a system database connection: ${err}`);
700
667
  };
701
668
  /** Check out a pool connection guarded for as long as we hold it. See {@link borrowClient}. */
702
- #connect() {
669
+ connect() {
703
670
  return borrowClient(this.pool, this.#onClientError);
704
671
  }
705
- /** Borrow a connection for a retention round, so destroy() can cut it. */
706
- async #borrowRetentionClient() {
707
- if (this.#destroyed) {
708
- throw new Error('System database shutting down');
709
- }
710
- const client = await this.#connect();
711
- this.#retentionClients.add(client);
712
- return client;
713
- }
714
- /** Return a retention connection, unless destroy() already cut it: a second release would throw. */
715
- #releaseRetentionClient(client) {
716
- if (this.#retentionClients.delete(client)) {
717
- // No error argument: a genuinely dead connection is still evicted by the pool's own check.
718
- client.release();
719
- }
720
- }
721
672
  /**
722
673
  * Cap an introspection read with a statement timeout, so one scanning a huge table cannot hold a
723
674
  * snapshot for minutes and stall autovacuum database-wide. Soft-private so tests can assert the cap
@@ -728,7 +679,7 @@ class SystemDatabase {
728
679
  */
729
680
  async observabilityQuery(fn) {
730
681
  const timeoutMs = this.observabilityQueryTimeoutMs;
731
- const client = await this.#connect();
682
+ const client = await this.connect();
732
683
  try {
733
684
  if (timeoutMs === undefined) {
734
685
  return await fn(client);
@@ -744,7 +695,7 @@ class SystemDatabase {
744
695
  }
745
696
  catch (e) {
746
697
  await client.query('ROLLBACK').catch(() => { });
747
- // No `cause`: dbRetry walks the cause chain and would retry 57014 forever as an operator intervention.
698
+ // No `cause`: the typed timeout replaces the driver's query_canceled error.
748
699
  throw isStatementTimeout(e) ? new error_1.DBOSQueryTimeoutError(timeoutMs) : e;
749
700
  }
750
701
  }
@@ -757,7 +708,7 @@ class SystemDatabase {
757
708
  * A predicate matching rows owned by these applications plus unclaimed ones, which belong to
758
709
  * every application; unset or empty matches everything. Appends its bind parameter to `params`.
759
710
  */
760
- #appNameFilter(column, value, params) {
711
+ appNameFilter(column, value, params) {
761
712
  // An empty name is no name: it is not a value any application could be configured with.
762
713
  const names = !value ? [] : Array.isArray(value) ? value : [value];
763
714
  if (names.length === 0)
@@ -770,8 +721,8 @@ class SystemDatabase {
770
721
  * this application owns, not to every application's rows. A handle with no application of its
771
722
  * own still matches every one.
772
723
  */
773
- #observabilityFilter(column, value, params) {
774
- return this.#appNameFilter(column, value ?? this.appName, params);
724
+ observabilityFilter(column, value, params) {
725
+ return this.appNameFilter(column, value ?? this.appName, params);
775
726
  }
776
727
  /**
777
728
  * The version name a dequeue treats as latest: this application's own plus unclaimed
@@ -779,7 +730,7 @@ class SystemDatabase {
779
730
  */
780
731
  async #latestApplicationVersionName(client) {
781
732
  const params = [];
782
- const scope = this.#appNameFilter('application_name', this.appName, params);
733
+ const scope = this.appNameFilter('application_name', this.appName, params);
783
734
  const { rows } = await client.query(`SELECT version_name
784
735
  FROM "${this.schemaName}".application_versions
785
736
  WHERE ${scope}
@@ -842,7 +793,7 @@ class SystemDatabase {
842
793
  // A retention round still running is cut here rather than waited for: returned with an
843
794
  // error, each connection is destroyed at once, the idle lock session lets go of the
844
795
  // advisory lock, and the round fails on its next statement instead of holding shutdown.
845
- for (const client of this.#retentionClients) {
796
+ for (const client of this.retentionClients) {
846
797
  // Cover the release() call itself, which tears the connection down and can surface a socket error.
847
798
  client.on('error', () => { });
848
799
  try {
@@ -852,7 +803,7 @@ class SystemDatabase {
852
803
  this.logger.warn(`Error releasing a retention connection: ${String(e)}`);
853
804
  }
854
805
  }
855
- this.#retentionClients.clear();
806
+ this.retentionClients.clear();
856
807
  // We attached nothing to the pool object itself, so there is nothing to unpick; only close one we own.
857
808
  if (!this.customPool) {
858
809
  await this.pool.end();
@@ -867,7 +818,7 @@ class SystemDatabase {
867
818
  return await this.initWorkflowStatusStandalone(initStatus, creatorXid, reusePolicy);
868
819
  }
869
820
  async initWorkflowStatusStandalone(initStatus, creatorXid, reusePolicy) {
870
- const client = await this.#connect();
821
+ const client = await this.connect();
871
822
  let shouldCommit = false;
872
823
  try {
873
824
  await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');
@@ -893,7 +844,7 @@ class SystemDatabase {
893
844
  }
894
845
  /** Insert a child workflow's row and record it as the parent's step in one transaction, so a crash leaves both or neither. */
895
846
  async initChildWorkflowStatus(initStatus, creatorXid, parentWorkflowID, parentFunctionID, startTimeEpochMs, endTimeEpochMs, reusePolicy = 'return-existing') {
896
- const client = await this.#connect();
847
+ const client = await this.connect();
897
848
  let shouldCommit = false;
898
849
  try {
899
850
  await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');
@@ -1070,7 +1021,7 @@ class SystemDatabase {
1070
1021
  'schedule_name',
1071
1022
  'application_name',
1072
1023
  ];
1073
- const client = await this.#connect();
1024
+ const client = await this.connect();
1074
1025
  try {
1075
1026
  await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');
1076
1027
  // Chunk to stay well under the bind-parameter limit.
@@ -1123,7 +1074,7 @@ class SystemDatabase {
1123
1074
  return inserted;
1124
1075
  }
1125
1076
  async recordWorkflowOutput(workflowID, status, ownerXid) {
1126
- const client = await this.#connect();
1077
+ const client = await this.connect();
1127
1078
  try {
1128
1079
  return await this.#recordWorkflowOutcome(client, workflowID, workflow_1.StatusString.SUCCESS, { output: status.output }, ownerXid);
1129
1080
  }
@@ -1132,7 +1083,7 @@ class SystemDatabase {
1132
1083
  }
1133
1084
  }
1134
1085
  async recordWorkflowError(workflowID, status, ownerXid) {
1135
- const client = await this.#connect();
1086
+ const client = await this.connect();
1136
1087
  try {
1137
1088
  return await this.#recordWorkflowOutcome(client, workflowID, workflow_1.StatusString.ERROR, { error: status.error }, ownerXid);
1138
1089
  }
@@ -1180,22 +1131,11 @@ class SystemDatabase {
1180
1131
  }
1181
1132
  }
1182
1133
  }
1183
- async getPendingWorkflows(executorID, appVersion) {
1184
- const params = [workflow_1.StatusString.PENDING, executorID, appVersion];
1185
- // executor_id defaults to "local", so it collides across applications.
1186
- const scope = this.#appNameFilter('application_name', this.appName, params);
1187
- const getWorkflows = await this.pool.query(`SELECT workflow_uuid
1188
- FROM "${this.schemaName}".workflow_status
1189
- WHERE status=$1 AND executor_id=$2 AND application_version=$3 AND ${scope}`, params);
1190
- return getWorkflows.rows.map((i) => ({
1191
- workflowUUID: i.workflow_uuid,
1192
- }));
1193
- }
1194
1134
  // Recovery re-enqueues rather than executing directly so the queue's atomic dequeue admits exactly one runner, and the executor ID predicate rejects sweeps for rows a live executor has already claimed.
1195
1135
  async reenqueueWorkflowsForRecovery(executorID, appVersion, recoveryQueueName) {
1196
1136
  const params = [workflow_1.StatusString.ENQUEUED, recoveryQueueName, workflow_1.StatusString.PENDING, executorID, appVersion];
1197
1137
  // executor_id defaults to "local", so it collides across applications.
1198
- const scope = this.#appNameFilter('application_name', this.appName, params);
1138
+ const scope = this.appNameFilter('application_name', this.appName, params);
1199
1139
  const result = await this.pool.query(`UPDATE "${this.schemaName}".workflow_status
1200
1140
  SET started_at_epoch_ms = NULL,
1201
1141
  status = $1,
@@ -1216,7 +1156,7 @@ class SystemDatabase {
1216
1156
  return status ? JSON.stringify(status) : null;
1217
1157
  };
1218
1158
  if (callerID && callerFN) {
1219
- const client = await this.#connect();
1159
+ const client = await this.connect();
1220
1160
  try {
1221
1161
  // Check if the operation has been done before for OAOO (only do this inside a workflow).
1222
1162
  const json = await this.#inTransaction(client, () => this.#runAndRecordResult(client, exports.DBOS_FUNCNAME_GETSTATUS, callerID, callerFN, funcGetStatus));
@@ -1252,7 +1192,7 @@ class SystemDatabase {
1252
1192
  }
1253
1193
  // Only used in tests
1254
1194
  async setWorkflowStatus(workflowID, status, resetRecoveryAttempts, internalOptions) {
1255
- const client = await this.#connect();
1195
+ const client = await this.connect();
1256
1196
  try {
1257
1197
  await this.updateWorkflowStatus(client, workflowID, status, {
1258
1198
  update: {
@@ -1270,7 +1210,7 @@ class SystemDatabase {
1270
1210
  }
1271
1211
  // ==================== Step Results ====================
1272
1212
  async getOperationResultAndThrowIfCancelled(workflowID, functionID) {
1273
- const client = await this.#connect();
1213
+ const client = await this.connect();
1274
1214
  try {
1275
1215
  return await this.#getOperationResultAndThrowIfCancelled(client, workflowID, functionID);
1276
1216
  }
@@ -1278,8 +1218,13 @@ class SystemDatabase {
1278
1218
  client.release();
1279
1219
  }
1280
1220
  }
1281
- async getAllOperationResults(workflowID, limit, offset) {
1282
- let query = `SELECT * FROM "${this.schemaName}".operation_outputs WHERE workflow_uuid=$1 ORDER BY function_id`;
1221
+ async getAllOperationResults(workflowID, limit, offset, loadOutput = true) {
1222
+ // Skip the output/error payload columns when the caller does not need them,
1223
+ // mirroring how listWorkflows skips its payload joins when loadOutput is false.
1224
+ const columns = loadOutput
1225
+ ? '*'
1226
+ : 'workflow_uuid, function_id, function_name, child_workflow_id, started_at_epoch_ms, completed_at_epoch_ms, application_name, retention_timestamp';
1227
+ let query = `SELECT ${columns} FROM "${this.schemaName}".operation_outputs WHERE workflow_uuid=$1 ORDER BY function_id`;
1283
1228
  const params = [workflowID];
1284
1229
  if (limit !== undefined) {
1285
1230
  params.push(limit);
@@ -1293,7 +1238,7 @@ class SystemDatabase {
1293
1238
  return rows;
1294
1239
  }
1295
1240
  async recordOperationResult(workflowID, functionID, functionName, checkConflict, startTimeEpochMs, endTimeEpochMs, options = {}) {
1296
- const client = await this.#connect();
1241
+ const client = await this.connect();
1297
1242
  try {
1298
1243
  await this.#inTransaction(client, () => this.recordOperationResultInternal(client, workflowID, functionID, functionName, checkConflict, startTimeEpochMs, endTimeEpochMs, options));
1299
1244
  }
@@ -1302,31 +1247,71 @@ class SystemDatabase {
1302
1247
  await (0, debugpoint_1.debugTriggerPoint)(debugpoint_1.DEBUG_TRIGGER_STEP_COMMIT);
1303
1248
  }
1304
1249
  }
1305
- async runTransactionalStep(workflowID, functionID, functionName, callback) {
1306
- const client = await this.#connect();
1250
+ /** Checkpoint an operation's failure so a replay re-throws it instead of re-running. */
1251
+ async recordOperationError(workflowID, functionID, functionName, startTimeEpochMs, error, childWorkflowID) {
1252
+ let serialized;
1307
1253
  try {
1308
- await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');
1309
- const existing = await this.#getOperationResultAndThrowIfCancelled(client, workflowID, functionID);
1310
- if (existing !== undefined) {
1254
+ serialized = await (0, serialization_1.serializeResErrorWithSerializer)(error, this.serializer, this.serializer.name());
1255
+ await (0, serialization_1.deserializeValue)(serialized.serializedValue, serialized.serialization, this.serializer);
1256
+ }
1257
+ catch (serError) {
1258
+ // A checkpoint replay could not load would wedge the workflow; leave it unrecorded so replay re-runs.
1259
+ this.logger.warn(`Not checkpointing ${functionName} failure in workflow ${workflowID}: its error cannot be serialized: ${String(serError)}`);
1260
+ return;
1261
+ }
1262
+ await this.recordOperationResult(workflowID, functionID, functionName, true, startTimeEpochMs, Date.now(), {
1263
+ error: serialized.serializedValue,
1264
+ serialization: serialized.serialization,
1265
+ childWorkflowID,
1266
+ });
1267
+ }
1268
+ async runTransactionalStep(workflowID, functionID, functionName, callback) {
1269
+ const startTime = Date.now();
1270
+ // The latest attempt's callback failure, told apart from a check or checkpoint failure.
1271
+ let callbackError = undefined;
1272
+ const attempt = async () => {
1273
+ callbackError = undefined;
1274
+ const client = await this.connect();
1275
+ try {
1276
+ await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');
1277
+ const existing = await this.#getOperationResultAndThrowIfCancelled(client, workflowID, functionID);
1278
+ if (existing !== undefined) {
1279
+ await client.query('ROLLBACK');
1280
+ return existing;
1281
+ }
1282
+ let output;
1283
+ try {
1284
+ output = await callback(client);
1285
+ }
1286
+ catch (e) {
1287
+ callbackError = e;
1288
+ throw e;
1289
+ }
1290
+ await this.recordOperationResultInternal(client, workflowID, functionID, functionName, true, startTime, Date.now(), {
1291
+ output,
1292
+ });
1293
+ await client.query('COMMIT');
1294
+ await (0, debugpoint_1.debugTriggerPoint)(debugpoint_1.DEBUG_TRIGGER_STEP_COMMIT);
1295
+ return undefined;
1296
+ }
1297
+ catch (e) {
1311
1298
  await client.query('ROLLBACK');
1312
- return existing;
1299
+ throw e;
1313
1300
  }
1314
- const startTime = Date.now();
1315
- const output = await callback(client);
1316
- await this.recordOperationResultInternal(client, workflowID, functionID, functionName, true, startTime, Date.now(), {
1317
- output,
1318
- });
1319
- await client.query('COMMIT');
1320
- await (0, debugpoint_1.debugTriggerPoint)(debugpoint_1.DEBUG_TRIGGER_STEP_COMMIT);
1321
- return undefined;
1301
+ finally {
1302
+ client.release();
1303
+ }
1304
+ };
1305
+ try {
1306
+ return await withDbRetry(attempt);
1322
1307
  }
1323
1308
  catch (e) {
1324
- await client.query('ROLLBACK');
1309
+ // Retries are done and the transaction rolled back, so record the callback's own failure separately.
1310
+ if (e === callbackError) {
1311
+ await this.recordOperationError(workflowID, functionID, functionName, startTime, e);
1312
+ }
1325
1313
  throw e;
1326
1314
  }
1327
- finally {
1328
- client.release();
1329
- }
1330
1315
  }
1331
1316
  async checkPatch(workflowID, functionID, patchName, deprecated) {
1332
1317
  // Not doing a cancel check at this point.
@@ -1349,7 +1334,7 @@ class SystemDatabase {
1349
1334
  }
1350
1335
  // Nondeprecated - skip matching entry, unpatched if nonmatching entry,
1351
1336
  // If there is no entry, we insert one that indicates it is patched, as an owner-checked checkpoint.
1352
- const client = await this.#connect();
1337
+ const client = await this.connect();
1353
1338
  try {
1354
1339
  return await this.#inTransaction(client, async () => {
1355
1340
  const checkpointName = await readCheckpointName(client);
@@ -1396,7 +1381,7 @@ class SystemDatabase {
1396
1381
  await this.#checkIfCanceled(this.pool, workflowID);
1397
1382
  }
1398
1383
  async resumeWorkflows(workflowIDs, queueName) {
1399
- const client = await this.#connect();
1384
+ const client = await this.connect();
1400
1385
  try {
1401
1386
  await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');
1402
1387
  // Check existence separately: a zero-row update also means "already complete", a legal no-op.
@@ -1460,7 +1445,7 @@ class SystemDatabase {
1460
1445
  return await this.debounceDelayedWorkflowStandalone(params);
1461
1446
  }
1462
1447
  async debounceDelayedWorkflowStandalone(params) {
1463
- const client = await this.#connect();
1448
+ const client = await this.connect();
1464
1449
  try {
1465
1450
  await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');
1466
1451
  const result = await this.#debounceDelayedWorkflowInternal(client, params);
@@ -1488,7 +1473,7 @@ class SystemDatabase {
1488
1473
  params.applicationName ?? null,
1489
1474
  ];
1490
1475
  // Never extend a workflow the target application doesn't own; falls through to the holder below.
1491
- const ownScope = this.#appNameFilter('application_name', params.applicationName, updateParams);
1476
+ const ownScope = this.appNameFilter('application_name', params.applicationName, updateParams);
1492
1477
  const updated = await client.query(`UPDATE "${this.schemaName}".workflow_status
1493
1478
  SET delay_until_epoch_ms = CASE
1494
1479
  WHEN debounce_deadline_epoch_ms IS NOT NULL AND debounce_deadline_epoch_ms < $1
@@ -1570,7 +1555,7 @@ class SystemDatabase {
1570
1555
  allIds.push(...(await this.getWorkflowChildren(wfid)));
1571
1556
  }
1572
1557
  }
1573
- const client = await this.#connect();
1558
+ const client = await this.connect();
1574
1559
  try {
1575
1560
  await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');
1576
1561
  // The payload tables carry no foreign key, so the status delete does not cascade
@@ -1616,7 +1601,7 @@ class SystemDatabase {
1616
1601
  throw new error_1.DBOSError(`startStep must be >= 0, got ${startStep}`);
1617
1602
  }
1618
1603
  const schema = this.schemaName;
1619
- const client = await this.#connect();
1604
+ const client = await this.connect();
1620
1605
  try {
1621
1606
  await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');
1622
1607
  const { rows: statusRows } = await client.query(`SELECT status FROM "${schema}".workflow_status WHERE workflow_uuid = $1`, [workflowID]);
@@ -1698,62 +1683,6 @@ class SystemDatabase {
1698
1683
  const result = await this.bulkForkWorkflows([workflowID], [newWorkflowID], [startStep], options);
1699
1684
  return result[0];
1700
1685
  }
1701
- async forkFromFailure(workflowIDs, options = {}) {
1702
- const modes = [
1703
- options.fromLastFailure ?? false,
1704
- options.fromLastStep ?? false,
1705
- options.fromStep !== undefined,
1706
- options.fromStepName !== undefined,
1707
- ].filter(Boolean).length;
1708
- if (modes !== 1) {
1709
- throw new Error('Exactly one of fromLastFailure, fromLastStep, fromStep, or fromStepName must be specified');
1710
- }
1711
- let startSteps;
1712
- if (options.fromStep !== undefined) {
1713
- startSteps = Array(workflowIDs.length).fill(options.fromStep);
1714
- }
1715
- else {
1716
- let query;
1717
- const params = [workflowIDs];
1718
- if (options.fromLastFailure) {
1719
- query = `SELECT workflow_uuid,
1720
- COALESCE(
1721
- MAX(function_id) FILTER (WHERE error IS NOT NULL),
1722
- MAX(function_id)
1723
- ) AS start_step
1724
- FROM "${this.schemaName}".operation_outputs
1725
- WHERE workflow_uuid = ANY($1)
1726
- GROUP BY workflow_uuid`;
1727
- }
1728
- else if (options.fromLastStep) {
1729
- query = `SELECT workflow_uuid, MAX(function_id) AS start_step
1730
- FROM "${this.schemaName}".operation_outputs
1731
- WHERE workflow_uuid = ANY($1)
1732
- GROUP BY workflow_uuid`;
1733
- }
1734
- else {
1735
- // fromStepName
1736
- query = `SELECT workflow_uuid, MAX(function_id) AS start_step
1737
- FROM "${this.schemaName}".operation_outputs
1738
- WHERE workflow_uuid = ANY($1) AND function_name = $2
1739
- GROUP BY workflow_uuid`;
1740
- params.push(options.fromStepName);
1741
- }
1742
- const result = await this.pool.query(query, params);
1743
- const startStepByID = new Map(result.rows.map((r) => [r.workflow_uuid, Number(r.start_step)]));
1744
- if (options.fromStepName !== undefined) {
1745
- for (const wid of workflowIDs) {
1746
- if (!startStepByID.has(wid)) {
1747
- throw new Error(`Workflow ${wid} has no step named '${options.fromStepName}'`);
1748
- }
1749
- }
1750
- }
1751
- // A workflow with no recorded steps has nothing to resume from, so restart it from the beginning.
1752
- startSteps = workflowIDs.map((wid) => startStepByID.get(wid) ?? 0);
1753
- }
1754
- const forkedIDs = workflowIDs.map(() => (0, crypto_1.randomUUID)());
1755
- return this.bulkForkWorkflows(workflowIDs, forkedIDs, startSteps, options);
1756
- }
1757
1686
  async bulkForkWorkflows(originalWorkflowIDs, forkedWorkflowIDs, startSteps, options = {}) {
1758
1687
  if (originalWorkflowIDs.length === 0) {
1759
1688
  return [];
@@ -1761,7 +1690,7 @@ class SystemDatabase {
1761
1690
  if (originalWorkflowIDs.length !== forkedWorkflowIDs.length || originalWorkflowIDs.length !== startSteps.length) {
1762
1691
  throw new Error('originalWorkflowIDs, forkedWorkflowIDs, and startSteps must have the same length');
1763
1692
  }
1764
- const client = await this.#connect();
1693
+ const client = await this.connect();
1765
1694
  try {
1766
1695
  await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');
1767
1696
  // Fetch the status of all original workflows inside the transaction.
@@ -1913,188 +1842,6 @@ class SystemDatabase {
1913
1842
  client.release();
1914
1843
  }
1915
1844
  }
1916
- async exportWorkflow(workflowID, exportChildren = false) {
1917
- const workflowIDs = [workflowID];
1918
- if (exportChildren) {
1919
- workflowIDs.push(...(await this.getWorkflowChildren(workflowID)));
1920
- }
1921
- const exportedWorkflows = [];
1922
- const client = await this.#connect();
1923
- try {
1924
- for (const wfID of workflowIDs) {
1925
- // Export workflow_status
1926
- const statusResult = await client.query(
1927
- // creator_xid and owner_xid are intentionally omitted: they are transient
1928
- // tokens, not logical workflow state, and a source database's
1929
- // tokens are meaningless in the target.
1930
- `SELECT
1931
- ws.workflow_uuid, ws.status, ws.name, ws.authenticated_user, ws.assumed_role,
1932
- ws.authenticated_roles,
1933
- COALESCE(wo.output, ws.output) AS output, COALESCE(wo.error, ws.error) AS error,
1934
- ws.executor_id,
1935
- ws.created_at, ws.updated_at, ws.application_version, ws.application_id,
1936
- ws.class_name, ws.config_name, ws.recovery_attempts, ws.queue_name,
1937
- ws.workflow_timeout_ms, ws.workflow_deadline_epoch_ms, ws.started_at_epoch_ms,
1938
- ws.deduplication_id, COALESCE(wi.inputs, ws.inputs) AS inputs,
1939
- ws.priority, ws.queue_partition_key, ws.forked_from,
1940
- ws.parent_workflow_id, ws.serialization, ws.delay_until_epoch_ms,
1941
- ws.was_forked_from, ws.rate_limited, ws.completed_at, ws.attributes, ws.schedule_name,
1942
- ws.debounce_deadline_epoch_ms, ws.is_debounced, ws.application_name
1943
- FROM "${this.schemaName}".workflow_status ws
1944
- LEFT JOIN "${this.schemaName}".workflow_input wi ON wi.workflow_uuid = ws.workflow_uuid
1945
- LEFT JOIN "${this.schemaName}".workflow_output wo ON wo.workflow_uuid = ws.workflow_uuid
1946
- WHERE ws.workflow_uuid = $1`, [wfID]);
1947
- if (statusResult.rows.length === 0) {
1948
- throw new error_1.DBOSNonExistentWorkflowError(`Workflow ${wfID} does not exist`);
1949
- }
1950
- const workflowStatus = statusResult.rows[0];
1951
- // Export operation_outputs
1952
- const outputsResult = await client.query(`SELECT
1953
- workflow_uuid, function_id, function_name, output, error,
1954
- child_workflow_id, started_at_epoch_ms, completed_at_epoch_ms,
1955
- serialization, application_name
1956
- FROM "${this.schemaName}".operation_outputs
1957
- WHERE workflow_uuid = $1`, [wfID]);
1958
- // Export workflow_events
1959
- const eventsResult = await client.query(`SELECT workflow_uuid, key, value, serialization
1960
- FROM "${this.schemaName}".workflow_events
1961
- WHERE workflow_uuid = $1`, [wfID]);
1962
- // Export workflow_events_history
1963
- const historyResult = await client.query(`SELECT workflow_uuid, function_id, key, value, serialization
1964
- FROM "${this.schemaName}".workflow_events_history
1965
- WHERE workflow_uuid = $1`, [wfID]);
1966
- // Export streams
1967
- const streamsResult = await client.query(`SELECT workflow_uuid, key, value, "offset", function_id, serialization
1968
- FROM "${this.schemaName}".streams
1969
- WHERE workflow_uuid = $1`, [wfID]);
1970
- exportedWorkflows.push({
1971
- workflow_status: workflowStatus,
1972
- operation_outputs: outputsResult.rows,
1973
- workflow_events: eventsResult.rows,
1974
- workflow_events_history: historyResult.rows,
1975
- streams: streamsResult.rows,
1976
- });
1977
- }
1978
- }
1979
- finally {
1980
- client.release();
1981
- }
1982
- return exportedWorkflows;
1983
- }
1984
- async importWorkflow(workflows) {
1985
- const client = await this.#connect();
1986
- try {
1987
- await client.query('BEGIN');
1988
- for (const workflow of workflows) {
1989
- const status = workflow.workflow_status;
1990
- // Import workflow_status
1991
- await client.query(`INSERT INTO "${this.schemaName}".workflow_status (
1992
- workflow_uuid, status, name, authenticated_user, assumed_role,
1993
- authenticated_roles, output, error, executor_id,
1994
- created_at, updated_at, application_version, application_id,
1995
- class_name, config_name, recovery_attempts, queue_name,
1996
- workflow_timeout_ms, workflow_deadline_epoch_ms, started_at_epoch_ms,
1997
- deduplication_id, inputs, priority, queue_partition_key, forked_from,
1998
- parent_workflow_id, serialization, delay_until_epoch_ms,
1999
- was_forked_from, rate_limited, completed_at, attributes, schedule_name,
2000
- debounce_deadline_epoch_ms, is_debounced, application_name
2001
- ) 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)`, [
2002
- status.workflow_uuid,
2003
- status.status,
2004
- status.name,
2005
- status.authenticated_user,
2006
- status.assumed_role,
2007
- status.authenticated_roles,
2008
- // Legacy columns: the payloads live in their own tables.
2009
- null,
2010
- null,
2011
- status.executor_id,
2012
- status.created_at,
2013
- status.updated_at,
2014
- status.application_version,
2015
- status.application_id,
2016
- status.class_name,
2017
- status.config_name,
2018
- status.recovery_attempts,
2019
- status.queue_name,
2020
- status.workflow_timeout_ms,
2021
- status.workflow_deadline_epoch_ms,
2022
- status.started_at_epoch_ms,
2023
- status.deduplication_id,
2024
- null,
2025
- status.priority,
2026
- status.queue_partition_key,
2027
- status.forked_from,
2028
- status.parent_workflow_id,
2029
- status.serialization,
2030
- status.delay_until_epoch_ms ?? null,
2031
- // NOT NULL columns: fall back to FALSE for payloads exported before
2032
- // these fields were included.
2033
- status.was_forked_from ?? false,
2034
- status.rate_limited ?? false,
2035
- status.completed_at ?? null,
2036
- status.attributes ? JSON.stringify(status.attributes) : null,
2037
- status.schedule_name ?? null,
2038
- status.debounce_deadline_epoch_ms ?? null,
2039
- status.is_debounced ?? false,
2040
- status.application_name ?? null,
2041
- ]);
2042
- // Retention starts at import: the original timestamps are long past the cutoff
2043
- // and would be collected immediately.
2044
- await client.query(`INSERT INTO "${this.schemaName}".workflow_input (workflow_uuid, inputs, retention_timestamp)
2045
- VALUES ($1, $2, (EXTRACT(EPOCH FROM now()) * 1000)::bigint)`, [status.workflow_uuid, status.inputs]);
2046
- if (status.output !== null || status.error !== null) {
2047
- await client.query(`INSERT INTO "${this.schemaName}".workflow_output (workflow_uuid, output, error, retention_timestamp)
2048
- VALUES ($1, $2, $3, (EXTRACT(EPOCH FROM now()) * 1000)::bigint)`, [status.workflow_uuid, status.output, status.error]);
2049
- }
2050
- // Import operation_outputs
2051
- for (const output of workflow.operation_outputs) {
2052
- await client.query(`INSERT INTO "${this.schemaName}".operation_outputs (
2053
- workflow_uuid, function_id, function_name, output, error,
2054
- child_workflow_id, started_at_epoch_ms, completed_at_epoch_ms,
2055
- serialization, application_name, retention_timestamp
2056
- ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, (EXTRACT(EPOCH FROM now()) * 1000)::bigint)`, [
2057
- output.workflow_uuid,
2058
- output.function_id,
2059
- output.function_name,
2060
- output.output,
2061
- output.error,
2062
- output.child_workflow_id,
2063
- output.started_at_epoch_ms,
2064
- output.completed_at_epoch_ms,
2065
- output.serialization,
2066
- output.application_name ?? null,
2067
- ]);
2068
- }
2069
- // Import workflow_events
2070
- for (const event of workflow.workflow_events) {
2071
- await client.query(`INSERT INTO "${this.schemaName}".workflow_events (
2072
- workflow_uuid, key, value, serialization
2073
- ) VALUES ($1, $2, $3, $4)`, [event.workflow_uuid, event.key, event.value, event.serialization]);
2074
- }
2075
- // Import workflow_events_history
2076
- for (const history of workflow.workflow_events_history) {
2077
- await client.query(`INSERT INTO "${this.schemaName}".workflow_events_history (
2078
- workflow_uuid, function_id, key, value, serialization
2079
- ) VALUES ($1, $2, $3, $4, $5)`, [history.workflow_uuid, history.function_id, history.key, history.value, history.serialization]);
2080
- }
2081
- // Import streams
2082
- for (const stream of workflow.streams) {
2083
- await client.query(`INSERT INTO "${this.schemaName}".streams (
2084
- workflow_uuid, key, value, "offset", function_id, serialization
2085
- ) VALUES ($1, $2, $3, $4, $5, $6)`, [stream.workflow_uuid, stream.key, stream.value, stream.offset, stream.function_id, stream.serialization]);
2086
- }
2087
- }
2088
- await client.query('COMMIT');
2089
- }
2090
- catch (error) {
2091
- await client.query('ROLLBACK');
2092
- throw error;
2093
- }
2094
- finally {
2095
- client.release();
2096
- }
2097
- }
2098
1845
  // ==================== Awaiting Workflows ====================
2099
1846
  /** Register one execution; the returned release is idempotent, so it can run both before a park and once the run settles. */
2100
1847
  registerRunningWorkflow(workflowID, queueName, queuePartitionKey) {
@@ -2382,7 +2129,9 @@ class SystemDatabase {
2382
2129
  async send(workflowID, functionID, destinationID, message, topic, serialization, idempotencyKey) {
2383
2130
  topic = topic ?? this.nullTopic;
2384
2131
  const messageUUID = idempotencyKey ? `${idempotencyKey}::${destinationID}` : (0, crypto_1.randomUUID)();
2385
- const client = await this.#connect();
2132
+ const startTime = Date.now();
2133
+ let missing;
2134
+ const client = await this.connect();
2386
2135
  try {
2387
2136
  await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');
2388
2137
  await this.#runAndRecordResult(client, exports.DBOS_FUNCNAME_SEND, workflowID, functionID, async () => {
@@ -2398,7 +2147,7 @@ class SystemDatabase {
2398
2147
  const err = error;
2399
2148
  if (err.code === '23503') {
2400
2149
  // Foreign key constraint violation (only expected for the INSERT query)
2401
- throw new error_1.DBOSNonExistentWorkflowError(`Sent to non-existent destination workflow UUID: ${destinationID}`);
2150
+ missing = new error_1.DBOSNonExistentWorkflowError(`Sent to non-existent destination workflow UUID: ${destinationID}`);
2402
2151
  }
2403
2152
  else {
2404
2153
  throw err;
@@ -2407,6 +2156,11 @@ class SystemDatabase {
2407
2156
  finally {
2408
2157
  client.release();
2409
2158
  }
2159
+ if (missing) {
2160
+ // A missing destination is the send's answer; the transaction has rolled back, so record it separately.
2161
+ await this.recordOperationError(workflowID, functionID, exports.DBOS_FUNCNAME_SEND, startTime, missing);
2162
+ throw missing;
2163
+ }
2410
2164
  }
2411
2165
  /** Runs on `client` if given, joining its transaction; otherwise on the pool with retries. */
2412
2166
  async sendDirect(destinationID, message, topic, serialization, idempotencyKey, client) {
@@ -2418,7 +2172,7 @@ class SystemDatabase {
2418
2172
  /** A send from inside a step of `workflowID`: not recorded, but it lands only while the caller still owns the workflow. */
2419
2173
  async sendFromStep(workflowID, destinationID, message, topic, serialization, idempotencyKey) {
2420
2174
  const ownerXid = (0, context_1.currentOwnerXid)(workflowID);
2421
- const client = await this.#connect();
2175
+ const client = await this.connect();
2422
2176
  try {
2423
2177
  await this.#inTransaction(client, async () => {
2424
2178
  await this.#sendDirectInternal(client, destinationID, message, topic, serialization, idempotencyKey);
@@ -2506,7 +2260,7 @@ class SystemDatabase {
2506
2260
  // Transactionally consume and return the message if it's in the DB, otherwise return null.
2507
2261
  let message = null;
2508
2262
  let serialization = null;
2509
- const client = await this.#connect();
2263
+ const client = await this.connect();
2510
2264
  try {
2511
2265
  await client.query(`BEGIN ISOLATION LEVEL READ COMMITTED`);
2512
2266
  const finalRecvRows = (await client.query(`UPDATE "${this.schemaName}".notifications
@@ -2546,7 +2300,7 @@ class SystemDatabase {
2546
2300
  }
2547
2301
  // ==================== Events ====================
2548
2302
  async setEvent(workflowID, functionID, key, message, serialization) {
2549
- const client = await this.#connect();
2303
+ const client = await this.connect();
2550
2304
  try {
2551
2305
  await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');
2552
2306
  // Only a real write (not a replay) should wake readers.
@@ -2654,7 +2408,7 @@ class SystemDatabase {
2654
2408
  // ==================== Streams ====================
2655
2409
  async writeStreamFromStep(workflowID, functionID, key, serializedValue, serialization) {
2656
2410
  const ownerXid = (0, context_1.currentOwnerXid)(workflowID);
2657
- const client = await this.#connect();
2411
+ const client = await this.connect();
2658
2412
  try {
2659
2413
  while (true) {
2660
2414
  try {
@@ -2691,7 +2445,7 @@ class SystemDatabase {
2691
2445
  }
2692
2446
  }
2693
2447
  async writeStreamFromWorkflow(workflowID, functionID, key, serializedValue, serialization, functionName) {
2694
- const client = await this.#connect();
2448
+ const client = await this.connect();
2695
2449
  try {
2696
2450
  while (true) {
2697
2451
  // Only a real insert (not a replay) should wake readers.
@@ -2825,7 +2579,7 @@ class SystemDatabase {
2825
2579
  }
2826
2580
  try {
2827
2581
  // One statement: one round trip, one async-notify queue-lock acquisition; unnest emits one notification per payload.
2828
- const client = await this.#connect();
2582
+ const client = await this.connect();
2829
2583
  try {
2830
2584
  await client.query(`SELECT pg_notify($1, p) FROM unnest($2::text[]) AS p`, [channel, Array.from(batch)]);
2831
2585
  }
@@ -2840,49 +2594,6 @@ class SystemDatabase {
2840
2594
  }
2841
2595
  }
2842
2596
  // ==================== Observability: Workflow Communications ====================
2843
- async getAllEvents(workflowID) {
2844
- const { rows } = await this.observabilityQuery((client) => client.query(`SELECT key, value, serialization FROM "${this.schemaName}".workflow_events
2845
- WHERE workflow_uuid = $1`, [workflowID]));
2846
- const events = {};
2847
- for (const row of rows) {
2848
- events[row.key] = await (0, serialization_1.safeParse)(this.serializer, row.value, row.serialization);
2849
- }
2850
- return events;
2851
- }
2852
- async getAllNotifications(workflowID) {
2853
- const { rows } = await this.observabilityQuery((client) => client.query(`SELECT topic, message, serialization, created_at_epoch_ms, consumed
2854
- FROM "${this.schemaName}".notifications
2855
- WHERE destination_uuid = $1
2856
- ORDER BY created_at_epoch_ms`, [workflowID]));
2857
- return await Promise.all(rows.map(async (row) => ({
2858
- topic: row.topic === this.nullTopic ? null : row.topic,
2859
- message: await (0, serialization_1.safeParse)(this.serializer, row.message, row.serialization),
2860
- createdAtEpochMs: Number(row.created_at_epoch_ms),
2861
- consumed: row.consumed,
2862
- })));
2863
- }
2864
- async getAllStreamEntries(workflowID) {
2865
- const { rows } = await this.observabilityQuery((client) => client.query(`SELECT key, value, serialization FROM "${this.schemaName}".streams
2866
- WHERE workflow_uuid = $1
2867
- ORDER BY key, "offset"`, [workflowID]));
2868
- const streams = {};
2869
- const closed = new Set();
2870
- for (const row of rows) {
2871
- if (closed.has(row.key)) {
2872
- continue;
2873
- }
2874
- // safeParse yields the raw string for the legacy unserialized marker, which does not parse.
2875
- const value = await (0, serialization_1.safeParse)(this.serializer, row.value, row.serialization);
2876
- if (isStreamClosedSentinel(value)) {
2877
- // End the stream where readStream does, so the two never disagree.
2878
- closed.add(row.key);
2879
- streams[row.key] ??= [];
2880
- continue;
2881
- }
2882
- (streams[row.key] ??= []).push(value);
2883
- }
2884
- return streams;
2885
- }
2886
2597
  // ==================== Queues ====================
2887
2598
  async transitionDelayedWorkflows() {
2888
2599
  // Transition workflows from DELAYED to ENQUEUED when their delay has expired.
@@ -2890,7 +2601,7 @@ class SystemDatabase {
2890
2601
  // debounce key held only while DELAYED, so a later same-key debounce starts a fresh workflow.
2891
2602
  const params = [workflow_1.StatusString.ENQUEUED, Date.now(), workflow_1.StatusString.DELAYED];
2892
2603
  // Only what this application would dequeue: a peer's debounce key is not ours to clear.
2893
- const scope = this.#appNameFilter('application_name', this.appName, params);
2604
+ const scope = this.appNameFilter('application_name', this.appName, params);
2894
2605
  await this.pool.query(`UPDATE "${this.schemaName}".workflow_status
2895
2606
  SET status = $1, updated_at = (EXTRACT(EPOCH FROM now()) * 1000)::bigint,
2896
2607
  deduplication_id = CASE WHEN is_debounced THEN NULL ELSE deduplication_id END
@@ -2899,7 +2610,7 @@ class SystemDatabase {
2899
2610
  /** Cancel up to `limit` of this application's active workflows whose deadline has passed, returning their IDs. */
2900
2611
  async cancelTimedOutWorkflows(limit) {
2901
2612
  const params = [workflow_1.StatusString.CANCELLED, Date.now(), limit];
2902
- const scope = this.#appNameFilter('application_name', this.appName, params);
2613
+ const scope = this.appNameFilter('application_name', this.appName, params);
2903
2614
  // Literal statuses let the planner match idx_workflow_status_deadline under any plan mode.
2904
2615
  // SKIP LOCKED leaves a row a dequeue or a peer's sweep holds for the next sweep.
2905
2616
  const { rows } = await this.pool.query(`UPDATE "${this.schemaName}".workflow_status
@@ -2930,7 +2641,7 @@ class SystemDatabase {
2930
2641
  // Recursive-CTE loose index scan: SELECT DISTINCT would scan every ENQUEUED row, whereas each iteration here is one seek on idx_workflow_status_partition_dequeue_v3, so cost scales with the number of partitions rather than the backlog depth.
2931
2642
  const params = [queueName, workflow_1.StatusString.ENQUEUED];
2932
2643
  // Only partitions this application can actually dequeue from.
2933
- const scope = this.#appNameFilter('application_name', this.appName, params);
2644
+ const scope = this.appNameFilter('application_name', this.appName, params);
2934
2645
  const { rows } = await this.pool.query(`WITH RECURSIVE partitions AS (
2935
2646
  (SELECT MIN(queue_partition_key) AS pk
2936
2647
  FROM "${this.schemaName}".workflow_status
@@ -2957,7 +2668,7 @@ class SystemDatabase {
2957
2668
  queue.partitionRateLimit !== undefined;
2958
2669
  // Shares that budget across partitions too, so sweeps of different partitions read disjoint rows and could each spend it.
2959
2670
  const hasWriteSkew = queuePartitionKey !== undefined && (queue.concurrency !== undefined || queue.rateLimit !== undefined);
2960
- const client = await this.#connect();
2671
+ const client = await this.connect();
2961
2672
  try {
2962
2673
  // Default to READ COMMITTED except with a budget shared across executors
2963
2674
  if (hasSharedBudget) {
@@ -2970,7 +2681,7 @@ class SystemDatabase {
2970
2681
  const rateLimitRemaining = async (rateLimit, partitionScoped) => {
2971
2682
  const params = [queue.name, workflow_1.StatusString.ENQUEUED, workflow_1.StatusString.DELAYED, rateLimit.periodSec * 1000];
2972
2683
  // Count only what this application would dequeue, matching the select below.
2973
- const scope = this.#appNameFilter('application_name', this.appName, params);
2684
+ const scope = this.appNameFilter('application_name', this.appName, params);
2974
2685
  const partitionFilter = partitionScoped ? `AND queue_partition_key = $${params.push(queuePartitionKey)}` : '';
2975
2686
  const { rows } = await client.query(`SELECT COUNT(*) FROM "${this.schemaName}".workflow_status
2976
2687
  WHERE queue_name = $1
@@ -2989,7 +2700,7 @@ class SystemDatabase {
2989
2700
  */
2990
2701
  const pendingCount = async (partitionScoped) => {
2991
2702
  const params = [queue.name, workflow_1.StatusString.PENDING];
2992
- const scope = this.#appNameFilter('application_name', this.appName, params);
2703
+ const scope = this.appNameFilter('application_name', this.appName, params);
2993
2704
  const partitionFilter = partitionScoped ? `AND queue_partition_key = $${params.push(queuePartitionKey)}` : '';
2994
2705
  const { rows } = await client.query(`SELECT COUNT(*) FROM "${this.schemaName}".workflow_status
2995
2706
  WHERE queue_name = $1 AND status = $2 AND ${scope} ${partitionFilter}`, params);
@@ -3050,7 +2761,7 @@ class SystemDatabase {
3050
2761
  const lockMode = hasSharedBudget ? 'FOR UPDATE NOWAIT' : 'FOR UPDATE SKIP LOCKED';
3051
2762
  const limitClause = maxTasks !== Infinity ? `LIMIT ${maxTasks}` : '';
3052
2763
  const selectParams = [workflow_1.StatusString.ENQUEUED, queue.name, appVersion, ...partitionParams];
3053
- const selectScope = this.#appNameFilter('application_name', this.appName, selectParams);
2764
+ const selectScope = this.appNameFilter('application_name', this.appName, selectParams);
3054
2765
  const selectQuery = `
3055
2766
  SELECT workflow_uuid
3056
2767
  FROM "${this.schemaName}".workflow_status
@@ -3083,7 +2794,7 @@ class SystemDatabase {
3083
2794
  ownerXid,
3084
2795
  ];
3085
2796
  // Re-check ownership alongside status, as the partitioned claim guard does.
3086
- const claimScope = this.#appNameFilter('application_name', this.appName, updateParams);
2797
+ const claimScope = this.appNameFilter('application_name', this.appName, updateParams);
3087
2798
  // RETURNING reports exactly the rows this statement flipped, so a row another worker won is absent.
3088
2799
  const flippedResult = await client.query(`UPDATE "${this.schemaName}".workflow_status
3089
2800
  SET status = $1,
@@ -3133,7 +2844,7 @@ class SystemDatabase {
3133
2844
  throw new error_1.DBOSError(`Batched partitioned dequeue requires a queue with partition concurrency 1 and no queue-wide concurrency or rate limit: ${queue.name}`);
3134
2845
  }
3135
2846
  // partitionWorkerConcurrency needs no handling here: it cannot exceed partition concurrency 1, which the PENDING gate already enforces globally.
3136
- const client = await this.#connect();
2847
+ const client = await this.connect();
3137
2848
  try {
3138
2849
  await client.query('BEGIN');
3139
2850
  const latestVersion = await this.#latestApplicationVersionName(client);
@@ -3152,7 +2863,7 @@ class SystemDatabase {
3152
2863
  workflow_1.StatusString.PENDING,
3153
2864
  sweepLimit,
3154
2865
  ];
3155
- const candidateScope = this.#appNameFilter('application_name', this.appName, candidateParams);
2866
+ const candidateScope = this.appNameFilter('application_name', this.appName, candidateParams);
3156
2867
  // Walk distinct partition keys with a recursive-CTE loose index scan (one seek per key, mirroring getQueuePartitions) so sweep cost scales with partition count, not backlog depth.
3157
2868
  const candidateResult = await client.query(`WITH RECURSIVE partitions AS (
3158
2869
  (SELECT MIN(queue_partition_key) AS pk
@@ -3209,7 +2920,7 @@ class SystemDatabase {
3209
2920
  AND ${versionClause(version)}
3210
2921
  AND ${scope}`;
3211
2922
  const lockedParams = [candidateIDs, workflow_1.StatusString.ENQUEUED, queue.name, appVersion];
3212
- const lockedScope = this.#appNameFilter('application_name', this.appName, lockedParams);
2923
+ const lockedScope = this.appNameFilter('application_name', this.appName, lockedParams);
3213
2924
  // Lock the fixed candidate set — never a LIMIT query, whose SKIP LOCKED could slide past a locked head and admit out of order.
3214
2925
  const lockedResult = await client.query(`SELECT workflow_uuid
3215
2926
  FROM "${this.schemaName}".workflow_status
@@ -3233,7 +2944,7 @@ class SystemDatabase {
3233
2944
  this.appName ?? null,
3234
2945
  ownerXid,
3235
2946
  ];
3236
- const flipScope = this.#appNameFilter('application_name', this.appName, flipParams);
2947
+ const flipScope = this.appNameFilter('application_name', this.appName, flipParams);
3237
2948
  // Start the workflows by marking them PENDING; RETURNING reports exactly the rows this statement flipped.
3238
2949
  const flippedResult = await client.query(`UPDATE "${this.schemaName}".workflow_status
3239
2950
  SET status = $1,
@@ -3359,8 +3070,8 @@ class SystemDatabase {
3359
3070
  // Falsy, not just undefined: a Conductor request carries an omitted list as JSON null.
3360
3071
  const idKeyed = (input.workflowIDs?.length ?? 0) > 0;
3361
3072
  whereClauses.push(idKeyed
3362
- ? this.#appNameFilter('application_name', input.applicationName, params)
3363
- : this.#observabilityFilter('application_name', input.applicationName, params));
3073
+ ? this.appNameFilter('application_name', input.applicationName, params)
3074
+ : this.observabilityFilter('application_name', input.applicationName, params));
3364
3075
  paramCounter = params.length + 1;
3365
3076
  if (input.workflow_id_prefix) {
3366
3077
  if (Array.isArray(input.workflow_id_prefix)) {
@@ -3389,6 +3100,10 @@ class SystemDatabase {
3389
3100
  params.push(input.wasForkedFrom);
3390
3101
  paramCounter++;
3391
3102
  }
3103
+ // Matches the forks themselves, as opposed to wasForkedFrom, which matches the workflows they were forked from.
3104
+ if (input.isFork !== undefined) {
3105
+ whereClauses.push(input.isFork ? `forked_from IS NOT NULL` : `forked_from IS NULL`);
3106
+ }
3392
3107
  if (input.hasParent !== undefined) {
3393
3108
  if (input.hasParent) {
3394
3109
  whereClauses.push(`parent_workflow_id IS NOT NULL`);
@@ -3469,630 +3184,6 @@ class SystemDatabase {
3469
3184
  : await this.observabilityQuery((client) => client.query(query, params));
3470
3185
  return result.rows.map(mapWorkflowStatus);
3471
3186
  }
3472
- async getWorkflowAggregates(input) {
3473
- if (input.timeBucketSizeMs !== undefined && input.timeBucketSizeMs <= 0) {
3474
- throw new Error('time_bucket_size_ms must be > 0');
3475
- }
3476
- const groupByFlags = [
3477
- ['status', input.groupByStatus ?? false, 'status'],
3478
- ['name', input.groupByName ?? false, 'name'],
3479
- ['queue_name', input.groupByQueueName ?? false, 'queue_name'],
3480
- ['executor_id', input.groupByExecutorId ?? false, 'executor_id'],
3481
- ['application_version', input.groupByApplicationVersion ?? false, 'application_version'],
3482
- ['application_name', input.groupByApplicationName ?? false, 'application_name'],
3483
- ];
3484
- const groupNames = [];
3485
- const groupColumns = [];
3486
- const groupSelectColumns = [];
3487
- for (const [colName, enabled, col] of groupByFlags) {
3488
- if (enabled) {
3489
- groupNames.push(colName);
3490
- groupColumns.push(col);
3491
- groupSelectColumns.push(col);
3492
- }
3493
- }
3494
- const params = [];
3495
- if (input.timeBucketSizeMs !== undefined) {
3496
- // Bucket on created_at — the indexed wall-clock timestamp on workflow_status.
3497
- // One placeholder shared by SELECT and GROUP BY, so Postgres sees the two expressions as identical.
3498
- params.push(input.timeBucketSizeMs);
3499
- const bucket = `$${params.length}::bigint`;
3500
- const bucketExpr = `(CAST(FLOOR(created_at / ${bucket}) AS BIGINT) * ${bucket})`;
3501
- groupNames.push('time_bucket');
3502
- groupColumns.push(bucketExpr);
3503
- groupSelectColumns.push(`${bucketExpr} AS time_bucket`);
3504
- }
3505
- if (groupColumns.length === 0) {
3506
- throw new Error('At least one group_by flag must be set to True');
3507
- }
3508
- // Build select columns from boolean flags. MAX ignores NULLs, so rows
3509
- // missing started_at_epoch_ms or completed_at naturally drop out of the
3510
- // latency maxes.
3511
- const selectFlags = [
3512
- ['count', input.selectCount ?? false, 'COUNT(*)'],
3513
- ['min_created_at', input.selectMinCreatedAt ?? false, 'MIN(created_at)'],
3514
- ['max_queue_wait_ms', input.selectMaxQueueWaitMs ?? false, 'MAX(started_at_epoch_ms - created_at)'],
3515
- ['max_total_latency_ms', input.selectMaxTotalLatencyMs ?? false, 'MAX(completed_at - created_at)'],
3516
- ];
3517
- const selectNames = [];
3518
- const selectColumns = [];
3519
- for (const [name, enabled, expr] of selectFlags) {
3520
- if (enabled) {
3521
- selectNames.push(name);
3522
- selectColumns.push(`${expr} AS ${name}`);
3523
- }
3524
- }
3525
- if (selectColumns.length === 0) {
3526
- throw new Error('At least one select_ flag must be set to True');
3527
- }
3528
- const whereClauses = [];
3529
- let paramIdx = params.length + 1;
3530
- const addFilter = (column, values) => {
3531
- if (!values || values.length === 0)
3532
- return;
3533
- const placeholders = values.map((_, i) => `$${paramIdx + i}`).join(', ');
3534
- whereClauses.push(`${column} IN (${placeholders})`);
3535
- params.push(...values);
3536
- paramIdx += values.length;
3537
- };
3538
- addFilter('status', input.status);
3539
- addFilter('name', input.name);
3540
- addFilter('application_version', input.appVersion);
3541
- addFilter('executor_id', input.executorId);
3542
- addFilter('queue_name', input.queueName);
3543
- if (input.workflowIdPrefix && input.workflowIdPrefix.length > 0) {
3544
- const likeClauses = input.workflowIdPrefix.map((p) => {
3545
- params.push(`${p}%`);
3546
- return `workflow_uuid LIKE $${paramIdx++}`;
3547
- });
3548
- whereClauses.push(`(${likeClauses.join(' OR ')})`);
3549
- }
3550
- addFilter('workflow_uuid', input.workflowIDs);
3551
- addFilter('authenticated_user', input.authenticatedUser);
3552
- addFilter('forked_from', input.forkedFrom);
3553
- addFilter('parent_workflow_id', input.parentWorkflowID);
3554
- addFilter('schedule_name', input.scheduleName);
3555
- // Unset scopes to this application, as on every other observability query.
3556
- whereClauses.push(this.#observabilityFilter('application_name', input.applicationName, params));
3557
- paramIdx = params.length + 1;
3558
- if (input.wasForkedFrom !== undefined) {
3559
- whereClauses.push(`was_forked_from = $${paramIdx}`);
3560
- params.push(input.wasForkedFrom);
3561
- paramIdx++;
3562
- }
3563
- if (input.hasParent !== undefined) {
3564
- whereClauses.push(input.hasParent ? `parent_workflow_id IS NOT NULL` : `parent_workflow_id IS NULL`);
3565
- }
3566
- // Match workflows whose attributes JSONB contains all the given key-value pairs.
3567
- if (input.attributes && Object.keys(input.attributes).length > 0) {
3568
- whereClauses.push(`attributes @> $${paramIdx}::jsonb`);
3569
- params.push(JSON.stringify(input.attributes));
3570
- paramIdx++;
3571
- }
3572
- if (input.startTime) {
3573
- whereClauses.push(`created_at >= $${paramIdx}`);
3574
- params.push(new Date(input.startTime).getTime());
3575
- paramIdx++;
3576
- }
3577
- if (input.endTime) {
3578
- whereClauses.push(`created_at <= $${paramIdx}`);
3579
- params.push(new Date(input.endTime).getTime());
3580
- paramIdx++;
3581
- }
3582
- if (input.completedAfter) {
3583
- whereClauses.push(`completed_at >= $${paramIdx}`);
3584
- params.push(new Date(input.completedAfter).getTime());
3585
- paramIdx++;
3586
- }
3587
- if (input.completedBefore) {
3588
- whereClauses.push(`completed_at <= $${paramIdx}`);
3589
- params.push(new Date(input.completedBefore).getTime());
3590
- paramIdx++;
3591
- }
3592
- // dequeuedAfter/Before filter on started_at_epoch_ms: that column is
3593
- // populated on dequeue and surfaced as WorkflowStatus.dequeuedAt.
3594
- if (input.dequeuedAfter) {
3595
- whereClauses.push(`started_at_epoch_ms >= $${paramIdx}`);
3596
- params.push(new Date(input.dequeuedAfter).getTime());
3597
- paramIdx++;
3598
- }
3599
- if (input.dequeuedBefore) {
3600
- whereClauses.push(`started_at_epoch_ms <= $${paramIdx}`);
3601
- params.push(new Date(input.dequeuedBefore).getTime());
3602
- paramIdx++;
3603
- }
3604
- const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : '';
3605
- const groupByClause = groupColumns.join(', ');
3606
- const selectClause = [...groupSelectColumns, ...selectColumns].join(', ');
3607
- const query = `
3608
- SELECT ${selectClause}
3609
- FROM "${this.schemaName}".workflow_status
3610
- ${whereClause}
3611
- GROUP BY ${groupByClause}
3612
- `;
3613
- const result = await this.observabilityQuery((client) => client.query(query, params));
3614
- const toIntOrNull = (v) => (v === null || v === undefined ? null : Number(v));
3615
- return result.rows.map((row) => {
3616
- const group = {};
3617
- for (const name of groupNames) {
3618
- const v = row[name];
3619
- group[name] = v === null || v === undefined ? null : String(v);
3620
- }
3621
- return {
3622
- group,
3623
- count: selectNames.includes('count') ? toIntOrNull(row.count) : null,
3624
- minCreatedAt: selectNames.includes('min_created_at') ? toIntOrNull(row.min_created_at) : null,
3625
- maxQueueWaitMs: selectNames.includes('max_queue_wait_ms') ? toIntOrNull(row.max_queue_wait_ms) : null,
3626
- maxTotalLatencyMs: selectNames.includes('max_total_latency_ms') ? toIntOrNull(row.max_total_latency_ms) : null,
3627
- };
3628
- });
3629
- }
3630
- async getStepAggregates(input) {
3631
- if (input.timeBucketSizeMs !== undefined && input.timeBucketSizeMs <= 0) {
3632
- throw new Error('time_bucket_size_ms must be > 0');
3633
- }
3634
- // operation_outputs has no explicit status column; derive it from whether `error` is populated.
3635
- // Child-workflow mapping rows have NULL error, so they appear as SUCCESS — callers filter by function_name.
3636
- const statusExpr = `CASE WHEN error IS NULL THEN 'SUCCESS' ELSE 'ERROR' END`;
3637
- const groupByFlags = [
3638
- ['function_name', input.groupByFunctionName ?? false, 'function_name'],
3639
- ['status', input.groupByStatus ?? false, statusExpr],
3640
- ];
3641
- const groupNames = [];
3642
- const groupColumns = [];
3643
- const groupSelectColumns = [];
3644
- for (const [colName, enabled, expr] of groupByFlags) {
3645
- if (enabled) {
3646
- groupNames.push(colName);
3647
- groupColumns.push(expr);
3648
- groupSelectColumns.push(`${expr} AS ${colName}`);
3649
- }
3650
- }
3651
- const params = [];
3652
- if (input.timeBucketSizeMs !== undefined) {
3653
- // Bucket on completed_at_epoch_ms — it's the indexed timestamp on
3654
- // this table.
3655
- params.push(input.timeBucketSizeMs);
3656
- const bucket = `$${params.length}::bigint`;
3657
- const bucketExpr = `(CAST(FLOOR(completed_at_epoch_ms / ${bucket}) AS BIGINT) * ${bucket})`;
3658
- groupNames.push('time_bucket');
3659
- groupColumns.push(bucketExpr);
3660
- groupSelectColumns.push(`${bucketExpr} AS time_bucket`);
3661
- }
3662
- if (groupColumns.length === 0) {
3663
- throw new Error('At least one group_by flag must be set to True');
3664
- }
3665
- // Build select columns from boolean flags. Child-workflow mapping rows record start and
3666
- // complete at nearly the same instant, so they contribute ~0; DBOS.getResult and DBOS.sleep
3667
- // rows span their whole wait, so those dominate the duration max.
3668
- const selectFlags = [
3669
- ['count', input.selectCount ?? false, 'COUNT(*)'],
3670
- ['max_duration_ms', input.selectMaxDurationMs ?? false, 'MAX(completed_at_epoch_ms - started_at_epoch_ms)'],
3671
- ];
3672
- const selectNames = [];
3673
- const selectColumns = [];
3674
- for (const [name, enabled, expr] of selectFlags) {
3675
- if (enabled) {
3676
- selectNames.push(name);
3677
- selectColumns.push(`${expr} AS ${name}`);
3678
- }
3679
- }
3680
- if (selectColumns.length === 0) {
3681
- throw new Error('At least one select_ flag must be set to True');
3682
- }
3683
- const whereClauses = [];
3684
- let paramIdx = params.length + 1;
3685
- if (input.status && input.status.length > 0) {
3686
- const placeholders = input.status.map((_, i) => `$${paramIdx + i}`).join(', ');
3687
- whereClauses.push(`(${statusExpr}) IN (${placeholders})`);
3688
- params.push(...input.status);
3689
- paramIdx += input.status.length;
3690
- }
3691
- if (input.functionName && input.functionName.length > 0) {
3692
- const placeholders = input.functionName.map((_, i) => `$${paramIdx + i}`).join(', ');
3693
- whereClauses.push(`function_name IN (${placeholders})`);
3694
- params.push(...input.functionName);
3695
- paramIdx += input.functionName.length;
3696
- }
3697
- if (input.workflowIdPrefix && input.workflowIdPrefix.length > 0) {
3698
- const likeClauses = input.workflowIdPrefix.map((p) => {
3699
- params.push(`${p}%`);
3700
- return `workflow_uuid LIKE $${paramIdx++}`;
3701
- });
3702
- whereClauses.push(`(${likeClauses.join(' OR ')})`);
3703
- }
3704
- if (input.completedAfter) {
3705
- whereClauses.push(`completed_at_epoch_ms >= $${paramIdx}`);
3706
- params.push(new Date(input.completedAfter).getTime());
3707
- paramIdx++;
3708
- }
3709
- if (input.completedBefore) {
3710
- whereClauses.push(`completed_at_epoch_ms <= $${paramIdx}`);
3711
- params.push(new Date(input.completedBefore).getTime());
3712
- paramIdx++;
3713
- }
3714
- // Unset scopes to this application, as on every other observability query.
3715
- whereClauses.push(this.#observabilityFilter('application_name', input.applicationName, params));
3716
- paramIdx = params.length + 1;
3717
- const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : '';
3718
- const groupByClause = groupColumns.join(', ');
3719
- const selectClause = [...groupSelectColumns, ...selectColumns].join(', ');
3720
- const query = `
3721
- SELECT ${selectClause}
3722
- FROM "${this.schemaName}".operation_outputs
3723
- ${whereClause}
3724
- GROUP BY ${groupByClause}
3725
- `;
3726
- const result = await this.observabilityQuery((client) => client.query(query, params));
3727
- const toIntOrNull = (v) => (v === null || v === undefined ? null : Number(v));
3728
- return result.rows.map((row) => {
3729
- const group = {};
3730
- for (const name of groupNames) {
3731
- const v = row[name];
3732
- group[name] = v === null || v === undefined ? null : String(v);
3733
- }
3734
- return {
3735
- group,
3736
- count: selectNames.includes('count') ? toIntOrNull(row.count) : null,
3737
- maxDurationMs: selectNames.includes('max_duration_ms') ? toIntOrNull(row.max_duration_ms) : null,
3738
- };
3739
- });
3740
- }
3741
- /** Whether the system database is CockroachDB. Resolved once, since detecting it costs a query. */
3742
- async #cockroach() {
3743
- if (this.#isCockroach === undefined) {
3744
- const client = await this.#connect();
3745
- try {
3746
- this.#isCockroach = await isCockroachDB(client);
3747
- }
3748
- finally {
3749
- client.release();
3750
- }
3751
- }
3752
- return this.#isCockroach;
3753
- }
3754
- /**
3755
- * Take a database-wide lock for one retention round, returning how to release it, or
3756
- * undefined when another round already holds it. The lock is session-scoped, so a round
3757
- * that crashes releases it. CockroachDB has no advisory locks and always takes it, so it
3758
- * collects unprotected rather than not at all.
3759
- */
3760
- async acquireRetentionLock() {
3761
- if (await this.#cockroach()) {
3762
- return { release: () => Promise.resolve() };
3763
- }
3764
- const key = retentionLockKey(this.schemaName).toString();
3765
- // The round holds this connection until it ends: releasing it would drop the lock.
3766
- const client = await this.#borrowRetentionClient();
3767
- let acquired = false;
3768
- try {
3769
- const { rows } = await client.query('SELECT pg_try_advisory_lock($1) AS locked', [key]);
3770
- acquired = rows[0]?.locked === true;
3771
- }
3772
- catch (e) {
3773
- this.#releaseRetentionClient(client);
3774
- throw e;
3775
- }
3776
- if (!acquired) {
3777
- this.#releaseRetentionClient(client);
3778
- return undefined;
3779
- }
3780
- return {
3781
- release: async () => {
3782
- // Already cut by destroy(): the session is gone and took the lock with it.
3783
- if (!this.#retentionClients.has(client)) {
3784
- return;
3785
- }
3786
- try {
3787
- // Explicit, since releasing the client only returns the session to the pool.
3788
- const { rows } = await client.query('SELECT pg_advisory_unlock($1) AS released', [
3789
- key,
3790
- ]);
3791
- if (rows[0]?.released !== true) {
3792
- // False means this session no longer holds it, which a transaction-pooling proxy
3793
- // causes by switching backends.
3794
- this.logger.warn('Could not release the retention lock: this session no longer holds it. Retention will ' +
3795
- 'not proceed until the lock is released, which happens when the holding backend closes. ' +
3796
- 'A transaction-pooling proxy in front of Postgres causes this; run DBOS through a ' +
3797
- 'session-pooled or direct connection.');
3798
- }
3799
- }
3800
- finally {
3801
- this.#releaseRetentionClient(client);
3802
- }
3803
- },
3804
- };
3805
- }
3806
- /** VACUUM the tables a sweep dirtied. No-op where there is no autovacuum to outrun. */
3807
- async #vacuumTables(tables) {
3808
- if (await this.#cockroach()) {
3809
- return;
3810
- }
3811
- const client = await this.#borrowRetentionClient();
3812
- let notices = [];
3813
- const onNotice = (notice) => notices.push(notice.message ?? '');
3814
- client.on('notice', onNotice);
3815
- try {
3816
- for (const table of tables) {
3817
- // Per table, so one refusal does not skip the rest.
3818
- notices = [];
3819
- try {
3820
- await client.query(`VACUUM (INDEX_CLEANUP ON, TRUNCATE OFF, ANALYZE) "${this.schemaName}"."${table}"`);
3821
- }
3822
- catch (e) {
3823
- if (!this.#retentionClients.has(client)) {
3824
- throw e;
3825
- }
3826
- this.logger.warn(`Payload retention could not vacuum ${table}: ${e.message}`);
3827
- continue;
3828
- }
3829
- // A refused or stalled VACUUM does not raise, it says so in a notice; a successful
3830
- // one is silent, so anything here is worth surfacing.
3831
- for (const notice of notices) {
3832
- this.logger.warn(`Payload retention vacuuming ${table}: ${notice}`);
3833
- }
3834
- }
3835
- }
3836
- finally {
3837
- client.removeListener('notice', onNotice);
3838
- this.#releaseRetentionClient(client);
3839
- }
3840
- }
3841
- /** Delete one payload table's orphans below the cutoff, one batch per transaction. */
3842
- async #garbageCollectTable(table, cutoff, batchSize) {
3843
- // A payload below the cutoff belongs to a workflow created before it, so the status side
3844
- // is the few such rows still present, not the whole table.
3845
- const orphaned = `NOT EXISTS (
3846
- SELECT 1 FROM "${this.schemaName}".workflow_status ws
3847
- WHERE ws.workflow_uuid = t.workflow_uuid AND ws.created_at < $1
3848
- )`;
3849
- // Seed from the oldest row in range.
3850
- const oldest = await retryOnSerializationError(async () => {
3851
- const { rows } = await this.pool.query(`SELECT retention_timestamp
3852
- FROM "${this.schemaName}".${table}
3853
- WHERE retention_timestamp < $1
3854
- ORDER BY retention_timestamp
3855
- LIMIT 1`, [cutoff]);
3856
- // retention_timestamp is a bigint, so node-postgres hands it back as a string.
3857
- return rows.length > 0 ? Number(rows[0].retention_timestamp) : undefined;
3858
- });
3859
- if (oldest === undefined) {
3860
- return 0;
3861
- }
3862
- let total = 0;
3863
- let watermark = oldest - 1;
3864
- for (;;) {
3865
- const batch = await retryOnSerializationError(async () => {
3866
- // Borrowed rather than pool.query'd: that releases with the error, which discards the
3867
- // connection on a deadlock, so the retry wrapping this would churn the pool per batch.
3868
- const client = await this.#borrowRetentionClient();
3869
- try {
3870
- // Batches are cut by candidate count, so rows spared by the anti-join only thin one
3871
- // out; they are re-checked next round.
3872
- const { rows } = await client.query(`SELECT retention_timestamp
3873
- FROM "${this.schemaName}".${table}
3874
- WHERE retention_timestamp < $1 AND retention_timestamp > $2
3875
- ORDER BY retention_timestamp
3876
- LIMIT 1 OFFSET ${batchSize - 1}`, [cutoff, watermark]);
3877
- const step = rows.length > 0 ? Number(rows[0].retention_timestamp) : undefined;
3878
- const params = [cutoff, watermark];
3879
- // Timestamp ties may push the batch slightly over batchSize, but never split across two.
3880
- const upperBound = step === undefined ? '' : `AND t.retention_timestamp <= $${params.push(step)} `;
3881
- const result = await client.query(`DELETE FROM "${this.schemaName}".${table} t
3882
- WHERE t.retention_timestamp < $1 AND t.retention_timestamp > $2 ${upperBound}AND ${orphaned}`, params);
3883
- return { step, deleted: result.rowCount ?? 0 };
3884
- }
3885
- finally {
3886
- this.#releaseRetentionClient(client);
3887
- }
3888
- });
3889
- total += batch.deleted;
3890
- if (batch.step === undefined) {
3891
- return total;
3892
- }
3893
- watermark = batch.step;
3894
- }
3895
- }
3896
- /**
3897
- * Delete payload and step rows below the cutoff whose workflow is gone, returning the count
3898
- * removed from each table. Runs after the status sweep, whose orphans all fall in range:
3899
- * every payload is stamped no later than the completion that made the row collectable.
3900
- */
3901
- async garbageCollectPayloads(cutoff, batchSize = exports.DEFAULT_GC_BATCH_SIZE) {
3902
- // A NaN survives a bare `< 1` test and would only fail once it reached SQL.
3903
- if (!Number.isInteger(batchSize) || batchSize < 1) {
3904
- throw new error_1.DBOSError(`batchSize must be a positive integer, got ${batchSize}`);
3905
- }
3906
- const tables = ['workflow_input', 'workflow_output', 'operation_outputs'];
3907
- // To optimize performance, vacuum payload tables both before and after collecting them.
3908
- await this.#vacuumTables(['workflow_status', ...tables]);
3909
- // One connection per concurrent sweep, on top of the one the retention lock holds for the
3910
- // whole round. A pool too small for all three runs them sequentially rather than leaving
3911
- // sweeps waiting on a connection that only the round itself would free.
3912
- const poolMax = this.pool.options.max ?? exports.DEFAULT_POOL_SIZE;
3913
- const concurrency = Math.max(1, Math.min(tables.length, poolMax - 1));
3914
- const deleted = new Array(tables.length).fill(0);
3915
- const failures = [];
3916
- let next = 0;
3917
- const sweep = async () => {
3918
- for (;;) {
3919
- const i = next++;
3920
- if (i >= tables.length)
3921
- return;
3922
- try {
3923
- deleted[i] = await this.#garbageCollectTable(tables[i], cutoff, batchSize);
3924
- }
3925
- catch (e) {
3926
- failures.push(e);
3927
- }
3928
- }
3929
- };
3930
- await Promise.all(Array.from({ length: concurrency }, () => sweep()));
3931
- // Only the first can be thrown, so the rest would otherwise be lost.
3932
- for (const extra of failures.slice(1)) {
3933
- this.logger.warn(`Payload retention sweep also failed: ${extra instanceof Error ? extra.message : String(extra)}`);
3934
- }
3935
- if (failures.length > 0) {
3936
- throw failures[0];
3937
- }
3938
- await this.#vacuumTables(tables);
3939
- this.logger.debug(`Payload retention deleted ${deleted[0]} inputs, ${deleted[1]} outputs, and ${deleted[2]} steps`);
3940
- return deleted;
3941
- }
3942
- /**
3943
- * Rows garbage collection may delete. completed_at is set on every terminal transition and
3944
- * cleared on resume, so one predicate covers eligibility: in-flight rows hold NULL and never
3945
- * compare true. Unscoped by application: retention is system-wide.
3946
- */
3947
- #gcFilter(cutoffEpochTimestampMs, params) {
3948
- params.push(cutoffEpochTimestampMs);
3949
- return `completed_at < $${params.length}`;
3950
- }
3951
- /**
3952
- * Delete one batch, returning the watermark to resume from, or undefined once the last one ran.
3953
- * The delete is its own transaction; it re-checks the filter, so it needs no snapshot shared
3954
- * with the select that bounds it.
3955
- */
3956
- async #garbageCollectBatch(cutoffEpochTimestampMs, batchSize, watermark) {
3957
- // Borrowed rather than pool.query'd: that releases with the error, which discards the
3958
- // connection on a deadlock, so the retry wrapping this would churn the pool per batch.
3959
- const client = await this.#borrowRetentionClient();
3960
- try {
3961
- // The batchSize-th oldest eligible row above the watermark bounds this range
3962
- const stepParams = [];
3963
- const stepScope = this.#gcFilter(cutoffEpochTimestampMs, stepParams);
3964
- stepParams.push(watermark);
3965
- const stepResult = await client.query(`SELECT completed_at
3966
- FROM "${this.schemaName}".workflow_status
3967
- WHERE ${stepScope} AND completed_at > $${stepParams.length}
3968
- ORDER BY completed_at
3969
- LIMIT 1 OFFSET ${batchSize - 1}`, stepParams);
3970
- // completed_at is a bigint, so node-postgres hands it back as a string.
3971
- const step = stepResult.rows.length > 0 ? Number(stepResult.rows[0].completed_at) : undefined;
3972
- const deleteParams = [];
3973
- let deleteScope = this.#gcFilter(cutoffEpochTimestampMs, deleteParams);
3974
- if (step !== undefined) {
3975
- // Inclusive upper bound: completed_at ties may push a batch over batchSize, but never split across two.
3976
- deleteParams.push(watermark, step);
3977
- deleteScope = `${deleteScope} AND completed_at > $${deleteParams.length - 1} AND completed_at <= $${deleteParams.length}`;
3978
- }
3979
- // The final batch drops the watermark: unbounded, since an import can land a
3980
- // completed_at below it mid-pass.
3981
- await client.query(`DELETE FROM "${this.schemaName}".workflow_status WHERE ${deleteScope}`, deleteParams);
3982
- return step;
3983
- }
3984
- finally {
3985
- this.#releaseRetentionClient(client);
3986
- }
3987
- }
3988
- /**
3989
- * Delete old terminal workflows throughout the system database, returning the cutoff
3990
- * actually used, or undefined when there is nothing to collect.
3991
- *
3992
- * Conductor sends cleared retention thresholds as JSON null, so every param is nullish.
3993
- */
3994
- async garbageCollect(cutoffEpochTimestampMs, rowsThreshold, options = {}) {
3995
- const batchSize = options.batchSize ?? exports.DEFAULT_GC_BATCH_SIZE;
3996
- // A NaN survives a bare `< 1` test and would only fail once it reached SQL, leaving GC half-applied.
3997
- if (!Number.isInteger(batchSize) || batchSize < 1) {
3998
- throw new error_1.DBOSError(`batchSize must be a positive integer, got ${batchSize}`);
3999
- }
4000
- if (rowsThreshold !== undefined && rowsThreshold !== null) {
4001
- // The completed_at of the rowsThreshold newest completed row
4002
- const result = await retryOnSerializationError(() => this.pool.query(`SELECT completed_at
4003
- FROM "${this.schemaName}".workflow_status
4004
- WHERE completed_at IS NOT NULL
4005
- ORDER BY completed_at DESC
4006
- LIMIT 1 OFFSET $1`, [rowsThreshold - 1]));
4007
- if (result.rows.length > 0) {
4008
- const rowsBasedCutoff = Number(result.rows[0].completed_at);
4009
- // Use the more restrictive cutoff (higher timestamp = more recent = more deletion)
4010
- if (cutoffEpochTimestampMs === undefined ||
4011
- cutoffEpochTimestampMs === null ||
4012
- rowsBasedCutoff > cutoffEpochTimestampMs) {
4013
- cutoffEpochTimestampMs = rowsBasedCutoff;
4014
- }
4015
- }
4016
- }
4017
- if (cutoffEpochTimestampMs === undefined || cutoffEpochTimestampMs === null) {
4018
- return undefined;
4019
- }
4020
- // Narrowed to a constant so the closures below keep it.
4021
- const cutoff = cutoffEpochTimestampMs;
4022
- // Advance a completed_at watermark, one committed transaction per batch, so a long
4023
- // history neither deletes in one transaction nor rescans what it already deleted.
4024
- const oldest = await retryOnSerializationError(async () => {
4025
- const params = [];
4026
- const scope = this.#gcFilter(cutoff, params);
4027
- const { rows } = await this.pool.query(`SELECT completed_at
4028
- FROM "${this.schemaName}".workflow_status
4029
- WHERE ${scope}
4030
- ORDER BY completed_at
4031
- LIMIT 1`, params);
4032
- return rows.length > 0 ? Number(rows[0].completed_at) : undefined;
4033
- });
4034
- let watermark = oldest === undefined ? 0 : oldest - 1;
4035
- for (;;) {
4036
- const next = await retryOnSerializationError(() => this.#garbageCollectBatch(cutoff, batchSize, watermark));
4037
- // Fewer than a full batch remained, so that delete took the rest.
4038
- if (next === undefined)
4039
- return cutoff;
4040
- watermark = next;
4041
- }
4042
- }
4043
- /**
4044
- * IDs of this application's in-flight workflows created at or before the cutoff.
4045
- * Claiming-scoped, so an upgrade still times out its own unclaimed workflows.
4046
- */
4047
- async listTimedOutWorkflowIds(cutoffEpochTimestampMs) {
4048
- const params = [
4049
- cutoffEpochTimestampMs,
4050
- workflow_1.StatusString.PENDING,
4051
- workflow_1.StatusString.ENQUEUED,
4052
- workflow_1.StatusString.DELAYED,
4053
- ];
4054
- const scope = this.#appNameFilter('application_name', this.appName, params);
4055
- const { rows } = await this.pool.query(`SELECT workflow_uuid
4056
- FROM "${this.schemaName}".workflow_status
4057
- WHERE created_at <= $1
4058
- AND status IN ($2, $3, $4)
4059
- AND ${scope}`, params);
4060
- return rows.map((row) => row.workflow_uuid);
4061
- }
4062
- async getMetrics(startTime, endTime, applicationName) {
4063
- const startEpochMs = new Date(startTime).getTime();
4064
- const endEpochMs = new Date(endTime).getTime();
4065
- const workflowParams = [startEpochMs, endEpochMs];
4066
- const workflowScope = this.#observabilityFilter('application_name', applicationName, workflowParams);
4067
- const stepParams = [startEpochMs, endEpochMs];
4068
- const stepScope = this.#observabilityFilter('application_name', applicationName, stepParams);
4069
- const [workflowResult, stepResult] = await this.observabilityQuery(async (client) => [
4070
- await client.query(`SELECT name, COUNT(workflow_uuid) as count
4071
- FROM "${this.schemaName}".workflow_status
4072
- WHERE created_at >= $1 AND created_at < $2 AND ${workflowScope}
4073
- GROUP BY name`, workflowParams),
4074
- await client.query(`SELECT function_name, COUNT(*) as count
4075
- FROM "${this.schemaName}".operation_outputs
4076
- WHERE completed_at_epoch_ms >= $1 AND completed_at_epoch_ms < $2 AND ${stepScope}
4077
- GROUP BY function_name`, stepParams),
4078
- ]);
4079
- const metrics = [];
4080
- for (const row of workflowResult.rows) {
4081
- metrics.push({
4082
- metricType: 'workflow_count',
4083
- metricName: row.name,
4084
- value: Number(row.count),
4085
- });
4086
- }
4087
- for (const row of stepResult.rows) {
4088
- metrics.push({
4089
- metricType: 'step_count',
4090
- metricName: row.function_name,
4091
- value: Number(row.count),
4092
- });
4093
- }
4094
- return metrics;
4095
- }
4096
3187
  // ==================== Scheduling ====================
4097
3188
  async createSchedule(schedule, client) {
4098
3189
  const q = client ?? this.pool;
@@ -4158,7 +3249,7 @@ class SystemDatabase {
4158
3249
  conditions.push(`(${likeClauses.join(' OR ')})`);
4159
3250
  }
4160
3251
  // Unset scopes to this application, as on every other observability query.
4161
- conditions.push(this.#observabilityFilter('application_name', filters?.applicationName, params));
3252
+ conditions.push(this.observabilityFilter('application_name', filters?.applicationName, params));
4162
3253
  paramIdx = params.length + 1;
4163
3254
  const where = conditions.length > 0 ? ` WHERE ${conditions.join(' AND ')}` : '';
4164
3255
  const result = await q.query(`SELECT ${SCHEDULE_COLUMNS}
@@ -4225,7 +3316,7 @@ class SystemDatabase {
4225
3316
  await this.pool.query(`UPDATE "${this.schemaName}".workflow_schedules SET last_fired_at = $1 WHERE schedule_name = $2`, [lastFiredAt, name]);
4226
3317
  }
4227
3318
  async applySchedules(schedules) {
4228
- const client = await this.#connect();
3319
+ const client = await this.connect();
4229
3320
  try {
4230
3321
  await client.query('BEGIN');
4231
3322
  for (const sched of schedules) {
@@ -4277,7 +3368,7 @@ class SystemDatabase {
4277
3368
  */
4278
3369
  async createApplicationVersion(versionName, applicationName) {
4279
3370
  const owner = applicationName ?? this.appName;
4280
- const client = await this.#connect();
3371
+ const client = await this.connect();
4281
3372
  try {
4282
3373
  await client.query('BEGIN');
4283
3374
  // Claim a pre-upgrade row in place, so the version is not recreated or retimed.
@@ -4308,7 +3399,7 @@ class SystemDatabase {
4308
3399
  */
4309
3400
  async updateApplicationVersionTimestamp(versionName, newTimestamp, applicationName) {
4310
3401
  const owner = applicationName ?? this.appName;
4311
- const client = await this.#connect();
3402
+ const client = await this.connect();
4312
3403
  try {
4313
3404
  await client.query('BEGIN');
4314
3405
  const resolved = await this.#resolveRowOwner(client, 'application_versions', 'version_name', versionName, owner, 'Application version');
@@ -4332,7 +3423,7 @@ class SystemDatabase {
4332
3423
  }
4333
3424
  async listApplicationVersions() {
4334
3425
  const params = [];
4335
- const scope = this.#appNameFilter('application_name', this.appName, params);
3426
+ const scope = this.appNameFilter('application_name', this.appName, params);
4336
3427
  const { rows } = await this.observabilityQuery((client) => client.query(`SELECT version_id, version_name, version_timestamp, created_at, application_name
4337
3428
  FROM "${this.schemaName}".application_versions
4338
3429
  WHERE ${scope}
@@ -4346,7 +3437,7 @@ class SystemDatabase {
4346
3437
  async getLatestApplicationVersion(applicationName) {
4347
3438
  const owner = applicationName ?? this.appName;
4348
3439
  const params = [];
4349
- const scope = this.#appNameFilter('application_name', owner, params);
3440
+ const scope = this.appNameFilter('application_name', owner, params);
4350
3441
  const { rows } = await this.pool.query(`SELECT version_id, version_name, version_timestamp, created_at, application_name
4351
3442
  FROM "${this.schemaName}".application_versions
4352
3443
  WHERE ${scope}
@@ -4370,7 +3461,7 @@ class SystemDatabase {
4370
3461
  */
4371
3462
  async listQueues(applicationName) {
4372
3463
  const params = [];
4373
- const scope = this.#observabilityFilter('application_name', applicationName, params);
3464
+ const scope = this.observabilityFilter('application_name', applicationName, params);
4374
3465
  const { rows } = await this.pool.query(`SELECT ${QUEUE_COLUMNS}
4375
3466
  FROM "${this.schemaName}".queues
4376
3467
  WHERE ${scope}`, params);
@@ -4418,7 +3509,7 @@ class SystemDatabase {
4418
3509
  application_name = COALESCE("${this.schemaName}".queues.application_name, EXCLUDED.application_name)`
4419
3510
  : `ON CONFLICT (name) DO NOTHING`;
4420
3511
  const owner = record.applicationName ?? this.appName;
4421
- const client = await this.#connect();
3512
+ const client = await this.connect();
4422
3513
  try {
4423
3514
  await client.query('BEGIN');
4424
3515
  const existed = await client.query(`SELECT name FROM "${this.schemaName}".queues WHERE name = $1`, [record.name]);
@@ -4543,7 +3634,7 @@ class SystemDatabase {
4543
3634
  throw new error_1.DBOSError(`batchSize must be a positive integer, got ${batchSize}`);
4544
3635
  }
4545
3636
  // Never a merge: queue, schedule, and version names are globally unique whatever their owner, so this cannot collide.
4546
- const client = await this.#connect();
3637
+ const client = await this.connect();
4547
3638
  let queues, schedules, versions, inFlight;
4548
3639
  try {
4549
3640
  await client.query('BEGIN');
@@ -4828,6 +3919,9 @@ class SystemDatabase {
4828
3919
  if (result.functionName !== functionName) {
4829
3920
  throw new error_1.DBOSUnexpectedStepError(workflowID, functionID, functionName, result.functionName);
4830
3921
  }
3922
+ if (result.error) {
3923
+ throw await (0, serialization_1.deserializeResError)(result.error, result.serialization ?? null, this.serializer);
3924
+ }
4831
3925
  return result.output;
4832
3926
  }
4833
3927
  const output = await func();
@@ -4853,7 +3947,7 @@ class SystemDatabase {
4853
3947
  const startTimeMs = Date.now();
4854
3948
  // Round once so the deadline stays integral: completed_at_epoch_ms is BIGINT and rejects fractional values.
4855
3949
  const endTimeMs = startTimeMs + Math.ceil(durationMS);
4856
- const client = await this.#connect();
3950
+ const client = await this.connect();
4857
3951
  try {
4858
3952
  const res = await this.#getOperationResultAndThrowIfCancelled(client, workflowID, functionID);
4859
3953
  if (res) {
@@ -4920,7 +4014,7 @@ class SystemDatabase {
4920
4014
  };
4921
4015
  let acquired = null;
4922
4016
  try {
4923
- const client = await this.#connect();
4017
+ const client = await this.connect();
4924
4018
  acquired = client;
4925
4019
  if (this.#abandonIfStopped(client))
4926
4020
  return;
@@ -5048,27 +4142,45 @@ __decorate([
5048
4142
  __decorate([
5049
4143
  dbRetry(),
5050
4144
  __metadata("design:type", Function),
5051
- __metadata("design:paramtypes", [String, Number, String, Boolean, Number, Number, Object]),
4145
+ __metadata("design:paramtypes", [String, Number, Number, Boolean]),
5052
4146
  __metadata("design:returntype", Promise)
5053
- ], SystemDatabase.prototype, "recordOperationResult", null);
4147
+ ], SystemDatabase.prototype, "getAllOperationResults", null);
5054
4148
  __decorate([
5055
4149
  dbRetry(),
5056
4150
  __metadata("design:type", Function),
5057
- __metadata("design:paramtypes", [String, Number, String, Function]),
4151
+ __metadata("design:paramtypes", [String, Number, String, Boolean, Number, Number, Object]),
5058
4152
  __metadata("design:returntype", Promise)
5059
- ], SystemDatabase.prototype, "runTransactionalStep", null);
4153
+ ], SystemDatabase.prototype, "recordOperationResult", null);
5060
4154
  __decorate([
5061
4155
  dbRetry(),
5062
4156
  __metadata("design:type", Function),
5063
4157
  __metadata("design:paramtypes", [String, Number, String, Boolean]),
5064
4158
  __metadata("design:returntype", Promise)
5065
4159
  ], SystemDatabase.prototype, "checkPatch", null);
4160
+ __decorate([
4161
+ dbRetry(),
4162
+ __metadata("design:type", Function),
4163
+ __metadata("design:paramtypes", [Array, Boolean]),
4164
+ __metadata("design:returntype", Promise)
4165
+ ], SystemDatabase.prototype, "cancelWorkflows", null);
5066
4166
  __decorate([
5067
4167
  dbRetry(),
5068
4168
  __metadata("design:type", Function),
5069
4169
  __metadata("design:paramtypes", [String]),
5070
4170
  __metadata("design:returntype", Promise)
5071
4171
  ], SystemDatabase.prototype, "checkIfCanceled", null);
4172
+ __decorate([
4173
+ dbRetry(),
4174
+ __metadata("design:type", Function),
4175
+ __metadata("design:paramtypes", [Array, String]),
4176
+ __metadata("design:returntype", Promise)
4177
+ ], SystemDatabase.prototype, "resumeWorkflows", null);
4178
+ __decorate([
4179
+ dbRetry(),
4180
+ __metadata("design:type", Function),
4181
+ __metadata("design:paramtypes", [String, Number]),
4182
+ __metadata("design:returntype", Promise)
4183
+ ], SystemDatabase.prototype, "setWorkflowDelay", null);
5072
4184
  __decorate([
5073
4185
  dbRetry(),
5074
4186
  __metadata("design:type", Function),
@@ -5078,9 +4190,15 @@ __decorate([
5078
4190
  __decorate([
5079
4191
  dbRetry(),
5080
4192
  __metadata("design:type", Function),
5081
- __metadata("design:paramtypes", [String, Number, Object]),
4193
+ __metadata("design:paramtypes", [String]),
4194
+ __metadata("design:returntype", Promise)
4195
+ ], SystemDatabase.prototype, "getWorkflowChildren", null);
4196
+ __decorate([
4197
+ dbRetry(),
4198
+ __metadata("design:type", Function),
4199
+ __metadata("design:paramtypes", [Array, Boolean]),
5082
4200
  __metadata("design:returntype", Promise)
5083
- ], SystemDatabase.prototype, "rewindWorkflow", null);
4201
+ ], SystemDatabase.prototype, "deleteWorkflows", null);
5084
4202
  __decorate([
5085
4203
  dbRetry()
5086
4204
  // A missing row normally means the workflow has not been inserted yet, so
@@ -5180,15 +4298,9 @@ __decorate([
5180
4298
  __decorate([
5181
4299
  dbRetry(),
5182
4300
  __metadata("design:type", Function),
5183
- __metadata("design:paramtypes", [Number]),
5184
- __metadata("design:returntype", Promise)
5185
- ], SystemDatabase.prototype, "listTimedOutWorkflowIds", null);
5186
- __decorate([
5187
- dbRetry(),
5188
- __metadata("design:type", Function),
5189
- __metadata("design:paramtypes", [String, String, Array]),
4301
+ __metadata("design:paramtypes", [Object]),
5190
4302
  __metadata("design:returntype", Promise)
5191
- ], SystemDatabase.prototype, "getMetrics", null);
4303
+ ], SystemDatabase.prototype, "listWorkflows", null);
5192
4304
  __decorate([
5193
4305
  dbRetry(),
5194
4306
  __metadata("design:type", Function),