Lesson 16 / 22
Automatic Failover
The in-memory store's availability: testing the sentinel processes' majority decision when their own views diverge, measuring outage duration and false-failover count across eight sentinel settings, counting how shortening the detection threshold shortens the outage while growing false failovers and split writes, showing that asynchronous replication guarantees a per-failover loss of delay times write rate, and calculating what the full copy a hot backup holds buys against outage and loss.
Contents
The previous lesson measured replication under the assumption that the primary node stayed up: a replica fell behind, overflowed its buffer, underwent a resync, but the node accepting writes always stayed the same. This lesson covers the moment that node stops, and asks two questions together: who decides it has stopped, and what remains once the decision is made.
Who Decides
Being unable to reach a node does not show that the node has stopped; the side that cannot
reach it may itself be broken. This is why the decision is not left to a single observer:
sentinel processes watch the primary independently, and failover begins only once a sentinel
majority votes the same way. The majority is floor(g/2)+1 votes; the mechanics of this
pattern, built up in the leader-election and recovery courses, are not repeated here.
This lesson’s question is the in-memory store’s own constraint. The store acknowledges a write from memory and does not wait for a replica’s apply; a backup is by definition behind the primary. The writes it does not have at the moment it takes over are loan records that were accepted and reported successful. Loss on failover is not a possibility, it is a guarantee; only its size remains to be measured.
Mechanism
The mechanism below is a model: no real sentinel process, network, or node is set up. A round is an abstract step; detection threshold, election, and load time are parameters counted in rounds, not measured time.
CU4 — the primary’s temporary pauses (2–7 rounds) and each sentinel’s own unreachability windows (3–6 rounds) come from separate generators and are independent of the setting. Rationale: the schedule is generated ahead of time, with a visible seed, so the same outage sequence can run across every setting; also, a sentinel’s own fault is indistinguishable from a pause in the node it is watching. CU5 — after a false failover, the old primary keeps accepting writes for 3 rounds. Rationale: learning that it has been displaced is not instantaneous; the records written during those rounds are split writes, discarded once the node is demoted to backup.
// failover/model.mjs — sentinel-driven failover is an IN-PROCESS MODEL. A round is an abstract // step; detection threshold, election, and load time are parameters counted in rounds, not // measured time. No real sentinel process, network, or node is set up. The previous lesson's // constraint still holds: replication is asynchronous, so writes the backup has not applied are // writes that were ACCEPTED but never arrived. export const ENTRY_BYTES = 240, RECORD_BYTES = 96, KEYS = 2000; export function generator(seed) { // linear congruential generator; seed is visible let s = seed >>> 0; return () => { s = (Math.imul(s, 1103515245) + 12345) >>> 0; return s / 4294967296; }; } export function run({ sentinels = 3, downSentinels = 0, detection = 3, election = 2, delay = 2, cold = null, rounds = 300, writes = 40, outage = 200, splitRounds = 3, pauseProb = 0.02, selfProb = 0.03, seed = 20260731 }) { const rnd = generator(seed); const schedule = (prob, minLen, variety) => { // schedules are generated independent of setting const d = new Uint8Array(rounds + 2); for (let t = 1; t <= rounds; t += 1) { if (d[t] || rnd() >= prob) continue; for (let x = t, b = minLen + Math.floor(rnd() * variety); x < t + b && x <= rounds; x += 1) d[x] = 1; } return d; }; const pause = schedule(pauseProb, 2, 6); // the primary's temporary pauses const own = [...Array(7)].map(() => schedule(selfProb, 3, 4)); // a sentinel's OWN unreachability const majority = Math.floor(sentinels / 2) + 1, voters = sentinels - downSentinels; const wait = election + (cold ? cold.load : 0), counter = Array(7).fill(0); let accepted = 0, applied = 0, outageActive = true, remaining = 0, cause = null, splitRemaining = 0; let writelessRounds = 0, realFailovers = 0, falseFailovers = 0, lostWrites = 0, splitWrites = 0, outageLoss = 0, outageDuration = null; for (let t = 1; t <= rounds; t += 1) { if (splitRemaining > 0) { splitRemaining -= 1; splitWrites += writes; } // old primary still writing const unreachable = (outageActive && t >= outage) || pause[t] === 1; if (remaining > 0) { // failover window: writes not accepted remaining -= 1; writelessRounds += 1; if (remaining > 0) continue; const floor = cold // cold backup starts from the last snapshot ? Math.floor(accepted / (cold.interval * writes)) * cold.interval * writes : applied; lostWrites += accepted - floor; if (cause === "real") { realFailovers += 1; outageActive = false; outageDuration = t - outage + 1; outageLoss = accepted - floor; } else { falseFailovers += 1; splitRemaining = splitRounds; } accepted = floor; applied = floor; counter.fill(0); for (let x = t; x <= rounds && pause[x] === 1; x += 1) pause[x] = 0; // new primary is not in that pause continue; } if (unreachable) writelessRounds += 1; else { accepted += writes; applied = Math.max(applied, accepted - delay * writes); } let votes = 0; // each sentinel votes by its OWN view for (let j = 0; j < voters; j += 1) { counter[j] = unreachable || own[j][t] === 1 ? counter[j] + 1 : 0; if (counter[j] >= detection) votes += 1; } if (votes >= majority) { remaining = wait; cause = outageActive && t >= outage ? "real" : "false"; } } return { majority, voters, realFailovers, falseFailovers, lostWrites, outageLoss, splitWrites, writelessRounds, outageDuration, data: (cold ? 1 : 2) * KEYS * ENTRY_BYTES }; // hot backup keeps the data set in memory }
// failover/measure.mjs — same outage across three settings: sentinel majority, detection threshold, backup kind import { run, ENTRY_BYTES, RECORD_BYTES, KEYS } from "./model.mjs"; const s = (x, n) => String(x).padStart(n); const e = (x, n) => String(x).padEnd(n); const kib = (b) => (b / 1024).toFixed(1) + " KiB"; const row = (cells, widths) => cells.map((c, i) => s(c, widths[i])).join(" | "); console.log("300 rounds; each round writes 40 loan records. Primary stops permanently at round 200. Seed"); console.log("20260731: temporary pauses of 2-7 rounds at the primary, each sentinel gets its own"); console.log("unreachability windows of 3-6 rounds. Delay 2 rounds, detection 3, election 2 rounds.\n"); const W1 = [9, 4, 8, 6, 16, 16, 9]; console.log(row(["sentinels", "down", "majority", "voters", "outage duration", "false failovers", "writeless"], W1)); console.log(W1.map((w) => "-".repeat(w)).join("-|-")); for (const [g, d] of [[1, 0], [2, 0], [3, 0], [3, 1], [3, 2], [5, 0], [5, 2], [5, 3]]) { const r = run({ sentinels: g, downSentinels: d }); console.log(row([g, d, r.majority, r.voters, r.outageDuration === null ? "n/a" : r.outageDuration + " rd", r.falseFailovers, r.writelessRounds + " rd"], W1)); } console.log("\n3 sentinels, all up; detection threshold varies:"); const W2 = [9, 16, 12, 16, 9, 10]; console.log(row(["threshold", "false failovers", "split writes", "outage duration", "writeless", "total lost"], W2)); console.log(W2.map((w) => "-".repeat(w)).join("-|-")); for (const a of [1, 2, 3, 4, 5, 6]) { const r = run({ detection: a }); console.log(row([a, r.falseFailovers, r.splitWrites, r.outageDuration + " rd", r.writelessRounds + " rd", r.lostWrites], W2)); } console.log("\nBackup kind. Temporary pauses off: a single-outage failover. Cold backup's data is not"); console.log("in memory; the last snapshot is 30 rounds old, loading it takes 12 rounds:"); const W3 = [13, 9, 16, 11, 10]; console.log(e("backup", W3[0]) + " | " + row(["data held", "outage duration", "lost writes", "lost bytes"], W3.slice(1))); console.log(W3.map((w) => "-".repeat(w)).join("-|-")); for (const g of [1, 2, 4, 8]) { const r = run({ delay: g, pauseProb: 0, selfProb: 0 }); console.log(e("hot, delay " + g, W3[0]) + " | " + row([kib(r.data), r.outageDuration + " rd", r.outageLoss, r.outageLoss * RECORD_BYTES + " B"], W3.slice(1))); } const c = run({ pauseProb: 0, selfProb: 0, cold: { load: 12, interval: 30 } }); console.log(e("cold backup", W3[0]) + " | " + row([kib(c.data), c.outageDuration + " rd", c.outageLoss, c.outageLoss * RECORD_BYTES + " B"], W3.slice(1))); console.log("\nquantities independent of the run:"); console.log(" sentinel majority = floor(g/2)+1; sentinel loss tolerated = g - majority"); console.log(" g : " + [1, 2, 3, 4, 5, 6, 7].map((n) => s(n, 4)).join("")); console.log(" majority : " + [1, 2, 3, 4, 5, 6, 7].map((n) => s(Math.floor(n / 2) + 1, 4)).join("")); console.log(" tolerated: " + [1, 2, 3, 4, 5, 6, 7].map((n) => s(n - (Math.floor(n / 2) + 1), 4)).join("")); console.log(" lost per failover = delay x write rate (hot backup)"); console.log(" delay : " + [1, 2, 4, 8].map((n) => s(n, 6)).join("")); console.log(" lost : " + [1, 2, 4, 8].map((n) => s(n * 40, 6)).join("")); console.log(" outage duration = detection threshold + election (+ load time for cold backup) rounds"); console.log(" worst-case loss for cold backup = snapshot interval x write rate = 30 x 40 = 1200"); console.log(" hot backup's cost = " + (KEYS * ENTRY_BYTES / 1024).toFixed(1) + " KiB, i.e. a second full copy of the data set (" + KEYS + " x " + ENTRY_BYTES + " bytes)");
300 rounds; each round writes 40 loan records. Primary stops permanently at round 200. Seed
20260731: temporary pauses of 2-7 rounds at the primary, each sentinel gets its own
unreachability windows of 3-6 rounds. Delay 2 rounds, detection 3, election 2 rounds.
sentinels | down | majority | voters | outage duration | false failovers | writeless
----------|------|----------|--------|------------------|------------------|----------
1 | 0 | 1 | 1 | 6 rd | 12 | 40 rd
2 | 0 | 2 | 2 | 5 rd | 6 | 29 rd
3 | 0 | 2 | 3 | 5 rd | 9 | 35 rd
3 | 1 | 2 | 2 | 5 rd | 6 | 29 rd
3 | 2 | 2 | 1 | n/a | 0 | 115 rd
5 | 0 | 3 | 5 | 5 rd | 4 | 25 rd
5 | 2 | 3 | 3 | 5 rd | 4 | 25 rd
5 | 3 | 3 | 2 | n/a | 0 | 115 rd
3 sentinels, all up; detection threshold varies:
threshold | false failovers | split writes | outage duration | writeless | total lost
----------|------------------|--------------|------------------|-----------|-----------
1 | 13 | 1560 | 3 rd | 33 rd | 960
2 | 11 | 1320 | 5 rd | 34 rd | 880
3 | 9 | 1080 | 5 rd | 35 rd | 800
4 | 4 | 480 | 6 rd | 28 rd | 400
5 | 2 | 240 | 7 rd | 26 rd | 240
6 | 2 | 240 | 8 rd | 28 rd | 240
Backup kind. Temporary pauses off: a single-outage failover. Cold backup's data is not
in memory; the last snapshot is 30 rounds old, loading it takes 12 rounds:
backup | data held | outage duration | lost writes | lost bytes
--------------|-----------|------------------|-------------|-----------
hot, delay 1 | 937.5 KiB | 5 rd | 40 | 3840 B
hot, delay 2 | 937.5 KiB | 5 rd | 80 | 7680 B
hot, delay 4 | 937.5 KiB | 5 rd | 160 | 15360 B
hot, delay 8 | 937.5 KiB | 5 rd | 320 | 30720 B
cold backup | 468.8 KiB | 17 rd | 760 | 72960 B
quantities independent of the run:
sentinel majority = floor(g/2)+1; sentinel loss tolerated = g - majority
g : 1 2 3 4 5 6 7
majority : 1 2 2 3 3 4 4
tolerated: 0 0 1 1 2 2 3
lost per failover = delay x write rate (hot backup)
delay : 1 2 4 8
lost : 40 80 160 320
outage duration = detection threshold + election (+ load time for cold backup) rounds
worst-case loss for cold backup = snapshot interval x write rate = 30 x 40 = 1200
hot backup's cost = 468.8 KiB, i.e. a second full copy of the data set (2000 x 240 bytes)
Two Faces of the Majority
When the majority cannot be reached, failover never starts. When two of three sentinels or three of five go down, the surviving sentinels correctly see that the primary has stopped; the vote count still falls short of the majority. The result is in the last column: 115 writeless rounds. This is the rule itself: a sentinel cannot decide until the others confirm what it sees.
The majority mainly guards against a sentinel’s own misjudgment. In the single-sentinel row, the majority is 1, and the sentinel’s own unreachability windows turn directly into decisions: 12 false failovers. At five sentinels, the majority rises to 3, and with the same schedule false failovers drop to 4. The primary’s outage schedule is identical across every row; what changes is whether one wrong sentinel is enough on its own.
The three-sentinel row looks backward at first glance: the majority is still 2, yet false failovers climb from 6 to 9. The reason is the voter count — at two sentinels, both must be wrong at once; at three, any two of them being wrong is enough, three times as many pairs. The confirmation is in the same table: when one of the three sentinels goes down, the row produces exactly the same numbers as the two-sentinel row.
The Price of the Threshold
The second table measures the detection threshold in both directions at once. At threshold 1, the real outage’s duration drops to 3 rounds; the same row shows 13 false failovers and 1,560 split-write records. At threshold 6, false failovers drop to 2 and split writes to 240; in exchange, the outage duration climbs to 8 rounds.
Split writes are the most expensive line item in this trade-off. In a false failover, the old primary is healthy and keeps accepting loan records for three rounds; these records never reach the new primary, and are discarded once the old node is demoted to backup. The difference between threshold 1 and threshold 6 in this line item is 1,320 records.
The total-lost column moves in the same direction (960 versus 240): every failover, right or wrong, produces its own loss. The writeless-round column is not one-directional (33, 34, 35, 28, 26, 28) — raising the threshold extends the outage on the real failure but closes the windows opened by false failovers.
The Backup’s Memory Cost
The third table turns off temporary pauses and isolates a single real outage. In the hot-backup
rows, the loss is exactly delay × write rate: 40, 80, 160, and 320 loan records. The outage
duration is 5 rounds across all four rows; delay grows the loss, not the outage.
The cold-backup row shows this course’s rule directly. When the backup’s data is not held in memory, data held drops from 937.5 KiB to 468.8 KiB. In exchange, the outage duration climbs from 5 rounds to 17, and the loss climbs from 80 records to 760 (72,960 bytes; up to 1,200 records in the worst case). The hot backup’s 468.8 KiB cost buys exactly this: a third of the outage and a tenth of the loss.
Summary
- The sentinel majority is
floor(g/2)+1; when two of three sentinels or three of five went down, failover never started, and not a single loan record was accepted in the 115 rounds after the outage. - The majority rule mainly guards against a sentinel’s own misjudgment: with the same outage schedule, 12 false failovers were measured at one sentinel, 4 at five.
- Adding sentinels without raising the majority ratio works in reverse: two sentinels (majority 2) gave 6 false failovers, three sentinels (majority still 2) gave 9.
- The detection threshold cuts both ways: at threshold 1, the outage is 3 rounds, false failovers 13, split writes 1,560 records; at threshold 6, the outage is 8 rounds, false failovers 2, split writes 240.
- Asynchronous replication guarantees a per-failover loss, and its size is
delay × write rate: 40, 80, 160, 320 loan records — all of them records that were reported successful. - A hot backup holds a second full copy of the data set, 468.8 KiB; in exchange it cuts the outage from 17 rounds to 5 and the loss from 760 records to 80.
Next Step
The two lessons so far carried the same implicit assumption: the entire data set fits on a single node. Replication produced copies of that set, failover promoted one of the copies into service; neither one split the set. When catalog and loan records outgrow a single node’s memory budget, only one path remains: splitting the key space. Its cost shows up immediately — if two keys touched by the same transaction land on different nodes, that transaction cannot run on a single node. The next lesson counts how a key is mapped to a partition, how many multi-key transactions run into the cluster’s cross-node constraint, and how much tagging keys into the same partition unbalances the bytes held per node.
To keep your progress and take notes, Log in
My notes
Log in to take notes.