@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.
@@ -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.pool.query(query, params);
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 client = await this.#connect();
2321
- try {
2322
- const result = await client.query(`SELECT key, value, serialization FROM "${this.schemaName}".workflow_events
2323
- WHERE workflow_uuid = $1`, [workflowID]);
2324
- const events = {};
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 client = await this.#connect();
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
- return await Promise.all(result.rows.map(async (row) => ({
2342
- topic: row.topic === this.nullTopic ? null : row.topic,
2343
- message: await (0, serialization_1.safeParse)(this.serializer, row.message, row.serialization),
2344
- createdAtEpochMs: Number(row.created_at_epoch_ms),
2345
- consumed: row.consumed,
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 client = await this.#connect();
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
- const streams = {};
2359
- const closed = new Set();
2360
- for (const row of result.rows) {
2361
- if (closed.has(row.key)) {
2362
- continue;
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
- return streams;
2375
- }
2376
- finally {
2377
- client.release();
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
- const result = await this.pool.query(query, params);
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.pool.query(query, params);
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.pool.query(query, params);
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
- const deleteParams = [
3215
- cutoffEpochTimestampMs,
3216
- workflow_1.StatusString.PENDING,
3217
- workflow_1.StatusString.ENQUEUED,
3218
- workflow_1.StatusString.DELAYED,
3219
- ];
3220
- // Unclaimed rows included: excluding them would leak pre-upgrade rows forever.
3221
- const deleteScope = this.#appNameFilter('application_name', this.appName, deleteParams);
3222
- // Delete all workflows older than cutoff that are NOT PENDING, ENQUEUED, or DELAYED
3223
- await this.pool.query(`DELETE FROM "${this.schemaName}".workflow_status
3224
- WHERE created_at < $1
3225
- AND status NOT IN ($2, $3, $4)
3226
- AND ${deleteScope}`, deleteParams);
3227
- return;
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 workflowResult = await this.pool.query(`SELECT name, COUNT(workflow_uuid) as count
3256
- FROM "${this.schemaName}".workflow_status
3257
- WHERE created_at >= $1 AND created_at < $2 AND ${workflowScope}
3258
- GROUP BY name`, workflowParams);
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.pool.query(`SELECT version_id, version_name, version_timestamp, created_at, application_name
3523
- FROM "${this.schemaName}".application_versions
3524
- WHERE ${scope}
3525
- ORDER BY version_timestamp DESC`, params);
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
  /**