Lesson 05 / 20
Versioning and Aliases
Weight shifting through the alias is measured: the version stays unchanged, the alias moves. At every step, the affected requests and the round where the difference disappears are counted; cold start's interference with the canary measurement and rollback being a single key turn are worked out separately.
Published Updated
In this lesson
- Reads two numbers at every step of the weight ladder: the requests that fall to the new version and the first round where the decision can be made
- Shows cold start interfering with the canary measurement: as the share shrinks, the duration a single cold call adds to the average grows
- Counts rollback being a single write, and how much that changes once the old pool empties after a full cutover
Contents
Thresholds were counted: the duration limit, the memory limit, the concurrency cap. Which job does not fit inside these thresholds was written down. What happens to the job that does fit was never asked — once that function is written, how does it enter the running system?
The pattern side of gradual release — by which metric, which window, which threshold the decision is made — is a topic measured elsewhere and is not repeated here. The question here is narrower: this ladder runs through a single pointer called an alias, and the mechanism has two facts of its own. Rollback is a single write; cold start starts from zero on the new version.
- FC25. The fictional regional measurement network’s validation function receives nine hundred requests a minute; variation by time of day is not modeled. Both the network and the numbers are fictional.
- FC26. On a warm instance processing takes two hundred twenty milliseconds; cold start adds one thousand eight hundred milliseconds. Package size’s effect is outside this lesson.
- FC27. An idle instance is dropped four minutes later. The pool is separate per version: even though the alias moves, the two versions’ pools do not merge.
- FC28. The gate reads a single figure: the new version’s average latency. The baseline is measured while weight is zero, and the threshold is one point two five times the baseline.
- FC29. Requests arrive at exponential intervals; the generator is self-written, and the seed is visible. Every weight step contains five two-minute rounds.
- FC30. The alias write, propagation, and republish durations are fictional.
The Version Stays Fixed, the Alias Moves
What is measured is not a real runtime but a model built with node. There are two objects: a
version, a frozen record whose digest never changes; an alias, a pointer that points at
one of these versions and can be overwritten. A separate warm pool is kept per version.
// alias.mjs — version, alias, and warm pool model; not a real runtime, a model export const SEED = 20260420, REQ_MIN = 900; // fictional: requests per minute to the validation function export const SERVICE_MS = 220, COLD_EXTRA_MS = 1800, IDLE_MS = 240000; // warm, cold extra, idle lifetime export const ROUND_MIN = 2, ROUNDS = 5, GATE = 1.25, REGRESSION_MS = 120; export const WEIGHTS = [1, 2, 5, 25, 100]; export function rng(seed) { // linear congruential generator, seed is visible let x = seed >>> 0; return () => { x = (Math.imul(x, 1664525) + 1013904223) >>> 0; return x / 4294967296; }; } // A version is frozen: its digest never changes again. An alias is a pointer that can be overwritten. export const version = (label, digest) => Object.freeze({ label, digest }); export const alias = (name, target) => ({ name, target, writes: 0 }); export const move = (a, target) => Object.assign(a, { target, writes: a.writes + 1 }); // The pool is per version: an idle instance runs warm if found, otherwise a new instance is opened. export const pool = () => ({ instances: [], cold: 0 }); export const alive = (p, t) => p.instances.filter((o) => o.last + IDLE_MS > t).length; export function call(p, t, extraMs) { p.instances = p.instances.filter((o) => o.last + IDLE_MS > t); const o = p.instances.find((x) => x.busy <= t), duration = SERVICE_MS + extraMs + (o ? 0 : COLD_EXTRA_MS); if (o) { o.busy = t + duration; o.last = t + duration; } else { p.cold += 1; p.instances.push({ busy: t + duration, last: t + duration }); } return duration; } // A step: requests arrive at exponential intervals, and are routed to the old or new version by weight. export function step(rnd, poolOld, poolNew, weight, extraMs, t0, rounds = ROUNDS, roundMin = ROUND_MIN) { const lam = REQ_MIN / 60000, roundData = []; let t = t0 - Math.log(1 - rnd()) / lam; for (let k = 0; k < rounds; k += 1) { const end = t0 + (k + 1) * roundMin * 60000; let nNew = 0, nOld = 0, sumNew = 0, sumOld = 0, coldStart = poolNew.cold; while (t < end) { if (rnd() * 100 < weight) { sumNew += call(poolNew, t, extraMs); nNew += 1; } else { sumOld += call(poolOld, t, 0); nOld += 1; } t -= Math.log(1 - rnd()) / lam; } roundData.push({ nNew, avgNew: nNew ? sumNew / nNew : 0, avgOld: nOld ? sumOld / nOld : 0, cold: poolNew.cold - coldStart }); } return roundData; } // Ladder: baseline is read first while weight is zero, then weight grows. Pools carry over between // steps: the new version's pool is empty only on the first step. export function ladder(seed, extraMs, weights = WEIGHTS, warm = 0) { const rnd = rng(seed), poolOld = pool(), poolNew = pool(); const baseline = step(rnd, poolOld, poolNew, 0, 0, 0, ROUNDS).reduce((x, y) => x + y.avgOld, 0) / ROUNDS; const rows = []; let t = ROUNDS * ROUND_MIN * 60000, affected = 0; for (let i = 0; i < warm; i += 1) call(poolNew, t - 30000, 0); // synchronous synthetic warm-up calls for (const w of weights) { const roundData = step(rnd, poolOld, poolNew, w, extraMs, t, ROUNDS); t += ROUNDS * ROUND_MIN * 60000; affected += roundData.reduce((x, y) => x + y.nNew, 0); rows.push({ weight: w, roundData, affected }); } return { baseline, rows }; } export const sign = (x) => `${x < 0 ? '' : '+'}${Math.round(x)}`; export const row = (g, ...x) => x.map((v, i) => String(v).padEnd(g[i])).join('');
The handed-off decision shows up here: which request goes to which version, how many instances open, is not written down — a single number is written in its place. The constraint sits in the same spot: because the pool is separate per version, the new version does its warming from zero.
Two Numbers at Every Step
// run1.mjs — alias weight is shifted step by step; cold calls, latency, and the gate are read at each step import { ladder, version, alias, move, sign, row, SEED, REQ_MIN, SERVICE_MS, COLD_EXTRA_MS, GATE, REGRESSION_MS, WEIGHTS } from './alias.mjs'; const v7 = version('v7', 'e3bb1237'), v8 = version('v8', 'd8bb00e6'), live = alias('live', v7); try { v7.digest = 'overwritten'; } catch { /* writing to a frozen record is rejected */ } console.log(`seed ${SEED} | ${REQ_MIN} req/min | warm ${SERVICE_MS} ms | cold extra ${COLD_EXTRA_MS} ms`); console.log(`alias "${live.name}" -> ${live.target.label}, moved -> ${move(live, v8).target.label}` + ` | writes ${live.writes} | digest ${v7.digest} (could not be overwritten)`); const G = [9, 11, 13, 12, 12, 13, 13, 13]; for (const [label, extra, show] of [['clean version', 0, WEIGHTS], [`flawed version (+${REGRESSION_MS} ms real regression)`, REGRESSION_MS, [1]]]) { const { baseline, rows } = ladder(SEED, extra); console.log(`\n${label} | ladder ${WEIGHTS.join('-')} | baseline ${Math.round(baseline)} ms | gate threshold ${Math.round(baseline * GATE)} ms`); console.log(row(G, 'weight', 'req/round', 'round1 cold', 'round1 avg', 'round2 avg', 'round1 diff', 'round2 diff', 'first clean') + 'affected'); for (const s of rows.filter((x) => show.includes(x.weight))) { const [a, b] = s.roundData, t = s.roundData.findIndex((x) => x.avgNew <= baseline * GATE); console.log(row(G, `${s.weight}%`, a.nNew, a.cold, Math.round(a.avgNew), Math.round(b.avgNew), sign(a.avgNew - baseline), sign(b.avgNew - baseline), t < 0 ? 'none' : `round ${t + 1}`) + s.affected); } }
seed 20260420 | 900 req/min | warm 220 ms | cold extra 1800 ms alias "live" -> v7, moved -> v8 | writes 1 | digest e3bb1237 (could not be overwritten) clean version | ladder 1-2-5-25-100 | baseline 226 ms | gate threshold 283 ms weight req/round round1 cold round1 avg round2 avg round1 diff round2 diff first clean affected 1% 21 2 391 220 +165 -6 round 2 85 2% 38 1 267 220 +41 -6 round 1 266 5% 99 1 238 220 +12 -6 round 1 717 25% 468 2 228 220 +1 -6 round 1 2968 100% 1823 7 227 220 +1 -6 round 1 11969 flawed version (+120 ms real regression) | ladder 1-2-5-25-100 | baseline 226 ms | gate threshold 283 ms weight req/round round1 cold round1 avg round2 avg round1 diff round2 diff first clean affected 1% 21 2 511 340 +285 +114 none 85
The second line confirms the lesson’s title: the alias moved from v7 to v8 — one write — and
at the same moment, the attempt to write to v7’s digest was rejected. Release is not changing
records; it is updating a single field that can be overwritten.
The affected requests column writes the gain from shifting: eighty-five at the end of the 1% step, eleven thousand nine hundred sixty-nine once it reaches 100% — a difference close to one hundred forty times.
The round-where-it-clears column, though, breaks down in an unexpected place. In the clean version, the first round of the 1% step trips the gate: the average is three hundred ninety-one milliseconds, the threshold two hundred eighty-three. The same version drops to two hundred twenty in the second round; in the flawed version the difference stays around one hundred fourteen milliseconds in every round. What distinguishes the two is not the size of the difference but whether it dies down.
The reason is that the pool is separate per version: the new version enters the first step with an empty pool, opens two instances, and those instances stay warm for the rest of the ladder. The cold start cost falls only on the first step — by definition the step with the smallest sample. The arithmetic is one line: the gate’s margin is fifty-seven milliseconds, the 1% share carries eighteen requests per round, and a single cold call of one thousand eight hundred milliseconds adds one hundred milliseconds to the average; fifty at 2%, twenty at 5%. A small share both lowers the affected requests and makes the measurement sensitive to cold start — the first was wanted, the second arrived as a constraint.
Rollback Is a Single Key Turn
// run2.mjs — two rollback paths, the old pool after a full cutover, the cost of the workaround paths import { ladder, rng, pool, step, alive, sign, row, SEED, REQ_MIN, IDLE_MS, ROUND_MIN, GATE, WEIGHTS } from './alias.mjs'; // Fictional durations: write 120, propagation 900, package load (42 MB / 20 MB-s) 2100, version 3000 ms. const PATHS = { 'alias flip': [120, 900], redeploy: [2100, 3000, 120, 900] }; console.log(row([20, 7, 11], 'rollback path', 'steps', 'duration') + 'requests hitting the flawed version in this time'); for (const [name, a] of Object.entries(PATHS)) { const ms = a.reduce((x, y) => x + y, 0); console.log(row([20, 7, 11], name, a.length, `${(ms / 1000).toFixed(2)} s`) + Math.round(REQ_MIN / 60000 * ms)); } // After a full cutover the old version's pool empties; rolling back has to reopen that pool. function cutoverAndRollback(waitMin) { const rnd = rng(SEED), poolOld = pool(), poolNew = pool(); step(rnd, poolOld, poolNew, 0, 0, 0, 5, 1); // old version warms up const moved = step(rnd, poolOld, poolNew, 100, 0, 300000, waitMin, 1).reduce((x, y) => x + y.nNew, 0); const t = 300000 + waitMin * 60000, remaining = alive(poolOld, t), coldStart = poolOld.cold; const rollback = step(rnd, poolOld, poolNew, 0, 0, t, 1, 1)[0]; // alias rolled back: one minute is read return { moved, remaining, cold: poolOld.cold - coldStart, avg: Math.round(rollback.avgOld) }; } const H = [9, 25, 29, 19]; console.log(` rollback after a full cutover (100%) — an idle instance is dropped after ${IDLE_MS / 60000} min`); console.log(row(H, 'wait', 'moved to new version', 'instances left in old pool', 'cold on rollback') + 'first-min avg'); for (const b of [3, 4, 5]) { const g = cutoverAndRollback(b); console.log(row(H, `${b} min`, g.moved, g.remaining, g.cold) + `${g.avg} ms`); } // Ways to escape cold start on the first step; each path's cost is a separate line item. const margin = ladder(SEED, 0).baseline * (GATE - 1), Y = [32, 16, 16, 13, 11, 12, 13]; console.log(` ways to escape cold start on the first step — gate margin ${Math.round(margin)} ms`); console.log(row(Y, 'path', 'ladder', 'decision round', 'publish min', 'synthetic', 'in round1', 'round1 cold') + 'diff at decision round'); for (const [label, weights, warm, decisionRound] of [['skip round1, decide on round2', WEIGHTS, 0, 2], ['1 synchronous synthetic call', WEIGHTS, 1, 1], ['3 synchronous synthetic calls', WEIGHTS, 3, 1], ['make the first step 25%', [25, 100], 0, 1]]) { const { baseline, rows } = ladder(SEED, 0, weights, warm), round1 = rows[0].roundData[0]; console.log(row(Y, label, weights.join('-'), decisionRound, weights.length * decisionRound * ROUND_MIN, warm, round1.nNew, round1.cold) + `${sign(rows[0].roundData[decisionRound - 1].avgNew - baseline)} ms`); }
rollback path steps duration requests hitting the flawed version in this time alias flip 2 1.02 s 15 redeploy 4 6.12 s 92 rollback after a full cutover (100%) — an idle instance is dropped after 4 min wait moved to new version instances left in old pool cold on rollback first-min avg 3 min 2680 12 0 220 ms 4 min 3584 4 14 247 ms 5 min 4490 0 39 296 ms ways to escape cold start on the first step — gate margin 57 ms path ladder decision round publish min synthetic in round1 round1 cold diff at decision round skip round1, decide on round2 1-2-5-25-100 2 20 0 21 2 -6 ms 1 synchronous synthetic call 1-2-5-25-100 1 10 1 21 1 +79 ms 3 synchronous synthetic calls 1-2-5-25-100 1 10 3 21 0 -6 ms make the first step 25% 25-100 1 4 0 477 5 +13 ms
The first table shows what the version being unchangeable buys. Because the old version stands as a record, rollback is two steps — one write and propagation — one point zero two seconds in total, and fifteen requests hitting the flawed version. If the version were not kept, the path would climb to four steps, six point one two seconds, and ninety-two requests; a sixfold difference.
The second table writes the limit of this comfort. Once weight is raised to 100%, the old version receives no requests at all, and its pool empties in four minutes. Rolled back three minutes later, twelve instances were still standing in the pool, and no cold call occurred; five minutes later zero instances remain, and the first minute pays thirty-nine cold calls, pushing the average up to two hundred ninety-six. Rollback is still a single write, but its cost comes not from the operation but from the time that has passed.
The third table writes the bill for the workaround. Discarding the first round clears the decision, but stretches the publish from ten minutes to twenty. Pre-warming keeps the duration, but a single synthetic call is not enough: the pile-up inside the round opens a second instance, and the difference stays at seventy-nine milliseconds. Three synchronous calls zero out the cold count; their cost is fifteen synthetic calls per ladder — requests that mix into the metric and never come from a real user. Starting the ladder from the top is the most expensive: the difference drops to thirteen milliseconds, but the affected requests in the first round climb from twenty-one to four hundred seventy-seven.
Summary
- The alias moved from
v7tov8: a single write. At the same moment, the attempt to write to the version’s digest was rejected — release is not changing records, it is updating a pointer. - Affected requests are eighty-five at the end of the 1% step, eleven thousand nine hundred sixty-nine once it reaches 100%.
- A clean version tripped the gate in the first round of the 1% step: two cold calls, three hundred ninety-one milliseconds, threshold two hundred eighty-three. It dropped to two hundred twenty in the second round; in the flawed version the difference never died down in any round.
- At the 1% share, eighteen requests fall to a round and a single cold call adds one hundred milliseconds to the average; the gate’s margin is fifty-seven.
- Rollback is two steps and one point zero two seconds; if the version were not kept, it would be four steps, six point one two seconds, and ninety-two requests.
- Five minutes after a full cutover, zero instances remain in the old pool; rollback’s first minute pays thirty-nine cold calls and the average climbs to two hundred ninety-six.
Next Step
The same number stood still like a constant everywhere in the measurement: the eighteen hundred milliseconds cold start adds. It was what broke the canary round, wrote rollback’s delayed cost, and made pre-warming necessary. But that number is not constant — the bigger the package, the more bytes there are to download. The next lesson measures the package: it scans the relationship between size and cold start, counts what moving dependencies into a shared layer shrinks and what it does not, works out how many functions version pinning and a layer update touch — and writes the cost of an unused dependency as bytes moved.
To keep your progress and take notes, Log in
My notes
Log in to take notes.