Skip to content

Commit 506bff4

Browse files
committed
feat: give back work to new replicas
1 parent c8c99b9 commit 506bff4

10 files changed

Lines changed: 240 additions & 172 deletions

File tree

docs/roadmap/12-horizontal-scaling.md

Lines changed: 47 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -45,13 +45,21 @@ untrusted Discord input.
4545
3. Greedily claim any index still free immediately above.
4646
4. Own the union of the fixed slices belonging to every index held.
4747

48+
**Indices and shard ids are different number spaces**, and they collide confusingly at the low end — index `0`
49+
entitles its holder to shards `0-3`. Only _indices_ are ever leased in redis; shard ids are derived from them
50+
locally, by arithmetic. For a 14-shard bot there are four redis keys, not fourteen.
51+
4852
```
4953
shardCount=14, SHARDS_PER_REPLICA=4 -> totalIndices=4
5054
51-
index slices: idx0 [0-3] idx1 [4-7] idx2 [8-11] idx3 [12,13]
55+
redis key what holding it means
56+
shardlease:<bot>:0 -> run shards 0,1,2,3
57+
shardlease:<bot>:1 -> run shards 4,5,6,7
58+
shardlease:<bot>:2 -> run shards 8,9,10,11
59+
shardlease:<bot>:3 -> run shards 12,13
5260
53-
4 replicas: one index each -> 4 / 4 / 4 / 2 shards
54-
3 replicas: the third holds idx2 AND idx3 -> 4 / 4 / 6 shards
61+
4 replicas: one index each -> 4 / 4 / 4 / 2 shards
62+
3 replicas: the third holds indices 2 AND 3 -> 4 / 4 / 6 shards
5563
5 replicas: the surplus finds nothing free and waits as a hot spare
5664
```
5765

