@@ -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