@@ -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} )
0 commit comments