Case study: a 100k-client streaming CDN network
A complete, end-to-end worked example of the YourSimulation plugin: take a vague real-world ask — "model a 100,000-client streaming network spread across 30 CDN points" — and turn it into a validated model, a runnable experiment, and an analysed result. Along the way it shows the two ideas that matter most when modelling at scale: arrivals are a rate, not a node and queueing KPIs are scale-invariant.
1. What the plugin does
YourSimulation is a discrete-event queue simulator driven from a single JSON model. The workflow is always the same four steps:
| Step | Command | Purpose |
|---|---|---|
| Author | — | Describe the system as a graph of nodes + edges (or generate it). |
| Validate | yoursim validate model.json | Catch structural errors before running. |
| Run | yoursim run model.json --pretty | Simulate and emit KPIs (mean + 95% CI). |
| Optimize | yoursim optimize model.json problem.json | Find the cheapest design meeting a target. |
The engine simulates entities (here: streaming sessions) flowing through generic node types. This case study uses four of them: source (arrivals), branch (routing), queue (waiting line), resource (servers), and sink (exit).
The CLI bin is named
yoursim. When the package is installed locally, invoke it asnode node_modules/@plantagoai/yoursim-engine/dist/cli.js <cmd>(or via theyoursimbin on your PATH).
2. From a sentence to parameters
"100k clients across 30 CDNs" is under-specified. Four questions pin it down:
| Question | Answer chosen | Modelling consequence |
|---|---|---|
| What is "100k clients"? | 100,000 stream-starts per second | source inter-arrival mean = 1/100000 = 0.00001 s… (see scaling) |
| How long is a session? | ~45 min (exp, mean 2,700 s) | resource service distribution |
| How are clients routed? | Evenly across 30 CDNs | branch mode probability, 1/30 per edge |
| What capacity / goal? | Size each CDN for stability | choose servers so utilization < 1 |
The first big idea: arrivals are a rate
A naïve model would create one node per client — a million nodes. Wrong. In discrete-event simulation, arrivals are a distribution on one source. 100,000 clients/sec is a single source with a tiny inter-arrival mean, not 100,000 nodes. The whole network is 63 nodes regardless of client count.
Sizing each CDN (Little's Law)
The offered load on the system is arrivals × holding time:
Split evenly over 30 CDNs → 9,000,000 concurrent streams per CDN (9,000,000 erlangs of offered load). A resource is stable only when servers > λ·service, so each CDN needs > 9M slots. Sizing for ~90% utilization gives 10,000,000 slots per CDN, 300,000,000 total.
3. The second big idea: scale-invariance
At literal full scale the simulation is intractable. To reach steady state the warmup must exceed one 45-min session (~2,700 s); at 100,000 arrivals/sec that is 270 million arrivals before measurement even begins — billions of events. No machine finishes that in useful time.
But queueing KPIs — utilization, p95 wait, blocking probability — depend only on the offered-load ratio:
not on absolute size. So we simulate a ÷1000 rate-scaled twin: divide the arrival rate (and the slot counts) by 1000. The ratio ρ is identical, so utilization and waits come out the same; only counts (throughput, concurrency) scale, and we multiply them back by 1000 to read full-scale numbers.
| Quantity | Full scale | Simulated twin (÷1000) | Read back |
|---|---|---|---|
| Stream-starts | 100,000 /s | 100 /s (inter-arrival 0.01 s) | rate ×1000 |
| Slots per CDN | 10,000,000 | 10,000 | ×1000 |
| Concurrent streams | 270 M (offered) | 270,000 | ×1000 |
| Utilization, p95 wait | — | identical | ×1 |
4. Model construction
Thirty near-identical CDN branches are best generated, not hand-written. The generator computes the slot count from the load math so the model stays correct if you change the CDN count or scale factor.
// Generate a streaming-CDN network model and print JSON to stdout.
//
// This is the "rate-scaled twin" of a 100k stream-starts/sec network held
// across 30 CDN edge points, with ~45-min sessions, even regional routing.
// Arrival rate is scaled down by SCALE so the discrete-event sim is tractable
// (every arrival is an event; warmup must exceed one ~45-min session).
// KPIs (utilization, p95 wait, blocking) are scale-invariant; multiply
// throughput / concurrency COUNTS by SCALE to read back to full scale.
//
// Run: npx tsx generate-cdn-network.ts [numCdns] [scale] > cdn-network.json
// numCdns : number of CDN edge points (default 30)
// scale : divide full-scale arrival rate by this (default 1000)
const FULL_RATE = 100_000; // stream-starts / sec at full scale
const SESSION_MEAN = 2700; // ~45 min, in seconds
const TARGET_UTIL = 0.9; // size each CDN to ~90% utilization
const numCdns = Number(process.argv[2] ?? 30);
const scale = Number(process.argv[3] ?? 1000);
const lambdaTotal = FULL_RATE / scale; // scaled total arrival rate
const interarrival = 1 / lambdaTotal; // mean seconds between starts
const lambdaPerCdn = lambdaTotal / numCdns; // even regional split
const offeredPerCdn = lambdaPerCdn * SESSION_MEAN; // erlangs (concurrent load)
const serversPerCdn = Math.ceil(offeredPerCdn / TARGET_UTIL);
const nodes: unknown[] = [
{
id: 'clients', type: 'source', label: `Clients (${lambdaTotal}/s, twin of ${FULL_RATE}/s)`,
position: { x: 0, y: 0 },
params: { interarrival: { dist: 'exp', mean: interarrival } },
},
{
id: 'steer', type: 'branch', label: 'Regional steering (even)',
position: { x: 250, y: 0 }, params: { mode: 'probability' },
},
{ id: 'served', type: 'sink', label: 'Session complete', position: { x: 900, y: 0 }, params: {} },
];
const edges: unknown[] = [
{ id: 'e-src', from: 'clients', to: 'steer' },
];
for (let i = 0; i < numCdns; i++) {
const q = `cdn${i}-q`, r = `cdn${i}`;
nodes.push({
id: q, type: 'queue', label: `CDN ${i} buffer`,
position: { x: 500, y: i * 50 }, params: { discipline: 'fifo' },
});
nodes.push({
id: r, type: 'resource', label: `CDN ${i} edge (${serversPerCdn} slots)`,
position: { x: 700, y: i * 50 },
params: { servers: serversPerCdn, service: { dist: 'exp', mean: SESSION_MEAN } },
});
edges.push({ id: `e-steer${i}`, from: 'steer', to: q, probability: 1 / numCdns });
edges.push({ id: `e-q${i}`, from: q, to: r });
edges.push({ id: `e-r${i}`, from: r, to: 'served' });
}
process.stdout.write(JSON.stringify({
schemaVersion: 1,
name: `Streaming CDN network (${numCdns} edges, 1/${scale} twin of ${FULL_RATE}/s)`,
settings: { timeUnit: 'sec', horizon: 11000, warmup: 8000, replications: 4, seed: 42 },
presentation: { theme: 'network' },
nodes, edges,
}, null, 2));Run it and validate:
npx tsx generate-cdn-network.ts 30 1000 > cdn-network.json
yoursim validate cdn-network.json # -> {"ok":true}Topology
┌─ cdn0-q → cdn0 (10k slots) ─┐
clients ─► steer ──┼─ cdn1-q → cdn1 (10k slots) ─┼─► served
(100/s) (1/30 ea) ├─ … ×30 │ (sink)
└─ cdn29-q → cdn29 (10k slots) ─┘Every resource is fed by its own queue (an engine validation rule — entities need somewhere to wait). Routing is an even probability branch. The full generated model (63 nodes / 91 edges) is in docs/examples/cdn-network.json; a representative excerpt — the source, the router, one CDN branch, and the sink (full file on GitHub):
{
"schemaVersion": 1,
"name": "Streaming CDN network (30 edges, 1/1000 twin of 100000/s)",
"settings": { "timeUnit": "sec", "horizon": 11000, "warmup": 8000, "replications": 4, "seed": 42 },
"nodes": [
{ "id": "clients", "type": "source", "params": { "interarrival": { "dist": "exp", "mean": 0.01 } } },
{ "id": "steer", "type": "branch", "params": { "mode": "probability" } },
{ "id": "cdn0-q", "type": "queue", "params": { "discipline": "fifo" } },
{ "id": "cdn0", "type": "resource", "params": { "servers": 10000, "service": { "dist": "exp", "mean": 2700 } } },
{ "id": "served", "type": "sink", "params": {} }
],
"edges": [
{ "id": "e-src", "from": "clients", "to": "steer" },
{ "id": "e-steer0", "from": "steer", "to": "cdn0-q", "probability": 0.03333 },
{ "id": "e-q0", "from": "cdn0-q", "to": "cdn0" },
{ "id": "e-r0", "from": "cdn0", "to": "served" }
]
}Run settings
warmup: 8000 s lets the 45-min-session pipe fill before stats are collected; horizon: 11000 s then measures a 3,000 s steady-state window. replications: 4 gives a mean + 95% CI. The run took ~202 s on engine @plantagoai/yoursim-engine v0.1.2 (results are deterministic for a fixed seed).
5. Results
yoursim run cdn-network.json --pretty > kpis.jsonVerdict — the network comfortably holds the load. Every CDN runs ~87% busy with zero queueing: no waits, no buffer build-up, no dropped sessions. ~13% headroom.
Headline KPIs
| KPI | Simulated (twin) | Full scale (×1000) | Notes |
|---|---|---|---|
| Stream-starts | 99.86 /s | 99,860 /s | matches the 100k/s target |
| Completed throughput | 96.92 /s | 96,920 /s | gap = sessions still in-flight at window end |
| Mean CDN utilization | 87.1 % | 87.1 % | target 90% (see §6) |
| Queue wait (avg / p95) | 0 s / 0 s | 0 s / 0 s | no congestion anywhere |
| Dropped / balked / reneged | 0 | 0 | no losses |
| Concurrent streams | 261,351 | 261.4 M | of 300 M provisioned |
Per-CDN utilization — even, no hot spots
All 30 edges land in a tight 86.2 %–88.2 % band, confirming the even router:
cdn13 ██████████████████████████████████ 86.2 % ← lowest
cdn00 ███████████████████████████████████ 87.2 %
cdn14 ███████████████████████████████████ 88.2 % ← highest
(all 30 cluster around mean 87.1 %)
spread 86.0 86.5 87.0 87.5 88.0 88.5 %
░░░░▓▓▓▓████████████▓▓▓▓▓░░░
min 86.2 mean 87.1 max 88.2Time-in-system = the streaming session itself
Because queue wait is zero, end-to-end time equals the exp(2,700 s) session — a workload property, not a congestion signal:
time 0 1500 3000 4500 6000 s
p50 29m ███████████
mean 40m ███████████████
p90 87m █████████████████████████████████
p95 106m ████████████████████████████████████████| Metric | Observed | exp(2700) theory |
|---|---|---|
| p50 | 1,753 s | 1,872 s |
| p90 | 5,245 s | 6,217 s |
| p95 | 6,381 s | 8,091 s |
| mean (completed) | 2,404 s | 2,700 s |
Capacity read-back (full scale)
| Quantity | Value |
|---|---|
| Slots provisioned | 300.0 M |
| Concurrent streams (steady state) | 261.4 M |
| Headroom | 12.9 % |
| Offered load (100k/s × 2,700 s) | 270 M erlangs |
6. Analysis
Why zero queueing? Each CDN is an M/M/c queue with c = 10,000 and ρ ≈ 0.87. The probability that all 10,000 servers are simultaneously busy (Erlang-C) is vanishingly small at this scale, so an arriving session almost always finds a free slot. Latency is therefore pure streaming time, not waiting time. A large, well-provisioned system barely queues even at high utilization — the opposite of a small M/M/1, where 87% utilization means long, volatile waits.
Why 87% and not the 90% it was sized for? The 45-min sessions fill the pipe slowly; at the 8,000 s warmup boundary the system was ~97% saturated. A longer warmup converges to 90% at higher run cost — a fidelity/runtime trade-off, not a model error.
Why throughput < arrivals and mean-TIS < 2,700 s? Both are right-censoring artifacts of observing 45-min sessions through a 3,000 s window: sessions still streaming at the window's end aren't counted as completed. The queues are provably empty (avg length and p95 wait are 0 on every CDN), so this is a measurement effect, not instability.
Recommendations
- Design is sound. 10M slots/CDN × 30 edges serves 100k/s of 45-min sessions with no queueing and ~13% headroom.
- Trim cost. At 87% you can drop to ~9.5M slots/CDN (285 M total) and still clear ~95% utilization with negligible wait. Use
optimizewith a p95-wait constraint to find the cheapest slot count. - Stress test. To find the breaking point, fix a lower capacity (e.g. 9M/CDN) or switch the router to a skewed probability split to expose hot-spot CDNs.
7. Reproduce it
# 1. generate the ÷1000 twin (30 CDNs)
npx tsx generate-cdn-network.ts 30 1000 > cdn-network.json
# 2. validate
yoursim validate cdn-network.json
# 3. run -> KPIs
yoursim run cdn-network.json --pretty > kpis.jsonFiles: docs/examples/cdn-network.json · docs/examples/generate-cdn-network.ts. Change the args to explore: generate-cdn-network.ts 50 500 models 50 CDNs at a ÷500 twin.