Appendix E · The web service model, in full
What this holds
| Purpose | The running example, through every toolkit output |
| Model | models/web_service/model.yaml |
| Built from | web_service-reference, web_service-sized_for_growth |
A web service and the data it keeps, on a fleet of Linux hosts, sized from a stated busy hour and costed over five years. No product is named and none is implied: what is on this page is a structure, and the numbers in it are placeholders for yours.
This is the model the chapters build a few nodes at a time, and it is a sizing model by the
rule and by its shape. Most of it is accounting identity and physics: requests times CPU time
per request, watts times hours times price, capital plus running cost over a horizon. That is the
structure the introduction says sampling the inputs is sufficient for. But it
carries one measured constant and six ceilings, so scripts/verify-models.py classifies it a
sizing model and holds it to the stricter rules. That is the right call: a compression ratio
belongs to a codec, and a fleet asked for more than it can serve does not slow down
proportionally. Appendix F is the book’s second model, and
shows what one looks like when a whole chain of it has not been measured.
The graph
Try it
The same model, as an interactive page: every slider comes from a range this model file declares, moving one recomputes the whole graph immediately, and clicking any node shows what fed it and how uncertain it is. Resample runs the book’s own sampler in your browser with the inputs you have moved held at their values.
Colour is kind: inputs, derived quantities, the one measured constant, and the ceilings. An input’s border says what it is claiming: solid for a fact, dashed for a vendor’s claim, dotted for an assumption. Arrows run from cause to effect, and every node sits immediately to the right of the last thing it depends on.
Read two things off it directly. The graph is wide at the left and narrow at the right: many quantities collapsing into a handful of answers, which is what makes a single wrong input so hard to spot downstream. And three separate chains reach the host count: the request rate, the working set and the data on disk. That is ch10’s whole subject.
Every formula
The graph above, as text: every derived quantity and every ceiling, with the formula the file gives it and the chapter that added it. The table is rendered from the file, so it cannot disagree with it.
| Quantity | Formula | Unit | Introduced in |
|---|---|---|---|
horizon periods (horizon_periods) | horizon / one_year | ch02 | |
peak request rate at horizon (peak_request_rate) | peak_request_rate_t0 * annual_growth ** horizon_periods | request/second | ch02 |
records held at horizon (stored_data) | stored_data_t0 * annual_growth ** horizon_periods | TB | ch02 |
memory the service can use, per host (ram_for_service) | ram_per_host * (1 - os_reserve) | TB/host | ch03 |
mean request rate at horizon (mean_request_rate) | peak_request_rate / peak_to_mean | request/second | ch04 |
cores in the fleet (cores) | hosts * cores_per_host | core | ch05 |
cores busy at the busy hour (busy_cores) | peak_request_rate * service_demand | core | ch05 |
utilisation (utilisation) | busy_cores / cores | ch05 | |
service time (service_seconds) | service_demand * one_request / one_core | second | ch05 |
requests in flight, if none waited (in_flight_unqueued) | peak_request_rate * service_seconds | request | ch05 |
effective utilisation (effective_utilisation) | min(utilisation, utilisation_cap) | ch06 | |
residence time (residence_time) | service_seconds / (1 - effective_utilisation) | second | ch06 |
time spent queueing (waiting_time) | residence_time - service_seconds | second | ch06 |
requests in the system (concurrency) | peak_request_rate * residence_time | request | ch06 |
utilisation at the busy hour (queueing_headroom) | utilisation against a limit of 1, keeping queueing_margin below it | ch06 | |
host count (host_count) | hosts / one_host | ch07 | |
throughput of one host alone (single_host_throughput) | cores_per_host / service_demand | request/second/host | ch07 |
throughput if scaling were free (linear_throughput) | hosts * single_host_throughput | request/second | ch07 |
throughput the fleet can actually reach (achievable_throughput) | linear_throughput / (1 + contention * (host_count - 1) + crosstalk * host_count * (host_count - 1)) | request/second | ch07 |
scaling efficiency (scaling_efficiency) | achievable_throughput / linear_throughput | ch07 | |
fraction of the fleet doing nothing useful (scaling_loss) | 1 - scaling_efficiency against a limit of 1, keeping 0.5 below it | ch07 | |
where adding hosts stops helping (peak_hosts) | one_host * sqrt((1 - contention) / crosstalk) | host | ch07 |
fraction of the peak already built (headroom_to_peak) | host_count / (peak_hosts / one_host) | ch07 | |
utilisation, counting coordination (utilisation_including_coordination) | peak_request_rate / achievable_throughput | ch07 | |
how much the queueing view understated it (optimism) | utilisation_including_coordination / utilisation | ch07 | |
utilisation, counting coordination (coordination_headroom) | utilisation_including_coordination against a limit of 1, keeping queueing_margin below it | ch07 | |
working set at horizon (working_set) | stored_data * hot_fraction | TB | ch08 |
memory the service can use, whole fleet (fleet_ram) | hosts * ram_for_service | TB | ch08 |
working set against memory (cache_fill) | working_set / fleet_ram against a limit of 1, keeping cache_margin below it | ch08 | |
raw bytes per stored byte (raw_per_stored) | replication_factor * index_overhead / record_compression | ch09 | |
raw disk needed at horizon (raw_data) | stored_data * raw_per_stored | TB | ch09 |
hosts for storage (hosts_for_storage) | ceil(raw_data / (disk_per_host * (1 - disk_margin))) | host | ch09 |
installed disk (installed_disk) | hosts * disk_per_host | TB | ch09 |
disk fill at horizon (disk_fill) | raw_data / installed_disk against a limit of 1, keeping disk_margin below it | ch09 | |
hosts for requests (hosts_for_requests) | ceil(busy_cores / (cores_per_host * (1 - queueing_margin))) | host | ch10 |
hosts for memory (hosts_for_memory) | ceil(working_set / (ram_for_service * (1 - cache_margin))) | host | ch10 |
hosts the model recommends (hosts_recommended) | max(hosts_for_requests, hosts_for_memory, hosts_for_storage) | host | ch10 |
hosts left when one dies (hosts_after_failure) | hosts - one_host | host | ch11 |
utilisation with one host down (utilisation_after_failure) | busy_cores / (hosts_after_failure * cores_per_host) | ch11 | |
utilisation with one host down (failure_headroom) | utilisation_after_failure against a limit of 1, keeping queueing_margin below it | ch11 | |
host capex (host_capex) | hosts * host_price | USD | ch18 |
network capex (network_capex) | hosts * network_price_per_host | USD | ch18 |
capex (capex) | host_capex + network_capex | USD | ch18 |
facility power (facility_power) | hosts * host_power * pue | kW | ch18 |
annual energy (annual_energy) | facility_power * hours_per_year | kWh / year | ch18 |
annual energy cost (annual_energy_cost) | annual_energy * electricity_price | USD / year | ch18 |
annual licences (annual_licences) | cores * licence_per_core + hosts * licence_per_host | USD / year | ch18 |
annual support (annual_support) | capex * support_rate | USD / year | ch18 |
annual staff cost (annual_staff_cost) | staff_fte * fully_loaded_salary | USD / year | ch18 |
annual opex (annual_opex) | annual_energy_cost + annual_licences + annual_support + annual_staff_cost | USD / year | ch18 |
lifecycle opex (lifecycle_opex) | annual_opex * horizon | USD | ch18 |
five-year total cost of ownership (tco) | capex + lifecycle_opex + migration_cost | USD | ch18 |
mean request rate over the horizon (average_request_rate) | (peak_request_rate_t0 / peak_to_mean + mean_request_rate) / 2 | request/second | ch18 |
requests over horizon (requests_over_horizon) | average_request_rate * seconds_per_year * horizon | request | ch18 |
cost per million requests (cost_per_million_requests) | tco / requests_over_horizon | USD/megarequest | ch18 |
average stored (average_stored) | (stored_data_t0 + stored_data) / 2 | TB | ch18 |
cost per stored TB per month (cost_per_stored_tb_month) | tco / average_stored / horizon | USD / TB / month | ch18 |
Source — models/web_service/model.yaml and its build order
The outputs
| Output | Point estimate | 90% interval | Unit |
|---|---|---|---|
| hosts the model recommends | 54 | 20 to 230 | host |
| hosts in the fleet | 54 | fixed | host |
| five-year total cost of ownership | $2,002,083 | $1,508,230 to $2,923,724 | USD |
| cost per million requests | $1.82 | $0.55 to $5.98 | USD/megarequest |
| cost per stored TB per month | $839.64 | $305.33 to $2,108 | USD / TB / month |
| capex | $421,214 | $272,130 to $658,773 | USD |
| annual opex | $316,174 | $227,660 to $484,257 | USD / year |
| annual energy | 206,269 | 159,636 to 269,269 | kWh / year |
| utilisation at the busy hour | 0.644 | 0.156 to 2.48 | |
| utilisation with one host down | 0.656 | 0.159 to 2.53 | |
| working set against memory | 0.746 | 0.194 to 2.67 | |
| disk fill at horizon | 0.670 | 0.213 to 2.12 | |
| fraction of the fleet doing nothing useful | 0.321 | 0.213 to 0.457 | |
| utilisation, counting coordination | 0.948 | 0.233 to 3.81 | |
| utilisation | 0.644 | 0.156 to 2.48 | |
| residence time | 0.0367 | 0.0128 to 0.857 | second |
| time spent queueing | 0.0236 | 0.0021 to 0.839 | second |
| requests in the system | 1,562 | 160 to 107,336 | request |
| requests in flight, if none waited | 556 | 135 to 2,147 | request |
| how much the queueing view understated it | 1.47 | 1.27 to 1.84 | |
| fraction of the peak already built | 0.328 | 0.186 to 0.584 |
Source — web_service-reference · every input on a slider
Where each ceiling sits
| Ceiling | At the plan | Headroom | Allowed | Limit | Verdict | Over allowed | Over limit |
|---|---|---|---|---|---|---|---|
| working set against memory | 0.75 | 25% | 0.75 | 1.00 | ok | 49% | 35% |
| utilisation, counting coordination | 0.95 | 30% | 0.70 | 1.00 | into the margin | 65% | 48% |
| disk fill at horizon | 0.67 | 25% | 0.75 | 1.00 | ok | 44% | 29% |
| utilisation with one host down | 0.66 | 30% | 0.70 | 1.00 | ok | 47% | 30% |
| utilisation at the busy hour | 0.64 | 30% | 0.70 | 1.00 | ok | 46% | 30% |
| fraction of the fleet doing nothing useful | 0.32 | 50% | 0.50 | 1.00 | ok | 1% | 0% |
Source — web_service-reference · every input on a slider
The verdict column is about the plan; the last two columns are about the world. A design that is under its hard limit and inside the margin it declared has not failed. It has spent the reserve it was keeping for the failure it has not had yet (ch11).
What moves the answer
| Input | Kind | five-year total cost of ownership at its p10 | at its p90 | Swing |
|---|---|---|---|---|
| engineers, full-time equivalent | input | $1,732,449 | $2,360,530 | $628,081 |
| licence per core | input | $1,829,283 | $2,347,683 | $518,400 |
| fully loaded salary | input | $1,805,056 | $2,258,646 | $453,590 |
| host price | input | $1,827,097 | $2,256,332 | $429,235 |
| support rate | input | $1,945,271 | $2,074,320 | $129,049 |
| electricity price | input | $1,951,956 | $2,080,874 | $128,918 |
| network price per host | input | $1,969,541 | $2,046,600 | $77,059 |
| host power | input | $1,979,689 | $2,028,960 | $49,271 |
Source — web_service-reference · every input on a slider
Each bar swings one input across the middle of its own declared range with everything else held still. The ordering is the useful part: it says which input to go and measure first (ch19). It is also a one-at-a-time analysis, so an input whose effect only appears in combination with another gets a short bar here and can still be the thing that sinks you.
Every bar is a cost input, and the demand side is not here at all. No request rate, no growth
rate, no records held. That is not an omission in the chart. The total prices the fleet that was
bought, and the fleet is hosts — a number somebody decided, not one the model derived. More
data does not buy more hosts by itself; it makes the fleet you have too small, and the model says
so through the disk and cache ceilings rather than through the bill. Drag records held, day one
on the interactive page and watch the recommendation and those two ceilings move while the total
sits still. The panel names, for any input, which outputs it can move and which it cannot.
To see demand move the price you re-decide the fleet, which is what the second scenario below does.
The answer as a distribution
Unit cost rather than total cost, because it is the figure that behaves least like people expect. A fleet bought for growth that then arrives is cheap per request; the same fleet is expensive if the growth never comes. The distribution carries both futures, and a single number carries neither.
Where the inputs came from
| Input | Provenance | Source | |
|---|---|---|---|
| ○ | annual growth factor | assumption | ch04 — lognormal because growth compounds and cannot be negative. The p10/p90 say: surprised below 12% a year, surprised above 60%. One factor for requests and for records, because users drive both. |
| ○ | cache margin | assumption | ch08 — how much of the fleet’s memory is kept free of the working set, for its own daily swing and for the process heaps. Declared once and used by the chain and by the ceiling |
| ○ | contention | assumption | ch07 — fitted from two measurements, where two exist. Triangular because a fit gives a central value and a range rather than a shape, and its maximum is the claim to distrust: a serial fraction can always be worse than the one you measured |
| ◐ | cores per host | vendor claim | spec sheet: physical cores. A hyperthread is not a core, and a sheet that counts threads doubles this number without doubling the work a host does (ch07) |
| ○ | crosstalk | assumption | ch07 — fitted, and the harder of the two to fit. Lognormal because it spans an order of magnitude and cannot be negative, and because a shape should not assert a hard upper bound on a quantity this weakly fitted |
| ○ | disk margin | assumption | ch11 — room to re-replicate a dead host’s records onto the survivors, plus what a filesystem needs to keep allocating well. Declared once and used by the chain and by the ceiling |
| ◐ | disk per host | vendor claim | spec sheet: one local drive. Decimal TB, not TiB — appendix D, and it is a 10% difference |
| ○ | electricity price | assumption | all-in delivered rate including transmission. Lognormal: it cannot go negative and its history is multiplicative |
| ○ | fully loaded salary | assumption | salary, employer costs, tooling and overhead. Lognormal because pay is right-skewed and cannot go negative; the p90 is a senior engineer in an expensive city |
| ○ | horizon | assumption | the refresh cycle this fleet is bought against |
| ◐ | host power | vendor claim | typical draw under load, per host as configured. Triangular, and one of the few inputs here whose bounds are physical rather than editorial: a host cannot draw less than it idles at, or more than its supply will give it |
| ◐ | host price | vendor claim | chassis, CPU, memory, drives and boot media, as configured. Lognormal like any price, and wide because a host is a configuration rather than a commodity |
| ○ | hosts in the fleet | assumption | the sizing decision, taken the way it is usually taken: hosts_recommended evaluated at every input’s point estimate. Change this number and watch the ceilings move — that is the exercise of ch12 |
| ○ | share of records touched in a busy hour | assumption | ch08 — the working set as a share of everything held. Triangular; nobody measures this and everybody has an opinion, and the maximum is a service whose users all look at the same week’s data |
| ● | hours per year | fact | by definition, 365.25 x 24. The quarter-day is worth about a fifth of a per cent over five years — less than this model’s other errors, and free to get right |
| ○ | index overhead | assumption | indexes, the write-ahead log and journals as a multiplier on stored bytes. Triangular, and the bounds are for a service with a few indexes per table — a search-heavy one is off the top of this range |
| ◐ | licence per core | vendor claim | the platform software’s per-core licence, as quoted. Lognormal: a price, and a wide one, because it is the line item most often negotiated. It is what makes the core count a cost as well as a capacity (ch17) |
| ◐ | licence per host | vendor claim | the price list: per core, with no per-host charge. A quote that licenses per host instead puts its figure here and zero in licence_per_core, and the two totals are then compared like for like |
| ○ | one-off cost of moving to this design | assumption | nothing to migrate: the platform the plan already runs on, on the hosts already quoted. A challenger’s scenario overrides this with the team’s own estimate of the move, marked as what it is |
| ○ | network price per host | assumption | switch ports, optics and cabling, amortised per host. Triangular rather than lognormal, although it is a price: it is a bill of materials divided by a host count somebody chose, so the bounds are the plausible designs rather than a market |
| ● | one core | fact | definition |
| ● | one host | fact | definition |
| ● | one request | fact | definition |
| ● | one year | fact | definition |
| ○ | os reserve | assumption | the share of memory the kernel, the agents and the page cache floor keep before the service sees any. Triangular: a floor, a usual figure, and a host with too many agents on it |
| ○ | peak request rate, day one | assumption | ch04 — the busy hour, not the daily mean. Triangular because this is an engineer’s min/likely/max and pretending to more shape than that would be invention. |
| ○ | peak-to-mean ratio | assumption | ch04 — the busy hour against the daily mean. Triangular, and it is a property of your traffic that belongs to the estate target: nothing here can measure it |
| ○ | PUE | assumption | ch16 — facility overhead. A multiplier on IT load, and the single number a colocation contract is most likely to disagree with you about. Triangular: the minimum is a good building, the maximum is a poor one, and below one is impossible |
| ○ | queueing margin | assumption | ch06 — how far under the knee the fleet is sized to run at the busy hour. Declared once, here, and used by the sizing chain and by the ceiling that checks it, so the two cannot drift apart |
| ◐ | ram per host | vendor claim | spec sheet: the modules fitted. The sheet says 64 GB and means GiB — appendix D — and the operating system will report less than either, which is os_reserve’s job |
| ○ | replication factor | assumption | three copies of every record, so that a host can die and take its disks with it. A different durability scheme substitutes its own factor here and the rest of the model is unchanged, which is the point of it being a node |
| ● | seconds per year | fact | by definition, 365.25 x 86,400 |
| ○ | CPU time per request | assumption | held as an assumption because no reference machine is declared in rig/machine.yml; make measure-rig on a declared machine replaces this node with a measured one. Triangular because it has not been measured: once it is, the shape becomes a normal around the measurement, which is a change of claim and not only of numbers (ch13) |
| ○ | engineers, full-time equivalent | assumption | engineers this fleet occupies, full-time equivalent. Triangular, and the shape cannot express what actually happens: people are not divisible, so the real distribution is lumpy in the way ch08 calls a regime change |
| ○ | records held, day one | assumption | stated workload (ch02) — what the service holds today: its database and the objects users have uploaded, before replication, indexes or compression |
| ◐ | support rate | vendor claim | annual support as a fraction of capital cost. Triangular because it is negotiated inside a band the market sets rather than drawn from one: the spread is what different buyers get, not what varies from year to year |
| ○ | utilisation the model will admit to | assumption | where this model stops being about queues (ch06) |
| 37 inputs | 6 fact, 8 vendor claim, 23 assumption |
Source — web_service-reference · every input on a slider
| Constant | Value | Standard error | Unit | Measured against |
|---|---|---|---|---|
| record compression ratio | 3.58 | ± 0.0009 | python zlib (DEFLATE level 6) |
Source — web_service-reference · every input on a slider
One measured constant, and its conditions are on the row: a compression ratio belongs to a codec and a body of data, and this one was measured over a synthetic corpus of application records that this repository generates. The method transfers; the number does not. Point the runner at a sample of your own records (ch03).
Two scenarios, side by side
| Output | Reference scenario | Sized for the growth we might get, not the growth we expect |
|---|---|---|
| hosts the model recommends | 54 20 to 230 | 54 20 to 230 |
| hosts in the fleet | 54 | 131 |
| five-year total cost of ownership | $2,002,083 $1,508,230 to $2,923,724 | $3,646,206 $2,781,458 to $5,393,738 |
| cost per million requests | $1.82 $0.55 to $5.98 | $3.32 $1.02 to $10.96 |
| cost per stored TB per month | $839.64 $305.33 to $2,108 | $1,529 $561.50 to $3,859 |
| capex | $421,214 $272,130 to $658,773 | $1,021,835 $660,167 to $1,598,135 |
| annual opex | $316,174 $227,660 to $484,257 | $524,874 $386,622 to $822,825 |
| annual energy | 206,269 159,636 to 269,269 | 500,394 387,264 to 653,228 |
| utilisation at the busy hour | 0.644 0.156 to 2.48 | 0.265 0.0643 to 1.02 |
| utilisation with one host down | 0.656 0.159 to 2.53 | 0.267 0.0648 to 1.03 |
| working set against memory | 0.746 0.194 to 2.67 | 0.307 0.0798 to 1.10 |
| disk fill at horizon | 0.670 0.213 to 2.12 | 0.276 0.0879 to 0.874 |
| fraction of the fleet doing nothing useful | 0.321 0.213 to 0.457 | 0.604 0.464 to 0.753 |
| utilisation, counting coordination | 0.948 0.233 to 3.81 | 0.671 0.163 to 2.90 |
| utilisation | 0.644 0.156 to 2.48 | 0.265 0.0643 to 1.02 |
| residence time | 0.0367 0.0128 to 0.857 | 0.0178 0.0110 to 0.619 |
| time spent queueing | 0.0236 0.0021 to 0.839 | 0.0047 0.0008 to 0.606 |
| requests in the system | 1,562 160 to 107,336 | 757 144 to 107,336 |
| requests in flight, if none waited | 556 135 to 2,147 | 556 135 to 2,147 |
| how much the queueing view understated it | 1.47 1.27 to 1.84 | 2.53 1.86 to 4.05 |
| fraction of the peak already built | 0.328 0.186 to 0.584 | 0.797 0.450 to 1.42 |
| working set against memory — over its limit | 35% | 6% |
| utilisation, counting coordination — over its limit | 48% | 34% |
| disk fill at horizon — over its limit | 29% | 3% |
| utilisation with one host down — over its limit | 30% | 5% |
| utilisation at the busy hour — over its limit | 30% | 5% |
| fraction of the fleet doing nothing useful — over its limit | 0% | 0% |
Source — web_service-reference and web_service-sized_for_growth · every input on a slider
The left column buys what the point estimates recommend. The right buys for the growth case the model thinks is plausible but not expected. The difference in capital is a number, and so is the difference in how often each ceiling breaks. Choosing between them is a judgement somebody has to make and defend, which is ch21 · A TCO for a finance audience.
What this cannot tell you
Whether the fleet is the right one. The model prices hosts, and hosts is a decision. Every
figure on this page is conditional on it, and nothing here argues for the number: the reference
scenario takes the recommendation at the point estimates, which is how the decision is usually
taken and is not the same as it being right. The ceilings say whether that fleet survives the
model’s futures; they cannot say whether a different fleet would have been a better buy. That
comparison is a second scenario and a judgement, which is ch21.
What the structure leaves out. The chains here are demand, memory and disk, and they meet only at the host count. Nothing connects a request to the record it writes, so a model where traffic and data grow together cannot be told from one where they do not — and this one assumes they do, in a single correlation, rather than deriving it. There is no tier that fails differently from the others, no request that costs more than its neighbour, and no second site. Sampling the inputs harder will not find any of that: an omission is not a wide interval, it is a chain that was never drawn (ch20).
Whether five years is the right horizon. It is a decision too, and it sets how much of the total is capital and how much is running cost. A different horizon does not just scale the answer; it changes which half of the bill the argument is about (ch15).
Running it yourself
python3 -m bench.run_models --model web_service # evaluate, sample, stamp
python3 scripts/verify-models.py # units, provenance, ceilings, shape
python3 scripts/render-figures.py # re-render every figure above
python3 -m pytest tests/test_models.py # the reference outputs, assertedEvery figure on this page came from bench.run_models. verify-models.py refuses a model that
does not typecheck, and tests/test_models.py is what stops any of it changing silently.