@dbos-inc/dbos-sdk 4.27.6 → 4.28.3-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.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");
@@ -58,6 +58,8 @@ exports.DBOS_STREAMS_CHANNEL = 'dbos_streams_channel';
58
58
  exports.DEFAULT_NOTIFICATION_COALESCE_MS = 10;
59
59
  // Workflows re-owned per transaction by a rename. Matches the GC default.
60
60
  exports.DEFAULT_RENAME_BATCH_SIZE = 10_000;
61
+ // Workflows deleted per transaction by garbage collection.
62
+ exports.DEFAULT_GC_BATCH_SIZE = 10_000;
61
63
  const QUEUE_COLUMN_BY_FIELD = {
62
64
  concurrency: 'concurrency',
63
65
  workerConcurrency: 'worker_concurrency',
@@ -323,6 +325,14 @@ const RETRY_SQLSTATE_PREFIXES = new Set([
323
325
  const RETRY_SQLSTATE_CODES = new Set([
324
326
  '40003', // statement_completion_unknown
325
327
  ]);
328
+ /**
329
+ * Kept out of the sets above: those feed `dbRetry`, which retries forever, and the step-recording
330
+ * path maps 40001 to a workflow conflict. Only bulk maintenance work retries on these.
331
+ */
332
+ const SERIALIZATION_SQLSTATE_CODES = new Set([
333
+ '40001', // serialization_failure (MVCC conflict)
334
+ '40P01', // deadlock_detected
335
+ ]);
326
336
  // Node.js transient network error codes (system call level)
327
337
  const RETRY_NODE_ERRNOS = new Set([
328
338
  'ECONNRESET',
@@ -412,6 +422,45 @@ function retriablePostgresException(err) {
412
422
  }
413
423
  return false;
414
424
  }
425
+ function isSerializationError(err) {
426
+ for (const e of unwrapErrors(err)) {
427
+ const anyErr = e;
428
+ if (isPgDatabaseError(anyErr) && !!anyErr.code && SERIALIZATION_SQLSTATE_CODES.has(anyErr.code)) {
429
+ return true;
430
+ }
431
+ }
432
+ return false;
433
+ }
434
+ /**
435
+ * Re-run a batch that lost a deadlock or serialization race. The database already rolled it
436
+ * back, so replaying it is safe.
437
+ */
438
+ async function retryOnSerializationError(operation) {
439
+ const maxAttempts = 10;
440
+ const maxBackoff = 2.0;
441
+ let backoff = 0.05;
442
+ for (let attempt = 1;; attempt++) {
443
+ try {
444
+ return await operation();
445
+ }
446
+ catch (e) {
447
+ if (!isSerializationError(e)) {
448
+ throw e;
449
+ }
450
+ const message = e instanceof Error ? e.message : String(e);
451
+ if (attempt === maxAttempts) {
452
+ dbos_executor_1.DBOSExecutor.globalInstance?.logger.warn(`Garbage collection failed after ${maxAttempts} attempts: ${message}`);
453
+ throw e;
454
+ }
455
+ // Jittered backoff, so peers that collided do not collide again
456
+ const actualBackoff = backoff * (0.5 + Math.random());
457
+ dbos_executor_1.DBOSExecutor.globalInstance?.logger.warn(`Contention or deadlock detected in workflow garbage collection: ${message}. ` +
458
+ `Retrying in ${actualBackoff.toFixed(2)}s (attempt ${attempt})`);
459
+ await (0, utils_1.sleepms)(actualBackoff * 1000);
460
+ backoff = Math.min(backoff * 2, maxBackoff);
461
+ }
462
+ }
463
+ }
415
464
  /**
416
465
  * If a workflow encounters a database connection issue while performing an operation,
417
466
  * block the workflow and retry the operation until it reconnects and succeeds.
@@ -3187,8 +3236,62 @@ class SystemDatabase {
3187
3236
  };
3188
3237
  });
3189
3238
  }
3239
+ /** Rows garbage collection may delete: terminal, older than the cutoff, and ours. */
3240
+ #gcFilter(cutoffEpochTimestampMs, params) {
3241
+ params.push(cutoffEpochTimestampMs);
3242
+ const cutoffClause = `created_at < $${params.length}`;
3243
+ const statuses = [workflow_1.StatusString.PENDING, workflow_1.StatusString.ENQUEUED, workflow_1.StatusString.DELAYED].map((status) => {
3244
+ params.push(status);
3245
+ return `$${params.length}`;
3246
+ });
3247
+ // Unclaimed rows included: excluding them would leak pre-upgrade rows forever.
3248
+ const scope = this.#appNameFilter('application_name', this.appName, params);
3249
+ return `${cutoffClause} AND status NOT IN (${statuses.join(', ')}) AND ${scope}`;
3250
+ }
3251
+ /**
3252
+ * Delete one batch, returning the watermark to resume from, or undefined once the last one ran.
3253
+ * The delete is its own transaction; it re-checks the filter, so it needs no snapshot shared
3254
+ * with the select that bounds it.
3255
+ */
3256
+ async #garbageCollectBatch(cutoffEpochTimestampMs, batchSize, watermark) {
3257
+ // Borrowed rather than pool.query'd: that releases with the error, which discards the
3258
+ // connection on a deadlock, so the retry wrapping this would churn the pool per batch.
3259
+ const client = await this.#connect();
3260
+ try {
3261
+ // The batchSize-th oldest eligible row above the watermark bounds this range
3262
+ const stepParams = [];
3263
+ const stepScope = this.#gcFilter(cutoffEpochTimestampMs, stepParams);
3264
+ stepParams.push(watermark);
3265
+ const stepResult = await client.query(`SELECT created_at
3266
+ FROM "${this.schemaName}".workflow_status
3267
+ WHERE ${stepScope} AND created_at > $${stepParams.length}
3268
+ ORDER BY created_at
3269
+ LIMIT 1 OFFSET ${batchSize - 1}`, stepParams);
3270
+ // created_at is a bigint, so node-postgres hands it back as a string.
3271
+ const step = stepResult.rows.length > 0 ? Number(stepResult.rows[0].created_at) : undefined;
3272
+ const deleteParams = [];
3273
+ let deleteScope = this.#gcFilter(cutoffEpochTimestampMs, deleteParams);
3274
+ if (step !== undefined) {
3275
+ // Inclusive upper bound: created_at ties may push a batch over batchSize, but never split across two.
3276
+ deleteParams.push(watermark, step);
3277
+ deleteScope = `${deleteScope} AND created_at > $${deleteParams.length - 1} AND created_at <= $${deleteParams.length}`;
3278
+ }
3279
+ // The final batch drops the watermark, so rows that appeared below it are still deleted.
3280
+ await client.query(`DELETE FROM "${this.schemaName}".workflow_status WHERE ${deleteScope}`, deleteParams);
3281
+ return step;
3282
+ }
3283
+ finally {
3284
+ // No error argument: a genuinely dead connection is still evicted by the pool's own check.
3285
+ client.release();
3286
+ }
3287
+ }
3190
3288
  // Conductor sends cleared retention thresholds as JSON null, so both params must be treated as nullish
3191
- async garbageCollect(cutoffEpochTimestampMs, rowsThreshold) {
3289
+ async garbageCollect(cutoffEpochTimestampMs, rowsThreshold, options = {}) {
3290
+ const batchSize = options.batchSize === null ? undefined : (options.batchSize ?? exports.DEFAULT_GC_BATCH_SIZE);
3291
+ // A NaN survives a bare `< 1` test and would only fail once it reached SQL, leaving GC half-applied.
3292
+ if (batchSize !== undefined && (!Number.isInteger(batchSize) || batchSize < 1)) {
3293
+ throw new error_1.DBOSError(`batchSize must be a positive integer, got ${batchSize}`);
3294
+ }
3192
3295
  if (rowsThreshold !== undefined && rowsThreshold !== null) {
3193
3296
  // Get the created_at timestamp of the rows_threshold newest row
3194
3297
  const params = [rowsThreshold - 1];
@@ -3211,20 +3314,32 @@ class SystemDatabase {
3211
3314
  if (cutoffEpochTimestampMs === undefined || cutoffEpochTimestampMs === null) {
3212
3315
  return;
3213
3316
  }
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;
3317
+ // Narrowed to a constant so the closures below keep it.
3318
+ const cutoff = cutoffEpochTimestampMs;
3319
+ if (batchSize === undefined) {
3320
+ await retryOnSerializationError(async () => {
3321
+ const deleteParams = [];
3322
+ const deleteScope = this.#gcFilter(cutoff, deleteParams);
3323
+ const client = await this.#connect();
3324
+ try {
3325
+ await client.query(`DELETE FROM "${this.schemaName}".workflow_status WHERE ${deleteScope}`, deleteParams);
3326
+ }
3327
+ finally {
3328
+ client.release();
3329
+ }
3330
+ });
3331
+ return;
3332
+ }
3333
+ // Advance a created_at watermark, one committed transaction per batch, so a long
3334
+ // history neither deletes in one transaction nor rescans what it already deleted.
3335
+ let watermark = 0;
3336
+ for (;;) {
3337
+ const next = await retryOnSerializationError(() => this.#garbageCollectBatch(cutoff, batchSize, watermark));
3338
+ // Fewer than a full batch remained, so that delete took the rest.
3339
+ if (next === undefined)
3340
+ return;
3341
+ watermark = next;
3342
+ }
3228
3343
  }
3229
3344
  /**
3230
3345
  * IDs of this application's in-flight workflows created at or before the cutoff.