@dbos-inc/dbos-sdk 4.27.6 → 4.28.4-preview
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/client.d.ts +3 -1
- package/dist/src/client.d.ts.map +1 -1
- package/dist/src/client.js +5 -4
- package/dist/src/client.js.map +1 -1
- package/dist/src/conductor/conductor.d.ts.map +1 -1
- package/dist/src/conductor/conductor.js +1 -1
- package/dist/src/conductor/conductor.js.map +1 -1
- package/dist/src/conductor/protocol.d.ts +1 -0
- package/dist/src/conductor/protocol.d.ts.map +1 -1
- package/dist/src/conductor/protocol.js.map +1 -1
- package/dist/src/config.d.ts.map +1 -1
- package/dist/src/config.js +3 -0
- package/dist/src/config.js.map +1 -1
- package/dist/src/dbos-executor.d.ts +6 -0
- package/dist/src/dbos-executor.d.ts.map +1 -1
- package/dist/src/dbos-executor.js +1 -1
- package/dist/src/dbos-executor.js.map +1 -1
- package/dist/src/error.d.ts +6 -0
- package/dist/src/error.d.ts.map +1 -1
- package/dist/src/error.js +13 -1
- package/dist/src/error.js.map +1 -1
- package/dist/src/system_database.d.ts +18 -2
- package/dist/src/system_database.d.ts.map +1 -1
- package/dist/src/system_database.js +242 -87
- package/dist/src/system_database.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
|
@@ -9,7 +9,7 @@ var __metadata = (this && this.__metadata) || function (k, v) {
|
|
|
9
9
|
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
10
10
|
};
|
|
11
11
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
-
exports.SystemDatabase = exports.verifySystemDatabase = exports.ensureSystemDatabase = exports.grantDbosSchemaPermissions = exports.getDbosSchemaPermissionsSql = exports.DEFAULT_RENAME_BATCH_SIZE = exports.DEFAULT_NOTIFICATION_COALESCE_MS = exports.DBOS_STREAMS_CHANNEL = exports.DBOS_WORKFLOW_EVENTS_CHANNEL = exports.DBOS_NOTIFICATIONS_CHANNEL = exports.isLegacyClosedSentinel = exports.isStreamClosedSentinel = exports.DBOS_STREAM_CLOSED_SENTINEL_SERIALIZED = exports.DBOS_STREAM_CLOSED_SENTINEL = exports.DEFAULT_POOL_SIZE = exports.DBOS_FUNCNAME_READSTREAMOFFSET = exports.DBOS_FUNCNAME_READSTREAM = exports.DBOS_FUNCNAME_CLOSESTREAM = exports.DBOS_FUNCNAME_WRITESTREAM = exports.DBOS_FUNCNAME_GETSTATUS = exports.DBOS_FUNCNAME_SLEEP = exports.DBOS_FUNCNAME_GETEVENT = exports.DBOS_FUNCNAME_SETEVENT = exports.DBOS_FUNCNAME_RECV = exports.DBOS_FUNCNAME_SEND = void 0;
|
|
12
|
+
exports.SystemDatabase = exports.verifySystemDatabase = exports.ensureSystemDatabase = exports.grantDbosSchemaPermissions = exports.getDbosSchemaPermissionsSql = exports.DEFAULT_GC_BATCH_SIZE = exports.DEFAULT_RENAME_BATCH_SIZE = exports.validateObservabilityQueryTimeoutMs = exports.DEFAULT_OBSERVABILITY_QUERY_TIMEOUT_MS = exports.DEFAULT_NOTIFICATION_COALESCE_MS = exports.DBOS_STREAMS_CHANNEL = exports.DBOS_WORKFLOW_EVENTS_CHANNEL = exports.DBOS_NOTIFICATIONS_CHANNEL = exports.isLegacyClosedSentinel = exports.isStreamClosedSentinel = exports.DBOS_STREAM_CLOSED_SENTINEL_SERIALIZED = exports.DBOS_STREAM_CLOSED_SENTINEL = exports.DEFAULT_POOL_SIZE = exports.DBOS_FUNCNAME_READSTREAMOFFSET = exports.DBOS_FUNCNAME_READSTREAM = exports.DBOS_FUNCNAME_CLOSESTREAM = exports.DBOS_FUNCNAME_WRITESTREAM = exports.DBOS_FUNCNAME_GETSTATUS = exports.DBOS_FUNCNAME_SLEEP = exports.DBOS_FUNCNAME_GETEVENT = exports.DBOS_FUNCNAME_SETEVENT = exports.DBOS_FUNCNAME_RECV = exports.DBOS_FUNCNAME_SEND = void 0;
|
|
13
13
|
const dbos_executor_1 = require("./dbos-executor");
|
|
14
14
|
const pg_1 = require("pg");
|
|
15
15
|
const error_1 = require("./error");
|
|
@@ -56,8 +56,21 @@ exports.DBOS_WORKFLOW_EVENTS_CHANNEL = 'dbos_workflow_events_channel';
|
|
|
56
56
|
exports.DBOS_STREAMS_CHANNEL = 'dbos_streams_channel';
|
|
57
57
|
// Interval for coalescing LISTEN/NOTIFY notifications off the write path; caps the rate of notifying commits regardless of write throughput.
|
|
58
58
|
exports.DEFAULT_NOTIFICATION_COALESCE_MS = 10;
|
|
59
|
+
// An introspection query scanning a huge table for minutes holds back xmin, stalling autovacuum database-wide.
|
|
60
|
+
exports.DEFAULT_OBSERVABILITY_QUERY_TIMEOUT_MS = 30_000;
|
|
61
|
+
// PostgreSQL's statement_timeout is an int32 count of milliseconds; anything larger is rejected.
|
|
62
|
+
const MAX_STATEMENT_TIMEOUT_MS = 2_147_483_647;
|
|
63
|
+
/** The timeout is rendered into a `SET LOCAL statement_timeout`, which takes only an in-range integer. */
|
|
64
|
+
function validateObservabilityQueryTimeoutMs(value) {
|
|
65
|
+
if (value !== undefined && (!Number.isFinite(value) || value > MAX_STATEMENT_TIMEOUT_MS)) {
|
|
66
|
+
throw new Error(`observabilityQueryTimeoutMs must be a finite number no greater than ${MAX_STATEMENT_TIMEOUT_MS}, got ${value}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
exports.validateObservabilityQueryTimeoutMs = validateObservabilityQueryTimeoutMs;
|
|
59
70
|
// Workflows re-owned per transaction by a rename. Matches the GC default.
|
|
60
71
|
exports.DEFAULT_RENAME_BATCH_SIZE = 10_000;
|
|
72
|
+
// Workflows deleted per transaction by garbage collection.
|
|
73
|
+
exports.DEFAULT_GC_BATCH_SIZE = 10_000;
|
|
61
74
|
const QUEUE_COLUMN_BY_FIELD = {
|
|
62
75
|
concurrency: 'concurrency',
|
|
63
76
|
workerConcurrency: 'worker_concurrency',
|
|
@@ -323,6 +336,14 @@ const RETRY_SQLSTATE_PREFIXES = new Set([
|
|
|
323
336
|
const RETRY_SQLSTATE_CODES = new Set([
|
|
324
337
|
'40003', // statement_completion_unknown
|
|
325
338
|
]);
|
|
339
|
+
/**
|
|
340
|
+
* Kept out of the sets above: those feed `dbRetry`, which retries forever, and the step-recording
|
|
341
|
+
* path maps 40001 to a workflow conflict. Only bulk maintenance work retries on these.
|
|
342
|
+
*/
|
|
343
|
+
const SERIALIZATION_SQLSTATE_CODES = new Set([
|
|
344
|
+
'40001', // serialization_failure (MVCC conflict)
|
|
345
|
+
'40P01', // deadlock_detected
|
|
346
|
+
]);
|
|
326
347
|
// Node.js transient network error codes (system call level)
|
|
327
348
|
const RETRY_NODE_ERRNOS = new Set([
|
|
328
349
|
'ECONNRESET',
|
|
@@ -412,6 +433,49 @@ function retriablePostgresException(err) {
|
|
|
412
433
|
}
|
|
413
434
|
return false;
|
|
414
435
|
}
|
|
436
|
+
/** 57014 is query_canceled, which is how statement_timeout cancels a query. */
|
|
437
|
+
function isStatementTimeout(err) {
|
|
438
|
+
return !!err && typeof err === 'object' && err.code === '57014';
|
|
439
|
+
}
|
|
440
|
+
function isSerializationError(err) {
|
|
441
|
+
for (const e of unwrapErrors(err)) {
|
|
442
|
+
const anyErr = e;
|
|
443
|
+
if (isPgDatabaseError(anyErr) && !!anyErr.code && SERIALIZATION_SQLSTATE_CODES.has(anyErr.code)) {
|
|
444
|
+
return true;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
return false;
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Re-run a batch that lost a deadlock or serialization race. The database already rolled it
|
|
451
|
+
* back, so replaying it is safe.
|
|
452
|
+
*/
|
|
453
|
+
async function retryOnSerializationError(operation) {
|
|
454
|
+
const maxAttempts = 10;
|
|
455
|
+
const maxBackoff = 2.0;
|
|
456
|
+
let backoff = 0.05;
|
|
457
|
+
for (let attempt = 1;; attempt++) {
|
|
458
|
+
try {
|
|
459
|
+
return await operation();
|
|
460
|
+
}
|
|
461
|
+
catch (e) {
|
|
462
|
+
if (!isSerializationError(e)) {
|
|
463
|
+
throw e;
|
|
464
|
+
}
|
|
465
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
466
|
+
if (attempt === maxAttempts) {
|
|
467
|
+
dbos_executor_1.DBOSExecutor.globalInstance?.logger.warn(`Garbage collection failed after ${maxAttempts} attempts: ${message}`);
|
|
468
|
+
throw e;
|
|
469
|
+
}
|
|
470
|
+
// Jittered backoff, so peers that collided do not collide again
|
|
471
|
+
const actualBackoff = backoff * (0.5 + Math.random());
|
|
472
|
+
dbos_executor_1.DBOSExecutor.globalInstance?.logger.warn(`Contention or deadlock detected in workflow garbage collection: ${message}. ` +
|
|
473
|
+
`Retrying in ${actualBackoff.toFixed(2)}s (attempt ${attempt})`);
|
|
474
|
+
await (0, utils_1.sleepms)(actualBackoff * 1000);
|
|
475
|
+
backoff = Math.min(backoff * 2, maxBackoff);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
415
479
|
/**
|
|
416
480
|
* If a workflow encounters a database connection issue while performing an operation,
|
|
417
481
|
* block the workflow and retry the operation until it reconnects and succeeds.
|
|
@@ -499,6 +563,8 @@ class SystemDatabase {
|
|
|
499
563
|
customPool = false;
|
|
500
564
|
// Interval for coalescing LISTEN/NOTIFY notifications pushed off the write path (Postgres + L/N only).
|
|
501
565
|
notificationCoalesceMs = exports.DEFAULT_NOTIFICATION_COALESCE_MS;
|
|
566
|
+
// Statement timeout for observability reads, in ms; undefined disables the cap.
|
|
567
|
+
observabilityQueryTimeoutMs;
|
|
502
568
|
// Coalesced NOTIFY payloads keyed by channel, flushed by the notifier loop; soft-private so tests can drive a flush.
|
|
503
569
|
pendingNotifications = new Map();
|
|
504
570
|
#notifierActive = false;
|
|
@@ -519,7 +585,7 @@ class SystemDatabase {
|
|
|
519
585
|
#destroyed = false;
|
|
520
586
|
constructor(systemDatabaseUrl, logger, serializer, sysDbPoolSize = exports.DEFAULT_POOL_SIZE, systemDatabasePool, schemaName = 'dbos', useListenNotify = true, pollingConcurrency, notificationCoalesceMs = exports.DEFAULT_NOTIFICATION_COALESCE_MS,
|
|
521
587
|
// The application this handle acts for; undefined writes unclaimed rows.
|
|
522
|
-
appName) {
|
|
588
|
+
appName, observabilityQueryTimeoutMs = exports.DEFAULT_OBSERVABILITY_QUERY_TIMEOUT_MS) {
|
|
523
589
|
this.systemDatabaseUrl = systemDatabaseUrl;
|
|
524
590
|
this.logger = logger;
|
|
525
591
|
this.serializer = serializer;
|
|
@@ -527,6 +593,10 @@ class SystemDatabase {
|
|
|
527
593
|
this.schemaName = schemaName;
|
|
528
594
|
this.shouldUseDBNotifications = useListenNotify;
|
|
529
595
|
this.notificationCoalesceMs = notificationCoalesceMs;
|
|
596
|
+
validateObservabilityQueryTimeoutMs(observabilityQueryTimeoutMs);
|
|
597
|
+
// Floor at 1ms: PostgreSQL reads 0 as "no timeout", the loosest cap rather than the tightest.
|
|
598
|
+
this.observabilityQueryTimeoutMs =
|
|
599
|
+
observabilityQueryTimeoutMs > 0 ? Math.max(1, Math.round(observabilityQueryTimeoutMs)) : undefined;
|
|
530
600
|
if (systemDatabasePool) {
|
|
531
601
|
this.pool = systemDatabasePool;
|
|
532
602
|
this.customPool = true;
|
|
@@ -560,6 +630,40 @@ class SystemDatabase {
|
|
|
560
630
|
#connect() {
|
|
561
631
|
return borrowClient(this.pool, this.#onClientError);
|
|
562
632
|
}
|
|
633
|
+
/**
|
|
634
|
+
* Cap an introspection read with a statement timeout, so one scanning a huge table cannot hold a
|
|
635
|
+
* snapshot for minutes and stall autovacuum database-wide. Soft-private so tests can assert the cap
|
|
636
|
+
* from inside the transaction.
|
|
637
|
+
*
|
|
638
|
+
* `fn` must only run queries: node-postgres leaves the unnamed portal, and the snapshot registered
|
|
639
|
+
* with it, alive until commit, so anything else it awaits extends the very hold this bounds.
|
|
640
|
+
*/
|
|
641
|
+
async observabilityQuery(fn) {
|
|
642
|
+
const timeoutMs = this.observabilityQueryTimeoutMs;
|
|
643
|
+
const client = await this.#connect();
|
|
644
|
+
try {
|
|
645
|
+
if (timeoutMs === undefined) {
|
|
646
|
+
return await fn(client);
|
|
647
|
+
}
|
|
648
|
+
try {
|
|
649
|
+
// Never inherit default_transaction_isolation: a repeatable-read snapshot would outlive its statement.
|
|
650
|
+
await client.query('BEGIN ISOLATION LEVEL READ COMMITTED READ ONLY');
|
|
651
|
+
// SET LOCAL, so the cap dies with its transaction instead of riding the pooled connection into unrelated queries.
|
|
652
|
+
await client.query(`SET LOCAL statement_timeout = ${timeoutMs}`);
|
|
653
|
+
const result = await fn(client);
|
|
654
|
+
await client.query('COMMIT');
|
|
655
|
+
return result;
|
|
656
|
+
}
|
|
657
|
+
catch (e) {
|
|
658
|
+
await client.query('ROLLBACK').catch(() => { });
|
|
659
|
+
// No `cause`: dbRetry walks the cause chain and would retry 57014 forever as an operator intervention.
|
|
660
|
+
throw isStatementTimeout(e) ? new error_1.DBOSQueryTimeoutError(timeoutMs) : e;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
finally {
|
|
664
|
+
client.release();
|
|
665
|
+
}
|
|
666
|
+
}
|
|
563
667
|
getSerializer() {
|
|
564
668
|
return this.serializer;
|
|
565
669
|
}
|
|
@@ -1014,7 +1118,7 @@ class SystemDatabase {
|
|
|
1014
1118
|
params.push(offset);
|
|
1015
1119
|
query += ` OFFSET $${params.length}`;
|
|
1016
1120
|
}
|
|
1017
|
-
const { rows } = await this.
|
|
1121
|
+
const { rows } = await this.observabilityQuery((client) => client.query(query, params));
|
|
1018
1122
|
return rows;
|
|
1019
1123
|
}
|
|
1020
1124
|
async recordOperationResult(workflowID, functionID, functionName, checkConflict, startTimeEpochMs, endTimeEpochMs, options = {}) {
|
|
@@ -2317,65 +2421,47 @@ class SystemDatabase {
|
|
|
2317
2421
|
}
|
|
2318
2422
|
// ==================== Observability: Workflow Communications ====================
|
|
2319
2423
|
async getAllEvents(workflowID) {
|
|
2320
|
-
const
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
for (const row of result.rows) {
|
|
2326
|
-
events[row.key] = await (0, serialization_1.safeParse)(this.serializer, row.value, row.serialization);
|
|
2327
|
-
}
|
|
2328
|
-
return events;
|
|
2329
|
-
}
|
|
2330
|
-
finally {
|
|
2331
|
-
client.release();
|
|
2424
|
+
const { rows } = await this.observabilityQuery((client) => client.query(`SELECT key, value, serialization FROM "${this.schemaName}".workflow_events
|
|
2425
|
+
WHERE workflow_uuid = $1`, [workflowID]));
|
|
2426
|
+
const events = {};
|
|
2427
|
+
for (const row of rows) {
|
|
2428
|
+
events[row.key] = await (0, serialization_1.safeParse)(this.serializer, row.value, row.serialization);
|
|
2332
2429
|
}
|
|
2430
|
+
return events;
|
|
2333
2431
|
}
|
|
2334
2432
|
async getAllNotifications(workflowID) {
|
|
2335
|
-
const
|
|
2336
|
-
try {
|
|
2337
|
-
const result = await client.query(`SELECT topic, message, serialization, created_at_epoch_ms, consumed
|
|
2433
|
+
const { rows } = await this.observabilityQuery((client) => client.query(`SELECT topic, message, serialization, created_at_epoch_ms, consumed
|
|
2338
2434
|
FROM "${this.schemaName}".notifications
|
|
2339
2435
|
WHERE destination_uuid = $1
|
|
2340
|
-
ORDER BY created_at_epoch_ms`, [workflowID]);
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
}
|
|
2348
|
-
finally {
|
|
2349
|
-
client.release();
|
|
2350
|
-
}
|
|
2436
|
+
ORDER BY created_at_epoch_ms`, [workflowID]));
|
|
2437
|
+
return await Promise.all(rows.map(async (row) => ({
|
|
2438
|
+
topic: row.topic === this.nullTopic ? null : row.topic,
|
|
2439
|
+
message: await (0, serialization_1.safeParse)(this.serializer, row.message, row.serialization),
|
|
2440
|
+
createdAtEpochMs: Number(row.created_at_epoch_ms),
|
|
2441
|
+
consumed: row.consumed,
|
|
2442
|
+
})));
|
|
2351
2443
|
}
|
|
2352
2444
|
async getAllStreamEntries(workflowID) {
|
|
2353
|
-
const
|
|
2354
|
-
try {
|
|
2355
|
-
const result = await client.query(`SELECT key, value, serialization FROM "${this.schemaName}".streams
|
|
2445
|
+
const { rows } = await this.observabilityQuery((client) => client.query(`SELECT key, value, serialization FROM "${this.schemaName}".streams
|
|
2356
2446
|
WHERE workflow_uuid = $1
|
|
2357
|
-
ORDER BY key, "offset"`, [workflowID]);
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
}
|
|
2364
|
-
// safeParse yields the raw string for the legacy unserialized marker, which does not parse.
|
|
2365
|
-
const value = await (0, serialization_1.safeParse)(this.serializer, row.value, row.serialization);
|
|
2366
|
-
if (isStreamClosedSentinel(value)) {
|
|
2367
|
-
// End the stream where readStream does, so the two never disagree.
|
|
2368
|
-
closed.add(row.key);
|
|
2369
|
-
streams[row.key] ??= [];
|
|
2370
|
-
continue;
|
|
2371
|
-
}
|
|
2372
|
-
(streams[row.key] ??= []).push(value);
|
|
2447
|
+
ORDER BY key, "offset"`, [workflowID]));
|
|
2448
|
+
const streams = {};
|
|
2449
|
+
const closed = new Set();
|
|
2450
|
+
for (const row of rows) {
|
|
2451
|
+
if (closed.has(row.key)) {
|
|
2452
|
+
continue;
|
|
2373
2453
|
}
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2454
|
+
// safeParse yields the raw string for the legacy unserialized marker, which does not parse.
|
|
2455
|
+
const value = await (0, serialization_1.safeParse)(this.serializer, row.value, row.serialization);
|
|
2456
|
+
if (isStreamClosedSentinel(value)) {
|
|
2457
|
+
// End the stream where readStream does, so the two never disagree.
|
|
2458
|
+
closed.add(row.key);
|
|
2459
|
+
streams[row.key] ??= [];
|
|
2460
|
+
continue;
|
|
2461
|
+
}
|
|
2462
|
+
(streams[row.key] ??= []).push(value);
|
|
2378
2463
|
}
|
|
2464
|
+
return streams;
|
|
2379
2465
|
}
|
|
2380
2466
|
// ==================== Queues ====================
|
|
2381
2467
|
async transitionDelayedWorkflows() {
|
|
@@ -2911,7 +2997,10 @@ class SystemDatabase {
|
|
|
2911
2997
|
${limitClause}
|
|
2912
2998
|
${offsetClause}
|
|
2913
2999
|
`;
|
|
2914
|
-
|
|
3000
|
+
// An ID-keyed read is bounded by its ID list, so it takes no cap: a status lookup must not fail its own caller.
|
|
3001
|
+
const result = idKeyed
|
|
3002
|
+
? await this.pool.query(query, params)
|
|
3003
|
+
: await this.observabilityQuery((client) => client.query(query, params));
|
|
2915
3004
|
return result.rows.map(mapWorkflowStatus);
|
|
2916
3005
|
}
|
|
2917
3006
|
async getWorkflowAggregates(input) {
|
|
@@ -3060,7 +3149,7 @@ class SystemDatabase {
|
|
|
3060
3149
|
${whereClause}
|
|
3061
3150
|
GROUP BY ${groupByClause}
|
|
3062
3151
|
`;
|
|
3063
|
-
const result = await this.
|
|
3152
|
+
const result = await this.observabilityQuery((client) => client.query(query, params));
|
|
3064
3153
|
const toIntOrNull = (v) => (v === null || v === undefined ? null : Number(v));
|
|
3065
3154
|
return result.rows.map((row) => {
|
|
3066
3155
|
const group = {};
|
|
@@ -3172,7 +3261,7 @@ class SystemDatabase {
|
|
|
3172
3261
|
${whereClause}
|
|
3173
3262
|
GROUP BY ${groupByClause}
|
|
3174
3263
|
`;
|
|
3175
|
-
const result = await this.
|
|
3264
|
+
const result = await this.observabilityQuery((client) => client.query(query, params));
|
|
3176
3265
|
const toIntOrNull = (v) => (v === null || v === undefined ? null : Number(v));
|
|
3177
3266
|
return result.rows.map((row) => {
|
|
3178
3267
|
const group = {};
|
|
@@ -3187,8 +3276,62 @@ class SystemDatabase {
|
|
|
3187
3276
|
};
|
|
3188
3277
|
});
|
|
3189
3278
|
}
|
|
3279
|
+
/** Rows garbage collection may delete: terminal, older than the cutoff, and ours. */
|
|
3280
|
+
#gcFilter(cutoffEpochTimestampMs, params) {
|
|
3281
|
+
params.push(cutoffEpochTimestampMs);
|
|
3282
|
+
const cutoffClause = `created_at < $${params.length}`;
|
|
3283
|
+
const statuses = [workflow_1.StatusString.PENDING, workflow_1.StatusString.ENQUEUED, workflow_1.StatusString.DELAYED].map((status) => {
|
|
3284
|
+
params.push(status);
|
|
3285
|
+
return `$${params.length}`;
|
|
3286
|
+
});
|
|
3287
|
+
// Unclaimed rows included: excluding them would leak pre-upgrade rows forever.
|
|
3288
|
+
const scope = this.#appNameFilter('application_name', this.appName, params);
|
|
3289
|
+
return `${cutoffClause} AND status NOT IN (${statuses.join(', ')}) AND ${scope}`;
|
|
3290
|
+
}
|
|
3291
|
+
/**
|
|
3292
|
+
* Delete one batch, returning the watermark to resume from, or undefined once the last one ran.
|
|
3293
|
+
* The delete is its own transaction; it re-checks the filter, so it needs no snapshot shared
|
|
3294
|
+
* with the select that bounds it.
|
|
3295
|
+
*/
|
|
3296
|
+
async #garbageCollectBatch(cutoffEpochTimestampMs, batchSize, watermark) {
|
|
3297
|
+
// Borrowed rather than pool.query'd: that releases with the error, which discards the
|
|
3298
|
+
// connection on a deadlock, so the retry wrapping this would churn the pool per batch.
|
|
3299
|
+
const client = await this.#connect();
|
|
3300
|
+
try {
|
|
3301
|
+
// The batchSize-th oldest eligible row above the watermark bounds this range
|
|
3302
|
+
const stepParams = [];
|
|
3303
|
+
const stepScope = this.#gcFilter(cutoffEpochTimestampMs, stepParams);
|
|
3304
|
+
stepParams.push(watermark);
|
|
3305
|
+
const stepResult = await client.query(`SELECT created_at
|
|
3306
|
+
FROM "${this.schemaName}".workflow_status
|
|
3307
|
+
WHERE ${stepScope} AND created_at > $${stepParams.length}
|
|
3308
|
+
ORDER BY created_at
|
|
3309
|
+
LIMIT 1 OFFSET ${batchSize - 1}`, stepParams);
|
|
3310
|
+
// created_at is a bigint, so node-postgres hands it back as a string.
|
|
3311
|
+
const step = stepResult.rows.length > 0 ? Number(stepResult.rows[0].created_at) : undefined;
|
|
3312
|
+
const deleteParams = [];
|
|
3313
|
+
let deleteScope = this.#gcFilter(cutoffEpochTimestampMs, deleteParams);
|
|
3314
|
+
if (step !== undefined) {
|
|
3315
|
+
// Inclusive upper bound: created_at ties may push a batch over batchSize, but never split across two.
|
|
3316
|
+
deleteParams.push(watermark, step);
|
|
3317
|
+
deleteScope = `${deleteScope} AND created_at > $${deleteParams.length - 1} AND created_at <= $${deleteParams.length}`;
|
|
3318
|
+
}
|
|
3319
|
+
// The final batch drops the watermark, so rows that appeared below it are still deleted.
|
|
3320
|
+
await client.query(`DELETE FROM "${this.schemaName}".workflow_status WHERE ${deleteScope}`, deleteParams);
|
|
3321
|
+
return step;
|
|
3322
|
+
}
|
|
3323
|
+
finally {
|
|
3324
|
+
// No error argument: a genuinely dead connection is still evicted by the pool's own check.
|
|
3325
|
+
client.release();
|
|
3326
|
+
}
|
|
3327
|
+
}
|
|
3190
3328
|
// Conductor sends cleared retention thresholds as JSON null, so both params must be treated as nullish
|
|
3191
|
-
async garbageCollect(cutoffEpochTimestampMs, rowsThreshold) {
|
|
3329
|
+
async garbageCollect(cutoffEpochTimestampMs, rowsThreshold, options = {}) {
|
|
3330
|
+
const batchSize = options.batchSize === null ? undefined : (options.batchSize ?? exports.DEFAULT_GC_BATCH_SIZE);
|
|
3331
|
+
// A NaN survives a bare `< 1` test and would only fail once it reached SQL, leaving GC half-applied.
|
|
3332
|
+
if (batchSize !== undefined && (!Number.isInteger(batchSize) || batchSize < 1)) {
|
|
3333
|
+
throw new error_1.DBOSError(`batchSize must be a positive integer, got ${batchSize}`);
|
|
3334
|
+
}
|
|
3192
3335
|
if (rowsThreshold !== undefined && rowsThreshold !== null) {
|
|
3193
3336
|
// Get the created_at timestamp of the rows_threshold newest row
|
|
3194
3337
|
const params = [rowsThreshold - 1];
|
|
@@ -3211,20 +3354,32 @@ class SystemDatabase {
|
|
|
3211
3354
|
if (cutoffEpochTimestampMs === undefined || cutoffEpochTimestampMs === null) {
|
|
3212
3355
|
return;
|
|
3213
3356
|
}
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3357
|
+
// Narrowed to a constant so the closures below keep it.
|
|
3358
|
+
const cutoff = cutoffEpochTimestampMs;
|
|
3359
|
+
if (batchSize === undefined) {
|
|
3360
|
+
await retryOnSerializationError(async () => {
|
|
3361
|
+
const deleteParams = [];
|
|
3362
|
+
const deleteScope = this.#gcFilter(cutoff, deleteParams);
|
|
3363
|
+
const client = await this.#connect();
|
|
3364
|
+
try {
|
|
3365
|
+
await client.query(`DELETE FROM "${this.schemaName}".workflow_status WHERE ${deleteScope}`, deleteParams);
|
|
3366
|
+
}
|
|
3367
|
+
finally {
|
|
3368
|
+
client.release();
|
|
3369
|
+
}
|
|
3370
|
+
});
|
|
3371
|
+
return;
|
|
3372
|
+
}
|
|
3373
|
+
// Advance a created_at watermark, one committed transaction per batch, so a long
|
|
3374
|
+
// history neither deletes in one transaction nor rescans what it already deleted.
|
|
3375
|
+
let watermark = 0;
|
|
3376
|
+
for (;;) {
|
|
3377
|
+
const next = await retryOnSerializationError(() => this.#garbageCollectBatch(cutoff, batchSize, watermark));
|
|
3378
|
+
// Fewer than a full batch remained, so that delete took the rest.
|
|
3379
|
+
if (next === undefined)
|
|
3380
|
+
return;
|
|
3381
|
+
watermark = next;
|
|
3382
|
+
}
|
|
3228
3383
|
}
|
|
3229
3384
|
/**
|
|
3230
3385
|
* IDs of this application's in-flight workflows created at or before the cutoff.
|
|
@@ -3248,14 +3403,21 @@ class SystemDatabase {
|
|
|
3248
3403
|
async getMetrics(startTime, endTime, applicationName) {
|
|
3249
3404
|
const startEpochMs = new Date(startTime).getTime();
|
|
3250
3405
|
const endEpochMs = new Date(endTime).getTime();
|
|
3251
|
-
const metrics = [];
|
|
3252
|
-
// Query workflow metrics
|
|
3253
3406
|
const workflowParams = [startEpochMs, endEpochMs];
|
|
3254
3407
|
const workflowScope = this.#observabilityFilter('application_name', applicationName, workflowParams);
|
|
3255
|
-
const
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
|
|
3408
|
+
const stepParams = [startEpochMs, endEpochMs];
|
|
3409
|
+
const stepScope = this.#observabilityFilter('application_name', applicationName, stepParams);
|
|
3410
|
+
const [workflowResult, stepResult] = await this.observabilityQuery(async (client) => [
|
|
3411
|
+
await client.query(`SELECT name, COUNT(workflow_uuid) as count
|
|
3412
|
+
FROM "${this.schemaName}".workflow_status
|
|
3413
|
+
WHERE created_at >= $1 AND created_at < $2 AND ${workflowScope}
|
|
3414
|
+
GROUP BY name`, workflowParams),
|
|
3415
|
+
await client.query(`SELECT function_name, COUNT(*) as count
|
|
3416
|
+
FROM "${this.schemaName}".operation_outputs
|
|
3417
|
+
WHERE completed_at_epoch_ms >= $1 AND completed_at_epoch_ms < $2 AND ${stepScope}
|
|
3418
|
+
GROUP BY function_name`, stepParams),
|
|
3419
|
+
]);
|
|
3420
|
+
const metrics = [];
|
|
3259
3421
|
for (const row of workflowResult.rows) {
|
|
3260
3422
|
metrics.push({
|
|
3261
3423
|
metricType: 'workflow_count',
|
|
@@ -3263,13 +3425,6 @@ class SystemDatabase {
|
|
|
3263
3425
|
value: Number(row.count),
|
|
3264
3426
|
});
|
|
3265
3427
|
}
|
|
3266
|
-
// Query step metrics
|
|
3267
|
-
const stepParams = [startEpochMs, endEpochMs];
|
|
3268
|
-
const stepScope = this.#observabilityFilter('application_name', applicationName, stepParams);
|
|
3269
|
-
const stepResult = await this.pool.query(`SELECT function_name, COUNT(*) as count
|
|
3270
|
-
FROM "${this.schemaName}".operation_outputs
|
|
3271
|
-
WHERE completed_at_epoch_ms >= $1 AND completed_at_epoch_ms < $2 AND ${stepScope}
|
|
3272
|
-
GROUP BY function_name`, stepParams);
|
|
3273
3428
|
for (const row of stepResult.rows) {
|
|
3274
3429
|
metrics.push({
|
|
3275
3430
|
metricType: 'step_count',
|
|
@@ -3519,10 +3674,10 @@ class SystemDatabase {
|
|
|
3519
3674
|
async listApplicationVersions() {
|
|
3520
3675
|
const params = [];
|
|
3521
3676
|
const scope = this.#appNameFilter('application_name', this.appName, params);
|
|
3522
|
-
const { rows } = await this.
|
|
3523
|
-
|
|
3524
|
-
|
|
3525
|
-
|
|
3677
|
+
const { rows } = await this.observabilityQuery((client) => client.query(`SELECT version_id, version_name, version_timestamp, created_at, application_name
|
|
3678
|
+
FROM "${this.schemaName}".application_versions
|
|
3679
|
+
WHERE ${scope}
|
|
3680
|
+
ORDER BY version_timestamp DESC`, params));
|
|
3526
3681
|
return rows.map(mapVersionInfo);
|
|
3527
3682
|
}
|
|
3528
3683
|
/**
|