@@ -100,7 +108,8 @@ Genuine imbalance has exactly two causes, and both are the cluster not being at
100108
replicas must cover four indices' worth of shards, one of them holds more. The alternative is leaving those
101109
shards uncovered for as long as the replica stays away, which is the trade this design refuses.
102110
- **A straggler.** A replica starting after its peers' settle window finds everything claimed and idles as a hot
103-
spare while some peer holds two indices. See below.
111+
spare while some peer holds two indices — until that peer notices it and hands one back, see
112+
[Handing an index back](#handing-an-index-back).
104113

105114
Perfect balance under a changing replica count would mean re-slicing `shardCount` across however many replicas are
106115
currently live — which makes every replica's assignment depend on every other's liveness, so one replica
@@ -114,11 +123,11 @@ than one replica temporarily carrying an extra index.
114123
replica holds the union of every index it claims, and it claims extras precisely when peers are missing — which is
115124
also when the surviving replicas are carrying the most load:
116125

117-
| Cluster state | Shards on the heaviest replica |
118-
| ------------------------------ | ------------------------------ |
119-
| Fully provisioned | `SHARDS_PER_REPLICA` |
120-
| One peer missing | `2 × SHARDS_PER_REPLICA` |
121-
| Worst case (only one survivor) | the entire `shardCount` |
126+
| Cluster state | Shards on the heaviest replica |
127+
| ------------------------------ | ----------------------------------------------------------------------- |
128+
| Fully provisioned | `SHARDS_PER_REPLICA` |
129+
| One peer missing | `2 × SHARDS_PER_REPLICA`, until the peer returns and is handed one back |
130+
| Worst case (only one survivor) | the entire `shardCount` |
122131

123132
Size for at least **twice** `SHARDS_PER_REPLICA` if a single replica loss should be absorbed without degrading,
124133
and treat `shardsOwned` in the boot log (and the `covering for missing replicas` message) as the signal that a
@@ -141,11 +150,33 @@ is the one case with nothing below it, so the lowest holder takes that one.
141150

142151
This is only affordable because restarts RESUME. See below.
143152

144-
### Known wart: stragglers
153+
### Handing an index back
154+
155+
A replica that finds every index claimed idles as a **hot spare**, advertising itself in a redis sorted set
156+
(`shardspares:<botId>`, scored by when it last checked in). That advertisement is what lets the cluster recover
157+
its balance, and it exists because without it recovery simply never happened:
158+
159+
> Four replicas, `C` dies. `B` is elected, restarts, and comes back holding indices 1 _and_ 2 — correct, and
160+
> carrying double load. `C`'s container then recovers, finds all four indices claimed, and idles forever. There is
161+
> no gap any more, so the watcher never fires. `B` runs 2× load next to an idle container until the next
162+
> `./compose up`. Restarting `B` doesn't help either: `B` and the spare just race for the freed indices and swap
163+
> roles.
164+
165+
So every transient replica loss used to cost balance permanently. The handoff closes that with two rules:
166+
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.
169+
2. **A replica stands down from greedy claiming while a spare is advertising.** This is the half that makes it
170+
stick: without it the shedding replica would grab its extra index straight back on the way up, swapping roles
171+
with the spare instead of rebalancing.
172+
173+
Suppressing the greedy step is deliberately all the negotiation there is — no replica tells another what to take.
174+
Whoever is left claims what the other declined, on its next poll. If the spare dies mid-handoff its advertisement
175+
goes stale, the next boot covers as before, and a gap (if any) falls back to the watcher.
145176

146-
A replica starting well after its peers' settle window finds everything claimed and idles as a hot spare, leaving
147-
the cluster correct but unbalanced until the next restart. It is logged as a warning. Fixing it live needs
148-
cross-replica negotiation that is not worth it when `./compose` starts replicas together by construction.
177+
The remaining rough edge is a **straggler**: a replica starting well after its peers' settle window still idles
178+
rather than triggering an immediate rebalance — it only gets an index once a covering peer notices it. That is one
179+
watcher interval, not "until the next deploy".
149180

150181
## What actually had to change
151182

@@ -217,7 +248,9 @@ exit with its sockets open instead leaves Discord holding a resumable session. T
217248
- `claimed replica slot, covering for missing replicas` means the cluster is short — one replica is carrying more
218249
than its target. Coverage is fine; capacity is not.
219250
- `no free replica index, idling as a hot spare` means more replicas are running than the shard count needs, or a
220-
straggler missed its settle window.
251+
straggler missed its settle window. Paired with `a hot spare is waiting...restarting to hand them over` on a
252+
covering peer, that is the rebalance working; on its own for more than a watcher interval, nobody was covering.
253+
- `hot spare took over a freed replica index` closes that loop -- the spare is no longer idle.
221254
- `lost replica lease, restarting to re-derive shard assignment` means a renewal found somebody else holding the
222255
index. Rare and self-healing, but a repeated one means redis latency is eating the lease TTL.
223256
- `replica indices still unclaimed, restarting to take them over` means a peer died or was scaled away.

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

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { setTimeout } from 'node:timers';
2+
import { setTimeout as sleepMs } from 'node:timers/promises';
23
import { beforeEach, expect, test, vi } from 'vitest';
34
import { claimReplicaSlot, computeTotalIndices, getReplicaIndex, shardIdsForIndices } from '../replica.js';
45

56
const keys = new Map<string, string>();
7+
const sortedSets = new Map<string, Map<string, number>>();
68
const info = vi.fn();
79
const warn = vi.fn();
810

@@ -24,6 +26,25 @@ vi.mock('@chatsift/backend-core', () => ({
2426
async eval() {
2527
return 1;
2628
},
29+
async zAdd(key: string, { score, value }: { score: number; value: string }) {
30+
const set = sortedSets.get(key) ?? new Map<string, number>();
31+
set.set(value, score);
32+
sortedSets.set(key, set);
33+
},
34+
async zRem(key: string, member: string) {
35+
sortedSets.get(key)?.delete(member);
36+
},
37+
async zRemRangeByScore(key: string, min: number, max: number) {
38+
const set = sortedSets.get(key);
39+
for (const [member, score] of set ?? []) {
40+
if (score >= min && score <= max) {
41+
set!.delete(member);
42+
}
43+
}
44+
},
45+
async zCard(key: string) {
46+
return sortedSets.get(key)?.size ?? 0;
47+
},
2748
},
2849
}),
2950
}));
@@ -38,8 +59,16 @@ async function boot(shardCount: number, shardsPerReplica: number) {
3859
return claimReplicaSlot({ botId: 'AMA', shardCount, shardsPerReplica, settleMs: 0 });
3960
}
4061

62+
/**
63+
* Mimics a replica parked in the hot-spare loop, which advertises itself on every poll.
64+
*/
65+
function advertiseSpare(atMs = Date.now()) {
66+
sortedSets.set('shardspares:AMA', new Map([['a-waiting-spare', atMs]]));
67+
}
68+
4169
beforeEach(() => {
4270
keys.clear();
71+
sortedSets.clear();
4372
info.mockReset();
4473
warn.mockReset();
4574
});
@@ -176,6 +205,27 @@ test('indices are claimed lowest-first so index 0 always has an owner', async ()
176205
expect(slots.flatMap((slot) => slot.shardIds)).toContain(0);
177206
});
178207

