@dbos-inc/dbos-sdk 4.26.7-preview → 4.26.9-preview

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,7 +9,7 @@ var __metadata = (this && this.__metadata) || function (k, v) {
9
9
  if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
10
10
  };
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
- exports.SystemDatabase = exports.ensureSystemDatabase = exports.grantDbosSchemaPermissions = exports.getDbosSchemaPermissionsSql = exports.DEFAULT_RENAME_BATCH_SIZE = exports.DEFAULT_NOTIFICATION_COALESCE_MS = exports.DBOS_STREAMS_CHANNEL = exports.DBOS_WORKFLOW_EVENTS_CHANNEL = exports.DBOS_NOTIFICATIONS_CHANNEL = exports.DBOS_STREAM_CLOSED_SENTINEL = exports.DEFAULT_POOL_SIZE = exports.DBOS_FUNCNAME_CLOSESTREAM = exports.DBOS_FUNCNAME_WRITESTREAM = exports.DBOS_FUNCNAME_GETSTATUS = exports.DBOS_FUNCNAME_SLEEP = exports.DBOS_FUNCNAME_GETEVENT = exports.DBOS_FUNCNAME_SETEVENT = exports.DBOS_FUNCNAME_RECV = exports.DBOS_FUNCNAME_SEND = void 0;
12
+ exports.SystemDatabase = exports.verifySystemDatabase = exports.ensureSystemDatabase = exports.grantDbosSchemaPermissions = exports.getDbosSchemaPermissionsSql = exports.DEFAULT_RENAME_BATCH_SIZE = exports.DEFAULT_NOTIFICATION_COALESCE_MS = exports.DBOS_STREAMS_CHANNEL = exports.DBOS_WORKFLOW_EVENTS_CHANNEL = exports.DBOS_NOTIFICATIONS_CHANNEL = exports.DBOS_STREAM_CLOSED_SENTINEL = exports.DEFAULT_POOL_SIZE = 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");
@@ -100,49 +100,94 @@ async function grantDbosSchemaPermissions(databaseUrl, roleName, logger, schemaN
100
100
  }
101
101
  }
102
102
  exports.grantDbosSchemaPermissions = grantDbosSchemaPermissions;
