Skip to content
academia.sh

Lesson 11 / 14

Video Streaming Service

The case where multiple representations of the same content are kept: budgeting the transcoding pipeline in processor-seconds, measuring the bitrate ladder's hit rate against the viewer bandwidth distribution, the shrinking bandwidth utilization gained per rung, and three separate constraints eliminating three separate ladders.

Contents

In the previous case, the object was a meaningless byte sequence to the service: stored as written, served as read. This case removes that assumption. The uploaded file is not stored just once; multiple representations are produced from its content, and which representation is served is decided by the requester’s bandwidth at that moment. The new line item is the transcoding pipeline, and the new trade-off is how many rungs adaptive streaming’s bitrate ladder carries.

The decision to measure is this: each rung costs both stored bytes and processor-seconds, but in return delivers a stream that better fits the viewer’s bandwidth. The rung count is chosen where these two curves cross, and the crossing point depends on the distribution of viewer bandwidth.

Constraints

Functional requirement: producing multiple quality rungs from the uploaded source copy, splitting each rung into fixed-duration segments, handing the client the segment list, and letting the client switch between rungs.

Non-functional requirement, in numbers: the share of viewers who cannot play because their bandwidth does not carry even the lowest rung stays under 10 percent; stored bytes do not exceed 2.5 times the source copy; processors continuously allocated to encoding do not exceed 1500; an hour of video does not take more than 10 minutes to go from upload to watchable.

Scope reduction: content protection, live streaming, recommendations, subtitle generation, and ad placement are not designed.

Assumptions

Code Assumption Value Rationale
VY1 daily uploaded video hours 4,000 the daily total across uploaders
VY2 source copy’s bit rate 20,000 kbit/s unencoded upload
VY3 daily watched hours 900,000 watching runs far heavier than uploading
VY4 peak multiplier 3 peak hour’s ratio to the daily average
VY5 retention period 365 days the catalog is kept for a year
VY6 encoding cost 0.4 + 0.25 × Mbit/s processor-seconds per video-second
VY7 viewer bandwidth distribution 0.25/1.5 + 0.50/6 + 0.25/20 a three-class mixture, exponential, Mbit/s
VY8 bandwidth’s usable share 0.8 protocol overhead and fluctuation margin
VY9 segment duration 4 s rung switching happens at this granularity
VY10 edge cache hit rate 0.95 segments are immutable and kept for a long time

VY6 and VY7 are this design’s two load-bearing assumptions: one sets the production cost, the other the hit rate. Both are reasoned, not measured.

Scale

// video-scale.mjs — the scale computation from the VY table; all of it is arithmetic
const VY = { uploadedHours: 4000, sourceKbit: 20_000, watchedHours: 900_000, retentionDays: 365,
  fixedCpu: 0.4, cpuPerKbit: 0.25, segmentSec: 4 };
const RUNGS = [400, 1200, 3000, 6000, 12_000];             // chosen bitrate ladder rungs, kbit/s
const total = RUNGS.reduce((a, b) => a + b, 0);
const cpu = RUNGS.reduce((a, k) => a + VY.fixedCpu + VY.cpuPerKbit * (k / 1000), 0);
const videoSec = VY.uploadedHours * 3600;
for (const [name, d] of [
  ["watched / uploaded hours", VY.watchedHours / VY.uploadedHours],
  ["rung total kbit/s", total],
  ["encoding processor-sec / video-sec", cpu],
  ["daily stored TB", (videoSec * (VY.sourceKbit + total) * 1000) / 8 / 1e12],
  ["year-end stored PB", (videoSec * (VY.sourceKbit + total) * 1000 * VY.retentionDays) / 8 / 1e15],
  ["daily segments produced", (videoSec / VY.segmentSec) * RUNGS.length],
]) console.log(name.padEnd(36) + d.toFixed(2).padStart(14));

console.log(`\nan hour of video requires ${(3600 * cpu).toFixed(0)} processor-sec:`);
for (const n of [30, 60, 120])
  console.log(`  ${n} workers -> upload-to-watchable ${((3600 * cpu) / n / 60).toFixed(2)} min`);
watched / uploaded hours                    225.00
rung total kbit/s                         22600.00
encoding processor-sec / video-sec            7.65
daily stored TB                              76.68
year-end stored PB                           27.99
daily segments produced                18000000.00

