Skip to content

Commit ba57004

Browse files
committed
fix(redis-worker): make the orphan sweep fail safe and page set members
Refuse to sweep when given no in-flight data keys: with nowhere to look for running messages every member would look orphaned and the sweep would erase all concurrency accounting at once. Guarded by a test. Iterate set members with SSCAN pages instead of one SMEMBERS snapshot, so a set that has accumulated many leaked members never forces an unbounded blocking read. The atomic per-member re-check makes the looser snapshot semantics safe.
1 parent 21eb0d3 commit ba57004

2 files changed

Lines changed: 80 additions & 16 deletions

File tree

packages/redis-worker/src/fair-queue/concurrency.ts

Lines changed: 43 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ import type {
88
} from "./types.js";
99

1010
/**
11-
* Caps how many set members a single sweep script invocation checks, bounding the time the
12-
* atomic Lua script can hold up Redis when a set has accumulated many leaked members.
11+
* Page size for iterating a concurrency set's members (SSCAN COUNT) and cap on how many
12+
* members one sweep script invocation checks, bounding both the snapshot reads and the
13+
* time the atomic Lua script can hold up Redis when a set has accumulated many leaked
14+
* members.
1315
*/
1416
const SWEEP_MEMBER_CHUNK_SIZE = 500;
1517

@@ -184,12 +186,21 @@ export class ConcurrencyManager {
184186
* record can only be a leak; if the message is about to be re-claimed, reserve simply
185187
* re-adds the member.
186188
*
187-
* @param inflightDataKeys - The in-flight data hash keys for every shard
189+
* @param inflightDataKeys - The in-flight data hash keys for every shard. The sweep
190+
* refuses to run when this is empty, since with nowhere to look for running messages
191+
* every member would look orphaned and all concurrency accounting would be erased.
188192
* @returns The message ids that were removed, and how many sets were checked
189193
*/
190194
async sweepOrphanedSlots(
191195
inflightDataKeys: string[]
192196
): Promise<{ scannedSets: number; removed: string[] }> {
197+
if (inflightDataKeys.length === 0) {
198+
this.logger.error(
199+
"Refusing to sweep concurrency slots without any in-flight data keys: every member would look orphaned and all concurrency accounting would be erased"
200+
);
201+
return { scannedSets: 0, removed: [] };
202+
}
203+
193204
const keyPrefix = this.options.redis.keyPrefix ?? "";
194205
let scannedSets = 0;
195206
const removed: string[] = [];
@@ -213,20 +224,36 @@ export class ConcurrencyManager {
213224
keyPrefix && fullKey.startsWith(keyPrefix) ? fullKey.slice(keyPrefix.length) : fullKey;
214225

215226
try {
216-
const members = await this.redis.smembers(key);
217-
if (members.length === 0) {
218-
continue;
219-
}
220-
scannedSets++;
221-
222-
for (let i = 0; i < members.length; i += SWEEP_MEMBER_CHUNK_SIZE) {
223-
const chunk = members.slice(i, i + SWEEP_MEMBER_CHUNK_SIZE);
224-
const removedIds = await this.redis.removeOrphanedConcurrencySlots(
225-
1 + inflightDataKeys.length,
226-
[key, ...inflightDataKeys],
227-
...chunk
227+
let sawMembers = false;
228+
let memberCursor = "0";
229+
230+
do {
231+
const [nextMemberCursor, page] = await this.redis.sscan(
232+
key,
233+
memberCursor,
234+
"COUNT",
235+
SWEEP_MEMBER_CHUNK_SIZE
228236
);
229-
removed.push(...removedIds);
237+
memberCursor = nextMemberCursor;
238+
239+
if (page.length === 0) {
240+
continue;
241+
}
242+
sawMembers = true;
243+
244+
for (let i = 0; i < page.length; i += SWEEP_MEMBER_CHUNK_SIZE) {
245+
const chunk = page.slice(i, i + SWEEP_MEMBER_CHUNK_SIZE);
246+
const removedIds = await this.redis.removeOrphanedConcurrencySlots(
247+
1 + inflightDataKeys.length,
248+
[key, ...inflightDataKeys],
249+
...chunk
250+
);
251+
removed.push(...removedIds);
252+
}
253+
} while (memberCursor !== "0");
254+
255+
if (sawMembers) {
256+
scannedSets++;
230257
}
231258
} catch (error) {
232259
this.logger.error("Failed to sweep concurrency set, skipping it", {

packages/redis-worker/src/fair-queue/tests/concurrency.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -820,5 +820,42 @@ describe("ConcurrencyManager", () => {
820820
}
821821
}
822822
);
823+
824+
redisTest(
825+
"should refuse to sweep when given no in-flight data keys",
826+
{ timeout: 10000 },
827+
async ({ redisOptions }) => {
828+
keys = new DefaultFairQueueKeyProducer({ prefix: "test" });
829+
830+
const manager = new ConcurrencyManager({
831+
redis: redisOptions,
832+
keys,
833+
groups: [
834+
{
835+
name: "tenant",
836+
extractGroupId: (q) => q.tenantId,
837+
getLimit: async () => 5,
838+
defaultLimit: 5,
839+
},
840+
],
841+
});
842+
843+
const redis = createRedisClient(redisOptions);
844+
const concurrencyKey = keys.concurrencyKey("tenant", "t1");
845+
846+
try {
847+
await redis.sadd(concurrencyKey, "active-1", "active-2");
848+
849+
const result = await manager.sweepOrphanedSlots([]);
850+
851+
expect(result.removed).toEqual([]);
852+
expect((await redis.smembers(concurrencyKey)).sort()).toEqual(["active-1", "active-2"]);
853+
} finally {
854+
await redis.del(concurrencyKey);
855+
await redis.quit();
856+
await manager.close();
857+
}
858+
}
859+
);
823860
});
824861
});

0 commit comments

Comments
 (0)