Skip to content

Commit a55f7cd

Browse files
authored
fix(run-engine): stop a '*' concurrency key stranding its whole base queue (#4628)
## The bug A concurrency key is an unrestricted client string (`ConcurrencyKeySchema` is `z.union([z.string(), z.number()]).transform(String)`), and `concurrencyKeySection` does no escaping, so `*` reaches the queue raw. `queueKey` then renders it as `...:queue:<q>:ck:*`, which is byte-identical to the wildcard member the CK scripts keep in the master queue to mean "this base queue has concurrency-key work". Every CK script ends with the same pair: ```lua -- Rebalance master queue with ck:* member redis.call('ZADD', masterQueueKey, earliestIdx[2], ckWildcardName) -- Remove old-format entry from master queue (transition cleanup) redis.call('ZREM', masterQueueKey, queueName) ``` `ckWildcardName` is `toCkWildcard(message.queue)`, and for a `*`-keyed run that returns the identical string, so the cleanup on the second line deletes what the rebalance on the first line just wrote. The master queue then has no entry for that base queue, while `ckIndex` and the variant queues still hold the work. **Every concurrency key on the queue stops being dequeued**, not just the `*` one. It is silent, and it only recovers if some later write happens to re-add the member. Reproduced before the fix: ``` master queue AFTER normal ck enqueue: ["{org:...}:queue:task/my-task:ck:*"] master queue AFTER ck='*' enqueue: [] ckIndex members (work still queued): [":ck:user-1", ":ck:*"] dequeued: [] ``` Blast radius is bounded to the environment that triggers it, so it is self-inflicted rather than cross-tenant, but a single trigger stalls the queue for everything on it. ## The fix Guard the cleanup so it never removes the wildcard member: ```lua if queueName ~= ckWildcardName then redis.call('ZREM', masterQueueKey, queueName) end ``` Applied to all 10 CK scripts (4 enqueue, 6 ack/nack/dead-letter). No key-format change and no migration: a queue already stranded in Redis is repaired by its next write. I considered rejecting `*` at the API boundary instead and rejected it. Existing Redis state and `TaskRun.concurrencyKey` rows already hold raw `:`-bearing and `*` keys, so changing key construction would orphan in-flight messages and split concurrency accounting mid-deploy. Boundary validation would still be reasonable as belt-and-braces later, but the Lua guard alone fixes it including for state already out there. ## Testing `ckWildcardKey.test.ts` covers the enqueue, ack and nack paths. All three pass with the guard and **all three fail without it**, verified by reverting. Full `src/run-queue/` suite is green (166 tests). ## Note for #4367 The virtual-time branch adds three more CK scripts with the same pattern (`enqueueMessageCkVtimeTracked`, `enqueueMessageWithTtlCkVtimeTracked`, `nackMessageCkVtimeTracked`). They do not exist on main so they are not in this PR; the same guard needs applying there, and I will do that on that branch.
1 parent 99f0787 commit a55f7cd

3 files changed

Lines changed: 266 additions & 20 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Using `*` as a concurrency key no longer stops a queue from being processed. Triggering a single run with that key could leave the whole queue stalled, including runs using other concurrency keys on it, until something else was triggered on the same queue.

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

Lines changed: 70 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3603,8 +3603,13 @@ if #earliestIdx > 0 then
36033603
redis.call('ZADD', masterQueueKey, earliestIdx[2], ckWildcardName)
36043604
end
36053605
3606-
-- Remove old-format entry from master queue (transition cleanup)
3607-
redis.call('ZREM', masterQueueKey, queueName)
3606+
-- Remove old-format entry from master queue (transition cleanup). Skipped when the
3607+
-- variant name IS the wildcard: a concurrency key of '*' produces a queue key identical
3608+
-- to the wildcard member, so an unguarded ZREM here deletes the entry the rebalance just
3609+
-- wrote and strands every concurrency key on this base queue.
3610+
if queueName ~= ckWildcardName then
3611+
redis.call('ZREM', masterQueueKey, queueName)
3612+
end
36083613
36093614
-- Update the concurrency keys
36103615
redis.call('SREM', queueCurrentConcurrencyKey, messageId)
@@ -3708,8 +3713,13 @@ if #earliestIdx > 0 then
37083713
redis.call('ZADD', masterQueueKey, earliestIdx[2], ckWildcardName)
37093714
end
37103715
3711-
-- Remove old-format entry from master queue (transition cleanup)
3712-
redis.call('ZREM', masterQueueKey, queueName)
3716+
-- Remove old-format entry from master queue (transition cleanup). Skipped when the
3717+
-- variant name IS the wildcard: a concurrency key of '*' produces a queue key identical
3718+
-- to the wildcard member, so an unguarded ZREM here deletes the entry the rebalance just
3719+
-- wrote and strands every concurrency key on this base queue.
3720+
if queueName ~= ckWildcardName then
3721+
redis.call('ZREM', masterQueueKey, queueName)
3722+
end
37133723
37143724
-- Update the concurrency keys
37153725
redis.call('SREM', queueCurrentConcurrencyKey, messageId)
@@ -3838,8 +3848,13 @@ if #earliestIdx > 0 then
38383848
redis.call('ZADD', masterQueueKey, earliestIdx[2], ckWildcardName)
38393849
end
38403850
3841-
-- Remove old-format entry from master queue (transition cleanup)
3842-
redis.call('ZREM', masterQueueKey, queueName)
3851+
-- Remove old-format entry from master queue (transition cleanup). Skipped when the
3852+
-- variant name IS the wildcard: a concurrency key of '*' produces a queue key identical
3853+
-- to the wildcard member, so an unguarded ZREM here deletes the entry the rebalance just
3854+
-- wrote and strands every concurrency key on this base queue.
3855+
if queueName ~= ckWildcardName then
3856+
redis.call('ZREM', masterQueueKey, queueName)
3857+
end
38433858
38443859
-- Update the concurrency keys
38453860
redis.call('SREM', queueCurrentConcurrencyKey, messageId)
@@ -3956,8 +3971,13 @@ if #earliestIdx > 0 then
39563971
redis.call('ZADD', masterQueueKey, earliestIdx[2], ckWildcardName)
39573972
end
39583973
3959-
-- Remove old-format entry from master queue (transition cleanup)
3960-
redis.call('ZREM', masterQueueKey, queueName)
3974+
-- Remove old-format entry from master queue (transition cleanup). Skipped when the
3975+
-- variant name IS the wildcard: a concurrency key of '*' produces a queue key identical
3976+
-- to the wildcard member, so an unguarded ZREM here deletes the entry the rebalance just
3977+
-- wrote and strands every concurrency key on this base queue.
3978+
if queueName ~= ckWildcardName then
3979+
redis.call('ZREM', masterQueueKey, queueName)
3980+
end
39613981
39623982
-- Update the concurrency keys
39633983
redis.call('SREM', queueCurrentConcurrencyKey, messageId)
@@ -4908,8 +4928,13 @@ else
49084928
redis.call('ZADD', masterQueueKey, earliestInCkIndex[2], ckWildcardName)
49094929
end
49104930
4911-
-- Remove old-format entry from master queue (transition cleanup)
4912-
redis.call('ZREM', masterQueueKey, messageQueueName)
4931+
-- Remove old-format entry from master queue (transition cleanup). Skipped when the
4932+
-- variant name IS the wildcard: a concurrency key of '*' produces a queue key identical
4933+
-- to the wildcard member, so an unguarded ZREM here deletes the entry the rebalance just
4934+
-- wrote and strands every concurrency key on this base queue.
4935+
if messageQueueName ~= ckWildcardName then
4936+
redis.call('ZREM', masterQueueKey, messageQueueName)
4937+
end
49134938
49144939
-- Update the concurrency keys
49154940
redis.call('SREM', queueCurrentConcurrencyKey, messageId)
@@ -4973,8 +4998,13 @@ else
49734998
redis.call('ZADD', masterQueueKey, earliestIdx[2], ckWildcardName)
49744999
end
49755000
4976-
-- Remove old-format entry from master queue (transition cleanup)
4977-
redis.call('ZREM', masterQueueKey, messageQueueName)
5001+
-- Remove old-format entry from master queue (transition cleanup). Skipped when the
5002+
-- variant name IS the wildcard: a concurrency key of '*' produces a queue key identical
5003+
-- to the wildcard member, so an unguarded ZREM here deletes the entry the rebalance just
5004+
-- wrote and strands every concurrency key on this base queue.
5005+
if messageQueueName ~= ckWildcardName then
5006+
redis.call('ZREM', masterQueueKey, messageQueueName)
5007+
end
49785008
`,
49795009
});
49805010

@@ -5019,8 +5049,13 @@ else
50195049
redis.call('ZADD', masterQueueKey, earliestIdx[2], ckWildcardName)
50205050
end
50215051
5022-
-- Remove old-format entry from master queue (transition cleanup)
5023-
redis.call('ZREM', masterQueueKey, messageQueueName)
5052+
-- Remove old-format entry from master queue (transition cleanup). Skipped when the
5053+
-- variant name IS the wildcard: a concurrency key of '*' produces a queue key identical
5054+
-- to the wildcard member, so an unguarded ZREM here deletes the entry the rebalance just
5055+
-- wrote and strands every concurrency key on this base queue.
5056+
if messageQueueName ~= ckWildcardName then
5057+
redis.call('ZREM', masterQueueKey, messageQueueName)
5058+
end
50245059
50255060
-- Add the message to the dead letter queue
50265061
redis.call('ZADD', deadLetterQueueKey, tonumber(redis.call('TIME')[1]), messageId)
@@ -5095,8 +5130,13 @@ else
50955130
redis.call('ZADD', masterQueueKey, earliestInCkIndex[2], ckWildcardName)
50965131
end
50975132
5098-
-- Remove old-format entry from master queue (transition cleanup)
5099-
redis.call('ZREM', masterQueueKey, messageQueueName)
5133+
-- Remove old-format entry from master queue (transition cleanup). Skipped when the
5134+
-- variant name IS the wildcard: a concurrency key of '*' produces a queue key identical
5135+
-- to the wildcard member, so an unguarded ZREM here deletes the entry the rebalance just
5136+
-- wrote and strands every concurrency key on this base queue.
5137+
if messageQueueName ~= ckWildcardName then
5138+
redis.call('ZREM', masterQueueKey, messageQueueName)
5139+
end
51005140
51015141
-- Update the concurrency keys. DECR runningCounter only when SREM
51025142
-- currentDequeued actually removed an entry (the message was in flight).
@@ -5201,8 +5241,13 @@ else
52015241
redis.call('ZADD', masterQueueKey, earliestIdx[2], ckWildcardName)
52025242
end
52035243
5204-
-- Remove old-format entry from master queue (transition cleanup)
5205-
redis.call('ZREM', masterQueueKey, messageQueueName)
5244+
-- Remove old-format entry from master queue (transition cleanup). Skipped when the
5245+
-- variant name IS the wildcard: a concurrency key of '*' produces a queue key identical
5246+
-- to the wildcard member, so an unguarded ZREM here deletes the entry the rebalance just
5247+
-- wrote and strands every concurrency key on this base queue.
5248+
if messageQueueName ~= ckWildcardName then
5249+
redis.call('ZREM', masterQueueKey, messageQueueName)
5250+
end
52065251
`,
52075252
});
52085253