an hour of video requires 27540 processor-sec:
  30 workers -> upload-to-watchable 15.30 min
  60 workers -> upload-to-watchable 7.65 min
  120 workers -> upload-to-watchable 3.83 min

These numbers are in the calculation class. Three of them drive the design. Every uploaded hour is watched for 225 hours: an encoding cost paid once is read two hundred twenty-five times, which on its face makes preparing the content at upload time, not at watch time, the sound choice. 18,000,000 segments are produced per day — that is the object count the distribution layer sees, not the video count. And the last line meets a constraint directly: the 10-minute latency constraint requires at least 46 workers; at 30 workers the figure runs to 15.30 minutes and breaks the constraint, at 60 workers it stays at 7.65 minutes.

Rung Selection

The rung count is defensible only once the viewer bandwidth distribution is given. The model below sets up no network and runs no encoder: it draws bandwidth from VY7’s three-class mixture, picks the highest rung that fits each viewer’s usable bandwidth, and compares four ladders over the same set of viewers.

// rung-selection.mjs — in-process model: how accurate rung selection is against the viewer
// bandwidth distribution. No network, no encoder; the distribution is a three-class mixture and is written as a model.
const VIEWERS = 200_000, SEED = 20260731, SHARE = 0.8;        // VY8: the usable share of bandwidth
const CLASS = [[0.25, 1500], [0.50, 6000], [0.25, 20_000]];   // VY7: share, average kbit/s
const SOURCE = 20_000, FIXED = 0.4, CPU_PER_KBIT = 0.25;      // VY2, VY6
const WATCHED_HOURS = 900_000, PEAK = 3, HIT = 0.95;          // VY3, VY4, VY10
const UPLOADED_SEC = 4000 * 3600;                              // VY1: video-seconds encoded per day

let state = SEED;
const rand = () => { state = (state * 1103515245 + 12345) % 2147483648; return state / 2147483648; };
function bandwidth() {                                         // pick a class, draw from its exponential
  let u = rand(), i = 0;
  while (i < CLASS.length - 1 && u > CLASS[i][0]) { u -= CLASS[i][0]; i += 1; }
  return -Math.log(1 - rand()) * CLASS[i][1];
}
const bandwidths = Array.from({ length: VIEWERS }, bandwidth);

const LADDER = { "1 rung": [3000], "3 rungs": [800, 3000, 9000],
  "5 rungs": [400, 1200, 3000, 6000, 12_000],
  "8 rungs": [300, 600, 1000, 1800, 3000, 5000, 8000, 12_000] };

function measure(m) {
  let notPlayable = 0, selectedTotal = 0, utilizationTotal = 0;
  for (const b of bandwidths) {
    const available = b * SHARE;
    const s = m.filter((k) => k <= available).pop();
    if (s === undefined) { notPlayable += 1; continue; }
    selectedTotal += s; utilizationTotal += s / available;
  }
  const playable = VIEWERS - notPlayable;
  const totalKbit = m.reduce((a, b) => a + b, 0);
  return { notPlayable: notPlayable / VIEWERS, avgSelected: selectedTotal / playable,
    utilization: utilizationTotal / playable, storage: (SOURCE + totalKbit) / SOURCE,
    cpu: m.reduce((a, k) => a + FIXED + CPU_PER_KBIT * (k / 1000), 0) };
}

console.log(`model: ${VIEWERS} viewers, seed ${SEED}, class shares ` +
  `${CLASS.map(([p, o]) => `${p}/${o}`).join(" ")} kbit/s (exponential)\n`);
console.log("ladder".padEnd(11) + "not playable".padStart(13) + "avg selected".padStart(13) +
  "bandwidth utilization".padStart(23) + "storage factor".padStart(16) + "sustained processors".padStart(22) +
  "peak edge Gbit/s".padStart(18) + "origin Gbit/s".padStart(15));
