Skip to content

Commit b8f9e21

Browse files
committed
feat(run-engine): per-concurrency-key limit override storage and methods
Sparse ckLimits HASH at the base queue whose fields are the exact ck-variant queue names, plus engine methods to set (atomic cardinality cap, default 1000 per queue), remove, and read the overrides. The admit-path gate wiring follows.
1 parent ad1fb33 commit b8f9e21

3 files changed

Lines changed: 112 additions & 0 deletions

File tree

‎internal-packages/run-engine/src/run-queue/index.ts‎

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
260267
export 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,

‎internal-packages/run-engine/src/run-queue/keyProducer.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ const constants = {
2626
RUNNING_COUNTER_PART: "runningCounter",
2727
GROUP_CONCURRENCY_PART: "groupConcurrency",
2828
TOTAL_CONCURRENCY_LIMIT_PART: "totalConcurrency",
29+
CK_LIMITS_PART: "ckLimits",
2930
} as const;
3031

3132
export class RunQueueFullKeyProducer implements RunQueueKeyProducer {
@@ -366,6 +367,20 @@ export class RunQueueFullKeyProducer implements RunQueueKeyProducer {
366367
return `${this.baseQueueKeyFromQueue(queue)}:${constants.TOTAL_CONCURRENCY_LIMIT_PART}`;
367368
}
368369

370+
/**
371+
* HASH of per-concurrency-key limit overrides for a queue. Lives at the base
372+
* queue; each field is the EXACT full ck-variant queue name (the ckIndex ZSET
373+
* member), so reads need no parsing, and values are the raw requested limits
374+
* (readers clamp to the environment limit).
375+
*/
376+
queueCkLimitsKey(env: RunQueueKeyProducerEnvironment, queue: string): string {
377+
return `${this.queueKey(env, queue)}:${constants.CK_LIMITS_PART}`;
378+
}
379+
380+
queueCkLimitsKeyFromQueue(queue: string): string {
381+
return `${this.baseQueueKeyFromQueue(queue)}:${constants.CK_LIMITS_PART}`;
382+
}
383+
369384
isCkWildcard(queue: string): boolean {
370385
return queue.endsWith(":ck:*");
371386
}

‎internal-packages/run-engine/src/run-queue/types.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,8 @@ export interface RunQueueKeyProducer {
110110
queueGroupConcurrencyKeyFromQueue(queue: string): string;
111111
queueTotalConcurrencyLimitKey(env: RunQueueKeyProducerEnvironment, queue: string): string;
112112
queueTotalConcurrencyLimitKeyFromQueue(queue: string): string;
113+
queueCkLimitsKey(env: RunQueueKeyProducerEnvironment, queue: string): string;
114+
queueCkLimitsKeyFromQueue(queue: string): string;
113115

114116
//env oncurrency
115117
envCurrentConcurrencyKey(env: EnvDescriptor): string;

0 commit comments

Comments
 (0)