@@ -9,7 +9,12 @@ import {
99 NoRetry ,
1010 WorkerQueueManager ,
1111} from "../index.js" ;
12- import type { FairQueueKeyProducer , FairQueueOptions , StoredMessage } from "../types.js" ;
12+ import type {
13+ FairQueueKeyProducer ,
14+ FairQueueOptions ,
15+ GlobalRateLimiter ,
16+ StoredMessage ,
17+ } from "../types.js" ;
1318import type { RedisOptions } from "@internal/redis" ;
1419
1520// Define a common payload schema for tests
@@ -1182,4 +1187,178 @@ describe("FairQueue", () => {
11821187 }
11831188 ) ;
11841189 } ) ;
1190+
1191+ describe ( "concurrency block should not trigger cooloff" , ( ) => {
1192+ redisTest (
1193+ "should not enter cooloff when queue hits concurrency limit" ,
1194+ { timeout : 15000 } ,
1195+ async ( { redisOptions } ) => {
1196+ const processed : string [ ] = [ ] ;
1197+ keys = new DefaultFairQueueKeyProducer ( { prefix : "test" } ) ;
1198+
1199+ const scheduler = new DRRScheduler ( {
1200+ redis : redisOptions ,
1201+ keys,
1202+ quantum : 10 ,
1203+ maxDeficit : 100 ,
1204+ } ) ;
1205+
1206+ const queue = new TestFairQueueHelper ( redisOptions , keys , {
1207+ scheduler,
1208+ payloadSchema : TestPayloadSchema ,
1209+ shardCount : 1 ,
1210+ consumerCount : 1 ,
1211+ consumerIntervalMs : 20 ,
1212+ visibilityTimeoutMs : 5000 ,
1213+ cooloff : {
1214+ periodMs : 5000 , // Long cooloff - if triggered, messages would stall
1215+ threshold : 1 , // Enter cooloff after just 1 increment
1216+ } ,
1217+ concurrencyGroups : [
1218+ {
1219+ name : "tenant" ,
1220+ extractGroupId : ( q ) => q . tenantId ,
1221+ getLimit : async ( ) => 1 , // Only 1 concurrent per tenant
1222+ defaultLimit : 1 ,
1223+ } ,
1224+ ] ,
1225+ startConsumers : false ,
1226+ } ) ;
1227+
1228+ // Hold first message to keep concurrency slot occupied
1229+ let releaseFirst : ( ( ) => void ) | undefined ;
1230+ const firstBlocking = new Promise < void > ( ( resolve ) => {
1231+ releaseFirst = resolve ;
1232+ } ) ;
1233+ let firstStarted = false ;
1234+
1235+ queue . onMessage ( async ( ctx ) => {
1236+ if ( ctx . message . payload . value === "msg-0" ) {
1237+ firstStarted = true ;
1238+ // Block this message to saturate concurrency
1239+ await firstBlocking ;
1240+ }
1241+ processed . push ( ctx . message . payload . value ) ;
1242+ await ctx . complete ( ) ;
1243+ } ) ;
1244+
1245+ // Enqueue 3 messages to same tenant
1246+ for ( let i = 0 ; i < 3 ; i ++ ) {
1247+ await queue . enqueue ( {
1248+ queueId : "tenant:t1:queue:q1" ,
1249+ tenantId : "t1" ,
1250+ payload : { value : `msg-${ i } ` } ,
1251+ } ) ;
1252+ }
1253+
1254+ queue . start ( ) ;
1255+
1256+ // Wait for first message to start processing (blocking the concurrency slot)
1257+ await vi . waitFor (
1258+ ( ) => {
1259+ expect ( firstStarted ) . toBe ( true ) ;
1260+ } ,
1261+ { timeout : 5000 }
1262+ ) ;
1263+
1264+ // Release the first message so others can proceed
1265+ releaseFirst ! ( ) ;
1266+
1267+ // All 3 messages should process within a reasonable time.
1268+ // If cooloff was incorrectly triggered, this would take 5+ seconds.
1269+ const startTime = Date . now ( ) ;
1270+ await vi . waitFor (
1271+ ( ) => {
1272+ expect ( processed ) . toHaveLength ( 3 ) ;
1273+ } ,
1274+ { timeout : 5000 }
1275+ ) ;
1276+ const elapsed = Date . now ( ) - startTime ;
1277+
1278+ // Should complete well under the 5s cooloff period
1279+ expect ( elapsed ) . toBeLessThan ( 3000 ) ;
1280+
1281+ // Cooloff states should be empty (no spurious cooloffs)
1282+ const cacheSizes = queue . fairQueue . getCacheSizes ( ) ;
1283+ expect ( cacheSizes . cooloffStatesSize ) . toBe ( 0 ) ;
1284+
1285+ await queue . close ( ) ;
1286+ }
1287+ ) ;
1288+ } ) ;
1289+
1290+ describe ( "global rate limiter should be non-blocking" , ( ) => {
1291+ redisTest (
1292+ "should not block consumer when rate limit is hit" ,
1293+ { timeout : 15000 } ,
1294+ async ( { redisOptions } ) => {
1295+ const processed : string [ ] = [ ] ;
1296+ keys = new DefaultFairQueueKeyProducer ( { prefix : "test" } ) ;
1297+
1298+ const scheduler = new DRRScheduler ( {
1299+ redis : redisOptions ,
1300+ keys,
1301+ quantum : 10 ,
1302+ maxDeficit : 100 ,
1303+ } ) ;
1304+
1305+ // Track how many times limit() was called and control when it allows
1306+ let limitCallCount = 0 ;
1307+ let allowAfter = 3 ; // Block first 3 calls, then allow
1308+
1309+ const mockRateLimiter : GlobalRateLimiter = {
1310+ limit : async ( ) => {
1311+ limitCallCount ++ ;
1312+ if ( limitCallCount <= allowAfter ) {
1313+ return { allowed : false , resetAt : Date . now ( ) + 1000 } ;
1314+ }
1315+ return { allowed : true } ;
1316+ } ,
1317+ } ;
1318+
1319+ const queue = new TestFairQueueHelper ( redisOptions , keys , {
1320+ scheduler,
1321+ payloadSchema : TestPayloadSchema ,
1322+ shardCount : 1 ,
1323+ consumerCount : 1 ,
1324+ consumerIntervalMs : 20 ,
1325+ visibilityTimeoutMs : 5000 ,
1326+ globalRateLimiter : mockRateLimiter ,
1327+ startConsumers : false ,
1328+ } ) ;
1329+
1330+ queue . onMessage ( async ( ctx ) => {
1331+ processed . push ( ctx . message . payload . value ) ;
1332+ await ctx . complete ( ) ;
1333+ } ) ;
1334+
1335+ await queue . enqueue ( {
1336+ queueId : "tenant:t1:queue:q1" ,
1337+ tenantId : "t1" ,
1338+ payload : { value : "msg-1" } ,
1339+ } ) ;
1340+
1341+ const startTime = Date . now ( ) ;
1342+ queue . start ( ) ;
1343+
1344+ // Message should be processed quickly despite rate limiter denials.
1345+ // Old behavior: each denial would sleep ~1s, so 3 denials = ~3s.
1346+ // New behavior: denials return immediately, retry on next loop tick.
1347+ await vi . waitFor (
1348+ ( ) => {
1349+ expect ( processed ) . toHaveLength ( 1 ) ;
1350+ } ,
1351+ { timeout : 5000 }
1352+ ) ;
1353+
1354+ const elapsed = Date . now ( ) - startTime ;
1355+
1356+ // Should complete well under 1s (old behavior would take ~3s)
1357+ expect ( elapsed ) . toBeLessThan ( 1000 ) ;
1358+ expect ( processed ) . toContain ( "msg-1" ) ;
1359+
1360+ await queue . close ( ) ;
1361+ }
1362+ ) ;
1363+ } ) ;
11851364} ) ;
0 commit comments