const R = {};
for (const [name, m] of Object.entries(LADDER)) {
  const r = measure(m);
  R[name] = r;
  const edge = (WATCHED_HOURS * 3600 * r.avgSelected * 1000 / 86_400) * PEAK / 1e9;
  console.log(name.padEnd(11) + `${(r.notPlayable * 100).toFixed(2)}%`.padStart(13) +
    r.avgSelected.toFixed(0).padStart(13) + `${(r.utilization * 100).toFixed(1)}%`.padStart(23) +
    r.storage.toFixed(2).padStart(16) + ((UPLOADED_SEC * r.cpu) / 86_400).toFixed(0).padStart(22) +
    edge.toFixed(2).padStart(18) + (edge * (1 - HIT)).toFixed(2).padStart(15));
}

const k = Object.keys(LADDER);
console.log("\nbandwidth utilization gained per rung:");
for (let i = 1; i < k.length; i += 1) {
  const d = LADDER[k[i]].length - LADDER[k[i - 1]].length;
  console.log(`  ${k[i - 1]} -> ${k[i]}: ${(((R[k[i]].utilization - R[k[i - 1]].utilization) * 100) / d).toFixed(2)} points/rung`);
}
const disabled = measure([400, 1200, 3000, 6000]);                    // if the top rung is disabled
console.log(`top rung disabled: avg selected ${disabled.avgSelected.toFixed(0)} kbit/s, edge output ` +
  `x${(disabled.avgSelected / R["5 rungs"].avgSelected).toFixed(2)}, not playable unchanged at ${(disabled.notPlayable * 100).toFixed(2)}%`);
const customCpu = WATCHED_HOURS * 3600 * (FIXED + CPU_PER_KBIT * 6);   // one stream per viewer
const ladderCpu = UPLOADED_SEC * R["5 rungs"].cpu;
console.log(`per-viewer custom encoding: ${customCpu.toExponential(2)} processor-sec/day, ${(customCpu / ladderCpu).toFixed(1)}x ` +
  `the 5-rung ladder's (${(ladderCpu).toExponential(2)})`);
model: 200000 viewers, seed 20260731, class shares 0.25/1500 0.5/6000 0.25/20000 kbit/s (exponential)

ladder      not playable avg selected  bandwidth utilization  storage factor  sustained processors  peak edge Gbit/s  origin Gbit/s
1 rung            45.75%         3000                  43.2%            1.15                   192            337.50          16.88
3 rungs           13.77%         3846                  56.5%            1.64                   733            432.71          21.64
5 rungs            8.91%         4442                  64.9%            2.13                  1275            499.72          24.99
8 rungs            6.08%         4607                  73.8%            2.58                  1854            518.24          25.91

bandwidth utilization gained per rung:
  1 rung -> 3 rungs: 6.66 points/rung
  3 rungs -> 5 rungs: 4.22 points/rung
  5 rungs -> 8 rungs: 2.96 points/rung
top rung disabled: avg selected 3242 kbit/s, edge output x0.73, not playable unchanged at 8.91%
per-viewer custom encoding: 6.16e+9 processor-sec/day, 55.9x the 5-rung ladder's (1.10e+8)

The ratios in the table are measurements, the cost columns are in the calculation class; the measured ratios depend on this run’s seed, while the differences that separate the constraints are magnitudes independent of the seed. Bandwidth utilization gained per rung drops from 6.66 to 4.22 to 2.96 points: each new rung gains less than the one before it, but its cost does not drop to match.

Design

The measurement points to the five-rung ladder; the design is built on it. The transcoding pipeline sits on a message queue and competing consumers (the Application Layer and Service Interaction course’s Queues and Workflows topic). The parameter is worker count: 60 workers make an hour of video watchable in 7.65 minutes. The same topic’s priority queue puts new uploads ahead of the re-encoding jobs that backfill the catalog; this keeps the latency constraint binding only on new uploads. The job message does not carry bytes, it carries the source object’s key — the Claim Check Pattern (the Resilience and Reliability course’s Distributed Correctness topic). An encoding job is idempotent (same topic); the uniqueness key is the pair of video ID and rung, so a job re-run after being left half-finished does not produce a second output.

The distribution side is built on a content delivery network and edge cache (the Traffic Layer course’s Entry Points topic). The parameter is the hit rate: VY10’s 95 percent brings the peak edge traffic of 499.72 Gbit/s down to 24.99 Gbit/s at the origin. Because segments never change once produced, there is no question of a staleness window; the same topic’s push-based distribution moves a newly uploaded piece of content’s first segments to the edge before it is ever watched, and the rest arrives by pull. The segment list and the segments are static files (same topic, Static Content Hosting), so there is no application code on the read path.

