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