@dbos-inc/dbos-sdk 4.26.10 → 4.27.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.
@@ -15,6 +15,7 @@ const pg_1 = require("pg");
15
15
  const error_1 = require("./error");
16
16
  const workflow_1 = require("./workflow");
17
17
  const utils_1 = require("./utils");
18
+ const wfqueue_1 = require("./wfqueue");
18
19
  const crypto_1 = require("crypto");
19
20
  const utils_2 = require("./utils");
20
21
  const database_utils_1 = require("./database_utils");
@@ -47,9 +48,15 @@ const QUEUE_COLUMN_BY_FIELD = {
47
48
  rateLimitPeriodSec: 'rate_limit_period_sec',
48
49
  priorityEnabled: 'priority_enabled',
49
50
  partitionQueue: 'partition_queue',
51
+ partitionConcurrency: 'partition_concurrency',
52
+ partitionWorkerConcurrency: 'partition_worker_concurrency',
53
+ partitionRateLimitMax: 'partition_rate_limit_max',
54
+ partitionRateLimitPeriodSec: 'partition_rate_limit_period_sec',
50
55
  pollingIntervalSec: 'polling_interval_sec',
51
56
  };
52
- const QUEUE_COLUMNS = 'name, concurrency, worker_concurrency, rate_limit_max, rate_limit_period_sec, priority_enabled, partition_queue, polling_interval_sec, application_name';
57
+ const QUEUE_COLUMNS = 'name, concurrency, worker_concurrency, rate_limit_max, rate_limit_period_sec, priority_enabled, partition_queue, ' +
58
+ 'partition_concurrency, partition_worker_concurrency, partition_rate_limit_max, partition_rate_limit_period_sec, ' +
59
+ 'polling_interval_sec, application_name';
53
60
  function queueRecordFromRow(row) {
54
61
  return {
55
62
  name: row.name,
@@ -59,6 +66,10 @@ function queueRecordFromRow(row) {
59
66
  rateLimitPeriodSec: row.rate_limit_period_sec,
60
67
  priorityEnabled: row.priority_enabled,
61
68
  partitionQueue: row.partition_queue,
69
+ partitionConcurrency: row.partition_concurrency,
70
+ partitionWorkerConcurrency: row.partition_worker_concurrency,
71
+ partitionRateLimitMax: row.partition_rate_limit_max,
72
+ partitionRateLimitPeriodSec: row.partition_rate_limit_period_sec,
62
73
  pollingIntervalSec: row.polling_interval_sec,
63
74
  applicationName: row.application_name ?? undefined,
64
75
  };
@@ -1635,7 +1646,17 @@ class SystemDatabase {
1635
1646
  clearRunningWorkflow(workflowID) {
1636
1647
  this.runningWorkflowMap.delete(workflowID);
1637
1648
  }
1638
- countRunningWorkflowsForQueue(queueName, queuePartitionKey) {
1649
+ /** Workflows this worker is running for a queue, across every partition of it. */
1650
+ countRunningWorkflowsForQueue(queueName) {
1651
+ let count = 0;
1652
+ for (const entry of this.runningWorkflowMap.values()) {
1653
+ if (entry.queueName === queueName)
1654
+ count++;
1655
+ }
1656
+ return count;
1657
+ }
1658
+ /** Workflows this worker is running for one partition of a queue. */
1659
+ countRunningWorkflowsForPartition(queueName, queuePartitionKey) {
1639
1660
  let count = 0;
1640
1661
  for (const entry of this.runningWorkflowMap.values()) {
1641
1662
  if (entry.queueName === queueName && entry.queuePartitionKey === queuePartitionKey)
@@ -2334,78 +2355,93 @@ class SystemDatabase {
2334
2355
  SELECT pk FROM partitions WHERE pk IS NOT NULL`, params);
2335
2356
  return rows.map((row) => row.pk);
2336
2357
  }
2337
- async findAndMarkStartableWorkflows(queue, executorID, appVersion, queuePartitionKey) {
2338
- const limiterPeriodMS = queue.rateLimit ? queue.rateLimit.periodSec * 1000 : 0;
2358
+ async findAndMarkStartableWorkflows(queue, executorID, appVersion, queuePartitionKey, localRunningCount = 0, partitionLocalRunningCount = 0) {
2339
2359
  const claimedIDs = [];
2340
- const localRunningForQueue = this.countRunningWorkflowsForQueue(queue.name, queuePartitionKey);
2341
- // Build partition key filter
2342
- let partitionFilter = '';
2343
- const partitionParams = [];
2344
- if (queuePartitionKey !== undefined) {
2345
- partitionFilter = `AND queue_partition_key = $PARTITION`;
2346
- partitionParams.push(queuePartitionKey);
2347
- }
2360
+ const limits = (0, wfqueue_1.resolveQueueLimits)(queue);
2361
+ const partitionParams = queuePartitionKey !== undefined ? [queuePartitionKey] : [];
2362
+ // Shares a concurrency or rate limit budget with other executors.
2363
+ const hasSharedBudget = limits.globalConcurrency !== undefined ||
2364
+ limits.partitionConcurrency !== undefined ||
2365
+ limits.rateLimit !== undefined ||
2366
+ limits.partitionRateLimit !== undefined;
2367
+ // Shares that budget across partitions too, so sweeps of different partitions read disjoint rows and could each spend it.
2368
+ const hasWriteSkew = queuePartitionKey !== undefined && (limits.globalConcurrency !== undefined || limits.rateLimit !== undefined);
2348
2369
  const client = await this.#connect();
2349
2370
  try {
2350
- // Default to READ COMMITTED except with global concurrency limits or rate limits
2351
- if (queue.concurrency !== undefined || queue.rateLimit !== undefined) {
2352
- await client.query('BEGIN ISOLATION LEVEL REPEATABLE READ');
2371
+ // Default to READ COMMITTED except with a budget shared across executors
2372
+ if (hasSharedBudget) {
2373
+ await client.query(`BEGIN ISOLATION LEVEL ${hasWriteSkew ? 'SERIALIZABLE' : 'REPEATABLE READ'}`);
2353
2374
  }
2354
2375
  else {
2355
2376
  await client.query('BEGIN');
2356
2377
  }
2357
- // If there is a rate limit, compute how many functions have started in its period.
2358
- let numRecentQueries = 0;
2359
- if (queue.rateLimit) {
2360
- const params = [
2361
- queue.name,
2362
- workflow_1.StatusString.ENQUEUED,
2363
- workflow_1.StatusString.DELAYED,
2364
- limiterPeriodMS,
2365
- ...partitionParams,
2366
- ];
2378
+ /** Slots left in a rate limit's rolling window, at the scope that limit applies to. */
2379
+ const rateLimitRemaining = async (rateLimit, partitionScoped) => {
2380
+ const params = [queue.name, workflow_1.StatusString.ENQUEUED, workflow_1.StatusString.DELAYED, rateLimit.periodSec * 1000];
2367
2381
  // Count only what this application would dequeue, matching the select below.
2368
2382
  const scope = this.#appNameFilter('application_name', this.appName, params);
2369
- const countResult = await client.query(`SELECT COUNT(*) FROM "${this.schemaName}".workflow_status
2383
+ const partitionFilter = partitionScoped ? `AND queue_partition_key = $${params.push(queuePartitionKey)}` : '';
2384
+ const { rows } = await client.query(`SELECT COUNT(*) FROM "${this.schemaName}".workflow_status
2370
2385
  WHERE queue_name = $1
2371
2386
  AND rate_limited = TRUE
2372
2387
  AND status NOT IN ($2, $3)
2373
2388
  -- Database clock on both sides, as the claim stamps started_at_epoch_ms with it.
2374
2389
  AND started_at_epoch_ms > (EXTRACT(epoch FROM now()) * 1000)::bigint - $4
2375
2390
  AND ${scope}
2376
- ${partitionFilter.replace('$PARTITION', '$5')}`, params);
2377
- numRecentQueries = Number(countResult.rows[0].count);
2378
- if (numRecentQueries >= queue.rateLimit.limitPerPeriod) {
2379
- await client.query('COMMIT');
2380
- return claimedIDs;
2381
- }
2382
- }
2383
- // Dequeue functions eligible for this worker and ordered by the time at which they were enqueued.
2384
- // If there is a global or local concurrency limit N, select only the N oldest enqueued
2385
- // functions, else select all of them.
2391
+ ${partitionFilter}`, params);
2392
+ return rateLimit.limitPerPeriod - Number(rows[0].count);
2393
+ };
2394
+ /**
2395
+ * Workflows already running, which peer workers count against too. Kept as its own query per
2396
+ * scope: the partition-scoped predicate rides idx_workflow_status_partition_dequeue_v2, which
2397
+ * a queue-wide scan loses.
2398
+ */
2399
+ const pendingCount = async (partitionScoped) => {
2400
+ const params = [queue.name, workflow_1.StatusString.PENDING];
2401
+ const scope = this.#appNameFilter('application_name', this.appName, params);
2402
+ const partitionFilter = partitionScoped ? `AND queue_partition_key = $${params.push(queuePartitionKey)}` : '';
2403
+ const { rows } = await client.query(`SELECT COUNT(*) FROM "${this.schemaName}".workflow_status
2404
+ WHERE queue_name = $1 AND status = $2 AND ${scope} ${partitionFilter}`, params);
2405
+ return Number(rows[0]?.count ?? 0);
2406
+ };
2407
+ // Compute maxTasks, the number of workflows startable under every flow control limit on this queue.
2386
2408
  let maxTasks = Infinity;
2387
- if (queue.rateLimit) {
2409
+ if (limits.workerConcurrency !== undefined) {
2410
+ // Use the in-memory registry for this worker's running count — avoids a DB round trip.
2411
+ maxTasks = Math.min(maxTasks, Math.max(0, limits.workerConcurrency - localRunningCount));
2412
+ }
2413
+ if (limits.partitionWorkerConcurrency !== undefined) {
2414
+ maxTasks = Math.min(maxTasks, Math.max(0, limits.partitionWorkerConcurrency - partitionLocalRunningCount));
2415
+ }
2416
+ if (maxTasks <= 0) {
2417
+ await client.query('COMMIT');
2418
+ return claimedIDs;
2419
+ }
2420
+ if (limits.rateLimit !== undefined) {
2388
2421
  // Bound the claim by the limiter's remaining slots so a backlogged queue locks only what it can start.
2389
- maxTasks = Math.max(0, queue.rateLimit.limitPerPeriod - numRecentQueries);
2422
+ maxTasks = Math.min(maxTasks, await rateLimitRemaining(limits.rateLimit, false));
2390
2423
  }
2391
- if (queue.workerConcurrency !== undefined) {
2392
- // Use the in-memory registry for this worker's running count — avoids a DB round trip.
2393
- maxTasks = Math.min(maxTasks, Math.max(0, queue.workerConcurrency - localRunningForQueue));
2424
+ if (limits.partitionRateLimit !== undefined) {
2425
+ maxTasks = Math.min(maxTasks, await rateLimitRemaining(limits.partitionRateLimit, true));
2394
2426
  }
2395
- if (queue.concurrency !== undefined) {
2427
+ if (maxTasks <= 0) {
2428
+ await client.query('COMMIT');
2429
+ return claimedIDs;
2430
+ }
2431
+ if (limits.globalConcurrency !== undefined) {
2396
2432
  // Global concurrency still requires a DB query since other workers may be running workflows too.
2397
- const params = [queue.name, workflow_1.StatusString.PENDING, ...partitionParams];
2398
- const scope = this.#appNameFilter('application_name', this.appName, params);
2399
- const runningTasksResult = await client.query(`SELECT COUNT(*) as task_count
2400
- FROM "${this.schemaName}".workflow_status
2401
- WHERE queue_name = $1 AND status = $2 AND ${scope}
2402
- ${partitionFilter.replace('$PARTITION', '$3')}`, params);
2403
- const totalRunningTasks = Number(runningTasksResult.rows[0]?.task_count ?? 0);
2404
- if (totalRunningTasks > queue.concurrency) {
2405
- this.logger.warn(`Total running tasks (${totalRunningTasks}) exceeds the global concurrency limit (${queue.concurrency})`);
2433
+ const totalRunningTasks = await pendingCount(false);
2434
+ if (totalRunningTasks > limits.globalConcurrency) {
2435
+ this.logger.warn(`Total running tasks (${totalRunningTasks}) exceeds the global concurrency limit (${limits.globalConcurrency})`);
2436
+ }
2437
+ maxTasks = Math.min(maxTasks, Math.max(0, limits.globalConcurrency - totalRunningTasks));
2438
+ }
2439
+ if (limits.partitionConcurrency !== undefined) {
2440
+ const partitionRunningTasks = await pendingCount(true);
2441
+ if (partitionRunningTasks > limits.partitionConcurrency) {
2442
+ this.logger.warn(`Total running tasks (${partitionRunningTasks}) on partition ${queuePartitionKey} of queue ${queue.name} exceeds the partition concurrency limit (${limits.partitionConcurrency})`);
2406
2443
  }
2407
- const availableTasks = Math.max(0, queue.concurrency - totalRunningTasks);
2408
- maxTasks = Math.min(maxTasks, availableTasks);
2444
+ maxTasks = Math.min(maxTasks, Math.max(0, limits.partitionConcurrency - partitionRunningTasks));
2409
2445
  }
2410
2446
  // Return immediately if there are no available tasks due to flow control limits
2411
2447
  if (maxTasks <= 0) {
@@ -2420,8 +2456,7 @@ class SystemDatabase {
2420
2456
  : 'application_version = $3';
2421
2457
  // A limit shared across processes needs a consistent view of the table: NOWAIT makes an
2422
2458
  // overlapping dequeuer abort rather than claim the next rows and spend the same budget twice.
2423
- const sharedBudget = queue.concurrency !== undefined || queue.rateLimit !== undefined;
2424
- const lockMode = sharedBudget ? 'FOR UPDATE NOWAIT' : 'FOR UPDATE SKIP LOCKED';
2459
+ const lockMode = hasSharedBudget ? 'FOR UPDATE NOWAIT' : 'FOR UPDATE SKIP LOCKED';
2425
2460
  const limitClause = maxTasks !== Infinity ? `LIMIT ${maxTasks}` : '';
2426
2461
  const selectParams = [workflow_1.StatusString.ENQUEUED, queue.name, appVersion, ...partitionParams];
2427
2462
  const selectScope = this.#appNameFilter('application_name', this.appName, selectParams);
@@ -2432,7 +2467,7 @@ class SystemDatabase {
2432
2467
  AND queue_name = $2
2433
2468
  AND ${versionClause}
2434
2469
  AND ${selectScope}
2435
- ${partitionFilter.replace('$PARTITION', '$4')}
2470
+ ${queuePartitionKey !== undefined ? 'AND queue_partition_key = $4' : ''}
2436
2471
  ORDER BY priority ASC, created_at ASC
2437
2472
  ${limitClause}
2438
2473
  ${lockMode}
@@ -2449,7 +2484,7 @@ class SystemDatabase {
2449
2484
  workflow_1.StatusString.PENDING,
2450
2485
  executorID,
2451
2486
  appVersion,
2452
- queue.rateLimit !== undefined,
2487
+ limits.rateLimit !== undefined || limits.partitionRateLimit !== undefined,
2453
2488
  workflowIDs,
2454
2489
  workflow_1.StatusString.ENQUEUED,
2455
2490
  // Claim an unclaimed row for this application; a nameless dequeuer leaves ownership untouched.
@@ -2492,14 +2527,18 @@ class SystemDatabase {
2492
2527
  // Return the IDs of all functions we marked started
2493
2528
  return claimedIDs;
2494
2529
  }
2495
- /** Max heads admitted per sweep: bounds dispatch, not the partition walk; lowest keys win, so higher keys can wait under sustained load. */
2530
+ /** Max heads admitted per sweep: bounds dispatch, not the partition walk; an unconstrained sweep takes the lowest keys, so higher keys can wait under sustained load. */
2496
2531
  partitionedDequeueSweepCap = 8192;
2497
- /** Dequeue each partition's head-of-line workflow in one transaction, at most {@link partitionedDequeueSweepCap} per sweep; only valid for concurrency=1, no-limiter queues. */
2498
- async findAndMarkStartablePartitionedWorkflows(queue, executorID, appVersion) {
2499
- if (queue.concurrency !== 1 || queue.rateLimit !== undefined) {
2500
- throw new error_1.DBOSError(`Batched partitioned dequeue requires a queue with concurrency 1 and no rate limit: ${queue.name}`);
2501
- }
2502
- // workerConcurrency needs no handling here: dispatch routes 0 (the pause-dequeue idiom) to the fallback path, and validation caps any other value at concurrency=1, which the PENDING gate already enforces globally.
2532
+ /** Dequeue each partition's head-of-line workflow in one transaction, at most {@link partitionedDequeueSweepCap} per sweep; only valid for partition-concurrency-1 queues with no queue-wide limit. */
2533
+ async findAndMarkStartablePartitionedWorkflows(queue, executorID, appVersion, maxTasks = Infinity) {
2534
+ const limits = (0, wfqueue_1.resolveQueueLimits)(queue);
2535
+ if (limits.partitionConcurrency !== 1 ||
2536
+ limits.globalConcurrency !== undefined ||
2537
+ limits.rateLimit !== undefined ||
2538
+ limits.partitionRateLimit !== undefined) {
2539
+ 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}`);
2540
+ }
2541
+ // partitionWorkerConcurrency needs no handling here: any value above 0 is capped at partition concurrency 1, which the PENDING gate already enforces globally, and 0 makes the caller's maxTasks 0.
2503
2542
  const client = await this.#connect();
2504
2543
  try {
2505
2544
  await client.query('BEGIN');
@@ -2508,12 +2547,16 @@ class SystemDatabase {
2508
2547
  const versionClause = (n) => isLatestVersion
2509
2548
  ? `(application_version = $${n} OR application_version IS NULL)`
2510
2549
  : `application_version = $${n}`;
2550
+ // This worker's own budget bounds the sweep alongside the cap.
2551
+ const sweepLimit = Math.min(this.partitionedDequeueSweepCap, maxTasks);
2552
+ // When the worker's budget is the binding constraint, probe partitions in random order to prevent starvation.
2553
+ const sweepOrder = sweepLimit < this.partitionedDequeueSweepCap ? 'random()' : 'partitions.pk ASC';
2511
2554
  const candidateParams = [
2512
2555
  queue.name,
2513
2556
  workflow_1.StatusString.ENQUEUED,
2514
2557
  appVersion,
2515
2558
  workflow_1.StatusString.PENDING,
2516
- this.partitionedDequeueSweepCap,
2559
+ sweepLimit,
2517
2560
  ];
2518
2561
  const candidateScope = this.#appNameFilter('application_name', this.appName, candidateParams);
2519
2562
  // 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.
@@ -2528,30 +2571,36 @@ class SystemDatabase {
2528
2571
  FROM partitions
2529
2572
  WHERE partitions.pk IS NOT NULL)
2530
2573
  )
2574
+ , chosen AS (
2575
+ SELECT partitions.pk
2576
+ FROM partitions
2577
+ WHERE partitions.pk IS NOT NULL
2578
+ -- Unscoped by design: a mutual-exclusion probe must block on any owner's row.
2579
+ AND NOT EXISTS (
2580
+ SELECT 1
2581
+ FROM "${this.schemaName}".workflow_status
2582
+ WHERE queue_name = $1 AND status = $4
2583
+ AND queue_partition_key IS NOT NULL AND queue_partition_key = partitions.pk
2584
+ )
2585
+ ORDER BY ${sweepOrder}
2586
+ LIMIT $5
2587
+ )
2531
2588
  SELECT head.workflow_uuid
2532
- FROM partitions
2589
+ FROM chosen
2533
2590
  -- LATERAL plans as a tight nested loop; a correlated scalar subquery runs as a slower per-row SubPlan.
2534
2591
  JOIN LATERAL (
2535
2592
  SELECT workflow_uuid
2536
2593
  FROM "${this.schemaName}".workflow_status
2537
2594
  WHERE queue_name = $1 AND status = $2
2538
- AND queue_partition_key = partitions.pk
2595
+ AND queue_partition_key = chosen.pk
2539
2596
  AND ${versionClause(3)}
2540
2597
  AND ${candidateScope}
2541
2598
  -- workflow_uuid totalizes the head order (same head for every worker under created_at ties) and the index's trailing workflow_uuid keeps this a pure top-1 probe.
2542
2599
  ORDER BY priority ASC, created_at ASC, workflow_uuid ASC
2543
2600
  LIMIT 1
2544
2601
  ) head ON TRUE
2545
- WHERE partitions.pk IS NOT NULL
2546
- -- Unscoped by design: a mutual-exclusion probe must block on any owner's row.
2547
- AND NOT EXISTS (
2548
- SELECT 1
2549
- FROM "${this.schemaName}".workflow_status
2550
- WHERE queue_name = $1 AND status = $4
2551
- AND queue_partition_key IS NOT NULL AND queue_partition_key = partitions.pk
2552
- )
2553
- ORDER BY partitions.pk ASC
2554
- LIMIT $5`, candidateParams);
2602
+ -- Which partitions were chosen is settled above; order the result so the claim, and the dispatch it feeds, are deterministic.
2603
+ ORDER BY chosen.pk ASC`, candidateParams);
2555
2604
  const candidateIDs = candidateResult.rows.map((row) => row.workflow_uuid);
2556
2605
  if (candidateIDs.length === 0) {
2557
2606
  await client.query('COMMIT');
@@ -3483,6 +3532,10 @@ class SystemDatabase {
3483
3532
  rate_limit_period_sec = EXCLUDED.rate_limit_period_sec,
3484
3533
  priority_enabled = EXCLUDED.priority_enabled,
3485
3534
  partition_queue = EXCLUDED.partition_queue,
3535
+ partition_concurrency = EXCLUDED.partition_concurrency,
3536
+ partition_worker_concurrency = EXCLUDED.partition_worker_concurrency,
3537
+ partition_rate_limit_max = EXCLUDED.partition_rate_limit_max,
3538
+ partition_rate_limit_period_sec = EXCLUDED.partition_rate_limit_period_sec,
3486
3539
  polling_interval_sec = EXCLUDED.polling_interval_sec,
3487
3540
  updated_at = EXCLUDED.updated_at,
3488
3541
  -- Claim only an unclaimed row, so a registration landing between the check above and this write keeps the name it just took.
@@ -3497,8 +3550,10 @@ class SystemDatabase {
3497
3550
  const resolvedOwner = await this.#resolveRowOwner(client, 'queues', 'name', record.name, owner, 'Queue');
3498
3551
  await client.query(`INSERT INTO "${this.schemaName}".queues
3499
3552
  (name, concurrency, worker_concurrency, rate_limit_max, rate_limit_period_sec,
3500
- priority_enabled, partition_queue, polling_interval_sec, updated_at, application_name)
3501
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
3553
+ priority_enabled, partition_queue, partition_concurrency, partition_worker_concurrency,
3554
+ partition_rate_limit_max, partition_rate_limit_period_sec,
3555
+ polling_interval_sec, updated_at, application_name)
3556
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
3502
3557
  ${onConflict}`, [
3503
3558
  record.name,
3504
3559
  record.concurrency,
@@ -3507,6 +3562,10 @@ class SystemDatabase {
3507
3562
  record.rateLimitPeriodSec,
3508
3563
  record.priorityEnabled,
3509
3564
  record.partitionQueue,
3565
+ record.partitionConcurrency,
3566
+ record.partitionWorkerConcurrency,
3567
+ record.partitionRateLimitMax,
3568
+ record.partitionRateLimitPeriodSec,
3510
3569
  record.pollingIntervalSec,
3511
3570
  now,
3512
3571
  resolvedOwner ?? null,