Skip to content

Commit 4ad2831

Browse files
committed
Avoid group concurrency active-count rescans
1 parent aa861d5 commit 4ad2831

1 file changed

Lines changed: 59 additions & 35 deletions

File tree

src/plans.ts

Lines changed: 59 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1343,30 +1343,42 @@ export function fetchNextJob (options: FetchJobOptions, noSkipLocked = false): S
13431343
const hasGroupConcurrency = groupConcurrency != null
13441344
const hasMinPriority = minPriority != null
13451345
const hasMaxPriority = maxPriority != null
1346+
const groupConcurrencyConfig = hasGroupConcurrency
1347+
? (typeof groupConcurrency === 'number' ? { default: groupConcurrency } : groupConcurrency)
1348+
: null
13461349
const hasTiers = hasGroupConcurrency &&
1347-
typeof groupConcurrency === 'object' &&
1348-
groupConcurrency.tiers &&
1349-
Object.keys(groupConcurrency.tiers).length > 0
1350+
groupConcurrencyConfig?.tiers &&
1351+
Object.keys(groupConcurrencyConfig.tiers).length > 0
1352+
const hasSingleGroupConcurrency = hasGroupConcurrency && !hasTiers && groupConcurrencyConfig?.default === 1
1353+
const hasActiveGroupCounts = hasGroupConcurrency && !hasSingleGroupConcurrency
13501354

13511355
const params = buildFetchParams(options)
1356+
const groupLimit = hasTiers
1357+
? `COALESCE((${params.tiersParam} ->> group_tier)::int, ${params.defaultGroupLimitParam})`
1358+
: params.defaultGroupLimitParam
1359+
const activeGroupCountExpression = hasActiveGroupCounts
1360+
? 'COALESCE(((SELECT counts FROM active_group_count_map) ->> j.group_id)::int, 0)'
1361+
: ''
13521362

13531363
const selectCols = [
13541364
'j.id',
13551365
singletonFetch ? 'j.singleton_key' : '',
1356-
hasGroupConcurrency ? 'j.group_id, j.group_tier' : ''
1366+
hasGroupConcurrency ? 'j.group_id, j.group_tier' : '',
1367+
hasActiveGroupCounts ? `${activeGroupCountExpression} as active_cnt` : ''
13571368
].filter(Boolean).join(', ')
13581369

