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