Skip to content

Commit 021d530

Browse files
committed
chore: final reviews
1 parent 506bff4 commit 021d530

4 files changed

Lines changed: 171 additions & 24 deletions

File tree

docs/roadmap/12-horizontal-scaling.md

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,8 +164,11 @@ its balance, and it exists because without it recovery simply never happened:
164164
165165
So every transient replica loss used to cost balance permanently. The handoff closes that with two rules:
166166

167-
1. **A covering replica sheds when a spare is waiting.** The watcher, on finding no gaps, checks whether it holds
168-
more than one index while a spare is advertising — and if so restarts. Shutdown releases every index it holds.
167+
1. **An elected covering replica sheds when a spare is waiting.** The watcher, on finding no gaps, works out
168+
which replicas are covering (from the lease owners) and elects the lowest-primary ones, **capped at the number
169+
of spares actually waiting**. Shedding means restarting, and a restart releases _every_ index that replica
170+
holds — so letting all covering replicas react to one spare would black out far more than that spare can take
171+
back, and every restart past the first would be churn. Debounced over two checks, like the gap branch.
169172
2. **A replica stands down from greedy claiming while a spare is advertising.** This is the half that makes it
170173
stick: without it the shedding replica would grab its extra index straight back on the way up, swapping roles
171174
with the spare instead of rebalancing.
@@ -174,6 +177,22 @@ Suppressing the greedy step is deliberately all the negotiation there is — no
174177
Whoever is left claims what the other declined, on its next poll. If the spare dies mid-handoff its advertisement
175178
goes stale, the next boot covers as before, and a gap (if any) falls back to the watcher.
176179

180+
**It converges, but not always in one step.** The rule is "the shedder drops to its primary, the spare takes the
181+
rest", which is exact when one peer died and returned (`B[1,2]``B[1] S[2]`) and coarser the more a single
182+
replica had absorbed:
183+
184+
| Before | One spare returns | Split |
185+
| ------------------------------- | ------------------ | --------------------------- |
186+
| `A[0] B[1,2]` | `A[0] B[1] S[2]` | even |
187+
| `A[0] B[1,2,3]` | `A[0] B[1] S[2,3]` | even for three replicas |
188+
| `A[0,1,2,3]` (only one running) | `A[0] S[1,2,3]` | 1/3 — `2/2` would be better |
189+
190+
Each returning replica pulls the largest holder down to one index and takes what it drops, so `1/3` becomes
191+
`1/1/2` and then `1/1/1/1` as the rest come back. No step is ever worse than the state before it, coverage stays
192+
complete throughout, and the fully-staffed end state is one index each. Landing evenly in a _single_ step would
193+
mean a replica knowing how many peers are live and how many indices each holds — the cross-replica bookkeeping
194+
this design avoids everywhere else.
195+
177196
The remaining rough edge is a **straggler**: a replica starting well after its peers' settle window still idles
178197
rather than triggering an immediate rebalance — it only gets an index once a covering peer notices it. That is one
179198
watcher interval, not "until the next deploy".

packages/private/bot-core/src/lib/__tests__/replica.test.ts

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
1+
import { Buffer } from 'node:buffer';
12
import { setTimeout } from 'node:timers';
23
import { setTimeout as sleepMs } from 'node:timers/promises';
34
import { beforeEach, expect, test, vi } from 'vitest';
4-
import { claimReplicaSlot, computeTotalIndices, getReplicaIndex, shardIdsForIndices } from '../replica.js';
5+
import {
6+
claimReplicaSlot,
7+
computeTotalIndices,
8+
electGiveBackOwners,
9+
getReplicaIndex,
10+
shardIdsForIndices,
11+
} from '../replica.js';
512

