Skip to content

Commit f92e339

Browse files
committed
fix(webapp): strict limit names at deploy and collision-proof anonymous limit rows
Deploy validation now enforces the SDK's limit-name charset, so sanitization can never merge two declared limits onto one row. A task's anonymous inline limit row hashes the raw task id whenever sanitizing it would be lossy or overflow the queue-name length, so distinct task ids always keep distinct rows.
1 parent 375bf64 commit f92e339

1 file changed

Lines changed: 38 additions & 15 deletions

File tree

‎apps/webapp/app/v3/services/createBackgroundWorker.server.ts‎

Lines changed: 38 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -434,22 +434,22 @@ async function createWorkerTask(
434434
`Task "${task.id}": an inline limit on a shared queue uses a gate slot, so at most one named limit can be combined with it.`
435435
);
436436
}
437-
const anonymousName = `task/${task.id}`;
437+
const anonymousQueueName = anonymousConcurrencyLimitQueueName(task.id);
438438
await createWorkerQueue(
439439
{
440-
name: concurrencyLimitQueueName(anonymousName),
440+
name: anonymousQueueName,
441441
concurrencyLimit: concurrency.inline.perKey ?? concurrency.inline.total ?? null,
442442
combinedConcurrencyLimit: concurrency.inline.total ?? null,
443443
},
444-
anonymousName,
444+
`task/${task.id}`,
445445
"NAMED",
446446
worker,
447447
environment,
448448
prisma,
449449
"LIMIT",
450450
"V2"
451451
);
452-
compiledGates = [{ queue: concurrencyLimitQueueName(anonymousName) }, ...compiledGates];
452+
compiledGates = [{ queue: anonymousQueueName }, ...compiledGates];
453453
}
454454
}
455455

@@ -614,7 +614,7 @@ function validateWorkerConcurrencyDeclarations(metadata: BackgroundWorkerMetadat
614614
}
615615

616616
for (const limit of metadata.concurrencyLimits ?? []) {
617-
concurrencyLimitQueueName(limit.name);
617+
assertValidConcurrencyLimitName(limit.name);
618618
}
619619

620620
for (const task of metadata.tasks) {
@@ -634,11 +634,10 @@ function validateWorkerConcurrencyDeclarations(metadata: BackgroundWorkerMetadat
634634
}
635635

636636
for (const name of concurrency.limits ?? []) {
637-
concurrencyLimitQueueName(name);
637+
assertValidConcurrencyLimitName(name);
638638
}
639639

640640
if (concurrency.inline && task.queue?.name) {
641-
concurrencyLimitQueueName(`task/${task.id}`);
642641
if ((concurrency.limits ?? []).length > 1) {
643642
throw new ServiceValidationError(
644643
`Task "${task.id}": an inline limit on a shared queue uses a gate slot, so at most one named limit can be combined with it.`
@@ -652,15 +651,39 @@ function validateWorkerConcurrencyDeclarations(metadata: BackgroundWorkerMetadat
652651
* they can never collide with a user's queue names. */
653652
const CONCURRENCY_LIMIT_QUEUE_PREFIX = "limit/";
654653

655-
function concurrencyLimitQueueName(limitName: string): string {
656-
const sanitized = sanitizeQueueName(limitName);
657-
const name = `${CONCURRENCY_LIMIT_QUEUE_PREFIX}${sanitized}`;
658-
if (sanitized.length === 0 || name.length > 128) {
654+
const CONCURRENCY_LIMIT_NAME_MAX_LENGTH = 128 - CONCURRENCY_LIMIT_QUEUE_PREFIX.length;
655+
656+
/**
657+
* Named limits require a strict charset so distinct declared names can never merge
658+
* onto one row after queue-name sanitization (which strips disallowed characters).
659+
*/
660+
function assertValidConcurrencyLimitName(name: string): void {
661+
if (!new RegExp(`^[a-zA-Z0-9_-]{1,${CONCURRENCY_LIMIT_NAME_MAX_LENGTH}}$`).test(name)) {
659662
throw new ServiceValidationError(
660-
`Concurrency limit name "${limitName}" must sanitize to between 1 and ${128 - CONCURRENCY_LIMIT_QUEUE_PREFIX.length} characters.`
663+
`Concurrency limit name "${name}" must be 1-${CONCURRENCY_LIMIT_NAME_MAX_LENGTH} characters using only letters, numbers, underscores and hyphens.`
661664
);
662665
}
663-
return name;
666+
}
667+
668+
function concurrencyLimitQueueName(limitName: string): string {
669+
assertValidConcurrencyLimitName(limitName);
670+
return `${CONCURRENCY_LIMIT_QUEUE_PREFIX}${limitName}`;
671+
}
672+
673+
/**
674+
* Row name for a task's anonymous inline limit. Task ids are not charset-restricted,
675+
* so when sanitization would be lossy (or the name would overflow the 128-char queue
676+
* name limit) a hash of the raw id keeps distinct task ids on distinct rows.
677+
*/
678+
function anonymousConcurrencyLimitQueueName(taskId: string): string {
679+
const sanitized = sanitizeQueueName(taskId);
680+
const name = `${CONCURRENCY_LIMIT_QUEUE_PREFIX}task/${sanitized}`;
681+
if (sanitized === taskId && name.length <= 128) {
682+
return name;
683+
}
684+
const hash = createHash("sha256").update(taskId).digest("hex").slice(0, 8);
685+
const budget = 128 - `${CONCURRENCY_LIMIT_QUEUE_PREFIX}task/`.length - hash.length - 1;
686+
return `${CONCURRENCY_LIMIT_QUEUE_PREFIX}task/${sanitized.slice(0, budget)}-${hash}`;
664687
}
665688

666689
/** User queue names may not claim the reserved limit/ namespace. */
@@ -675,8 +698,8 @@ function assertNotReservedQueueName(name: string, context: string): void {
675698
/**
676699
* Materializes the worker's declared named concurrency limits (plus any names tasks
677700
* reference without declaring, created uncapped) as LIMIT-role TaskQueue rows. A
678-
* total-only limit stores the total as its per-key limit too, so it truly caps
679-
* keyless runs as well as the keyed group.
701+
* total-only limit stores the total as its per-key limit too, so no single key (or
702+
* the keyless pool) can exceed it even before the group check applies.
680703
*/
681704
async function createWorkerConcurrencyLimits(
682705
metadata: BackgroundWorkerMetadata,

0 commit comments

Comments
 (0)