1359-
// MATERIALIZED forces Postgres to compute this aggregation once and cache the
1360-
// result. Without it, Postgres 12+ may inline the CTE and re-evaluate the
1361-
// COUNT query at each reference site. active_group_counts is referenced twice:
1362-
// once in the next CTE join (to pre-filter saturated groups before LIMIT) and
1363-
// once in group_ranking (to enforce the per-batch concurrency limit).
1364-
const activeGroupCountsCte = hasGroupConcurrency
1365-
? `active_group_counts AS MATERIALIZED (
1366-
SELECT group_id, COUNT(*)::int as active_cnt
1367-
FROM ${schema}.${table}
1368-
WHERE name = '${name}' AND state = '${JOB_STATES.active}' AND group_id IS NOT NULL
1369-
GROUP BY group_id
1370+
// For limits above 1, aggregate active counts into a single JSONB value. Each
1371+
// candidate uses a keyed lookup through an uncorrelated InitPlan, so the planner
1372+
// cannot turn stale active-group estimates into a per-candidate relation scan.
1373+
const activeGroupCountMapCte = hasActiveGroupCounts
1374+
? `active_group_count_map AS MATERIALIZED (
1375+
SELECT COALESCE(jsonb_object_agg(group_id, active_cnt), '{}'::jsonb) as counts
1376+
FROM (
1377+
SELECT group_id, COUNT(*)::int as active_cnt
1378+
FROM ${schema}.${table}
1379+
WHERE name = '${name}' AND state = '${JOB_STATES.active}' AND group_id IS NOT NULL
1380+
GROUP BY group_id
1381+
) active_groups
13701382
), `
13711383
: ''
13721384

@@ -1375,8 +1387,22 @@ export function fetchNextJob (options: FetchJobOptions, noSkipLocked = false): S
13751387
const lockClause = noSkipLocked ? '' : 'FOR UPDATE OF j SKIP LOCKED'
13761388

13771389
// Column references are qualified with j. throughout so both the base case and
1378-
// the groupConcurrency branch (which joins active_group_counts) share one set of
1379-
// expressions. The join introduces agc.group_id which would otherwise be ambiguous.
1390+
// the groupConcurrency branches share one set of expressions.
1391+
const groupConcurrencyFilter = hasGroupConcurrency
1392+
? hasSingleGroupConcurrency
1393+
? `(j.group_id IS NULL
1394+
OR NOT EXISTS (
1395+
SELECT 1
1396+
FROM ${schema}.${table} active_group_probe
1397+
WHERE active_group_probe.name = '${name}'
1398+
AND active_group_probe.state = '${JOB_STATES.active}'
1399+
AND active_group_probe.group_id IS NOT NULL
1400+
AND active_group_probe.group_id = j.group_id
1401+
))`
1402+
: `(j.group_id IS NULL
1403+
OR ${activeGroupCountExpression} < ${groupLimit})`
1404+
: ''
1405+
13801406
const whereConditions = [
13811407
`j.name = '${name}'`,
13821408
`j.state < '${JOB_STATES.active}'`,
@@ -1391,20 +1417,13 @@ export function fetchNextJob (options: FetchJobOptions, noSkipLocked = false): S
13911417
hasIgnoreGroups ? `(j.group_id IS NULL OR j.group_id <> ALL(${params.ignoreGroupsParam}))` : '',
13921418
hasMinPriority ? `j.priority >= ${params.minPriorityParam}` : '',
13931419
hasMaxPriority ? `j.priority <= ${params.maxPriorityParam}` : '',
1394-
hasGroupConcurrency
1395-
? `(j.group_id IS NULL
1396-
OR agc.active_cnt IS NULL
1397-
OR agc.active_cnt < ${hasTiers
1398-
? `COALESCE((${params.tiersParam} ->> j.group_tier)::int, ${params.defaultGroupLimitParam})`
1399-
: params.defaultGroupLimitParam})`
1400-
: ''
1420+
groupConcurrencyFilter
14011421
].filter(Boolean).join('\n AND ')
14021422

14031423
const nextCte = `
14041424
next AS (
14051425
SELECT ${selectCols}
14061426
FROM ${schema}.${table} j
1407-
${hasGroupConcurrency ? 'LEFT JOIN active_group_counts agc ON j.group_id = agc.group_id' : ''}
14081427
WHERE ${whereConditions}
14091428
ORDER BY ${priority ? 'j.priority desc, ' : ''}${orderByCreatedOn ? 'j.created_on, ' : ''}j.id
14101429
LIMIT ${limit}
@@ -1413,7 +1432,7 @@ export function fetchNextJob (options: FetchJobOptions, noSkipLocked = false): S
14131432

14141433
const singletonCte = singletonFetch
14151434
? `, singleton_ranking AS (
1416-
SELECT id, ${hasGroupConcurrency ? 'group_id, group_tier, ' : ''}
1435+
SELECT id, ${hasGroupConcurrency ? 'group_id, group_tier, ' : ''}${hasActiveGroupCounts ? 'active_cnt, ' : ''}
14171436
row_number() OVER (PARTITION BY singleton_key) as singleton_rn
14181437
FROM next
14191438
)`
@@ -1427,34 +1446,39 @@ export function fetchNextJob (options: FetchJobOptions, noSkipLocked = false): S
14271446
, t.group_tier
14281447
${singletonFetch ? ', singleton_rn' : ''}
14291448
, ROW_NUMBER() OVER (PARTITION BY t.group_id ORDER BY t.id) as group_rn
1430-
, COALESCE(agc.active_cnt, 0) as active_cnt
1449+
, ${hasActiveGroupCounts ? 't.active_cnt' : '0'} as active_cnt
14311450
FROM ${singletonFetch ? 'singleton_ranking' : 'next'} t
1432-
LEFT JOIN active_group_counts agc ON t.group_id = agc.group_id
14331451
${singletonFetch ? 'WHERE singleton_rn = 1' : ''}
14341452
),
14351453
group_filtered AS (
14361454
SELECT id FROM group_ranking
14371455
WHERE group_id IS NULL
1438-
OR (active_cnt + group_rn) <= ${hasTiers
1439-
? `COALESCE((${params.tiersParam} ->> group_tier)::int, ${params.defaultGroupLimitParam})`
1440-
: params.defaultGroupLimitParam}
1456+
OR (active_cnt + group_rn) <= ${groupLimit}
14411457
)`
14421458
: ''
14431459

1444-
const finalCte = (hasGroupConcurrency)
1460+
const finalCte = hasGroupConcurrency
14451461
? 'group_filtered'
14461462
: (singletonFetch)
14471463
? 'singleton_ranking'
14481464
: 'next'
14491465

1466+
// An uncorrelated array InitPlan makes the selected ids a one-time input to the
1467+
// UPDATE. Without it, stale estimates can make Postgres put the inlined ranking
1468+
// query on the inner side of a nested loop and execute it once per job table row.
1469+
const updateSource = hasGroupConcurrency ? '' : `FROM ${finalCte}`
1470+
const updateMatch = hasGroupConcurrency
1471+
? `j.id = ANY (ARRAY(SELECT id FROM ${finalCte}))`
1472+
: `j.id = ${finalCte}.id`
1473+
14501474
// Without SKIP LOCKED, add a state check to prevent duplicate processing
14511475
// when multiple workers try to claim the same jobs concurrently
14521476
const distributedStateCheck = noSkipLocked ? `AND j.state < '${JOB_STATES.active}'` : ''
14531477

14541478
return {
14551479
text: `
14561480
WITH
1457-
${activeGroupCountsCte}
1481+
${activeGroupCountMapCte}
14581482
${nextCte}
14591483
${singletonCte}
14601484
${groupConcurrencyCtes}
@@ -1463,8 +1487,8 @@ export function fetchNextJob (options: FetchJobOptions, noSkipLocked = false): S
14631487
started_on = now(),
14641488
heartbeat_on = now(),
14651489
retry_count = CASE WHEN started_on IS NOT NULL THEN retry_count + 1 ELSE retry_count END
1466-
FROM ${finalCte}
1467-
WHERE name = '${name}' AND j.id = ${finalCte}.id
1490+
${updateSource}
1491+
WHERE name = '${name}' AND ${updateMatch}
14681492
${singletonFetch && !hasGroupConcurrency ? 'AND singleton_rn = 1' : ''}
14691493
${distributedStateCheck}
14701494
RETURNING j.${includeMetadata ? JOB_COLUMNS_ALL : JOB_COLUMNS_MIN}

0 commit comments

Comments
 (0)