Skip to content

Commit aa861d5

Browse files
committed
Add group concurrency plan regression coverage
1 parent a5a3e71 commit aa861d5

2 files changed

Lines changed: 369 additions & 0 deletions

File tree

test/concurrencyGroupTest.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,71 @@ describe('groupConcurrency', function () {
190190
expect(maxByGroup[freeGroup]).toBeGreaterThanOrEqual(1)
191191
})
192192

193+
it('should preserve tiered batch capacity when groups are already active', async function () {
194+
ctx.boss = await helper.start(ctx.bossConfig)
195+
196+
const schema = ctx.schema
197+
const queueName = ctx.schema
198+
const db = await helper.getDb()
199+
200+
try {
201+
// vip-full is already at its tier limit, so its pending jobs must be skipped.
202+
await db.executeSql(`
203+
INSERT INTO ${schema}.job_common (name, data, state, group_id, group_tier, start_after, created_on, started_on)
204+
SELECT $1, jsonb_build_object('group', 'vip-full'), 'active'::${schema}.job_state, 'vip-full', 'vip', now() - interval '5 minutes', now() - interval '5 minutes', now() - interval '5 minutes'
205+
FROM generate_series(1, 3)
206+
`, [queueName])
207+
208+
// vip has one active job and a tier limit of three, leaving two slots in this fetch batch.
209+
await db.executeSql(`
210+
INSERT INTO ${schema}.job_common (name, data, state, group_id, group_tier, start_after, created_on, started_on)
211+
VALUES ($1, jsonb_build_object('group', 'vip'), 'active'::${schema}.job_state, 'vip', 'vip', now() - interval '5 minutes', now() - interval '5 minutes', now() - interval '5 minutes')
212+
`, [queueName])
213+
214+
await db.executeSql(`
215+
INSERT INTO ${schema}.job_common (name, data, state, group_id, group_tier, start_after, created_on)
216+
SELECT $1, jsonb_build_object('group', 'vip-full'), 'created'::${schema}.job_state, 'vip-full', 'vip', now() - interval '5 minutes', now() - interval '4 minutes' + (g * interval '1 millisecond')
217+
FROM generate_series(1, 3) g
218+
`, [queueName])
219+
220+
await db.executeSql(`
221+
INSERT INTO ${schema}.job_common (name, data, state, group_id, group_tier, start_after, created_on)
222+
SELECT $1, jsonb_build_object('group', 'vip'), 'created'::${schema}.job_state, 'vip', 'vip', now() - interval '5 minutes', now() - interval '3 minutes' + (g * interval '1 millisecond')
223+
FROM generate_series(1, 3) g
224+
`, [queueName])
225+
226+
await db.executeSql(`
227+
INSERT INTO ${schema}.job_common (name, data, state, group_id, start_after, created_on)
228+
SELECT $1, jsonb_build_object('group', 'default'), 'created'::${schema}.job_state, 'default', now() - interval '5 minutes', now() - interval '2 minutes' + (g * interval '1 millisecond')
229+
FROM generate_series(1, 2) g
230+
`, [queueName])
231+
232+
const jobs = await ctx.boss.fetch(queueName, {
233+
batchSize: 10,
234+
includeMetadata: true,
235+
priority: false,
236+
orderByCreatedOn: true,
237+
groupConcurrency: {
238+
default: 1,
239+
tiers: { vip: 3 }
240+
}
241+
})
242+
243+
const groupCounts = jobs.reduce<Record<string, number>>((counts, job) => {
244+
const group = (job.data as { group: string }).group
245+
counts[group] = (counts[group] ?? 0) + 1
246+
return counts
247+
}, {})
248+
249+
expect(jobs).toHaveLength(3)
250+
expect(groupCounts['vip-full'] ?? 0).toBe(0)
251+
expect(groupCounts.vip).toBe(2)
252+
expect(groupCounts.default).toBe(1)
253+
} finally {
254+
await db.close()
255+
}
256+
})
257+
193258
it('should allow jobs without group to bypass group concurrency limits', async function () {
194259
ctx.boss = await helper.start({ ...ctx.bossConfig, __test__enableSpies: true })
195260

Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
import { describe, expect, it } from 'vitest'
2+
import * as helper from './testHelper.ts'
3+
import { ctx } from './hooks.ts'
4+
import * as plans from '../src/plans.ts'
5+
6+
const describeRepro = describe.skipIf(
7+
helper.isCockroachDb ||
8+
helper.isYugabyteDb ||
9+
helper.isCitus ||
10+
helper.isPglite ||
11+
helper.isDistributed
12+
)
13+
14+
interface PlanNode {
15+
'Node Type': string
16+
'CTE Name'?: string
17+
'Actual Rows'?: number
18+
'Actual Loops'?: number
19+
'Index Name'?: string
20+
'Plan Rows'?: number
21+
'Relation Name'?: string
22+
'Subplan Name'?: string
23+
Plans?: PlanNode[]
24+
}
25+
26+
interface ExplainedPlan {
27+
plan: PlanNode
28+
nodes: PlanNode[]
29+
}
30+
31+
const saturatedGroup = 'group-saturated'
32+
const activeGroupCount = 115
33+
const saturatedPendingCount = 12_000
34+
const minimumAvailablePendingCount = 36
35+
36+
function collectPlanNodes (node: PlanNode, nodes: PlanNode[] = []): PlanNode[] {
37+
nodes.push(node)
38+
39+
for (const child of node.Plans ?? []) {
40+
collectPlanNodes(child, nodes)
41+
}
42+
43+
return nodes
44+
}
45+
46+
function extractPlan (rows: any[]): PlanNode {
47+
const rawPlan = rows[0]['QUERY PLAN']
48+
const parsed = typeof rawPlan === 'string' ? JSON.parse(rawPlan) : rawPlan
49+
return parsed[0].Plan
50+
}
51+
52+
function planSummary (nodes: PlanNode[]): string {
53+
return nodes
54+
.filter(node =>
55+
node['Node Type'] === 'Aggregate' ||
56+
node['Node Type'] === 'CTE Scan' ||
57+
node['Node Type'] === 'Hash Join' ||
58+
node['Node Type'] === 'Index Only Scan' ||
59+
node['Node Type'] === 'Index Scan' ||
60+
node['Node Type'] === 'Nested Loop' ||
61+
node['Node Type'] === 'Subquery Scan' ||
62+
node['Node Type'] === 'WindowAgg'
63+
)
64+
.map(node => {
65+
const type = node['Node Type']
66+
const planRows = node['Plan Rows'] ?? '?'
67+
const actualRows = node['Actual Rows'] ?? '?'
68+
const actualLoops = node['Actual Loops'] ?? '?'
69+
const subplan = node['Subplan Name'] ? `, subplan=${node['Subplan Name']}` : ''
70+
const cte = node['CTE Name'] ? `, cte=${node['CTE Name']}` : ''
71+
const index = node['Index Name'] ? `, index=${node['Index Name']}` : ''
72+
return `${type}${subplan}${cte}${index}: planRows=${planRows}, actualRows=${actualRows}, actualLoops=${actualLoops}`
73+
})
74+
.join('\n')
75+
}
76+
77+
function findRepeatedActiveGroupScan (nodes: PlanNode[]): PlanNode | undefined {
78+
return nodes.find(node =>
79+
node['Node Type'] === 'CTE Scan' &&
80+
node['CTE Name']?.startsWith('active_group_') === true &&
81+
(node['Actual Loops'] ?? 0) > 1
82+
)
83+
}
84+
85+
function expectNoRepeatedActiveGroupScan (nodes: PlanNode[]): void {
86+
expect(findRepeatedActiveGroupScan(nodes), planSummary(nodes)).toBeUndefined()
87+
}
88+
89+
function findRepeatedGroupRanking (nodes: PlanNode[]): PlanNode | undefined {
90+
return nodes.find(node =>
91+
node['Node Type'] === 'WindowAgg' &&
92+
(node['Actual Loops'] ?? 0) > 1
93+
)
94+
}
95+
96+
function expectNoRepeatedGroupRanking (nodes: PlanNode[]): void {
97+
expect(findRepeatedGroupRanking(nodes), planSummary(nodes)).toBeUndefined()
98+
}
99+
100+
function findRepeatedJobTableScan (nodes: PlanNode[]): PlanNode | undefined {
101+
return nodes.find(node =>
102+
node['Relation Name'] === 'job_common' &&
103+
(
104+
node['Node Type'] === 'Index Only Scan' ||
105+
node['Node Type'] === 'Index Scan' ||
106+
node['Node Type'] === 'Seq Scan'
107+
) &&
108+
(node['Actual Loops'] ?? 0) >= saturatedPendingCount
109+
)
110+
}
111+
112+
function expectNoRepeatedJobTableScan (nodes: PlanNode[]): void {
113+
expect(findRepeatedJobTableScan(nodes), planSummary(nodes)).toBeUndefined()
114+
}
115+
116+
function expectClaimedJobs (plan: PlanNode, expected: number): void {
117+
expect(plan['Actual Rows']).toBe(expected)
118+
}
119+
120+
async function explainGroupConcurrencyFetchPlan ({
121+
refreshStatisticsAfterFixture,
122+
groupConcurrency,
123+
batchSize = 1
124+
}: {
125+
refreshStatisticsAfterFixture: boolean
126+
groupConcurrency: number
127+
batchSize?: number
128+
}): Promise<ExplainedPlan> {
129+
// The shared hooks create a schema unique to each test and drop it after a passing run.
130+
// This repro owns cleanup explicitly because one case is expected to fail until the query is fixed.
131+
ctx.boss = await helper.start(ctx.bossConfig)
132+
133+
const schema = ctx.schema
134+
const queueName = ctx.schema
135+
const db = await helper.getDb()
136+
137+
try {
138+
// Seed one active saturated-group slot and analyze immediately. In the stale-statistics case
139+
// this makes Postgres believe the active-group aggregate contains a single group with one row.
140+
await db.executeSql(`
141+
INSERT INTO ${schema}.job_common (name, data, state, group_id, start_after, created_on, started_on)
142+
VALUES ($1, '{}'::jsonb, 'active'::${schema}.job_state, $2, now() - interval '5 minutes', now() - interval '5 minutes', now() - interval '5 minutes')
143+
`, [queueName, saturatedGroup])
144+
145+
await db.executeSql(`ANALYZE ${schema}.job_common`)
146+
147+
// Fill the rest of the saturated group's active slots after ANALYZE. For groupConcurrency: 1
148+
// this inserts no rows; for higher limits it keeps the group actually saturated while leaving
149+
// planner stats stale.
150+
await db.executeSql(`
151+
INSERT INTO ${schema}.job_common (name, data, state, group_id, start_after, created_on, started_on)
152+
SELECT $1, '{}'::jsonb, 'active'::${schema}.job_state, $2, now() - interval '5 minutes', now() - interval '5 minutes', now() - interval '5 minutes'
153+
FROM generate_series(1, $3::int)
154+
`, [queueName, saturatedGroup, groupConcurrency - 1])
155+
156+
// Add the real active-group cardinality after the first ANALYZE. Without a later ANALYZE,
157+
// the fetch CTE actually returns about 100 active groups while planner stats can still
158+
// estimate it near one row.
159+
await db.executeSql(`
160+
INSERT INTO ${schema}.job_common (name, data, state, group_id, start_after, created_on, started_on)
161+
SELECT $1, '{}'::jsonb, 'active'::${schema}.job_state, 'active-group-' || g::text, now() - interval '5 minutes', now() - interval '5 minutes', now() - interval '5 minutes'
162+
FROM generate_series(1, $2::int) g
163+
`, [queueName, activeGroupCount - 1])
164+
165+
// Fill the front of the queue with one saturated group. Since every groupConcurrency slot is
166+
// already active, these rows are runnable by state but ineligible by group.
167+
await db.executeSql(`
168+
INSERT INTO ${schema}.job_common (name, data, state, group_id, start_after, created_on)
169+
SELECT $1, '{}'::jsonb, 'created'::${schema}.job_state, $2, now() - interval '5 minutes', now() - interval '4 minutes' + (g * interval '1 millisecond')
170+
FROM generate_series(1, $3::int) g
171+
`, [queueName, saturatedGroup, saturatedPendingCount])
172+
173+
// Add enough eligible groups behind the saturated backlog to fill the requested batch. This
174+
// makes the large-batch case exercise both the hot-path filter and the post-LIMIT ranking work.
175+
const availablePendingCount = Math.max(minimumAvailablePendingCount, batchSize)
176+
await db.executeSql(`
177+
INSERT INTO ${schema}.job_common (name, data, state, group_id, start_after, created_on)
178+
SELECT $1, '{}'::jsonb, 'created'::${schema}.job_state, 'available-group-' || g::text, now() - interval '5 minutes', now() - interval '3 minutes' + (g * interval '1 millisecond')
179+
FROM generate_series(1, $2::int) g
180+
`, [queueName, availablePendingCount])
181+
182+
if (refreshStatisticsAfterFixture) {
183+
// The passing control case refreshes stats after all rows exist. The stale-stats repro
184+
// intentionally skips this to model the planner blind spot from production.
185+
await db.executeSql(`ANALYZE ${schema}.job_common`)
186+
}
187+
188+
// Use pg-boss's actual groupConcurrency fetch query. Current pg-boss fails the stale-stats
189+
// check because Postgres can choose a CTE-rescan plan; the fix should make both cases pass.
190+
const query = plans.fetchNextJob({
191+
schema,
192+
table: 'job_common',
193+
name: queueName,
194+
policy: 'standard',
195+
limit: batchSize,
196+
priority: false,
197+
orderByCreatedOn: true,
198+
ignoreSingletons: null,
199+
groupConcurrency
200+
})
201+
202+
const explain = await db.executeSql(`EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) ${query.text}`, query.values)
203+
const plan = extractPlan(explain.rows)
204+
return { plan, nodes: collectPlanNodes(plan) }
205+
} finally {
206+
await db.close()
207+
208+
if (ctx.boss) {
209+
await ctx.boss.stop({ timeout: 2000 })
210+
ctx.boss = undefined
211+
}
212+
213+
await helper.dropSchema(schema)
214+
}
215+
}
216+
217+
describeRepro('groupConcurrency fetch plan repro', function () {
218+
it('does not rescan active_group_counts when table statistics are current', { timeout: 120000 }, async function () {
219+
const { plan, nodes } = await explainGroupConcurrencyFetchPlan({
220+
refreshStatisticsAfterFixture: true,
221+
groupConcurrency: 1
222+
})
223+
224+
expectClaimedJobs(plan, 1)
225+
expectNoRepeatedActiveGroupScan(nodes)
226+
expectNoRepeatedGroupRanking(nodes)
227+
})
228+
229+
it('does not rescan active_group_counts once per saturated pending job with stale statistics', { timeout: 120000 }, async function () {
230+
const { plan, nodes } = await explainGroupConcurrencyFetchPlan({
231+
refreshStatisticsAfterFixture: false,
232+
groupConcurrency: 1
233+
})
234+
235+
// The pathological plan estimates active_group_counts as one row, then nested-loop scans that
236+
// CTE once per saturated pending row. A robust query shape should avoid this even when stats
237+
// are stale.
238+
expectClaimedJobs(plan, 1)
239+
expectNoRepeatedActiveGroupScan(nodes)
240+
expectNoRepeatedGroupRanking(nodes)
241+
})
242+
243+
it('does not rescan active_group_counts when table statistics are current and groupConcurrency is greater than 1', { timeout: 120000 }, async function () {
244+
const { plan, nodes } = await explainGroupConcurrencyFetchPlan({
245+
refreshStatisticsAfterFixture: true,
246+
groupConcurrency: 2
247+
})
248+
249+
expectClaimedJobs(plan, 1)
250+
expectNoRepeatedActiveGroupScan(nodes)
251+
expectNoRepeatedGroupRanking(nodes)
252+
expectNoRepeatedJobTableScan(nodes)
253+
})
254+
255+
it('does not rescan active_group_counts once per saturated pending job with stale statistics and groupConcurrency is greater than 1', { timeout: 120000 }, async function () {
256+
const { plan, nodes } = await explainGroupConcurrencyFetchPlan({
257+
refreshStatisticsAfterFixture: false,
258+
groupConcurrency: 2
259+
})
260+
261+
// The pathological plan estimates active_group_counts as one row, then nested-loop scans that
262+
// CTE once per saturated pending row. A robust query shape should avoid this even when stats
263+
// are stale.
264+
expectClaimedJobs(plan, 1)
265+
expectNoRepeatedActiveGroupScan(nodes)
266+
expectNoRepeatedGroupRanking(nodes)
267+
expectNoRepeatedJobTableScan(nodes)
268+
})
269+
270+
it('does not rescan active counts or group ranking for a large batch with stale statistics', { timeout: 120000 }, async function () {
271+
const batchSize = 100
272+
const { plan, nodes } = await explainGroupConcurrencyFetchPlan({
273+
refreshStatisticsAfterFixture: false,
274+
groupConcurrency: 2,
275+
batchSize
276+
})
277+
278+
expectClaimedJobs(plan, batchSize)
279+
expectNoRepeatedActiveGroupScan(nodes)
280+
expectNoRepeatedGroupRanking(nodes)
281+
expectNoRepeatedJobTableScan(nodes)
282+
})
283+
284+
it('keeps the tiered fetch query shape independent of configured tier count', function () {
285+
const buildQuery = (tiers: Record<string, number>) => plans.fetchNextJob({
286+
schema: ctx.schema,
287+
table: 'job_common',
288+
name: ctx.schema,
289+
policy: 'standard',
290+
limit: 1,
291+
priority: false,
292+
orderByCreatedOn: true,
293+
ignoreSingletons: null,
294+
groupConcurrency: { default: 1, tiers }
295+
})
296+
297+
const oneTier = buildQuery({ tier0: 2 })
298+
const manyTiers = buildQuery(Object.fromEntries(
299+
Array.from({ length: 100 }, (_, index) => [`tier${index}`, 2])
300+
))
301+
302+
expect(manyTiers.text).toBe(oneTier.text)
303+
})
304+
})

0 commit comments

Comments
 (0)