@@ -5261,8 +5306,13 @@ else
52615306
redis.call('ZADD', masterQueueKey, earliestIdx[2], ckWildcardName)
52625307
end
52635308
5264-
-- Remove old-format entry from master queue (transition cleanup)
5265-
redis.call('ZREM', masterQueueKey, messageQueueName)
5309+
-- Remove old-format entry from master queue (transition cleanup). Skipped when the
5310+
-- variant name IS the wildcard: a concurrency key of '*' produces a queue key identical
5311+
-- to the wildcard member, so an unguarded ZREM here deletes the entry the rebalance just
5312+
-- wrote and strands every concurrency key on this base queue.
5313+
if messageQueueName ~= ckWildcardName then
5314+
redis.call('ZREM', masterQueueKey, messageQueueName)
5315+
end
52665316
52675317
-- Add the message to the dead letter queue
52685318
redis.call('ZADD', deadLetterQueueKey, tonumber(redis.call('TIME')[1]), messageId)
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import { redisTest } from "@internal/testcontainers";
2+
import { trace } from "@internal/tracing";
3+
import { Logger } from "@trigger.dev/core/logger";
4+
import { Decimal } from "@trigger.dev/database";
5+
import { FairQueueSelectionStrategy } from "../fairQueueSelectionStrategy.js";
6+
import { RunQueue } from "../index.js";
7+
import { RunQueueFullKeyProducer } from "../keyProducer.js";
8+
import type { InputPayload } from "../types.js";
9+
10+
const testOptions = {
11+
name: "rq",
12+
tracer: trace.getTracer("rq"),
13+
workers: 1,
14+
defaultEnvConcurrency: 25,
15+
logger: new Logger("RunQueue", "warn"),
16+
retryOptions: {
17+
maxAttempts: 5,
18+
factor: 1.1,
19+
minTimeoutInMs: 100,
20+
maxTimeoutInMs: 1_000,
21+
randomize: true,
22+
},
23+
keys: new RunQueueFullKeyProducer(),
24+
};
25+
26+
const authenticatedEnvDev = {
27+
id: "e1234",
28+
type: "DEVELOPMENT" as const,
29+
maximumConcurrencyLimit: 10,
30+
concurrencyLimitBurstFactor: new Decimal(2.0),
31+
project: { id: "p1234" },
32+
organization: { id: "o1234" },
33+
};
34+
35+
function createQueue(redisContainer: any) {
36+
return new RunQueue({
37+
...testOptions,
38+
masterQueueConsumersDisabled: true,
39+
workerOptions: { disabled: true },
40+
queueSelectionStrategy: new FairQueueSelectionStrategy({
41+
redis: {
42+
keyPrefix: "runqueue:test:",
43+
host: redisContainer.getHost(),
44+
port: redisContainer.getPort(),
45+
},
46+
keys: testOptions.keys,
47+
}),
48+
redis: {
49+
keyPrefix: "runqueue:test:",
50+
host: redisContainer.getHost(),
51+
port: redisContainer.getPort(),
52+
},
53+
});
54+
}
55+
56+
function makeMessage(overrides: Partial<InputPayload> = {}): InputPayload {
57+
return {
58+
runId: "r1",
59+
taskIdentifier: "task/my-task",
60+
orgId: "o1234",
61+
projectId: "p1234",
62+
environmentId: "e1234",
63+
environmentType: "DEVELOPMENT",
64+
queue: "task/my-task",
65+
timestamp: Date.now(),
66+
attempt: 0,
67+
...overrides,
68+
};
69+
}
70+
71+
const QUEUE = "task/my-task";
72+
73+
vi.setConfig({ testTimeout: 60_000 });
74+
75+
// A concurrency key is an unrestricted client string, so `*` is reachable from the public
76+
// API, and `queueKey` renders it as `...:queue:<q>:ck:*`, which is byte-identical to the
77+
// wildcard member the CK scripts keep in the master queue. Each of those scripts rebalances
78+
// the master queue with that wildcard member and then removes the "old-format" entry for the
79+
// variant it just touched. When the variant IS the wildcard, the second call undid the
80+
// first, taking the whole base queue's master-queue entry with it: nothing pointed at the
81+
// queue any more, so every concurrency key on it stopped being dequeued, silently, until
82+
// some later write happened to re-add the member.
83+
describe("concurrency key of '*'", () => {
84+
redisTest("enqueueing it leaves the base queue reachable", async ({ redisContainer }) => {
85+
const queue = createQueue(redisContainer);
86+
try {
87+
const t0 = Date.now() - 100_000;
88+
const shard = testOptions.keys.masterQueueShardForEnvironment(authenticatedEnvDev.id, 2);
89+
const masterQueueKey = testOptions.keys.masterQueueKeyForShard(shard);
90+
91+
// An ordinary key with real queued work: the bystander that used to be taken down.
92+
await queue.enqueueMessage({
93+
env: authenticatedEnvDev,
94+
message: makeMessage({ runId: "r-victim", concurrencyKey: "user-1", timestamp: t0 }),
95+
workerQueue: authenticatedEnvDev.id,
96+
skipDequeueProcessing: true,
97+
});
98+
expect(await queue.redis.zcard(masterQueueKey)).toBe(1);
99+
100+
await queue.enqueueMessage({
101+
env: authenticatedEnvDev,
102+
message: makeMessage({ runId: "r-star", concurrencyKey: "*", timestamp: t0 + 1 }),
103+
workerQueue: authenticatedEnvDev.id,
104+
skipDequeueProcessing: true,
105+
});
106+
107+
// The master queue still points at this base queue.
108+
expect(await queue.redis.zcard(masterQueueKey)).toBe(1);
109+
110+
// Both variants are registered, and both runs come back out.
111+
const ckIndexKey = testOptions.keys.ckIndexKeyFromQueue(
112+
testOptions.keys.queueKey(authenticatedEnvDev, QUEUE, "user-1")
113+
);
114+
expect((await queue.redis.zrange(ckIndexKey, 0, -1)).length).toBe(2);
115+
116+
const served = await queue.testDequeueFromMasterQueue(shard, authenticatedEnvDev.id, 10);
117+
expect(served.map((m) => m.messageId).sort()).toEqual(["r-star", "r-victim"]);
118+
} finally {
119+
await queue.quit();
120+
}
121+
});
122+
123+
redisTest("acking it leaves the base queue reachable", async ({ redisContainer }) => {
124+
const queue = createQueue(redisContainer);
125+
try {
126+
const t0 = Date.now() - 100_000;
127+
const shard = testOptions.keys.masterQueueShardForEnvironment(authenticatedEnvDev.id, 2);
128+
const masterQueueKey = testOptions.keys.masterQueueKeyForShard(shard);
129+
130+
await queue.enqueueMessage({
131+
env: authenticatedEnvDev,
132+
message: makeMessage({ runId: "r-star", concurrencyKey: "*", timestamp: t0 }),
133+
workerQueue: authenticatedEnvDev.id,
134+
skipDequeueProcessing: true,
135+
});
136+
await queue.enqueueMessage({
137+
env: authenticatedEnvDev,
138+
message: makeMessage({ runId: "r-victim", concurrencyKey: "user-1", timestamp: t0 + 1 }),
139+
workerQueue: authenticatedEnvDev.id,
140+
skipDequeueProcessing: true,
141+
});
142+
143+
// Ack the '*' run while the other key still has work queued: the ack script runs the
144+
// same rebalance-then-cleanup pair as the enqueue one.
145+
await queue.acknowledgeMessage(authenticatedEnvDev.organization.id, "r-star", {
146+
skipDequeueProcessing: true,
147+
});
148+
149+
expect(await queue.redis.zcard(masterQueueKey)).toBe(1);
150+
151+
const served = await queue.testDequeueFromMasterQueue(shard, authenticatedEnvDev.id, 10);
152+
expect(served.map((m) => m.messageId)).toEqual(["r-victim"]);
153+
} finally {
154+
await queue.quit();
155+
}
156+
});
157+
158+
redisTest("nacking it leaves the base queue reachable", async ({ redisContainer }) => {
159+
const queue = createQueue(redisContainer);
160+
try {
161+
const t0 = Date.now() - 100_000;
162+
const shard = testOptions.keys.masterQueueShardForEnvironment(authenticatedEnvDev.id, 2);
163+
const masterQueueKey = testOptions.keys.masterQueueKeyForShard(shard);
164+
165+
await queue.enqueueMessage({
166+
env: authenticatedEnvDev,
167+
message: makeMessage({ runId: "r-star", concurrencyKey: "*", timestamp: t0 }),
168+
workerQueue: authenticatedEnvDev.id,
169+
skipDequeueProcessing: true,
170+
});
171+
172+
const [dequeued] = await queue.testDequeueFromMasterQueue(shard, authenticatedEnvDev.id, 1);
173+
expect(dequeued?.messageId).toBe("r-star");
174+
175+
await queue.nackMessage({
176+
orgId: authenticatedEnvDev.organization.id,
177+
messageId: "r-star",
178+
retryAt: Date.now() - 1,
179+
skipDequeueProcessing: true,
180+
});
181+
182+
expect(await queue.redis.zcard(masterQueueKey)).toBe(1);
183+
184+
const served = await queue.testDequeueFromMasterQueue(shard, authenticatedEnvDev.id, 1);
185+
expect(served.map((m) => m.messageId)).toEqual(["r-star"]);
186+
} finally {
187+
await queue.quit();
188+
}
189+
});
190+
});

0 commit comments

Comments
 (0)