@@ -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 */
1416const 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" , {
0 commit comments