@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.
- package/dist/src/client.d.ts +50 -7
- package/dist/src/client.d.ts.map +1 -1
- package/dist/src/client.js +70 -45
- package/dist/src/client.js.map +1 -1
- package/dist/src/config.d.ts.map +1 -1
- package/dist/src/config.js +1 -0
- package/dist/src/config.js.map +1 -1
- package/dist/src/dbos-executor.d.ts +27 -7
- package/dist/src/dbos-executor.d.ts.map +1 -1
- package/dist/src/dbos-executor.js +85 -39
- package/dist/src/dbos-executor.js.map +1 -1
- package/dist/src/dbos.d.ts +10 -1
- package/dist/src/dbos.d.ts.map +1 -1
- package/dist/src/dbos.js +12 -3
- package/dist/src/dbos.js.map +1 -1
- package/dist/src/enqueue_options.d.ts.map +1 -1
- package/dist/src/enqueue_options.js +0 -1
- package/dist/src/enqueue_options.js.map +1 -1
- package/dist/src/error.d.ts +2 -1
- package/dist/src/error.d.ts.map +1 -1
- package/dist/src/error.js +4 -4
- package/dist/src/error.js.map +1 -1
- package/dist/src/scheduler/scheduler.d.ts.map +1 -1
- package/dist/src/scheduler/scheduler.js +0 -1
- package/dist/src/scheduler/scheduler.js.map +1 -1
- package/dist/src/system_database.d.ts +21 -9
- package/dist/src/system_database.d.ts.map +1 -1
- package/dist/src/system_database.js +294 -196
- package/dist/src/system_database.js.map +1 -1
- package/dist/src/wfqueue.d.ts.map +1 -1
- package/dist/src/wfqueue.js +1 -8
- package/dist/src/wfqueue.js.map +1 -1
- package/dist/src/workflow_management.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -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.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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
120
|
-
await
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
539
|
-
|
|
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
|
-
|
|
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
|
-
|
|
568
|
-
|
|
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
|
-
|
|
573
|
-
//
|
|
574
|
-
|
|
575
|
-
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
849
|
-
queue_name = COALESCE(queue_name, $
|
|
850
|
-
WHERE status = $
|
|
851
|
-
AND executor_id = $
|
|
852
|
-
AND application_version = $
|
|
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
|
|
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
|
|
952
|
+
const client = await this.#connect();
|
|
885
953
|
try {
|
|
886
954
|
await this.updateWorkflowStatus(client, workflowID, status, {
|
|
887
|
-
update: {
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
1027
|
-
WHERE workflow_uuid = $
|
|
1028
|
-
AND status IN ($
|
|
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 =
|
|
1033
|
-
WHERE workflow_uuid = $
|
|
1034
|
-
AND status = $
|
|
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
|
|
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,
|
|
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, $
|
|
1093
|
-
WHERE name = $
|
|
1094
|
-
AND queue_name = $
|
|
1095
|
-
AND status = $
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
2379
|
-
rate_limited = $
|
|
2380
|
-
application_name = COALESCE(application_name, $
|
|
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 = $
|
|
2387
|
-
|
|
2388
|
-
|
|
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
|
|
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 =
|
|
2598
|
+
started_at_epoch_ms = (EXTRACT(epoch FROM now()) * 1000)::bigint,
|
|
2509
2599
|
rate_limited = FALSE,
|
|
2510
|
-
application_name = COALESCE(application_name, $
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
|
4046
|
+
__metadata("design:paramtypes", [Object, Object]),
|
|
3961
4047
|
__metadata("design:returntype", Promise)
|
|
3962
|
-
], SystemDatabase.prototype, "
|
|
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, "
|
|
4150
|
+
], SystemDatabase.prototype, "sendDirectStandalone", null);
|
|
4053
4151
|
__decorate([
|
|
4054
4152
|
dbRetry(),
|
|
4055
4153
|
__metadata("design:type", Function),
|