613
const keys = new Map<string, string>();
714
const sortedSets = new Map<string, Map<string, number>>();
@@ -23,6 +30,11 @@ vi.mock('@chatsift/backend-core', () => ({
2330
async exists(key: string) {
2431
return keys.has(key) ? 1 : 0;
2532
},
33+
async get(key: string) {
34+
// Buffers, like the shared client's type mapping produces.
35+
const value = keys.get(key);
36+
return value === undefined ? null : Buffer.from(value);
37+
},
2638
async eval() {
2739
return 1;
2840
},
@@ -59,6 +71,13 @@ async function boot(shardCount: number, shardsPerReplica: number) {
5971
return claimReplicaSlot({ botId: 'AMA', shardCount, shardsPerReplica, settleMs: 0 });
6072
}
6173

74+
/**
75+
* Boots a replica that is expected to find nothing free, so it parks in the hot-spare loop and advertises.
76+
*/
77+
async function parkSpare() {
78+
return claimReplicaSlot({ botId: 'AMA', shardCount: 16, shardsPerReplica: 4, settleMs: 0, hotSparePollMs: 5 });
79+
}
80+
6281
/**
6382
* Mimics a replica parked in the hot-spare loop, which advertises itself on every poll.
6483
*/
@@ -334,3 +353,65 @@ test('a returning replica gets an index back instead of idling forever', async (
334353
expect(tookOver.heldIndices).toHaveLength(1);
335354
expect([...revived.heldIndices, ...tookOver.heldIndices].sort((left, right) => left - right)).toStrictEqual([2, 3]);
336355
});
356+
357+
test('a replica can hold three or four indices when peers are missing', async () => {
358+
const [a, b] = await Promise.all([boot(16, 4), boot(16, 4)]);
359+
expect(b.heldIndices).toStrictEqual([1, 2, 3]);
360+
expect(a.heldIndices).toStrictEqual([0]);
361+
362+
keys.clear();
363+
const lone = await boot(16, 4);
364+
expect(lone.heldIndices).toStrictEqual([0, 1, 2, 3]);
365+
});
366+
367+
test('each returning spare pulls the biggest holder down and never loses coverage', async () => {
368+
// The handoff is "the shedder drops to its primary, the spare takes the rest" -- exactly right when one
369+
// peer died and came back, and progressively coarser the more indices one replica had absorbed. What must
370+
// hold at every step is that coverage stays complete and the spare stops being idle.
371+
let held = [await boot(16, 4)];
372+
expect(held[0]!.heldIndices).toStrictEqual([0, 1, 2, 3]);
373+
374+
// Replicas trickle back one at a time; each one forces the largest holder to shed.
375+
for (const expectedMax of [3, 2, 1]) {
376+
const spare = parkSpare();
377+
await sleepMs(30);
378+
379+
const covering = held.find((slot) => slot.heldIndices.length > 1)!;
380+
for (const index of covering.heldIndices) {
381+
keys.delete(`shardlease:AMA:${index}`);
382+
}
383+
384+
const [rebooted, tookOver] = await Promise.all([boot(16, 4), spare]);
385+
held = [...held.filter((slot) => slot !== covering), rebooted, tookOver];
386+
387+
// Coverage is whole again, and nobody is idle.
388+
expect(held.flatMap((slot) => slot.heldIndices).sort((left, right) => left - right)).toStrictEqual([0, 1, 2, 3]);
389+
expect(Math.max(...held.map((slot) => slot.heldIndices.length))).toBe(expectedMax);
390+
}
391+
392+
// Fully staffed: one index each.
393+
expect(held.map((slot) => slot.heldIndices.length)).toStrictEqual([1, 1, 1, 1]);
394+
});
395+
396+
test('only as many replicas give back as there are spares to take the work', () => {
397+
// Two replicas each covering two indices, one spare. A restart sheds *every* index a replica holds, so if
398+
// both reacted the cluster would go fully dark to hand back a single index -- and the second restart would
399+
// achieve nothing, since one spare can only absorb one.
400+
const owners = ['r0', 'r0', 'r1', 'r1'];
401+
402+
expect(electGiveBackOwners(owners, 1)).toStrictEqual(['r0']);
403+
expect(electGiveBackOwners(owners, 2)).toStrictEqual(['r0', 'r1']);
404+
// More spares than covering replicas is fine -- nobody extra is elected.
405+
expect(electGiveBackOwners(owners, 5)).toStrictEqual(['r0', 'r1']);
406+
});
407+
408+
test('nobody gives back when no spare is waiting, or when nobody is covering', () => {
409+
expect(electGiveBackOwners(['r0', 'r0', 'r1', 'r1'], 0)).toStrictEqual([]);
410+
expect(electGiveBackOwners(['r0', 'r1', 'r2', 'r3'], 2)).toStrictEqual([]);
411+
});
412+
413+
test('give-back is elected lowest primary first, and ignores unclaimed indices', () => {
414+
// Deterministic ordering matters: every replica runs this independently and must reach the same answer.
415+
expect(electGiveBackOwners(['r2', 'r2', 'r0', 'r0'], 1)).toStrictEqual(['r2']);
416+
expect(electGiveBackOwners([null, 'r1', 'r1', null], 1)).toStrictEqual(['r1']);
417+
});

packages/private/bot-core/src/lib/replica.ts

Lines changed: 67 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -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
*/
269300
function 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
*/
369417
async function releaseAll(botId: GuildListKey, heldIndices: number[], token: string): Promise<void> {
370418
const { redis } = getContext();

packages/private/bot-core/src/lib/shutdown.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,7 @@ async function shutdown(signal: string): Promise<void> {
5555
await sleep(DRAIN_GRACE_MS);
5656
await runRegisteredSteps();
5757

58-
// TODO: We don't kill the gateway to prevent it from settings state to null/none. Should investigate
59-
// if discord.js is making the right assumption here, and if not, open a PR.
58+
// TODO: discord.js PR
6059
try {
6160
await Promise.all([redis.quit(), db.end({ timeout: 2 })]);
6261
} catch (error) {

0 commit comments

Comments
 (0)