@@ -45,13 +45,21 @@ untrusted Discord input.
45453 . Greedily claim any index still free immediately above.
46464 . 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```
4953shardCount=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
55635 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
105114Perfect balance under a changing replica count would mean re-slicing ` shardCount ` across however many replicas are
106115currently 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.
114123replica holds the union of every index it claims, and it claims extras precisely when peers are missing — which is
115124also 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
123132Size for at least ** twice** ` SHARDS_PER_REPLICA ` if a single replica loss should be absorbed without degrading,
124133and 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
142151This 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.
0 commit comments