Skip to content

Commit 2cb7d5b

Browse files
committed
fix(run-engine): stop a '*' concurrency key stranding its whole base queue
A concurrency key is an unrestricted client string, so '*' reaches the queue unescaped, and queueKey renders it as a variant name byte-identical to the wildcard member the CK scripts keep in the master queue for that base queue. Each CK script rebalances the master queue with that wildcard member and then removes the 'old-format' entry for the variant it just touched. Where the variant IS the wildcard those two calls name the same member, so the cleanup undid the rebalance and took the base queue's only master-queue entry with it. Nothing then pointed at the queue, so every concurrency key on it silently stopped being dequeued until some later write happened to re-add the member. Guards the cleanup in all 10 CK scripts (4 enqueue, 6 ack/nack/dead-letter). No key-format change, so state already in Redis is repaired by the next write rather than needing a migration. Tests cover the enqueue, ack and nack paths, and fail without the guard.
1 parent 69f396f commit 2cb7d5b

3 files changed

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

0 commit comments

Comments
 (0)