@@ -257,6 +257,13 @@ export interface RunQueueMetricsEmitter {
257257 emitGauge ( shardKey : string , fields : Record < string , string | number > ) : void ;
258258}
259259
260+ export class RunQueueConcurrencyKeyLimitExceededError extends Error {
261+ constructor ( message : string ) {
262+ super ( message ) ;
263+ this . name = "RunQueueConcurrencyKeyLimitExceededError" ;
264+ }
265+ }
266+
260267export type RunQueueOptions = {
261268 name : string ;
262269 tracer : Tracer ;
@@ -316,6 +323,8 @@ export type RunQueueOptions = {
316323 * the total cap covering releases from builds without the mirror.
317324 */
318325 gatesEnabled ?: boolean ;
326+ /** Cap on per-concurrency-key limit overrides stored per queue. Default 1000. */
327+ maxConcurrencyKeyOverridesPerQueue ?: number ;
319328 workerOptions ?: {
320329 pollIntervalMs ?: number ;
321330 immediatePollIntervalMs ?: number ;
@@ -429,6 +438,7 @@ export class RunQueue {
429438 private queueSelectionStrategy : RunQueueSelectionStrategy ;
430439 private shardCount : number ;
431440 private counterTtlSeconds : number ;
441+ private maxConcurrencyKeyOverridesPerQueue : number ;
432442 private abortController : AbortController ;
433443 private worker : Worker < typeof workerCatalog > ;
434444 private workerQueueResolver : WorkerQueueResolver ;
@@ -439,6 +449,7 @@ export class RunQueue {
439449 constructor ( public readonly options : RunQueueOptions ) {
440450 this . shardCount = options . shardCount ?? 2 ;
441451 this . counterTtlSeconds = options . counterTtlSeconds ?? 86400 ;
452+ this . maxConcurrencyKeyOverridesPerQueue = options . maxConcurrencyKeyOverridesPerQueue ?? 1000 ;
442453 this . retryOptions = options . retryOptions ?? defaultRetrySettings ;
443454 this . redis = createRedisClient ( options . redis , {
444455 onError : ( error ) => {
@@ -633,6 +644,62 @@ export class RunQueue {
633644 return this . redis . scard ( this . keys . queueGroupConcurrencyKey ( env , queue ) ) ;
634645 }
635646
647+ /**
648+ * Sets a per-concurrency-key limit override for a queue. The stored value is the
649+ * raw requested limit; admit paths clamp to the environment limit at read time.
650+ * Throws RunQueueConcurrencyKeyLimitExceededError when a NEW key would push the
651+ * queue past maxConcurrencyKeyOverridesPerQueue (updates to existing keys always
652+ * succeed).
653+ */
654+ public async updateQueueConcurrencyKeyLimit (
655+ env : MinimalAuthenticatedEnvironment ,
656+ queue : string ,
657+ concurrencyKey : string ,
658+ limit : number
659+ ) {
660+ const result = await this . redis . setQueueConcurrencyKeyLimit (
661+ this . keys . queueCkLimitsKey ( env , queue ) ,
662+ this . keys . queueKey ( env , queue , concurrencyKey ) ,
663+ String ( limit ) ,
664+ String ( this . maxConcurrencyKeyOverridesPerQueue )
665+ ) ;
666+
667+ if ( result === 0 ) {
668+ throw new RunQueueConcurrencyKeyLimitExceededError (
669+ `Cannot add a concurrency key override to queue ${ queue } : the queue already has ${ this . maxConcurrencyKeyOverridesPerQueue } overrides`
670+ ) ;
671+ }
672+ }
673+
674+ public async removeQueueConcurrencyKeyLimit (
675+ env : MinimalAuthenticatedEnvironment ,
676+ queue : string ,
677+ concurrencyKey : string
678+ ) {
679+ return this . redis . hdel (
680+ this . keys . queueCkLimitsKey ( env , queue ) ,
681+ this . keys . queueKey ( env , queue , concurrencyKey )
682+ ) ;
683+ }
684+
685+ /** Returns the raw per-concurrency-key limit overrides for a queue, keyed by concurrency key value. */
686+ public async getQueueConcurrencyKeyLimits (
687+ env : MinimalAuthenticatedEnvironment ,
688+ queue : string
689+ ) : Promise < Record < string , number > > {
690+ const raw = await this . redis . hgetall ( this . keys . queueCkLimitsKey ( env , queue ) ) ;
691+
692+ const limits : Record < string , number > = { } ;
693+ for ( const [ variantName , value ] of Object . entries ( raw ) ) {
694+ const ckIndex = variantName . indexOf ( ":ck:" ) ;
695+ if ( ckIndex === - 1 ) {
696+ continue ;
697+ }
698+ limits [ variantName . slice ( ckIndex + 4 ) ] = Number ( value ) ;
699+ }
700+ return limits ;
701+ }
702+
636703 public async updateEnvConcurrencyLimits ( env : MinimalAuthenticatedEnvironment ) {
637704 await this . #callUpdateEnvironmentConcurrencyLimits( {
638705 envConcurrencyLimitKey : this . keys . envConcurrencyLimitKey ( env ) ,
@@ -5895,6 +5962,26 @@ __gatesRelease(keyPrefix, redis.call('GET', messageKey), messageId)
58955962` ,
58965963 } ) ;
58975964
5965+ this . redis . defineCommand ( "setQueueConcurrencyKeyLimit" , {
5966+ numberOfKeys : 1 ,
5967+ lua : `
5968+ local ckLimitsKey = KEYS[1]
5969+
5970+ local fieldName = ARGV[1]
5971+ local limit = ARGV[2]
5972+ local maxFields = tonumber(ARGV[3])
5973+
5974+ if redis.call('HEXISTS', ckLimitsKey, fieldName) == 0 then
5975+ if redis.call('HLEN', ckLimitsKey) >= maxFields then
5976+ return 0
5977+ end
5978+ end
5979+
5980+ redis.call('HSET', ckLimitsKey, fieldName, limit)
5981+ return 1
5982+ ` ,
5983+ } ) ;
5984+
58985985 this . redis . defineCommand ( "updateEnvironmentConcurrencyLimits" , {
58995986 numberOfKeys : 2 ,
59005987 lua : `
@@ -6256,6 +6343,14 @@ declare module "@internal/redis" {
62566343 callback ?: Callback < void >
62576344 ) : Result < void , Context > ;
62586345
6346+ setQueueConcurrencyKeyLimit (
6347+ ckLimitsKey : string ,
6348+ fieldName : string ,
6349+ limit : string ,
6350+ maxFields : string ,
6351+ callback ?: Callback < number >
6352+ ) : Result < number , Context > ;
6353+
62596354 updateEnvironmentConcurrencyLimits (
62606355 // keys
62616356 envConcurrencyLimitKey : string ,
0 commit comments