@dbos-inc/dbos-sdk 4.26.10 → 4.27.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/schemas/system_db_schema.d.ts +4 -0
- package/dist/schemas/system_db_schema.d.ts.map +1 -1
- package/dist/schemas/system_db_schema.js.map +1 -1
- package/dist/src/adminserver.d.ts.map +1 -1
- package/dist/src/adminserver.js +6 -0
- package/dist/src/adminserver.js.map +1 -1
- package/dist/src/conductor/conductor.d.ts.map +1 -1
- package/dist/src/conductor/conductor.js +8 -0
- package/dist/src/conductor/conductor.js.map +1 -1
- package/dist/src/conductor/protocol.d.ts +4 -0
- package/dist/src/conductor/protocol.d.ts.map +1 -1
- package/dist/src/conductor/protocol.js.map +1 -1
- package/dist/src/dbos-executor.d.ts +2 -0
- package/dist/src/dbos-executor.d.ts.map +1 -1
- package/dist/src/dbos-executor.js +8 -7
- package/dist/src/dbos-executor.js.map +1 -1
- package/dist/src/dbos.d.ts +7 -1
- package/dist/src/dbos.d.ts.map +1 -1
- package/dist/src/dbos.js +67 -36
- package/dist/src/dbos.js.map +1 -1
- package/dist/src/sysdb_migrations/internal/migrations.js +10 -0
- package/dist/src/sysdb_migrations/internal/migrations.js.map +1 -1
- package/dist/src/system_database.d.ts +14 -6
- package/dist/src/system_database.d.ts.map +1 -1
- package/dist/src/system_database.js +168 -83
- package/dist/src/system_database.js.map +1 -1
- package/dist/src/wfqueue.d.ts +45 -6
- package/dist/src/wfqueue.d.ts.map +1 -1
- package/dist/src/wfqueue.js +336 -52
- package/dist/src/wfqueue.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
|
@@ -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,
|
|
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
|
};
|
|
@@ -1618,7 +1629,8 @@ class SystemDatabase {
|
|
|
1618
1629
|
// Need to await for the workflow and capture errors.
|
|
1619
1630
|
const awaitWorkflowPromise = workflowPromise
|
|
1620
1631
|
.catch((error) => {
|
|
1621
|
-
this
|
|
1632
|
+
const outcome = this.#destroyed ? 'was abandoned by shutdown' : 'failed';
|
|
1633
|
+
this.logger.debug(`Workflow ${workflowID} ${outcome}: ${error}`);
|
|
1622
1634
|
})
|
|
1623
1635
|
.finally(() => {
|
|
1624
1636
|
onSettled();
|
|
@@ -1635,7 +1647,17 @@ class SystemDatabase {
|
|
|
1635
1647
|
clearRunningWorkflow(workflowID) {
|
|
1636
1648
|
this.runningWorkflowMap.delete(workflowID);
|
|
1637
1649
|
}
|
|
1638
|
-
|
|
1650
|
+
/** Workflows this worker is running for a queue, across every partition of it. */
|
|
1651
|
+
countRunningWorkflowsForQueue(queueName) {
|
|
1652
|
+
let count = 0;
|
|
1653
|
+
for (const entry of this.runningWorkflowMap.values()) {
|
|
1654
|
+
if (entry.queueName === queueName)
|
|
1655
|
+
count++;
|
|
1656
|
+
}
|
|
1657
|
+
return count;
|
|
1658
|
+
}
|
|
1659
|
+
/** Workflows this worker is running for one partition of a queue. */
|
|
1660
|
+
countRunningWorkflowsForPartition(queueName, queuePartitionKey) {
|
|
1639
1661
|
let count = 0;
|
|
1640
1662
|
for (const entry of this.runningWorkflowMap.values()) {
|
|
1641
1663
|
if (entry.queueName === queueName && entry.queuePartitionKey === queuePartitionKey)
|
|
@@ -1643,10 +1665,35 @@ class SystemDatabase {
|
|
|
1643
1665
|
}
|
|
1644
1666
|
return count;
|
|
1645
1667
|
}
|
|
1646
|
-
|
|
1668
|
+
/** Wait up to `timeoutMS` for locally-running workflows to finish. Without a timeout, do not wait at all. */
|
|
1669
|
+
async awaitRunningWorkflows(timeoutMS) {
|
|
1670
|
+
if (timeoutMS !== undefined && timeoutMS > 0) {
|
|
1671
|
+
const deadline = Date.now() + timeoutMS;
|
|
1672
|
+
if (this.runningWorkflowMap.size > 0) {
|
|
1673
|
+
this.logger.info('Waiting for pending workflows to finish.');
|
|
1674
|
+
}
|
|
1675
|
+
// Each pass picks up workflows a draining workflow started, and awaits any given run only once.
|
|
1676
|
+
const awaited = new Set();
|
|
1677
|
+
for (;;) {
|
|
1678
|
+
const pending = Array.from(this.runningWorkflowMap.values(), (entry) => entry.promise).filter((promise) => !awaited.has(promise));
|
|
1679
|
+
if (pending.length === 0)
|
|
1680
|
+
break;
|
|
1681
|
+
for (const promise of pending)
|
|
1682
|
+
awaited.add(promise);
|
|
1683
|
+
let timer;
|
|
1684
|
+
const timedOut = await Promise.race([
|
|
1685
|
+
Promise.allSettled(pending).then(() => false),
|
|
1686
|
+
new Promise((resolve) => {
|
|
1687
|
+
timer = setTimeout(() => resolve(true), Math.max(0, deadline - Date.now()));
|
|
1688
|
+
}),
|
|
1689
|
+
]);
|
|
1690
|
+
clearTimeout(timer);
|
|
1691
|
+
if (timedOut)
|
|
1692
|
+
break;
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1647
1695
|
if (this.runningWorkflowMap.size > 0) {
|
|
1648
|
-
this.logger.
|
|
1649
|
-
await Promise.allSettled(Array.from(this.runningWorkflowMap.values(), (entry) => entry.promise));
|
|
1696
|
+
this.logger.warn(`Shutting down while ${this.runningWorkflowMap.size} workflows are still running: ${Array.from(this.runningWorkflowMap.keys()).join(', ')}`);
|
|
1650
1697
|
}
|
|
1651
1698
|
if (this.workflowEventsMap.map.size > 0) {
|
|
1652
1699
|
this.logger.warn('Workflow events map is not empty - shutdown is not clean.');
|
|
@@ -2334,78 +2381,93 @@ class SystemDatabase {
|
|
|
2334
2381
|
SELECT pk FROM partitions WHERE pk IS NOT NULL`, params);
|
|
2335
2382
|
return rows.map((row) => row.pk);
|
|
2336
2383
|
}
|
|
2337
|
-
async findAndMarkStartableWorkflows(queue, executorID, appVersion, queuePartitionKey) {
|
|
2338
|
-
const limiterPeriodMS = queue.rateLimit ? queue.rateLimit.periodSec * 1000 : 0;
|
|
2384
|
+
async findAndMarkStartableWorkflows(queue, executorID, appVersion, queuePartitionKey, localRunningCount = 0, partitionLocalRunningCount = 0) {
|
|
2339
2385
|
const claimedIDs = [];
|
|
2340
|
-
const
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
const
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2386
|
+
const limits = (0, wfqueue_1.resolveQueueLimits)(queue);
|
|
2387
|
+
const partitionParams = queuePartitionKey !== undefined ? [queuePartitionKey] : [];
|
|
2388
|
+
// Shares a concurrency or rate limit budget with other executors.
|
|
2389
|
+
const hasSharedBudget = limits.globalConcurrency !== undefined ||
|
|
2390
|
+
limits.partitionConcurrency !== undefined ||
|
|
2391
|
+
limits.rateLimit !== undefined ||
|
|
2392
|
+
limits.partitionRateLimit !== undefined;
|
|
2393
|
+
// Shares that budget across partitions too, so sweeps of different partitions read disjoint rows and could each spend it.
|
|
2394
|
+
const hasWriteSkew = queuePartitionKey !== undefined && (limits.globalConcurrency !== undefined || limits.rateLimit !== undefined);
|
|
2348
2395
|
const client = await this.#connect();
|
|
2349
2396
|
try {
|
|
2350
|
-
// Default to READ COMMITTED except with
|
|
2351
|
-
if (
|
|
2352
|
-
await client.query(
|
|
2397
|
+
// Default to READ COMMITTED except with a budget shared across executors
|
|
2398
|
+
if (hasSharedBudget) {
|
|
2399
|
+
await client.query(`BEGIN ISOLATION LEVEL ${hasWriteSkew ? 'SERIALIZABLE' : 'REPEATABLE READ'}`);
|
|
2353
2400
|
}
|
|
2354
2401
|
else {
|
|
2355
2402
|
await client.query('BEGIN');
|
|
2356
2403
|
}
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
const params = [
|
|
2361
|
-
queue.name,
|
|
2362
|
-
workflow_1.StatusString.ENQUEUED,
|
|
2363
|
-
workflow_1.StatusString.DELAYED,
|
|
2364
|
-
limiterPeriodMS,
|
|
2365
|
-
...partitionParams,
|
|
2366
|
-
];
|
|
2404
|
+
/** Slots left in a rate limit's rolling window, at the scope that limit applies to. */
|
|
2405
|
+
const rateLimitRemaining = async (rateLimit, partitionScoped) => {
|
|
2406
|
+
const params = [queue.name, workflow_1.StatusString.ENQUEUED, workflow_1.StatusString.DELAYED, rateLimit.periodSec * 1000];
|
|
2367
2407
|
// Count only what this application would dequeue, matching the select below.
|
|
2368
2408
|
const scope = this.#appNameFilter('application_name', this.appName, params);
|
|
2369
|
-
const
|
|
2409
|
+
const partitionFilter = partitionScoped ? `AND queue_partition_key = $${params.push(queuePartitionKey)}` : '';
|
|
2410
|
+
const { rows } = await client.query(`SELECT COUNT(*) FROM "${this.schemaName}".workflow_status
|
|
2370
2411
|
WHERE queue_name = $1
|
|
2371
2412
|
AND rate_limited = TRUE
|
|
2372
2413
|
AND status NOT IN ($2, $3)
|
|
2373
2414
|
-- Database clock on both sides, as the claim stamps started_at_epoch_ms with it.
|
|
2374
2415
|
AND started_at_epoch_ms > (EXTRACT(epoch FROM now()) * 1000)::bigint - $4
|
|
2375
2416
|
AND ${scope}
|
|
2376
|
-
${partitionFilter
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2417
|
+
${partitionFilter}`, params);
|
|
2418
|
+
return rateLimit.limitPerPeriod - Number(rows[0].count);
|
|
2419
|
+
};
|
|
2420
|
+
/**
|
|
2421
|
+
* Workflows already running, which peer workers count against too. Kept as its own query per
|
|
2422
|
+
* scope: the partition-scoped predicate rides idx_workflow_status_partition_dequeue_v2, which
|
|
2423
|
+
* a queue-wide scan loses.
|
|
2424
|
+
*/
|
|
2425
|
+
const pendingCount = async (partitionScoped) => {
|
|
2426
|
+
const params = [queue.name, workflow_1.StatusString.PENDING];
|
|
2427
|
+
const scope = this.#appNameFilter('application_name', this.appName, params);
|
|
2428
|
+
const partitionFilter = partitionScoped ? `AND queue_partition_key = $${params.push(queuePartitionKey)}` : '';
|
|
2429
|
+
const { rows } = await client.query(`SELECT COUNT(*) FROM "${this.schemaName}".workflow_status
|
|
2430
|
+
WHERE queue_name = $1 AND status = $2 AND ${scope} ${partitionFilter}`, params);
|
|
2431
|
+
return Number(rows[0]?.count ?? 0);
|
|
2432
|
+
};
|
|
2433
|
+
// Compute maxTasks, the number of workflows startable under every flow control limit on this queue.
|
|
2386
2434
|
let maxTasks = Infinity;
|
|
2387
|
-
if (
|
|
2435
|
+
if (limits.workerConcurrency !== undefined) {
|
|
2436
|
+
// Use the in-memory registry for this worker's running count — avoids a DB round trip.
|
|
2437
|
+
maxTasks = Math.min(maxTasks, Math.max(0, limits.workerConcurrency - localRunningCount));
|
|
2438
|
+
}
|
|
2439
|
+
if (limits.partitionWorkerConcurrency !== undefined) {
|
|
2440
|
+
maxTasks = Math.min(maxTasks, Math.max(0, limits.partitionWorkerConcurrency - partitionLocalRunningCount));
|
|
2441
|
+
}
|
|
2442
|
+
if (maxTasks <= 0) {
|
|
2443
|
+
await client.query('COMMIT');
|
|
2444
|
+
return claimedIDs;
|
|
2445
|
+
}
|
|
2446
|
+
if (limits.rateLimit !== undefined) {
|
|
2388
2447
|
// Bound the claim by the limiter's remaining slots so a backlogged queue locks only what it can start.
|
|
2389
|
-
maxTasks = Math.
|
|
2448
|
+
maxTasks = Math.min(maxTasks, await rateLimitRemaining(limits.rateLimit, false));
|
|
2390
2449
|
}
|
|
2391
|
-
if (
|
|
2392
|
-
|
|
2393
|
-
|
|
2450
|
+
if (limits.partitionRateLimit !== undefined) {
|
|
2451
|
+
maxTasks = Math.min(maxTasks, await rateLimitRemaining(limits.partitionRateLimit, true));
|
|
2452
|
+
}
|
|
2453
|
+
if (maxTasks <= 0) {
|
|
2454
|
+
await client.query('COMMIT');
|
|
2455
|
+
return claimedIDs;
|
|
2394
2456
|
}
|
|
2395
|
-
if (
|
|
2457
|
+
if (limits.globalConcurrency !== undefined) {
|
|
2396
2458
|
// Global concurrency still requires a DB query since other workers may be running workflows too.
|
|
2397
|
-
const
|
|
2398
|
-
|
|
2399
|
-
|
|
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})`);
|
|
2459
|
+
const totalRunningTasks = await pendingCount(false);
|
|
2460
|
+
if (totalRunningTasks > limits.globalConcurrency) {
|
|
2461
|
+
this.logger.warn(`Total running tasks (${totalRunningTasks}) exceeds the global concurrency limit (${limits.globalConcurrency})`);
|
|
2406
2462
|
}
|
|
2407
|
-
|
|
2408
|
-
|
|
2463
|
+
maxTasks = Math.min(maxTasks, Math.max(0, limits.globalConcurrency - totalRunningTasks));
|
|
2464
|
+
}
|
|
2465
|
+
if (limits.partitionConcurrency !== undefined) {
|
|
2466
|
+
const partitionRunningTasks = await pendingCount(true);
|
|
2467
|
+
if (partitionRunningTasks > limits.partitionConcurrency) {
|
|
2468
|
+
this.logger.warn(`Total running tasks (${partitionRunningTasks}) on partition ${queuePartitionKey} of queue ${queue.name} exceeds the partition concurrency limit (${limits.partitionConcurrency})`);
|
|
2469
|
+
}
|
|
2470
|
+
maxTasks = Math.min(maxTasks, Math.max(0, limits.partitionConcurrency - partitionRunningTasks));
|
|
2409
2471
|
}
|
|
2410
2472
|
// Return immediately if there are no available tasks due to flow control limits
|
|
2411
2473
|
if (maxTasks <= 0) {
|
|
@@ -2420,8 +2482,7 @@ class SystemDatabase {
|
|
|
2420
2482
|
: 'application_version = $3';
|
|
2421
2483
|
// A limit shared across processes needs a consistent view of the table: NOWAIT makes an
|
|
2422
2484
|
// overlapping dequeuer abort rather than claim the next rows and spend the same budget twice.
|
|
2423
|
-
const
|
|
2424
|
-
const lockMode = sharedBudget ? 'FOR UPDATE NOWAIT' : 'FOR UPDATE SKIP LOCKED';
|
|
2485
|
+
const lockMode = hasSharedBudget ? 'FOR UPDATE NOWAIT' : 'FOR UPDATE SKIP LOCKED';
|
|
2425
2486
|
const limitClause = maxTasks !== Infinity ? `LIMIT ${maxTasks}` : '';
|
|
2426
2487
|
const selectParams = [workflow_1.StatusString.ENQUEUED, queue.name, appVersion, ...partitionParams];
|
|
2427
2488
|
const selectScope = this.#appNameFilter('application_name', this.appName, selectParams);
|
|
@@ -2432,7 +2493,7 @@ class SystemDatabase {
|
|
|
2432
2493
|
AND queue_name = $2
|
|
2433
2494
|
AND ${versionClause}
|
|
2434
2495
|
AND ${selectScope}
|
|
2435
|
-
${
|
|
2496
|
+
${queuePartitionKey !== undefined ? 'AND queue_partition_key = $4' : ''}
|
|
2436
2497
|
ORDER BY priority ASC, created_at ASC
|
|
2437
2498
|
${limitClause}
|
|
2438
2499
|
${lockMode}
|
|
@@ -2449,7 +2510,7 @@ class SystemDatabase {
|
|
|
2449
2510
|
workflow_1.StatusString.PENDING,
|
|
2450
2511
|
executorID,
|
|
2451
2512
|
appVersion,
|
|
2452
|
-
|
|
2513
|
+
limits.rateLimit !== undefined || limits.partitionRateLimit !== undefined,
|
|
2453
2514
|
workflowIDs,
|
|
2454
2515
|
workflow_1.StatusString.ENQUEUED,
|
|
2455
2516
|
// Claim an unclaimed row for this application; a nameless dequeuer leaves ownership untouched.
|
|
@@ -2492,14 +2553,18 @@ class SystemDatabase {
|
|
|
2492
2553
|
// Return the IDs of all functions we marked started
|
|
2493
2554
|
return claimedIDs;
|
|
2494
2555
|
}
|
|
2495
|
-
/** Max heads admitted per sweep: bounds dispatch, not the partition walk; lowest keys
|
|
2556
|
+
/** 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
2557
|
partitionedDequeueSweepCap = 8192;
|
|
2497
|
-
/** Dequeue each partition's head-of-line workflow in one transaction, at most {@link partitionedDequeueSweepCap} per sweep; only valid for concurrency
|
|
2498
|
-
async findAndMarkStartablePartitionedWorkflows(queue, executorID, appVersion) {
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2558
|
+
/** 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. */
|
|
2559
|
+
async findAndMarkStartablePartitionedWorkflows(queue, executorID, appVersion, maxTasks = Infinity) {
|
|
2560
|
+
const limits = (0, wfqueue_1.resolveQueueLimits)(queue);
|
|
2561
|
+
if (limits.partitionConcurrency !== 1 ||
|
|
2562
|
+
limits.globalConcurrency !== undefined ||
|
|
2563
|
+
limits.rateLimit !== undefined ||
|
|
2564
|
+
limits.partitionRateLimit !== undefined) {
|
|
2565
|
+
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}`);
|
|
2566
|
+
}
|
|
2567
|
+
// 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
2568
|
const client = await this.#connect();
|
|
2504
2569
|
try {
|
|
2505
2570
|
await client.query('BEGIN');
|
|
@@ -2508,12 +2573,16 @@ class SystemDatabase {
|
|
|
2508
2573
|
const versionClause = (n) => isLatestVersion
|
|
2509
2574
|
? `(application_version = $${n} OR application_version IS NULL)`
|
|
2510
2575
|
: `application_version = $${n}`;
|
|
2576
|
+
// This worker's own budget bounds the sweep alongside the cap.
|
|
2577
|
+
const sweepLimit = Math.min(this.partitionedDequeueSweepCap, maxTasks);
|
|
2578
|
+
// When the worker's budget is the binding constraint, probe partitions in random order to prevent starvation.
|
|
2579
|
+
const sweepOrder = sweepLimit < this.partitionedDequeueSweepCap ? 'random()' : 'partitions.pk ASC';
|
|
2511
2580
|
const candidateParams = [
|
|
2512
2581
|
queue.name,
|
|
2513
2582
|
workflow_1.StatusString.ENQUEUED,
|
|
2514
2583
|
appVersion,
|
|
2515
2584
|
workflow_1.StatusString.PENDING,
|
|
2516
|
-
|
|
2585
|
+
sweepLimit,
|
|
2517
2586
|
];
|
|
2518
2587
|
const candidateScope = this.#appNameFilter('application_name', this.appName, candidateParams);
|
|
2519
2588
|
// 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 +2597,36 @@ class SystemDatabase {
|
|
|
2528
2597
|
FROM partitions
|
|
2529
2598
|
WHERE partitions.pk IS NOT NULL)
|
|
2530
2599
|
)
|
|
2600
|
+
, chosen AS (
|
|
2601
|
+
SELECT partitions.pk
|
|
2602
|
+
FROM partitions
|
|
2603
|
+
WHERE partitions.pk IS NOT NULL
|
|
2604
|
+
-- Unscoped by design: a mutual-exclusion probe must block on any owner's row.
|
|
2605
|
+
AND NOT EXISTS (
|
|
2606
|
+
SELECT 1
|
|
2607
|
+
FROM "${this.schemaName}".workflow_status
|
|
2608
|
+
WHERE queue_name = $1 AND status = $4
|
|
2609
|
+
AND queue_partition_key IS NOT NULL AND queue_partition_key = partitions.pk
|
|
2610
|
+
)
|
|
2611
|
+
ORDER BY ${sweepOrder}
|
|
2612
|
+
LIMIT $5
|
|
2613
|
+
)
|
|
2531
2614
|
SELECT head.workflow_uuid
|
|
2532
|
-
FROM
|
|
2615
|
+
FROM chosen
|
|
2533
2616
|
-- LATERAL plans as a tight nested loop; a correlated scalar subquery runs as a slower per-row SubPlan.
|
|
2534
2617
|
JOIN LATERAL (
|
|
2535
2618
|
SELECT workflow_uuid
|
|
2536
2619
|
FROM "${this.schemaName}".workflow_status
|
|
2537
2620
|
WHERE queue_name = $1 AND status = $2
|
|
2538
|
-
AND queue_partition_key =
|
|
2621
|
+
AND queue_partition_key = chosen.pk
|
|
2539
2622
|
AND ${versionClause(3)}
|
|
2540
2623
|
AND ${candidateScope}
|
|
2541
2624
|
-- 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
2625
|
ORDER BY priority ASC, created_at ASC, workflow_uuid ASC
|
|
2543
2626
|
LIMIT 1
|
|
2544
2627
|
) head ON TRUE
|
|
2545
|
-
|
|
2546
|
-
|
|
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);
|
|
2628
|
+
-- Which partitions were chosen is settled above; order the result so the claim, and the dispatch it feeds, are deterministic.
|
|
2629
|
+
ORDER BY chosen.pk ASC`, candidateParams);
|
|
2555
2630
|
const candidateIDs = candidateResult.rows.map((row) => row.workflow_uuid);
|
|
2556
2631
|
if (candidateIDs.length === 0) {
|
|
2557
2632
|
await client.query('COMMIT');
|
|
@@ -3483,6 +3558,10 @@ class SystemDatabase {
|
|
|
3483
3558
|
rate_limit_period_sec = EXCLUDED.rate_limit_period_sec,
|
|
3484
3559
|
priority_enabled = EXCLUDED.priority_enabled,
|
|
3485
3560
|
partition_queue = EXCLUDED.partition_queue,
|
|
3561
|
+
partition_concurrency = EXCLUDED.partition_concurrency,
|
|
3562
|
+
partition_worker_concurrency = EXCLUDED.partition_worker_concurrency,
|
|
3563
|
+
partition_rate_limit_max = EXCLUDED.partition_rate_limit_max,
|
|
3564
|
+
partition_rate_limit_period_sec = EXCLUDED.partition_rate_limit_period_sec,
|
|
3486
3565
|
polling_interval_sec = EXCLUDED.polling_interval_sec,
|
|
3487
3566
|
updated_at = EXCLUDED.updated_at,
|
|
3488
3567
|
-- Claim only an unclaimed row, so a registration landing between the check above and this write keeps the name it just took.
|
|
@@ -3497,8 +3576,10 @@ class SystemDatabase {
|
|
|
3497
3576
|
const resolvedOwner = await this.#resolveRowOwner(client, 'queues', 'name', record.name, owner, 'Queue');
|
|
3498
3577
|
await client.query(`INSERT INTO "${this.schemaName}".queues
|
|
3499
3578
|
(name, concurrency, worker_concurrency, rate_limit_max, rate_limit_period_sec,
|
|
3500
|
-
priority_enabled, partition_queue,
|
|
3501
|
-
|
|
3579
|
+
priority_enabled, partition_queue, partition_concurrency, partition_worker_concurrency,
|
|
3580
|
+
partition_rate_limit_max, partition_rate_limit_period_sec,
|
|
3581
|
+
polling_interval_sec, updated_at, application_name)
|
|
3582
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
|
3502
3583
|
${onConflict}`, [
|
|
3503
3584
|
record.name,
|
|
3504
3585
|
record.concurrency,
|
|
@@ -3507,6 +3588,10 @@ class SystemDatabase {
|
|
|
3507
3588
|
record.rateLimitPeriodSec,
|
|
3508
3589
|
record.priorityEnabled,
|
|
3509
3590
|
record.partitionQueue,
|
|
3591
|
+
record.partitionConcurrency,
|
|
3592
|
+
record.partitionWorkerConcurrency,
|
|
3593
|
+
record.partitionRateLimitMax,
|
|
3594
|
+
record.partitionRateLimitPeriodSec,
|
|
3510
3595
|
record.pollingIntervalSec,
|
|
3511
3596
|
now,
|
|
3512
3597
|
resolvedOwner ?? null,
|