@@ -44,12 +44,39 @@ async function withdrawSpare(botId: GuildListKey, token: string): Promise<void>
4444}
4545
4646/**
47- * Whether any replica is currently idle and waiting for an index, pruning advertisements that have gone stale.
47+ * How many replicas are currently idle and waiting for an index, pruning advertisements that have gone stale.
4848 */
49- async function hasWaitingSpare ( botId : GuildListKey ) : Promise < boolean > {
49+ async function countWaitingSpares ( botId : GuildListKey ) : Promise < number > {
5050 const { redis } = getContext ( ) ;
5151 await redis . zRemRangeByScore ( sparesKey ( botId ) , 0 , Date . now ( ) - SPARE_STALE_MS ) ;
52- return ( await redis . zCard ( sparesKey ( botId ) ) ) > 0 ;
52+ return redis . zCard ( sparesKey ( botId ) ) ;
53+ }
54+
55+ async function hasWaitingSpare ( botId : GuildListKey ) : Promise < boolean > {
56+ return ( await countWaitingSpares ( botId ) ) > 0 ;
57+ }
58+
59+ /**
60+ * Which replicas should give an index back this round, lowest primary first, capped at the number of spares
61+ * actually waiting to take one.
62+ */
63+ export function electGiveBackOwners ( owners : ( string | null ) [ ] , spareCount : number ) : string [ ] {
64+ if ( spareCount <= 0 ) {
65+ return [ ] ;
66+ }
67+
68+ const indicesByOwner = new Map < string , number [ ] > ( ) ;
69+ for ( const [ index , owner ] of owners . entries ( ) ) {
70+ if ( owner ) {
71+ indicesByOwner . set ( owner , [ ...( indicesByOwner . get ( owner ) ?? [ ] ) , index ] ) ;
72+ }
73+ }
74+
75+ return [ ...indicesByOwner . entries ( ) ]
76+ . filter ( ( [ , indices ] ) => indices . length > 1 )
77+ . sort ( ( [ , left ] , [ , right ] ) => left [ 0 ] ! - right [ 0 ] ! )
78+ . slice ( 0 , spareCount )
79+ . map ( ( [ owner ] ) => owner ) ;
5380}
5481
5582/**
@@ -203,6 +230,10 @@ export async function claimReplicaSlot({
203230 // advertised, not silently: a peer covering two indices watches for this and hands one back.
204231 logger . warn ( { botId, totalIndices, shardCount, shardsPerReplica } , 'no free replica index, idling as a hot spare' ) ;
205232
233+ // Registered here rather than after a slot is claimed: a spare `SIGTERM`ed mid-idle would otherwise leave
234+ // its advertisement to expire, and a covering peer could restart to hand off to a replica already gone.
235+ onShutdown ( 'replica-spare' , async ( ) => withdrawSpare ( botId , token ) ) ;
236+
206237 while ( primary === null ) {
207238 await advertiseSpare ( botId , token ) ;
208239 await sleep ( hotSparePollMs ) ;
@@ -257,14 +288,14 @@ export async function claimReplicaSlot({
257288 shardIds . length > shardsPerReplica ? 'claimed replica slot, covering for missing replicas' : 'claimed replica slot' ,
258289 ) ;
259290
260- startWatching ( botId , heldIndices , totalIndices ) ;
291+ startWatching ( botId , heldIndices , totalIndices , token ) ;
261292 onShutdown ( 'replica-lease' , async ( ) => releaseAll ( botId , heldIndices , token ) ) ;
262293
263294 return { index : primary , heldIndices, shardIds } ;
264295}
265296
266297/**
267- * Re-asserts what indeces this replica is responsible for
298+ * Re-asserts what indices this replica is responsible for
268299 */
269300function startRenewing ( botId : GuildListKey , heldIndices : number [ ] , token : string ) : void {
270301 let lastRenewedAt = Date . now ( ) ;
@@ -311,18 +342,22 @@ function startRenewing(botId: GuildListKey, heldIndices: number[], token: string
311342 * `claimReplicaSlot` never claims past a live peer, so nobody else could fill it anyway. A gap at index 0
312343 * has nothing below it, so the lowest remaining holder takes that one.
313344 */
314- function startWatching ( botId : GuildListKey , heldIndices : number [ ] , totalIndices : number ) : void {
345+ function startWatching ( botId : GuildListKey , heldIndices : number [ ] , totalIndices : number , token : string ) : void {
315346 let consecutiveGaps = 0 ;
347+ let consecutiveGiveBacks = 0 ;
316348
317349 setInterval ( async ( ) => {
318350 const { logger, redis } = getContext ( ) ;
319351
320352 try {
321- const holders = await Promise . all (
322- Array . from ( { length : totalIndices } , async ( _ , index ) => redis . exists ( leaseKey ( botId , index ) ) ) ,
323- ) ;
324-
325- const gaps = holders . flatMap ( ( held , index ) => ( held ? [ ] : [ index ] ) ) ;
353+ // Owners rather than mere existence: the give-back branch has to see who holds what across the whole
354+ // cluster, not just whether an index is taken. Values come back as buffers under the shared client's
355+ // type mapping, so normalise before comparing to our own token.
356+ const owners = (
357+ await Promise . all ( Array . from ( { length : totalIndices } , async ( _ , index ) => redis . get ( leaseKey ( botId , index ) ) ) )
358+ ) . map ( ( owner ) => owner ?. toString ( ) ?? null ) ;
359+
360+ const gaps = owners . flatMap ( ( owner , index ) => ( owner ? [ ] : [ index ] ) ) ;
326361 if ( gaps . length === 0 ) {
327362 consecutiveGaps = 0 ;
328363
@@ -331,19 +366,32 @@ function startWatching(botId: GuildListKey, heldIndices: number[], totalIndices:
331366 // being a gap the moment it absorbs it. Nothing else would ever notice, so the cluster would run
332367 // permanently lopsided until the next deploy. Restarting sheds the extras (shutdown releases every
333368 // held index) and the greedy step stands down on the way back up, leaving them for the spare.
334- if ( heldIndices . length > 1 && ( await hasWaitingSpare ( botId ) ) ) {
335- logger . info (
336- { botId, heldIndices } ,
337- 'a hot spare is waiting and this replica is covering extra indices, restarting to hand them over' ,
338- ) ;
339- process . kill ( process . pid , 'SIGTERM' ) ;
369+
370+ // Elected rather than "whoever is covering", and debounced like the gap branch below: a restart
371+ // sheds every index this replica holds, so several covering replicas all reacting to one spare
372+ // would black out far more than that spare can take back.
373+ const givingBack = electGiveBackOwners ( owners , await countWaitingSpares ( botId ) ) ;
374+ if ( ! givingBack . includes ( token ) ) {
375+ consecutiveGiveBacks = 0 ;
376+ return ;
377+ }
378+
379+ consecutiveGiveBacks += 1 ;
380+ if ( consecutiveGiveBacks < 2 ) {
381+ return ;
340382 }
341383
384+ logger . info (
385+ { botId, heldIndices } ,
386+ 'a hot spare is waiting and this replica is covering extra indices, restarting to hand them over' ,
387+ ) ;
388+ process . kill ( process . pid , 'SIGTERM' ) ;
342389 return ;
343390 }
344391
392+ consecutiveGiveBacks = 0 ;
345393 const firstGap = gaps [ 0 ] ! ;
346- const lowestHeld = Math . min ( ...holders . flatMap ( ( held , index ) => ( held ? [ index ] : [ ] ) ) ) ;
394+ const lowestHeld = Math . min ( ...owners . flatMap ( ( owner , index ) => ( owner ? [ index ] : [ ] ) ) ) ;
347395 const canFillIt = firstGap === 0 ? lowestHeld === heldIndices [ 0 ] : heldIndices . includes ( firstGap - 1 ) ;
348396 if ( ! canFillIt ) {
349397 return ;
@@ -364,7 +412,7 @@ function startWatching(botId: GuildListKey, heldIndices: number[], totalIndices:
364412}
365413
366414/**
367- * Release all our indeces
415+ * Release all our indices
368416 */
369417async function releaseAll ( botId : GuildListKey , heldIndices : number [ ] , token : string ) : Promise < void > {
370418 const { redis } = getContext ( ) ;
0 commit comments