103
- async function ensureSystemDatabase(sysDbUrl, logger, customPool, schemaName = 'dbos', useListenNotify = true) {
104
- let client = null;
103
+ /**
104
+ * Check out a connection carrying our own error handler, so a socket death while we hold it is
105
+ * logged instead of thrown at an emitter with no listener. `release()` takes the handler back off,
106
+ * so nothing we attach outlives the checkout and the pool itself is never touched.
107
+ */
108
+ async function borrowClient(pool, onError) {
109
+ const client = await pool.connect();
110
+ // Nothing awaits between the checkout and this attach, nor between the removal and release()
111
+ // below, so there is no turn of the event loop in which we hold the connection unguarded.
112
+ client.on('error', onError);
113
+ const release = client.release.bind(client);
114
+ client.release = (err) => {
115
+ client.removeListener('error', onError);
116
+ release(err);
117
+ };
118
+ return client;
119
+ }
120
+ /** Connect to the system database without creating it. The caller releases the client. */
121
+ async function connectToSystemDatabase(sysDbUrl, logger, customPool) {
105
122
  if (customPool) {
106
- // If a custom pool is passed in, assume the database already exists and create
107
- // a client to run migrations.
108
- client = await customPool.connect();
123
+ return await borrowClient(customPool, (err) => logger.warn(`Unexpected error in system database client: ${err}`));
109
124
  }
110
- else {
111
- // Otherwise, create the system database if it does not exist.
112
- await (0, database_utils_1.ensurePGDatabase)(sysDbUrl, logger);
113
- const sysClient = new pg_1.Client((0, utils_2.getClientConfig)(sysDbUrl));
114
- // An 'error' event with no listener would take down the process.
115
- sysClient.on('error', (err) => logger.warn(`Unexpected error in system database client: ${err}`));
116
- try {
117
- await sysClient.connect();
125
+ const sysClient = new pg_1.Client((0, utils_2.getClientConfig)(sysDbUrl));
126
+ // An 'error' event with no listener would take down the process.
127
+ sysClient.on('error', (err) => logger.warn(`Unexpected error in system database client: ${err}`));
128
+ try {
129
+ await sysClient.connect();
130
+ }
131
+ catch (e) {
132
+ await sysClient.end().catch(() => { });
133
+ throw new error_1.DBOSInitializationError(`Unable to connect to system database at ${(0, database_utils_1.maskDatabaseUrl)(sysDbUrl)}: ${e.message}`, e instanceof Error ? e : undefined);
134
+ }
135
+ return sysClient;
136
+ }
137
+ async function releaseSystemDatabaseClient(client, customPool) {
138
+ try {
139
+ if (customPool) {
140
+ client.release();
118
141
  }
119
- catch (e) {
120
- await sysClient.end().catch(() => { });
121
- throw new error_1.DBOSInitializationError(`Unable to connect to system database at ${(0, database_utils_1.maskDatabaseUrl)(sysDbUrl)}: ${e.message}`, e instanceof Error ? e : undefined);
142
+ else {
143
+ await client.end();
122
144
  }
123
- client = sysClient;
124
145
  }
146
+ catch (e) { }
147
+ }
148
+ async function isCockroachDB(client) {
149
+ const versionRes = await client.query('SELECT version() AS version');
150
+ return /cockroachdb/i.test(versionRes.rows[0]?.version ?? '');
151
+ }
152
+ async function ensureSystemDatabase(sysDbUrl, logger, customPool, schemaName = 'dbos', useListenNotify = true) {
153
+ if (!customPool) {
154
+ // A custom pool means the database already exists; otherwise, create it if it does not.
155
+ await (0, database_utils_1.ensurePGDatabase)(sysDbUrl, logger);
156
+ }
157
+ const client = await connectToSystemDatabase(sysDbUrl, logger, customPool);
125
158
  try {
126
- const versionRes = await client.query('SELECT version() AS version');
127
- const isCockroach = /cockroachdb/i.test(versionRes.rows[0]?.version ?? '');
159
+ const isCockroach = await isCockroachDB(client);
128
160
  await (0, migration_runner_1.runSysMigrationsPg)(client, (0, migrations_1.allMigrations)(schemaName, { useListenNotify, isCockroach }), schemaName, {
129
161
  onWarn: (e) => logger.info(e),
130
162
  isCockroach,
131
163
  });
132
164
  }
133
165
  finally {
134
- try {
135
- if (customPool) {
136
- client.release();
137
- }
138
- else {
139
- await client.end();
140
- }
141
- }
142
- catch (e) { }
166
+ await releaseSystemDatabaseClient(client, customPool);
143
167
  }
144
168
  }
145
169
  exports.ensureSystemDatabase = ensureSystemDatabase;
170
+ /** Check the system database is migrated to the version this build requires, creating and changing nothing. */
171
+ async function verifySystemDatabase(sysDbUrl, logger, customPool, schemaName = 'dbos', useListenNotify = true) {
172
+ const client = await connectToSystemDatabase(sysDbUrl, logger, customPool);
173
+ try {
174
+ const isCockroach = await isCockroachDB(client);
175
+ const requiredVersion = (0, migrations_1.allMigrations)(schemaName, { useListenNotify, isCockroach }).length;
176
+ const currentVersion = await (0, migration_runner_1.getCurrentSysDBVersion)(client, schemaName);
177
+ // A database ahead of this build belongs to a newer peer, which the migration runner also tolerates.
178
+ if (currentVersion < requiredVersion) {
179
+ throw new error_1.DBOSInitializationError(`System database ${(0, database_utils_1.maskDatabaseUrl)(sysDbUrl)} is at schema version ${currentVersion}, but this version ` +
180
+ `of DBOS requires ${requiredVersion}. This process is configured with runMigrations disabled, so it ` +
181
+ `will not migrate it: either migrate the system database out of band (\`npx dbos schema\`) or launch ` +
182
+ `with runMigrations enabled.`);
183
+ }
184
+ logger.debug(`System database schema version ${currentVersion} satisfies the required version ${requiredVersion}`);
185
+ }
186
+ finally {
187
+ await releaseSystemDatabaseClient(client, customPool);
188
+ }
189
+ }
190
+ exports.verifySystemDatabase = verifySystemDatabase;
146
191
  class NotificationMap {
147
192
  map = new Map();
148
193
  curCK = 0;
@@ -441,6 +486,8 @@ class SystemDatabase {
441
486
  runningWorkflowMap = new Map(); // Map from workflowID to workflow promise, queue name and partition key
442
487
  // Per-partition-key created_at cursors: keep per-key queue order monotonic across batches
443
488
  #batchCreatedAtCursors = new Map();
489
+ // Set by destroy(), so polling waits end instead of running on against a pool that outlives this handle.
490
+ #destroyed = false;
444
491
  constructor(systemDatabaseUrl, logger, serializer, sysDbPoolSize = exports.DEFAULT_POOL_SIZE, systemDatabasePool, schemaName = 'dbos', useListenNotify = true, pollingConcurrency, notificationCoalesceMs = exports.DEFAULT_NOTIFICATION_COALESCE_MS,
445
492
  // The application this handle acts for; undefined writes unclaimed rows.
446
493
  appName) {
@@ -469,14 +516,20 @@ class SystemDatabase {
469
516
  const effectivePoolSize = this.pool.options.max ?? sysDbPoolSize;
470
517
  const pollingLimit = pollingConcurrency ?? Math.max(1, Math.floor(effectivePoolSize / 2));
471
518
  this.pollLimiter = new utils_1.Semaphore(pollingLimit);
472
- this.pool.on('error', (err) => {
473
- this.logger.warn(`Unexpected error in pool: ${err}`);
474
- });
475
- this.pool.on('connect', (client) => {
476
- client.on('error', (err) => {
477
- this.logger.warn(`Unexpected error in idle client: ${err}`);
519
+ // Only ever attach listeners to a pool we own; a caller's pool is theirs to instrument. Idle
520
+ // connections are all this covers, since #connect guards the ones we are holding.
521
+ if (!this.customPool) {
522
+ this.pool.on('error', (err) => {
523
+ this.logger.warn(`Unexpected error in pool: ${err}`);
478
524
  });
479
- });
525
+ }
526
+ }
527
+ #onClientError = (err) => {
528
+ this.logger.warn(`Unexpected error on a system database connection: ${err}`);
529
+ };
530
+ /** Check out a pool connection guarded for as long as we hold it. See {@link borrowClient}. */
531
+ #connect() {
532
+ return borrowClient(this.pool, this.#onClientError);
480
533
  }
481
534
  getSerializer() {
482
535
  return this.serializer;
@@ -535,8 +588,10 @@ class SystemDatabase {
535
588
  `Either ${takeANewName}, or, if '${current}' was renamed to '${owner}', ` +
536
589
  `re-own its rows first with dbos rename-application`);
537
590
  }
538
- async init() {
539
- await ensureSystemDatabase(this.systemDatabaseUrl, this.logger, this.customPool ? this.pool : undefined, this.schemaName, this.shouldUseDBNotifications);
591
+ /** Migrates the system database, or, when `runMigrations` is false, verifies it is already migrated. */
592
+ async init(runMigrations = true) {
593
+ const migrateOrVerify = runMigrations ? ensureSystemDatabase : verifySystemDatabase;
594
+ await migrateOrVerify(this.systemDatabaseUrl, this.logger, this.customPool ? this.pool : undefined, this.schemaName, this.shouldUseDBNotifications);
540
595
  if (this.shouldUseDBNotifications) {
541
596
  await this.#listenForNotifications();
542
597
  // Push coalesced stream and event notifications off the write path.
@@ -547,6 +602,7 @@ class SystemDatabase {
547
602
  async destroy() {
548
603
  // Set synchronously, before any await, so no reconnect is scheduled or published after this point.
549
604
  this.#notificationsStopped = true;
605
+ this.#destroyed = true;
550
606
  if (this.reconnectTimeout) {
551
607
  clearTimeout(this.reconnectTimeout);
552
608
  this.reconnectTimeout = null;
@@ -561,66 +617,28 @@ class SystemDatabase {
561
617
  if (this.notificationsClient) {
562
618
  this.#retireNotificationsClient(this.notificationsClient);
563
619
  }
564
- await this.pool.end();
620
+ // We attached nothing to the pool object itself, so there is nothing to unpick; only close one we own.
621
+ if (!this.customPool) {
622
+ await this.pool.end();
623
+ }
565
624
  }
566
625
  // ==================== Workflow Status ====================
567
- async initWorkflowStatus(initStatus, ownerXid, options) {
568
- const client = await this.pool.connect();
626
+ /** Runs on `client` if given, joining its transaction; otherwise in its own retried transaction. */
627
+ async initWorkflowStatus(initStatus, ownerXid, client) {
628
+ if (client !== undefined) {
629
+ return await this.#initWorkflowStatusInternal(client, initStatus, ownerXid);
630
+ }
631
+ return await this.initWorkflowStatusStandalone(initStatus, ownerXid);
632
+ }
633
+ async initWorkflowStatusStandalone(initStatus, ownerXid) {
634
+ const client = await this.#connect();
569
635
  let shouldCommit = false;
570
636
  try {
571
637
  await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');
572
- // Moving from enqueued to pending asks to increment recovery attempts... rather than in the recovery process
573
- // where it moves from pending back to enqueued.
574
- const resRow = await this.insertWorkflowStatus(client, initStatus, ownerXid, !!options?.isRecoveryRequest || !!options?.isDequeuedRequest);
575
- if (resRow.name !== initStatus.workflowName) {
576
- const msg = `Workflow already exists with a different function name: ${resRow.name}, but the provided function name is: ${initStatus.workflowName}`;
577
- throw new error_1.DBOSConflictingWorkflowError(initStatus.workflowUUID, msg);
578
- }
579
- else if (resRow.class_name !== initStatus.workflowClassName) {
580
- const msg = `Workflow already exists with a different class name: ${resRow.class_name}, but the provided class name is: ${initStatus.workflowClassName}`;
581
- throw new error_1.DBOSConflictingWorkflowError(initStatus.workflowUUID, msg);
582
- }
583
- else if ((resRow.config_name || '') !== (initStatus.workflowConfigName || '')) {
584
- const msg = `Workflow already exists with a different class configuration: ${resRow.config_name}, but the provided class configuration is: ${initStatus.workflowConfigName}`;
585
- throw new error_1.DBOSConflictingWorkflowError(initStatus.workflowUUID, msg);
586
- }
587
- else if ((resRow.queue_name ?? undefined) !== (initStatus.queueName ?? undefined)) {
588
- // This is a warning because a different queue name is not necessarily an error.
589
- this.logger.warn(`Workflow (${initStatus.workflowUUID}) already exists in queue: ${resRow.queue_name}, but the provided queue name is: ${initStatus.queueName}. The queue is not updated. ${new Error().stack}`);
590
- }
591
- const status = resRow.status;
592
- const deadlineEpochMS = resRow.workflow_deadline_epoch_ms ?? undefined;
593
- // If there is an existing DB record and we aren't here to recover it,
594
- // leave it be. Roll back the change to max recovery attempts.
595
- if (ownerXid !== resRow.owner_xid && !options?.isRecoveryRequest && !options?.isDequeuedRequest) {
596
- // It is not clear if getting the handle should throw the error, or getting the result from the handle should error.
597
- // Current precedent is the former.
598
- if (status === workflow_1.StatusString.MAX_RECOVERY_ATTEMPTS_EXCEEDED) {
599
- throw new error_1.DBOSMaxRecoveryAttemptsExceededError(initStatus.workflowUUID, options?.maxRetries ?? -1);
600
- }
601
- return { status, deadlineEpochMS, shouldExecuteOnThisExecutor: false, serialization: resRow.serialization };
602
- }
603
- // Upsert above already set executor assignment and incremented the recovery attempt
604
- shouldCommit = true;
605
- // recovery_attempt means "attempts" (we kept the name for backward compatibility). It's default value is 1.
606
- // Every time we init the status, we increment `recovery_attempts` by 1.
607
- // Thus, when this number becomes equal to `maxRetries + 1`, we should mark the workflow as `MAX_RECOVERY_ATTEMPTS_EXCEEDED`.
608
- const attempts = resRow.recovery_attempts;
609
- if (options?.maxRetries && attempts > options?.maxRetries + 1) {
610
- await this.updateWorkflowStatus(client, initStatus.workflowUUID, workflow_1.StatusString.MAX_RECOVERY_ATTEMPTS_EXCEEDED, {
611
- where: { status: workflow_1.StatusString.PENDING },
612
- throwOnFailure: false,
613
- update: { resetDeduplicationID: true },
614
- });
615
- throw new error_1.DBOSMaxRecoveryAttemptsExceededError(initStatus.workflowUUID, options.maxRetries);
616
- }
617
- this.logger.debug(`Workflow ${initStatus.workflowUUID} attempt number: ${attempts}.`);
618
- return {
619
- status,
620
- deadlineEpochMS,
621
- shouldExecuteOnThisExecutor: true,
622
- serialization: resRow.serialization,
623
- };
638
+ const result = await this.#initWorkflowStatusInternal(client, initStatus, ownerXid);
639
+ // If there is an existing DB record and we aren't here to recover it, leave it be.
640
+ shouldCommit = result.shouldExecuteOnThisExecutor;
641
+ return result;
624
642
  }
625
643
  finally {
626
644
  try {
@@ -637,6 +655,47 @@ class SystemDatabase {
637
655
  }
638
656
  }
639
657
  }
658
+ async #initWorkflowStatusInternal(client, initStatus, ownerXid) {
659
+ const resRow = await this.insertWorkflowStatus(client, initStatus, ownerXid);
660
+ if (resRow.name !== initStatus.workflowName) {
661
+ const msg = `Workflow already exists with a different function name: ${resRow.name}, but the provided function name is: ${initStatus.workflowName}`;
662
+ throw new error_1.DBOSConflictingWorkflowError(initStatus.workflowUUID, msg);
663
+ }
664
+ else if (resRow.class_name !== initStatus.workflowClassName) {
665
+ const msg = `Workflow already exists with a different class name: ${resRow.class_name}, but the provided class name is: ${initStatus.workflowClassName}`;
666
+ throw new error_1.DBOSConflictingWorkflowError(initStatus.workflowUUID, msg);
667
+ }
668
+ else if ((resRow.config_name || '') !== (initStatus.workflowConfigName || '')) {
669
+ const msg = `Workflow already exists with a different class configuration: ${resRow.config_name}, but the provided class configuration is: ${initStatus.workflowConfigName}`;
670
+ throw new error_1.DBOSConflictingWorkflowError(initStatus.workflowUUID, msg);
671
+ }
672
+ else if ((resRow.queue_name ?? undefined) !== (initStatus.queueName ?? undefined)) {
673
+ // This is a warning because a different queue name is not necessarily an error.
674
+ this.logger.warn(`Workflow (${initStatus.workflowUUID}) already exists in queue: ${resRow.queue_name}, but the provided queue name is: ${initStatus.queueName}. The queue is not updated. ${new Error().stack}`);
675
+ }
676
+ const status = resRow.status;
677
+ const deadlineEpochMS = resRow.workflow_deadline_epoch_ms ?? undefined;
678
+ // The upsert above already set executor assignment for a row we own.
679
+ return {
680
+ status,
681
+ deadlineEpochMS,
682
+ shouldExecuteOnThisExecutor: ownerXid === resRow.owner_xid,
683
+ serialization: resRow.serialization,
684
+ };
685
+ }
686
+ /** Move claimed workflows that exhausted their attempts off the queue, leaving rows others have moved on alone. */
687
+ async deadLetterWorkflows(workflowIDs, minRecoveryAttempts) {
688
+ if (workflowIDs.length === 0)
689
+ return;
690
+ await this.pool.query(`UPDATE "${this.schemaName}".workflow_status
691
+ SET status = $1,
692
+ deduplication_id = NULL,
693
+ started_at_epoch_ms = NULL,
694
+ queue_name = NULL,
695
+ updated_at = (EXTRACT(EPOCH FROM now()) * 1000)::bigint,
696
+ completed_at = (EXTRACT(EPOCH FROM now()) * 1000)::bigint
697
+ WHERE workflow_uuid = ANY($2::text[]) AND status = $3 AND recovery_attempts >= $4`, [workflow_1.StatusString.MAX_RECOVERY_ATTEMPTS_EXCEEDED, workflowIDs, workflow_1.StatusString.PENDING, minRecoveryAttempts]);
698
+ }
640
699
  /** Highest created_at among still-active rows per partition key, used to seed the in-memory cursor. */
641
700
  async #maxPartitionKeyCreatedAt(keys) {
642
701
  const maxima = new Map();
@@ -745,7 +804,7 @@ class SystemDatabase {
745
804
  'schedule_name',
746
805
  'application_name',
747
806
  ];
748
- const client = await this.pool.connect();
807
+ const client = await this.#connect();
749
808
  try {
750
809
  await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');
751
810
  // Chunk to stay well under the bind-parameter limit.
@@ -783,7 +842,7 @@ class SystemDatabase {
783
842
  return inserted;
784
843
  }
785
844
  async recordWorkflowOutput(workflowID, status) {
786
- const client = await this.pool.connect();
845
+ const client = await this.#connect();
787
846
  try {
788
847
  return await this.#recordWorkflowOutcome(client, workflowID, workflow_1.StatusString.SUCCESS, { output: status.output });
789
848
  }
@@ -792,7 +851,7 @@ class SystemDatabase {
792
851
  }
793
852
  }
794
853
  async recordWorkflowError(workflowID, status) {
795
- const client = await this.pool.connect();
854
+ const client = await this.#connect();
796
855
  try {
797
856
  return await this.#recordWorkflowOutcome(client, workflowID, workflow_1.StatusString.ERROR, { error: status.error });
798
857
  }
@@ -832,24 +891,17 @@ class SystemDatabase {
832
891
  }
833
892
  // 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.
834
893
  async reenqueueWorkflowsForRecovery(executorID, appVersion, recoveryQueueName) {
835
- const params = [
836
- workflow_1.StatusString.ENQUEUED,
837
- Date.now(),
838
- recoveryQueueName,
839
- workflow_1.StatusString.PENDING,
840
- executorID,
841
- appVersion,
842
- ];
894
+ const params = [workflow_1.StatusString.ENQUEUED, recoveryQueueName, workflow_1.StatusString.PENDING, executorID, appVersion];
843
895
  // executor_id defaults to "local", so it collides across applications.
844
896
  const scope = this.#appNameFilter('application_name', this.appName, params);
845
897
  const result = await this.pool.query(`UPDATE "${this.schemaName}".workflow_status
846
898
  SET started_at_epoch_ms = NULL,
847
899
  status = $1,
848
- updated_at = $2,
849
- queue_name = COALESCE(queue_name, $3)
850
- WHERE status = $4
851
- AND executor_id = $5
852
- AND application_version = $6
900
+ updated_at = (EXTRACT(EPOCH FROM now()) * 1000)::bigint,
901
+ queue_name = COALESCE(queue_name, $2)
902
+ WHERE status = $3
903
+ AND executor_id = $4
904
+ AND application_version = $5
853
905
  AND ${scope}
854
906
  RETURNING workflow_uuid`, params);
855
907
  return result.rows.map((row) => row.workflow_uuid);
@@ -861,7 +913,7 @@ class SystemDatabase {
861
913
  return status ? JSON.stringify(status) : null;
862
914
  };
863
915
  if (callerID && callerFN) {
864
- const client = await this.pool.connect();
916
+ const client = await this.#connect();
865
917
  try {
866
918
  // Check if the operation has been done before for OAOO (only do this inside a workflow).
867
919
  const json = await this.#runAndRecordResult(client, exports.DBOS_FUNCNAME_GETSTATUS, callerID, callerFN, funcGetStatus);
@@ -879,12 +931,33 @@ class SystemDatabase {
879
931
  return json ? JSON.parse(json) : null;
880
932
  }
881
933
  }
934
+ /** Max IDs per {@link getWorkflowStatuses} fetch: listWorkflows binds one parameter per ID. */
935
+ statusFetchChunkSize = 500;
936
+ // Retried per chunk so a reconnect refetches one chunk, not every chunk before it.
937
+ async fetchWorkflowStatusChunk(workflowIDs) {
938
+ return await this.listWorkflows({ workflowIDs, loadInput: true, loadOutput: false });
939
+ }
940
+ /** Fetch many statuses in as few round trips as possible. IDs with no row are omitted. */
941
+ async getWorkflowStatuses(workflowIDs) {
942
+ const statuses = new Map();
943
+ for (let start = 0; start < workflowIDs.length; start += this.statusFetchChunkSize) {
944
+ for (const status of await this.fetchWorkflowStatusChunk(workflowIDs.slice(start, start + this.statusFetchChunkSize))) {
945
+ statuses.set(status.workflowUUID, status);
946
+ }
947
+ }
948
+ return statuses;
949
+ }
882
950
  // Only used in tests
883
951
  async setWorkflowStatus(workflowID, status, resetRecoveryAttempts, internalOptions) {
884
- const client = await this.pool.connect();
952
+ const client = await this.#connect();
885
953
  try {
886
954
  await this.updateWorkflowStatus(client, workflowID, status, {
887
- update: { resetRecoveryAttempts, resetNameTo: internalOptions?.updateName },
955
+ update: {
956
+ resetRecoveryAttempts,
957
+ resetNameTo: internalOptions?.updateName,
958
+ queueName: internalOptions?.queueName,
959
+ resetStartedAtEpochMs: internalOptions?.resetStartedAtEpochMs,
960
+ },
888
961
  });
889
962
  }
890
963
  finally {
@@ -893,7 +966,7 @@ class SystemDatabase {
893
966
  }
894
967
  // ==================== Step Results ====================
895
968
  async getOperationResultAndThrowIfCancelled(workflowID, functionID) {
896
- const client = await this.pool.connect();
969
+ const client = await this.#connect();
897
970
  try {
898
971
  return await this.#getOperationResultAndThrowIfCancelled(client, workflowID, functionID);
899
972
  }
@@ -916,7 +989,7 @@ class SystemDatabase {
916
989
  return rows;
917
990
  }
918
991
  async recordOperationResult(workflowID, functionID, functionName, checkConflict, startTimeEpochMs, endTimeEpochMs, options = {}) {
919
- const client = await this.pool.connect();
992
+ const client = await this.#connect();
920
993
  try {
921
994
  await this.recordOperationResultInternal(client, workflowID, functionID, functionName, checkConflict, startTimeEpochMs, endTimeEpochMs, options);
922
995
  }
@@ -926,7 +999,7 @@ class SystemDatabase {
926
999
  }
927
1000
  }
928
1001
  async runTransactionalStep(workflowID, functionID, functionName, callback) {
929
- const client = await this.pool.connect();
1002
+ const client = await this.#connect();
930
1003
  try {
931
1004
  await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');
932
1005
  const existing = await this.#getOperationResultAndThrowIfCancelled(client, workflowID, functionID);
@@ -1023,15 +1096,15 @@ class SystemDatabase {
1023
1096
  }
1024
1097
  async setWorkflowPriority(workflowID, priority) {
1025
1098
  await this.pool.query(`UPDATE "${this.schemaName}".workflow_status
1026
- SET priority = $1, updated_at = $2
1027
- WHERE workflow_uuid = $3
1028
- AND status IN ($4, $5)`, [priority, Date.now(), workflowID, workflow_1.StatusString.ENQUEUED, workflow_1.StatusString.DELAYED]);
1099
+ SET priority = $1, updated_at = (EXTRACT(EPOCH FROM now()) * 1000)::bigint
1100
+ WHERE workflow_uuid = $2
1101
+ AND status IN ($3, $4)`, [priority, workflowID, workflow_1.StatusString.ENQUEUED, workflow_1.StatusString.DELAYED]);
1029
1102
  }
1030
1103
  async setWorkflowDelay(workflowID, delayUntilEpochMS) {
1031
1104
  await this.pool.query(`UPDATE "${this.schemaName}".workflow_status
1032
- SET delay_until_epoch_ms = $1, updated_at = $2
1033
- WHERE workflow_uuid = $3
1034
- AND status = $4`, [delayUntilEpochMS, Date.now(), workflowID, workflow_1.StatusString.DELAYED]);
1105
+ SET delay_until_epoch_ms = $1, updated_at = (EXTRACT(EPOCH FROM now()) * 1000)::bigint
1106
+ WHERE workflow_uuid = $2
1107
+ AND status = $3`, [delayUntilEpochMS, workflowID, workflow_1.StatusString.DELAYED]);
1035
1108
  }
1036
1109
  /**
1037
1110
  * Extend an existing debounced DELAYED workflow's delay and update its inputs, atomically.
@@ -1050,7 +1123,7 @@ class SystemDatabase {
1050
1123
  return await this.debounceDelayedWorkflowStandalone(params);
1051
1124
  }
1052
1125
  async debounceDelayedWorkflowStandalone(params) {
1053
- const client = await this.pool.connect();
1126
+ const client = await this.#connect();
1054
1127
  try {
1055
1128
  await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');
1056
1129
  const result = await this.#debounceDelayedWorkflowInternal(client, params);
@@ -1071,7 +1144,6 @@ class SystemDatabase {
1071
1144
  params.delayUntilEpochMS,
1072
1145
  params.input,
1073
1146
  params.serialization,
1074
- Date.now(),
1075
1147
  params.workflowName,
1076
1148
  classNameOrNull,
1077
1149
  params.queueName,
@@ -1087,12 +1159,13 @@ class SystemDatabase {
1087
1159
  THEN debounce_deadline_epoch_ms
1088
1160
  ELSE $1
1089
1161
  END,
1090
- inputs = $2, serialization = $3, updated_at = $4,
1162
+ inputs = $2, serialization = $3,
1163
+ updated_at = (EXTRACT(EPOCH FROM now()) * 1000)::bigint,
1091
1164
  -- Claim it for the target, as its dequeue would: left unclaimed, every peer coalesces onto the one workflow and the last inputs win.
1092
- application_name = COALESCE(application_name, $10)
1093
- WHERE name = $5 AND class_name IS NOT DISTINCT FROM $6
1094
- AND queue_name = $7 AND deduplication_id = $8
1095
- AND status = $9 AND is_debounced = TRUE
1165
+ application_name = COALESCE(application_name, $9)
1166
+ WHERE name = $4 AND class_name IS NOT DISTINCT FROM $5
1167
+ AND queue_name = $6 AND deduplication_id = $7
1168
+ AND status = $8 AND is_debounced = TRUE
1096
1169
  AND ${ownScope}
1097
1170
  RETURNING workflow_uuid`, updateParams);
1098
1171
  if (updated.rows.length > 0) {
@@ -1231,7 +1304,7 @@ class SystemDatabase {
1231
1304
  if (originalWorkflowIDs.length !== forkedWorkflowIDs.length || originalWorkflowIDs.length !== startSteps.length) {
1232
1305
  throw new Error('originalWorkflowIDs, forkedWorkflowIDs, and startSteps must have the same length');
1233
1306
  }
1234
- const client = await this.pool.connect();
1307
+ const client = await this.#connect();
1235
1308
  try {
1236
1309
  await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');
1237
1310
  // Fetch the status of all original workflows inside the transaction.
@@ -1377,7 +1450,7 @@ class SystemDatabase {
1377
1450
  workflowIDs.push(...(await this.getWorkflowChildren(workflowID)));
1378
1451
  }
1379
1452
  const exportedWorkflows = [];
1380
- const client = await this.pool.connect();
1453
+ const client = await this.#connect();
1381
1454
  try {
1382
1455
  for (const wfID of workflowIDs) {
1383
1456
  // Export workflow_status
@@ -1435,7 +1508,7 @@ class SystemDatabase {
1435
1508
  return exportedWorkflows;
1436
1509
  }
1437
1510
  async importWorkflow(workflows) {
1438
- const client = await this.pool.connect();
1511
+ const client = await this.#connect();
1439
1512
  try {
1440
1513
  await client.query('BEGIN');
1441
1514
  for (const workflow of workflows) {
@@ -1591,6 +1664,10 @@ class SystemDatabase {
1591
1664
  * control-plane work hits the pool directly and bypasses the limiter.
1592
1665
  */
1593
1666
  #pollWithLimiter(query) {
1667
+ // Closing our own pool used to end these waits; a caller's pool stays open, so end them here.
1668
+ if (this.#destroyed) {
1669
+ return Promise.reject(new error_1.DBOSError('The system database has been shut down'));
1670
+ }
1594
1671
  return this.pollLimiter.runExclusive(query);
1595
1672
  }
1596
1673
  /**
@@ -1705,7 +1782,7 @@ class SystemDatabase {
1705
1782
  async send(workflowID, functionID, destinationID, message, topic, serialization, idempotencyKey) {
1706
1783
  topic = topic ?? this.nullTopic;
1707
1784
  const messageUUID = idempotencyKey ? `${idempotencyKey}::${destinationID}` : (0, crypto_1.randomUUID)();
1708
- const client = await this.pool.connect();
1785
+ const client = await this.#connect();
1709
1786
  try {
1710
1787
  await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');
1711
1788
  await this.#runAndRecordResult(client, exports.DBOS_FUNCNAME_SEND, workflowID, functionID, async () => {
@@ -1731,12 +1808,22 @@ class SystemDatabase {
1731
1808
  client.release();
1732
1809
  }
1733
1810
  }
1734
- async sendDirect(destinationID, message, topic, serialization, idempotencyKey) {
1811
+ /** Runs on `client` if given, joining its transaction; otherwise on the pool with retries. */
1812
+ async sendDirect(destinationID, message, topic, serialization, idempotencyKey, client) {
1813
+ if (client !== undefined) {
1814
+ return await this.#sendDirectInternal(client, destinationID, message, topic, serialization, idempotencyKey);
1815
+ }
1816
+ return await this.sendDirectStandalone(destinationID, message, topic, serialization, idempotencyKey);
1817
+ }
1818
+ async sendDirectStandalone(destinationID, message, topic, serialization, idempotencyKey) {
1819
+ return await this.#sendDirectInternal(this.pool, destinationID, message, topic, serialization, idempotencyKey);
1820
+ }
1821
+ async #sendDirectInternal(db, destinationID, message, topic, serialization, idempotencyKey) {
1735
1822
  topic = topic ?? this.nullTopic;
1736
1823
  // Same per-destination scoping as send() above.
1737
1824
  const messageUUID = idempotencyKey ? `${idempotencyKey}::${destinationID}` : (0, crypto_1.randomUUID)();
1738
1825
  try {
1739
- await this.pool.query(`INSERT INTO "${this.schemaName}".notifications (destination_uuid, topic, message, serialization, message_uuid)
1826
+ await db.query(`INSERT INTO "${this.schemaName}".notifications (destination_uuid, topic, message, serialization, message_uuid)
1740
1827
  VALUES ($1, $2, $3, $4, $5)
1741
1828
  ON CONFLICT (message_uuid) DO NOTHING;`, [destinationID, topic, message, serialization, messageUUID]);
1742
1829
  }
@@ -1802,7 +1889,7 @@ class SystemDatabase {
1802
1889
  // Transactionally consume and return the message if it's in the DB, otherwise return null.
1803
1890
  let message = null;
1804
1891
  let serialization = null;
1805
- const client = await this.pool.connect();
1892
+ const client = await this.#connect();
1806
1893
  try {
1807
1894
  await client.query(`BEGIN ISOLATION LEVEL READ COMMITTED`);
1808
1895
  const finalRecvRows = (await client.query(`UPDATE "${this.schemaName}".notifications
@@ -1842,7 +1929,7 @@ class SystemDatabase {
1842
1929
  }
1843
1930
  // ==================== Events ====================
1844
1931
  async setEvent(workflowID, functionID, key, message, serialization) {
1845
- const client = await this.pool.connect();
1932
+ const client = await this.#connect();
1846
1933
  try {
1847
1934
  await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');
1848
1935
  // Only a real write (not a replay) should wake readers.
@@ -1990,7 +2077,7 @@ class SystemDatabase {
1990
2077
  }
1991
2078
  // ==================== Streams ====================
1992
2079
  async writeStreamFromStep(workflowID, functionID, key, serializedValue, serialization) {
1993
- const client = await this.pool.connect();
2080
+ const client = await this.#connect();
1994
2081
  try {
1995
2082
  await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');
1996
2083
  // Find the maximum offset for this workflow_uuid and key combination
@@ -2016,7 +2103,7 @@ class SystemDatabase {
2016
2103
  }
2017
2104
  }
2018
2105
  async writeStreamFromWorkflow(workflowID, functionID, key, serializedValue, serialization, functionName) {
2019
- const client = await this.pool.connect();
2106
+ const client = await this.#connect();
2020
2107
  try {
2021
2108
  await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');
2022
2109
  // Only a real insert (not a replay) should wake readers.
@@ -2136,7 +2223,7 @@ class SystemDatabase {
2136
2223
  }
2137
2224
  try {
2138
2225
  // One statement: one round trip, one async-notify queue-lock acquisition; unnest emits one notification per payload.
2139
- const client = await this.pool.connect();
2226
+ const client = await this.#connect();
2140
2227
  try {
2141
2228
  await client.query(`SELECT pg_notify($1, p) FROM unnest($2::text[]) AS p`, [channel, Array.from(batch)]);
2142
2229
  }
@@ -2152,7 +2239,7 @@ class SystemDatabase {
2152
2239
  }
2153
2240
  // ==================== Observability: Workflow Communications ====================
2154
2241
  async getAllEvents(workflowID) {
2155
- const client = await this.pool.connect();
2242
+ const client = await this.#connect();
2156
2243
  try {
2157
2244
  const result = await client.query(`SELECT key, value, serialization FROM "${this.schemaName}".workflow_events
2158
2245
  WHERE workflow_uuid = $1`, [workflowID]);
@@ -2167,7 +2254,7 @@ class SystemDatabase {
2167
2254
  }
2168
2255
  }
2169
2256
  async getAllNotifications(workflowID) {
2170
- const client = await this.pool.connect();
2257
+ const client = await this.#connect();
2171
2258
  try {
2172
2259
  const result = await client.query(`SELECT topic, message, serialization, created_at_epoch_ms, consumed
2173
2260
  FROM "${this.schemaName}".notifications
@@ -2185,7 +2272,7 @@ class SystemDatabase {
2185
2272
  }
2186
2273
  }
2187
2274
  async getAllStreamEntries(workflowID) {
2188
- const client = await this.pool.connect();
2275
+ const client = await this.#connect();
2189
2276
  try {
2190
2277
  const result = await client.query(`SELECT key, value, serialization FROM "${this.schemaName}".streams
2191
2278
  WHERE workflow_uuid = $1
@@ -2216,7 +2303,7 @@ class SystemDatabase {
2216
2303
  // Only what this application would dequeue: a peer's debounce key is not ours to clear.
2217
2304
  const scope = this.#appNameFilter('application_name', this.appName, params);
2218
2305
  await this.pool.query(`UPDATE "${this.schemaName}".workflow_status
2219
- SET status = $1, updated_at = $2,
2306
+ SET status = $1, updated_at = (EXTRACT(EPOCH FROM now()) * 1000)::bigint,
2220
2307
  deduplication_id = CASE WHEN is_debounced THEN NULL ELSE deduplication_id END
2221
2308
  WHERE status = $3 AND delay_until_epoch_ms <= $2 AND ${scope}`, params);
2222
2309
  }
@@ -2248,7 +2335,6 @@ class SystemDatabase {
2248
2335
  return rows.map((row) => row.pk);
2249
2336
  }
2250
2337
  async findAndMarkStartableWorkflows(queue, executorID, appVersion, queuePartitionKey) {
2251
- const startTimeMs = Date.now();
2252
2338
  const limiterPeriodMS = queue.rateLimit ? queue.rateLimit.periodSec * 1000 : 0;
2253
2339
  const claimedIDs = [];
2254
2340
  const localRunningForQueue = this.countRunningWorkflowsForQueue(queue.name, queuePartitionKey);
@@ -2259,7 +2345,7 @@ class SystemDatabase {
2259
2345
  partitionFilter = `AND queue_partition_key = $PARTITION`;
2260
2346
  partitionParams.push(queuePartitionKey);
2261
2347
  }
2262
- const client = await this.pool.connect();
2348
+ const client = await this.#connect();
2263
2349
  try {
2264
2350
  // Default to READ COMMITTED except with global concurrency limits or rate limits
2265
2351
  if (queue.concurrency !== undefined || queue.rateLimit !== undefined) {
@@ -2275,7 +2361,7 @@ class SystemDatabase {
2275
2361
  queue.name,
2276
2362
  workflow_1.StatusString.ENQUEUED,
2277
2363
  workflow_1.StatusString.DELAYED,
2278
- startTimeMs - limiterPeriodMS,
2364
+ limiterPeriodMS,
2279
2365
  ...partitionParams,
2280
2366
  ];
2281
2367
  // Count only what this application would dequeue, matching the select below.
@@ -2284,7 +2370,8 @@ class SystemDatabase {
2284
2370
  WHERE queue_name = $1
2285
2371
  AND rate_limited = TRUE
2286
2372
  AND status NOT IN ($2, $3)
2287
- AND started_at_epoch_ms > $4
2373
+ -- Database clock on both sides, as the claim stamps started_at_epoch_ms with it.
2374
+ AND started_at_epoch_ms > (EXTRACT(epoch FROM now()) * 1000)::bigint - $4
2288
2375
  AND ${scope}
2289
2376
  ${partitionFilter.replace('$PARTITION', '$5')}`, params);
2290
2377
  numRecentQueries = Number(countResult.rows[0].count);
@@ -2297,9 +2384,13 @@ class SystemDatabase {
2297
2384
  // If there is a global or local concurrency limit N, select only the N oldest enqueued
2298
2385
  // functions, else select all of them.
2299
2386
  let maxTasks = Infinity;
2387
+ if (queue.rateLimit) {
2388
+ // Bound the claim by the limiter's remaining slots so a backlogged queue locks only what it can start.
2389
+ maxTasks = Math.max(0, queue.rateLimit.limitPerPeriod - numRecentQueries);
2390
+ }
2300
2391
  if (queue.workerConcurrency !== undefined) {
2301
2392
  // Use the in-memory registry for this worker's running count — avoids a DB round trip.
2302
- maxTasks = Math.max(0, queue.workerConcurrency - localRunningForQueue);
2393
+ maxTasks = Math.min(maxTasks, Math.max(0, queue.workerConcurrency - localRunningForQueue));
2303
2394
  }
2304
2395
  if (queue.concurrency !== undefined) {
2305
2396
  // Global concurrency still requires a DB query since other workers may be running workflows too.
@@ -2327,7 +2418,10 @@ class SystemDatabase {
2327
2418
  const versionClause = isLatestVersion
2328
2419
  ? '(application_version = $3 OR application_version IS NULL)'
2329
2420
  : 'application_version = $3';
2330
- const lockMode = queue.concurrency ? 'FOR UPDATE NOWAIT' : 'FOR UPDATE SKIP LOCKED';
2421
+ // A limit shared across processes needs a consistent view of the table: NOWAIT makes an
2422
+ // overlapping dequeuer abort rather than claim the next rows and spend the same budget twice.
2423
+ const sharedBudget = queue.concurrency !== undefined || queue.rateLimit !== undefined;
2424
+ const lockMode = sharedBudget ? 'FOR UPDATE NOWAIT' : 'FOR UPDATE SKIP LOCKED';
2331
2425
  const limitClause = maxTasks !== Infinity ? `LIMIT ${maxTasks}` : '';
2332
2426
  const selectParams = [workflow_1.StatusString.ENQUEUED, queue.name, appVersion, ...partitionParams];
2333
2427
  const selectScope = this.#appNameFilter('application_name', this.appName, selectParams);
@@ -2349,43 +2443,41 @@ class SystemDatabase {
2349
2443
  await (0, debugpoint_1.debugTriggerPoint)(debugpoint_1.DEBUG_TRIGGER_FIND_AND_MARK_AFTER_SELECT);
2350
2444
  // Start the workflows
2351
2445
  const workflowIDs = rows.map((row) => row.workflow_uuid);
2352
- for (const id of workflowIDs) {
2353
- // If we have a rate limit, stop starting functions when the number
2354
- // of functions started this period exceeds the limit.
2355
- if (queue.rateLimit && claimedIDs.length + numRecentQueries >= queue.rateLimit.limitPerPeriod) {
2356
- break;
2357
- }
2446
+ if (workflowIDs.length > 0) {
2358
2447
  // Start the functions by marking them as pending and updating their executor IDs.
2359
- // Only claim the workflow if the UPDATE actually transitioned an ENQUEUED row —
2360
- // otherwise another worker won the race and we must not re-dispatch it.
2361
2448
  const updateParams = [
2362
2449
  workflow_1.StatusString.PENDING,
2363
2450
  executorID,
2364
2451
  appVersion,
2365
- startTimeMs,
2366
2452
  queue.rateLimit !== undefined,
2367
- id,
2453
+ workflowIDs,
2368
2454
  workflow_1.StatusString.ENQUEUED,
2369
2455
  // Claim an unclaimed row for this application; a nameless dequeuer leaves ownership untouched.
2370
2456
  this.appName ?? null,
2371
2457
  ];
2372
2458
  // Re-check ownership alongside status, as the partitioned claim guard does.
2373
2459
  const claimScope = this.#appNameFilter('application_name', this.appName, updateParams);
2374
- const updateRes = await client.query(`UPDATE "${this.schemaName}".workflow_status
2460
+ // RETURNING reports exactly the rows this statement flipped, so a row another worker won is absent.
2461
+ const flippedResult = await client.query(`UPDATE "${this.schemaName}".workflow_status
2375
2462
  SET status = $1,
2376
2463
  executor_id = $2,
2377
2464
  application_version = $3,
2378
- started_at_epoch_ms = $4,
2379
- rate_limited = $5,
2380
- application_name = COALESCE(application_name, $8),
2465
+ started_at_epoch_ms = (EXTRACT(epoch FROM now()) * 1000)::bigint,
2466
+ rate_limited = $4,
2467
+ application_name = COALESCE(application_name, $7),
2468
+ recovery_attempts = recovery_attempts + 1,
2469
+ updated_at = (EXTRACT(epoch FROM now()) * 1000)::bigint,
2381
2470
  workflow_deadline_epoch_ms = CASE
2382
2471
  WHEN workflow_timeout_ms IS NOT NULL AND workflow_deadline_epoch_ms IS NULL
2383
2472
  THEN (EXTRACT(epoch FROM now()) * 1000)::bigint + workflow_timeout_ms
2384
2473
  ELSE workflow_deadline_epoch_ms
2385
2474
  END
2386
- WHERE workflow_uuid = $6 AND status = $7 AND ${claimScope}`, updateParams);
2387
- if ((updateRes.rowCount ?? 0) > 0) {
2388
- claimedIDs.push(id);
2475
+ WHERE workflow_uuid = ANY($5::text[]) AND status = $6 AND ${claimScope}
2476
+ RETURNING workflow_uuid`, updateParams);
2477
+ const flippedIDs = new Set(flippedResult.rows.map((row) => row.workflow_uuid));
2478
+ for (const id of workflowIDs) {
2479
+ if (flippedIDs.has(id))
2480
+ claimedIDs.push(id);
2389
2481
  }
2390
2482
  }
2391
2483
  await client.query('COMMIT');
@@ -2408,8 +2500,7 @@ class SystemDatabase {
2408
2500
  throw new error_1.DBOSError(`Batched partitioned dequeue requires a queue with concurrency 1 and no rate limit: ${queue.name}`);
2409
2501
  }
2410
2502
  // workerConcurrency needs no handling here: dispatch routes 0 (the pause-dequeue idiom) to the fallback path, and validation caps any other value at concurrency=1, which the PENDING gate already enforces globally.
2411
- const startTimeMs = Date.now();
2412
- const client = await this.pool.connect();
2503
+ const client = await this.#connect();
2413
2504
  try {
2414
2505
  await client.query('BEGIN');
2415
2506
  const latestVersion = await this.#latestApplicationVersionName(client);
@@ -2495,7 +2586,6 @@ class SystemDatabase {
2495
2586
  workflow_1.StatusString.ENQUEUED,
2496
2587
  queue.name,
2497
2588
  appVersion,
2498
- startTimeMs,
2499
2589
  // Claim the row, as the unpartitioned dequeue does.
2500
2590
  this.appName ?? null,
2501
2591
  ];
@@ -2505,9 +2595,11 @@ class SystemDatabase {
2505
2595
  SET status = $1,
2506
2596
  executor_id = $2,
2507
2597
  application_version = $6,
2508
- started_at_epoch_ms = $7,
2598
+ started_at_epoch_ms = (EXTRACT(epoch FROM now()) * 1000)::bigint,
2509
2599
  rate_limited = FALSE,
2510
- application_name = COALESCE(application_name, $8),
2600
+ application_name = COALESCE(application_name, $7),
2601
+ recovery_attempts = recovery_attempts + 1,
2602
+ updated_at = (EXTRACT(epoch FROM now()) * 1000)::bigint,
2511
2603
  workflow_deadline_epoch_ms = CASE
2512
2604
  WHEN workflow_timeout_ms IS NOT NULL AND workflow_deadline_epoch_ms IS NULL
2513
2605
  THEN (EXTRACT(epoch FROM now()) * 1000)::bigint + workflow_timeout_ms
@@ -3208,7 +3300,7 @@ class SystemDatabase {
3208
3300
  await this.pool.query(`UPDATE "${this.schemaName}".workflow_schedules SET last_fired_at = $1 WHERE schedule_name = $2`, [lastFiredAt, name]);
3209
3301
  }
3210
3302
  async applySchedules(schedules) {
3211
- const client = await this.pool.connect();
3303
+ const client = await this.#connect();
3212
3304
  try {
3213
3305
  await client.query('BEGIN');
3214
3306
  for (const sched of schedules) {
@@ -3260,7 +3352,7 @@ class SystemDatabase {
3260
3352
  */
3261
3353
  async createApplicationVersion(versionName, applicationName) {
3262
3354
  const owner = applicationName ?? this.appName;
3263
- const client = await this.pool.connect();
3355
+ const client = await this.#connect();
3264
3356
  try {
3265
3357
  await client.query('BEGIN');
3266
3358
  // Claim a pre-upgrade row in place, so the version is not recreated or retimed.
@@ -3291,7 +3383,7 @@ class SystemDatabase {
3291
3383
  */
3292
3384
  async updateApplicationVersionTimestamp(versionName, newTimestamp, applicationName) {
3293
3385
  const owner = applicationName ?? this.appName;
3294
- const client = await this.pool.connect();
3386
+ const client = await this.#connect();
3295
3387
  try {
3296
3388
  await client.query('BEGIN');
3297
3389
  const resolved = await this.#resolveRowOwner(client, 'application_versions', 'version_name', versionName, owner, 'Application version');
@@ -3397,7 +3489,7 @@ class SystemDatabase {
3397
3489
  application_name = COALESCE("${this.schemaName}".queues.application_name, EXCLUDED.application_name)`
3398
3490
  : `ON CONFLICT (name) DO NOTHING`;
3399
3491
  const owner = record.applicationName ?? this.appName;
3400
- const client = await this.pool.connect();
3492
+ const client = await this.#connect();
3401
3493
  try {
3402
3494
  await client.query('BEGIN');
3403
3495
  const existed = await client.query(`SELECT name FROM "${this.schemaName}".queues WHERE name = $1`, [record.name]);
@@ -3516,7 +3608,7 @@ class SystemDatabase {
3516
3608
  throw new error_1.DBOSError(`batchSize must be a positive integer, got ${batchSize}`);
3517
3609
  }
3518
3610
  // Never a merge: queue, schedule, and version names are globally unique whatever their owner, so this cannot collide.
3519
- const client = await this.pool.connect();
3611
+ const client = await this.#connect();
3520
3612
  let queues, schedules, versions, inFlight;
3521
3613
  try {
3522
3614
  await client.query('BEGIN');
@@ -3550,7 +3642,7 @@ class SystemDatabase {
3550
3642
  return { queues, schedules, versions, workflows: inFlight + terminal, steps };
3551
3643
  }
3552
3644
  // ==================== Internal ====================
3553
- async insertWorkflowStatus(client, initStatus, ownerXid, incrementAttempts = false) {
3645
+ async insertWorkflowStatus(client, initStatus, ownerXid) {
3554
3646
  try {
3555
3647
  const { rows } = await client.query(`INSERT INTO "${this.schemaName}".workflow_status (
3556
3648
  workflow_uuid,
@@ -3566,9 +3658,7 @@ class SystemDatabase {
3566
3658
  executor_id,
3567
3659
  application_version,
3568
3660
  application_id,
3569
- created_at,
3570
3661
  recovery_attempts,
3571
- updated_at,
3572
3662
  workflow_timeout_ms,
3573
3663
  workflow_deadline_epoch_ms,
3574
3664
  inputs,
@@ -3585,21 +3675,16 @@ class SystemDatabase {
3585
3675
  debounce_deadline_epoch_ms,
3586
3676
  is_debounced,
3587
3677
  application_name
3588
- ) 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, $26, $27, $28, $29, $30, $31, $32, $33)
3678
+ ) 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)
3589
3679
  ON CONFLICT (workflow_uuid)
3590
3680
  DO UPDATE SET
3591
- recovery_attempts = CASE
3592
- WHEN workflow_status.status != '${workflow_1.StatusString.ENQUEUED}' AND workflow_status.status != '${workflow_1.StatusString.DELAYED}'
3593
- THEN workflow_status.recovery_attempts + $25
3594
- ELSE workflow_status.recovery_attempts
3595
- END,
3596
- updated_at = EXCLUDED.updated_at,
3681
+ updated_at = (EXTRACT(EPOCH FROM now()) * 1000)::bigint,
3597
3682
  executor_id = CASE
3598
3683
  WHEN EXCLUDED.status != '${workflow_1.StatusString.ENQUEUED}' AND EXCLUDED.status != '${workflow_1.StatusString.DELAYED}'
3599
3684
  THEN EXCLUDED.executor_id
3600
3685
  ELSE workflow_status.executor_id
3601
3686
  END
3602
- RETURNING recovery_attempts, status, name, class_name, config_name, queue_name, workflow_deadline_epoch_ms, executor_id, owner_xid, serialization`, [
3687
+ RETURNING status, name, class_name, config_name, queue_name, workflow_deadline_epoch_ms, executor_id, owner_xid, serialization`, [
3603
3688
  initStatus.workflowUUID,
3604
3689
  initStatus.status,
3605
3690
  initStatus.workflowName,
@@ -3614,9 +3699,7 @@ class SystemDatabase {
3614
3699
  initStatus.executorId,
3615
3700
  initStatus.applicationVersion ?? null,
3616
3701
  initStatus.applicationID,
3617
- initStatus.createdAt,
3618
3702
  initStatus.status === workflow_1.StatusString.ENQUEUED || initStatus.status === workflow_1.StatusString.DELAYED ? 0 : 1,
3619
- initStatus.updatedAt ?? Date.now(),
3620
3703
  initStatus.timeoutMS ?? null,
3621
3704
  initStatus.deadlineEpochMS ?? null,
3622
3705
  initStatus.input ?? null,
@@ -3625,7 +3708,6 @@ class SystemDatabase {
3625
3708
  initStatus.queuePartitionKey ?? null,
3626
3709
  initStatus.forkedFrom ?? null,
3627
3710
  initStatus.parentWorkflowID ?? null,
3628
- (incrementAttempts ?? false) ? 1 : 0,
3629
3711
  initStatus.serialization,
3630
3712
  ownerXid,
3631
3713
  initStatus.delayUntilEpochMS ?? null,
@@ -3814,7 +3896,7 @@ class SystemDatabase {
3814
3896
  const startTimeMs = Date.now();
3815
3897
  // Round once so the deadline stays integral: completed_at_epoch_ms is BIGINT and rejects fractional values.
3816
3898
  const endTimeMs = startTimeMs + Math.ceil(durationMS);
3817
- const client = await this.pool.connect();
3899
+ const client = await this.#connect();
3818
3900
  try {
3819
3901
  const res = await this.#getOperationResultAndThrowIfCancelled(client, workflowID, functionID);
3820
3902
  if (res) {
@@ -3846,7 +3928,7 @@ class SystemDatabase {
3846
3928
  this.notificationsClient = null;
3847
3929
  }
3848
3930
  client.removeAllListeners();
3849
- // Errors can still arrive while release() tears the connection down; a bare emit would crash the process.
3931
+ // Cover the release() call itself, which tears the connection down and can surface a socket error.
3850
3932
  client.on('error', () => { });
3851
3933
  try {
3852
3934
  client.release(true);
@@ -3854,6 +3936,10 @@ class SystemDatabase {
3854
3936
  catch (e) {
3855
3937
  this.logger.warn(`Error releasing notifications client: ${String(e)}`);
3856
3938
  }
3939
+ // release() re-attached pg's idle listener, which would forward this dead client's error on to the
3940
+ // pool. A caller's pool may have no 'error' listener at all, so this client's death has to stay ours.
3941
+ client.removeAllListeners('error');
3942
+ client.on('error', (e) => this.logger.warn(`Error on retired notifications client: ${e}`));
3857
3943
  }
3858
3944
  // Shutdown can begin during any await in the setup below; releasing the client instead of carrying on
3859
3945
  // keeps pool.end() from waiting on a connection that will never be published.
@@ -3877,7 +3963,7 @@ class SystemDatabase {
3877
3963
  };
3878
3964
  let acquired = null;
3879
3965
  try {
3880
- const client = await this.pool.connect();
3966
+ const client = await this.#connect();
3881
3967
  acquired = client;
3882
3968
  if (this.#abandonIfStopped(client))
3883
3969
  return;
@@ -3957,9 +4043,15 @@ exports.SystemDatabase = SystemDatabase;
3957
4043
  __decorate([
3958
4044
  dbRetry(),
3959
4045
  __metadata("design:type", Function),
3960
- __metadata("design:paramtypes", [Object, Object, Object]),
4046
+ __metadata("design:paramtypes", [Object, Object]),
3961
4047
  __metadata("design:returntype", Promise)
3962
- ], SystemDatabase.prototype, "initWorkflowStatus", null);
4048
+ ], SystemDatabase.prototype, "initWorkflowStatusStandalone", null);
4049
+ __decorate([
4050
+ dbRetry(),
4051
+ __metadata("design:type", Function),
4052
+ __metadata("design:paramtypes", [Array, Number]),
4053
+ __metadata("design:returntype", Promise)
4054
+ ], SystemDatabase.prototype, "deadLetterWorkflows", null);
3963
4055
  __decorate([
3964
4056
  dbRetry(),
3965
4057
  __metadata("design:type", Function),
@@ -3978,6 +4070,12 @@ __decorate([
3978
4070
  __metadata("design:paramtypes", [String, String, Number]),
3979
4071
  __metadata("design:returntype", Promise)
3980
4072
  ], SystemDatabase.prototype, "getWorkflowStatus", null);
4073
+ __decorate([
4074
+ dbRetry(),
4075
+ __metadata("design:type", Function),
4076
+ __metadata("design:paramtypes", [Array]),
4077
+ __metadata("design:returntype", Promise)
4078
+ ], SystemDatabase.prototype, "fetchWorkflowStatusChunk", null);
3981
4079
  __decorate([
3982
4080
  dbRetry(),
3983
4081
  __metadata("design:type", Function),
@@ -4049,7 +4147,7 @@ __decorate([
4049
4147
  __metadata("design:type", Function),
4050
4148
  __metadata("design:paramtypes", [String, Object, Object, Object, String]),
4051
4149
  __metadata("design:returntype", Promise)
4052
- ], SystemDatabase.prototype, "sendDirect", null);
4150
+ ], SystemDatabase.prototype, "sendDirectStandalone", null);
4053
4151
  __decorate([
4054
4152
  dbRetry(),
4055
4153
  __metadata("design:type", Function),