208+
test('held indices are always a contiguous run, never hopping over a live peer', async () => {
209+
// `startWatching` picks the holder of `firstGap - 1` to close a gap, which is only a valid choice because a
210+
// replica's run stops dead at the first index a peer holds. If the greedy loop ever `continue`d instead of
211+
// breaking, a replica could hold [0, 3] and the watcher would start electing replicas that cannot actually
212+
// reach the gap -- with every other test still green. This is the guard for that.
213+
// Two replicas, five indices: indices 2-4 are free, but they sit above a live peer from A's point of view.
214+
const slots = await Promise.all([boot(20, 4), boot(20, 4)]);
215+
const [a, b] = slots.sort((left, right) => left.index - right.index);
216+
217+
// A stops dead at live B rather than hopping it to collect the free indices above.
218+
expect(a!.heldIndices).toStrictEqual([0]);
219+
// B is the one that reaches them, and takes them as an unbroken run.
220+
expect(b!.heldIndices).toStrictEqual([1, 2, 3, 4]);
221+
222+
// Stated as the general invariant: whatever a replica holds is always start..start+n with no holes.
223+
for (const slot of slots) {
224+
const expected = Array.from({ length: slot.heldIndices.length }, (_, offset) => slot.heldIndices[0]! + offset);
225+
expect(slot.heldIndices).toStrictEqual(expected);
226+
}
227+
});
228+
179229
test('a dead middle replica is taken over by the peer directly below it, not the lowest', async () => {
180230
// Re-derivation claims the lowest free index then extends upward, stopping at the first index a live peer
181231
// holds -- so a replica can never jump over a living peer. With A[0] B[1] C[2] D[3] and C gone, having A
@@ -236,3 +286,51 @@ test('a straggler that finds nothing free waits as a hot spare, then takes over
236286
await expect(straggler).resolves.toMatchObject({ index: 2, shardIds: [8, 9, 10, 11] });
237287
expect(warn.mock.calls[0]?.[1]).toBe('no free replica index, idling as a hot spare');
238288
});
289+
290+
test('a covering replica stands down from greedy claiming while a spare is waiting', async () => {
291+
advertiseSpare();
292+
293+
const slot = await boot(16, 4);
294+
295+
// Without the stand-down it would sweep up all four indices, leaving the spare nothing to take -- which is
296+
// what made a recovered failure stay permanently lopsided.
297+
expect(slot.heldIndices).toStrictEqual([0]);
298+
});
299+
300+
test('a spare that stopped advertising does not keep a replica from covering', async () => {
301+
// A spare that died mid-wait must not block its peers from covering the indices it will never claim.
302+
advertiseSpare(Date.now() - 120_000);
303+
304+
const slot = await boot(16, 4);
305+
306+
expect(slot.heldIndices).toStrictEqual([0, 1, 2, 3]);
307+
});
308+
309+
test('a returning replica gets an index back instead of idling forever', async () => {
310+
// Three replicas for four indices: one of them is covering.
311+
const slots = await Promise.all([boot(16, 4), boot(16, 4), boot(16, 4)]);
312+
const covering = slots.find((slot) => slot.heldIndices.length > 1)!;
313+
expect(covering.heldIndices).toStrictEqual([2, 3]);
314+
315+
// The fourth replica comes back. Nothing is free, so it advertises and waits.
316+
const returning = claimReplicaSlot({
317+
botId: 'AMA',
318+
shardCount: 16,
319+
shardsPerReplica: 4,
320+
settleMs: 0,
321+
hotSparePollMs: 10,
322+
});
323+
await sleepMs(40);
324+
325+
// The covering replica sheds: shutdown releases every index it holds, then it re-derives.
326+
for (const index of covering.heldIndices) {
327+
keys.delete(`shardlease:AMA:${index}`);
328+
}
329+
330+
const [revived, tookOver] = await Promise.all([boot(16, 4), returning]);
331+
332+
// Balanced: one index each, and between them they still cover everything the covering replica had.
333+
expect(revived.heldIndices).toHaveLength(1);
334+
expect(tookOver.heldIndices).toHaveLength(1);
335+
expect([...revived.heldIndices, ...tookOver.heldIndices].sort((left, right) => left - right)).toStrictEqual([2, 3]);
336+
});

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

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,6 @@ import { createSessionStore } from './sessions.js';
1010
import { onShutdown } from './shutdown.js';
1111

1212
export interface CreateBotGatewayOptions {
13-
/**
14-
* Namespaces this bot's gateway sessions, replica leases and identify buckets in the shared redis. Same
15-
* widened key as `createBotClient`'s, since a custom ModMail instance (#216) is its own Discord application
16-
* with its own sessions and its own shard count.
17-
*/
1813
readonly botId: GuildListKey;
1914
readonly intents: GatewayIntentBits;
2015
readonly rest: REST;
@@ -46,8 +41,6 @@ export async function createBotGateway({
4641
const { shardIds } = await claimReplicaSlot({
4742
botId,
4843
shardCount,
49-
// Unset means this replica takes everything, so the claim resolves to a single index. Same code path,
50-
// same redis keys, just a cluster of one.
5144
shardsPerReplica: ENV.SHARDS_PER_REPLICA ?? shardCount,
5245
});
5346

@@ -60,9 +53,6 @@ export async function createBotGateway({
6053
shardIds,
6154
retrieveSessionInfo: async (shardId) => sessions.retrieveSessionInfo(shardId),
6255
updateSessionInfo: async (shardId, sessionInfo) => sessions.updateSessionInfo(shardId, sessionInfo),
63-
// Read off the manager rather than the `gatewayInformation` above, matching how the default builds
64-
// `SimpleIdentifyThrottler` -- the manager caches its own fetch, so this costs nothing and stays correct
65-
// if the value is ever re-read (e.g. after `updateShardCount`).
6656
buildIdentifyThrottler: async (manager) =>
6757
createRedisIdentifyThrottler(
6858
botId,

0 commit comments

Comments
 (0)