Skip to content

Commit c490543

Browse files
committed
test fixes
1 parent 7fc2485 commit c490543

2 files changed

Lines changed: 119 additions & 109 deletions

File tree

test/monitorBackoffTest.ts

Lines changed: 113 additions & 108 deletions
Original file line numberDiff line numberDiff line change
@@ -181,143 +181,148 @@ helper.describePostgresOnly('monitor backoff', function () {
181181
expect(new Date(forced.capturedOn).getTime()).toBe(new Date(cached.capturedOn).getTime())
182182
})
183183

184-
it('measures the pin on the server, not the latency of the call', async function () {
185-
const boss = await startBoss()
184+
it('does not run when vacuum monitoring is turned off', async function () {
185+
const boss = await startBoss({ __test__monitor_stats_seconds: 90, monitorVacuum: false })
186186

187-
await boss.send('backoff', {})
187+
await boss.supervise()
188188

189-
// One connection, and something else already holding it. This is the shape a busy app hands the
190-
// supervisor: the same pool serves every fetch and complete, so the wait for a connection lands
191-
// inside any stopwatch the client wraps around the call — while pinning nothing, because the
192-
// transaction has not begun yet. Deferring on that number would blame the job table for pool
193-
// contention, and deferring monitoring would do nothing to relieve it.
194-
const pool = new pg.Pool({ connectionString: helper.getConnectionString(), max: 1 })
189+
expect(await backoffUntil(ctx.schema)).toBeNull()
190+
expect(await warnings(ctx.schema)).toHaveLength(0)
191+
})
195192

196-
try {
197-
const hog = pool.query('SELECT pg_sleep(2)')
193+
it('stamps the capture time when the counts land, not when the pass claimed them', async function () {
194+
const boss = await startBoss({ __test__delay_maint_ms: 0 })
198195

199-
await new Promise(resolve => setTimeout(resolve, 50))
196+
await boss.send('backoff', {})
200197

201-
const started = Date.now()
202-
const result = await pool.query(plans.cacheQueueStats(ctx.schema, 'job', ['backoff']))
203-
const callSeconds = (Date.now() - started) / 1000
198+
const before = new Date()
199+
await boss.supervise()
204200

205-
const rows = (Array.isArray(result) ? result : [result]).flatMap(r => r?.rows ?? [])
206-
const pinSeconds = rows.reduce((max, row) => Math.max(max, Number(row.pinSeconds) || 0), 0)
201+
const stamped = await monitorOn(ctx.schema, 'backoff')
207202

208-
expect(callSeconds).toBeGreaterThan(1.5)
209-
expect(pinSeconds).toBeLessThan(0.5)
203+
helper.assertTruthy(stamped)
210204

211-
await hog
212-
} finally {
213-
await pool.end()
214-
}
205+
// cacheQueueStats re-stamps monitor_on in the same statement that writes the counts. Without
206+
// that the interval is measured start-to-start, and stops delaying anything at all the moment
207+
// one aggregate outruns it.
208+
expect(new Date(stamped).getTime()).toBeGreaterThanOrEqual(before.getTime())
215209
})
216210

217-
// The same key cacheQueueStats/refreshQueueStats derive, computed in SQL so the test cannot drift
218-
// from the implementation's hashing.
219-
const LOCK_KEY = (schema: string) =>
220-
`('x' || encode(sha224((current_database() || '.pgboss.${schema}queue-stats')::bytea), 'hex'))::bit(64)::bigint`
221-
222-
async function holdStatsLock (schema: string) {
223-
const client = new pg.Client(helper.getConnectionString())
224-
await client.connect()
225-
await client.query('BEGIN')
226-
await client.query(`SELECT pg_advisory_xact_lock(${LOCK_KEY(schema)})`)
227-
228-
return async () => {
229-
await client.query('ROLLBACK')
230-
await client.end()
211+
// These three need a second, independent connection - one to hold the queue-stats lock or the
212+
// pool while another instance tries to proceed. PGlite has no server and CockroachDB has no
213+
// advisory locks, so the scenario cannot be staged on either.
214+
helper.describeMultiConnectionOnly('under contention', function () {
215+
it('measures the pin on the server, not the latency of the call', async function () {
216+
const boss = await startBoss()
217+
218+
await boss.send('backoff', {})
219+
220+
// One connection, and something else already holding it. This is the shape a busy app hands the
221+
// supervisor: the same pool serves every fetch and complete, so the wait for a connection lands
222+
// inside any stopwatch the client wraps around the call — while pinning nothing, because the
223+
// transaction has not begun yet. Deferring on that number would blame the job table for pool
224+
// contention, and deferring monitoring would do nothing to relieve it.
225+
const pool = new pg.Pool({ connectionString: helper.getConnectionString(), max: 1 })
226+
227+
try {
228+
const hog = pool.query('SELECT pg_sleep(2)')
229+
230+
await new Promise(resolve => setTimeout(resolve, 50))
231+
232+
const started = Date.now()
233+
const result = await pool.query(plans.cacheQueueStats(ctx.schema, 'job', ['backoff']))
234+
const callSeconds = (Date.now() - started) / 1000
235+
236+
const rows = (Array.isArray(result) ? result : [result]).flatMap(r => r?.rows ?? [])
237+
const pinSeconds = rows.reduce((max, row) => Math.max(max, Number(row.pinSeconds) || 0), 0)
238+
239+
expect(callSeconds).toBeGreaterThan(1.5)
240+
expect(pinSeconds).toBeLessThan(0.5)
241+
242+
await hog
243+
} finally {
244+
await pool.end()
245+
}
246+
})
247+
248+
// The same key cacheQueueStats/refreshQueueStats derive, computed in SQL so the test cannot drift
249+
// from the implementation's hashing.
250+
const LOCK_KEY = (schema: string) =>
251+
`('x' || encode(sha224((current_database() || '.pgboss.${schema}queue-stats')::bytea), 'hex'))::bit(64)::bigint`
252+
253+
async function holdStatsLock (schema: string) {
254+
const client = new pg.Client(helper.getConnectionString())
255+
await client.connect()
256+
await client.query('BEGIN')
257+
await client.query(`SELECT pg_advisory_xact_lock(${LOCK_KEY(schema)})`)
258+
259+
return async () => {
260+
await client.query('ROLLBACK')
261+
await client.end()
262+
}
231263
}
232-
}
233-
234-
it('skips the aggregate instead of queueing behind another instance', async function () {
235-
const boss = await startBoss({ monitorIntervalSeconds: 1 })
236-
237-
await boss.send('backoff', {})
238-
await boss.supervise()
239-
240-
const [before] = await boss.getQueueStats('backoff')
241-
242-
await boss.send('backoff', {})
243-
244-
const release = await holdStatsLock(ctx.schema)
245264

246-
try {
247-
await new Promise(resolve => setTimeout(resolve, 1500))
265+
it('skips the aggregate instead of queueing behind another instance', async function () {
266+
const boss = await startBoss({ monitorIntervalSeconds: 1 })
248267

249-
// Blocking here would hold this backend's snapshot for the whole of the other instance's
250-
// scan, which is the overlap the backoff exists to prevent. The pass gives up instead.
251-
const started = Date.now()
268+
await boss.send('backoff', {})
252269
await boss.supervise()
253-
const seconds = (Date.now() - started) / 1000
254270

255-
expect(seconds).toBeLessThan(1)
271+
const [before] = await boss.getQueueStats('backoff')
256272

257-
const [after] = await boss.getQueueStats('backoff')
273+
await boss.send('backoff', {})
258274

259-
// And it gives up without writing counts: an empty stats CTE still LEFT JOINs, so an
260-
// unguarded UPDATE would COALESCE every one of them to zero.
261-
expect(after.totalCount).toBe(before.totalCount)
275+
const release = await holdStatsLock(ctx.schema)
262276

263-
// capturedOn does move, because trySetQueueMonitorTime stamped monitor_on when it claimed the
264-
// interval, a statement earlier and before the lock was attempted. The queue therefore sits
265-
// out one interval rather than retrying immediately — the counts are at most one
266-
// monitorIntervalSeconds behind, against a staleness budget measured in hours.
267-
expect(new Date(after.capturedOn).getTime()).toBeGreaterThan(new Date(before.capturedOn).getTime())
268-
} finally {
269-
await release()
270-
}
271-
})
272-
273-
it('serves the cache when a forced read loses the same race', async function () {
274-
const boss = await startBoss()
275-
276-
await boss.send('backoff', {})
277-
await boss.supervise()
278-
279-
const [cached] = await boss.getQueueStats('backoff')
277+
try {
278+
await new Promise(resolve => setTimeout(resolve, 1500))
280279

281-
await boss.send('backoff', {})
280+
// Blocking here would hold this backend's snapshot for the whole of the other instance's
281+
// scan, which is the overlap the backoff exists to prevent. The pass gives up instead.
282+
const started = Date.now()
283+
await boss.supervise()
284+
const seconds = (Date.now() - started) / 1000
282285

283-
const release = await holdStatsLock(ctx.schema)
286+
expect(seconds).toBeLessThan(1)
284287

285-
try {
286-
// refreshQueueStats had no gate at all before this: every instance holding a stale cache ran
287-
// its own whole-table scan, and two different queue names did not even contend.
288-
const [forced] = await boss.getQueueStats('backoff', { force: true })
288+
const [after] = await boss.getQueueStats('backoff')
289289

290-
expect(forced.totalCount).toBe(cached.totalCount)
291-
expect(new Date(forced.capturedOn).getTime()).toBe(new Date(cached.capturedOn).getTime())
292-
} finally {
293-
await release()
294-
}
295-
})
290+
// And it gives up without writing counts: an empty stats CTE still LEFT JOINs, so an
291+
// unguarded UPDATE would COALESCE every one of them to zero.
292+
expect(after.totalCount).toBe(before.totalCount)
296293

297-
it('does not run when vacuum monitoring is turned off', async function () {
298-
const boss = await startBoss({ __test__monitor_stats_seconds: 90, monitorVacuum: false })
294+
// capturedOn does move, because trySetQueueMonitorTime stamped monitor_on when it claimed the
295+
// interval, a statement earlier and before the lock was attempted. The queue therefore sits
296+
// out one interval rather than retrying immediately — the counts are at most one
297+
// monitorIntervalSeconds behind, against a staleness budget measured in hours.
298+
expect(new Date(after.capturedOn).getTime()).toBeGreaterThan(new Date(before.capturedOn).getTime())
299+
} finally {
300+
await release()
301+
}
302+
})
299303

300-
await boss.supervise()
304+
it('serves the cache when a forced read loses the same race', async function () {
305+
const boss = await startBoss()
301306

302-
expect(await backoffUntil(ctx.schema)).toBeNull()
303-
expect(await warnings(ctx.schema)).toHaveLength(0)
304-
})
307+
await boss.send('backoff', {})
308+
await boss.supervise()
305309

306-
it('stamps the capture time when the counts land, not when the pass claimed them', async function () {
307-
const boss = await startBoss({ __test__delay_maint_ms: 0 })
310+
const [cached] = await boss.getQueueStats('backoff')
308311

309-
await boss.send('backoff', {})
312+
await boss.send('backoff', {})
310313

311-
const before = new Date()
312-
await boss.supervise()
314+
const release = await holdStatsLock(ctx.schema)
313315

314-
const stamped = await monitorOn(ctx.schema, 'backoff')
315-
316-
helper.assertTruthy(stamped)
316+
try {
317+
// refreshQueueStats had no gate at all before this: every instance holding a stale cache ran
318+
// its own whole-table scan, and two different queue names did not even contend.
319+
const [forced] = await boss.getQueueStats('backoff', { force: true })
317320

318-
// cacheQueueStats re-stamps monitor_on in the same statement that writes the counts. Without
319-
// that the interval is measured start-to-start, and stops delaying anything at all the moment
320-
// one aggregate outruns it.
321-
expect(new Date(stamped).getTime()).toBeGreaterThanOrEqual(before.getTime())
321+
expect(forced.totalCount).toBe(cached.totalCount)
322+
expect(new Date(forced.capturedOn).getTime()).toBe(new Date(cached.capturedOn).getTime())
323+
} finally {
324+
await release()
325+
}
326+
})
322327
})
323328
})

test/monitorVacuumTest.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,12 @@ async function startBoss (options: object = {}) {
8888
return boss
8989
}
9090

91-
helper.describePostgresOnly('vacuum monitoring', function () {
91+
// describeMultiConnectionOnly, not describePostgresOnly: nearly every case here needs a second
92+
// independent connection to hold the horizon while pg-boss observes it, and PGlite has no server to
93+
// open one against (getConnectionString returns a placeholder that fails DNS). The subsystem is not
94+
// meaningfully exercisable there anyway - it reads real autovacuum activity out of
95+
// pg_stat_user_tables against a genuine MVCC horizon.
96+
helper.describeMultiConnectionOnly('vacuum monitoring', function () {
9297
it('stays quiet while the table is under its own autovacuum budget', async function () {
9398
const boss = await startBoss()
9499

0 commit comments

Comments
 (0)