Deliberately unused pattern: circuit breaker. The circuit breaker from the Resilience and Reliability course’s Fault Isolation topic has no place in the encoding layer, because workers make no synchronous call; they pull from the queue and re-surface a failed job — there is no call path to cut. The second is Command and Query Separation (the Scaling the Data Layer course’s Read–Write Separation topic): the read path is not a query, it is fetching a file whose name is already known, and deriving a separate read model has no payoff.

Eliminated Alternatives

Three alternatives are eliminated by three separate numbers, and each is caught by a different constraint.

One rung is the cheapest: a storage factor of 1.15, 192 sustained processors. But 45.75 percent of viewers cannot carry that single rung and cannot play at all — four and a half times the 10 percent constraint. Three rungs also stays above the constraint, at 13.77 percent.

Eight rungs is best on the hit side: bandwidth utilization of 73.8 percent, 6.08 percent not playable. It breaks two constraints at once: a storage factor of 2.58 (limit 2.5) and 1854 sustained processors (limit 1500). The 8.9 points of utilization it gains cost 21 percent more on storage and 45 percent more on processors.

Per-viewer custom encoding — producing each stream on the fly, fitted exactly to the viewer’s bandwidth — solves the hit-rate problem completely, because utilization reaches 100 percent and no extra rung is stored. It requires 6.16 billion processor-seconds a day, 55.9 times the five-rung ladder’s 110 million processor-seconds. The reason is the first line of the scale calculation: encoding is paid not once per upload but once per watch, and watching runs at 225 times uploading.

What remains is five rungs: 8.91 percent not playable, a storage factor of 2.13, 1275 sustained processors — all three constraints are met. Which constraint change flips the alternative: if the storage limit rises to 2.6, eight rungs gets in and raises bandwidth utilization by 8.9 points; if the viewer bandwidth distribution narrows (the three classes move closer together), fewer rungs suffice and three rungs starts meeting the constraint.

Failure Behavior and What Is Sacrificed

When the encoding layer goes down entirely, the catalog keeps playing; only new uploads fail to become watchable and back up in the queue. This is graceful degradation (the Fault Isolation topic), and its cost is suspending the latency constraint. When the origin goes down, the edge cache keeps serving 95 percent of requests, because segments are immutable and there is no risk of a stale copy. On the output side, when the peak load limit is forced, the top rung is disabled: the average selected bit rate drops from 4442 to 3242 kbit/s, edge output falls to 0.73x, and the not-playable share of viewers holds unchanged at 8.91 percent — degradation lowers quality, it does not leave anyone out.

What is sacrificed: bandwidth utilization was left at 64.9 percent. A third of the viewer’s usable bandwidth sits idle, and that is the price of keeping storage under 2.5x and encoding under 1500 processors.

Summary

  • Every uploaded hour is watched for 225 hours; this ratio is why the encoding cost is paid at upload time rather than watch time, and it is also what makes per-viewer custom encoding 55.9 times more expensive.
  • The hit rate of the bitrate ladder can only be measured together with the viewer bandwidth distribution: at five rungs, bandwidth utilization is 64.9 percent, and 8.91 percent of viewers cannot play.
  • Utilization gained per rung drops from 6.66 to 4.22 to 2.96 points while the cost per rung does not drop; that is why the ladder has an end.
  • Three alternatives are eliminated by three separate constraints: one rung by its not-playable share (45.75 percent), eight rungs by its storage (2.58) and processor (1854) limits, per-viewer custom encoding by processor-seconds.
  • The edge cache brings output down from 499.72 Gbit/s to 24.99 Gbit/s, and because segments are immutable, the question of a staleness window never even opens.

Next Step

In this case too, data was stored as written: a piece was produced, put somewhere, and later read back unchanged. Every byte produced was worth storing. The next case removes that assumption. There, records are produced by the hundreds of thousands per second, none of them meaningful on its own, and storing all of them is impossible. The question becomes: how long is a given record kept raw, when is it aggregated, and how far does an answer read from aggregated data diverge from one read from raw data.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close