Chapter 22: What Held, and What Did Not
For the last 639 seconds of a twenty-minute run, this crawler reported the following about itself.
GOMAXPROCS, as §21.4 predictedSix numbers, every one correct, and every one taken from a chapter of this book that argues for exporting it. A dashboard drawn from those six is entirely green.
Measured in those same 639 seconds the crawler fetched zero pages. It had been doing nothing for more than ten minutes, and not one of the six numbers moved when it stopped.That is the subject. Not a bug — no race, no deadlock, no panic, no leak, and every component correct at every instant. The failure is that the thing which went wrong has no value you could have alarmed on. Its signal is a slope: a counter that stopped climbing. §19's instruments take snapshots. §20's measure functions. Neither can see a trend, and a trend is what this was.
Measured the same run, in the two columns nobody puts on a dashboard.It found 900,219 URLs. It fetched 315,945 of them and failed on 6,494 more. The remaining 577,780 it recorded as visited, never queued, and made unreachable for the rest of the process — and the three add to 900,219 exactly, because every URL the crawler ever saw is in one of the three buckets.
Twenty-one chapters have given advice. This one composes that advice into one system, registers twelve predictions before running anything, runs it for twenty minutes at one sample a second, and publishes the scorecard.
Nine of twelve predictions held. Of the three that failed, all three describe one event, and a five-minute control run withdrew it. A fourth prediction that this chapter first scored as held is re-scored here as failed — and it failed for a reason that is the chapter’s thesis arriving inside its own scorecard.
- What a system built strictly from this book’s advice does over twenty minutes, measured at 1 Hz across nine series, against predictions written down first
- Why a work queue whose producers are its own consumers has three possible implementations — grow, drop, or deadlock — and why all three of the book’s bounding rules fail to reach it
- Measured, 64% of every URL the crawler found was recorded permanently and fetched never; 81% on a graph ten times larger
- Why a prediction stated about a value can be refuted while the identical prediction stated about a slope is confirmed — measured, at R² 0.796
- Measured, deleting every key from a two-million-entry map returns 45.8% of its cost, and
lenreads zero while 112.0 MB remains - Which signal moved first: the one that led by 201 seconds is on no list in this book, and the one from §17.7.5 that led by 56 moved in the direction nobody alarms on
- Where this book stops, and what it deliberately does not cover
- Pattern instruction. Chapter 7 owns pipelines, fan-out and worker pools — 89 uses of “pipeline” and 76 of “fan-out” in one chapter. This one composes and cites
- Two correct mechanisms that are wrong together. §18.6 owns that, in seven measured instances across 5,228 words, and says of itself that they are “the only material in this chapter that is new rather than indexed.” Nothing here is an eighth
- Reviewing code you did not write — §18.7
- What to instrument — §17.7.5's Four Numbers and §19.4.6's addition to them. This chapter issues no third prescription; §22.6 asks an empirical question instead
- When to stop optimising — §20.7.9, extended to a system rather than restated
- A general decision index across the book. That is the appendices' job, and §22.7.3 says what they owe you
Nothing. This is the last chapter and it has no successor to hand anything to. What it does instead is put the preceding twenty-one under test: every claim below is scored against a prediction written before the run, and the scorecard is published whichever way it came out.
§5.2 and §5.3, which disagree about buffering and are both right. §7.3's worker pool and §20.3.4's measured cost of the alternative. §11.3.3's check-then-act. §17.1.3's Little’s Law, §17.7.2's keyed-limiter sweep and §17.7.5's Four Numbers. §19.4's runtime/metrics habit. §21.4's arithmetic for what a blocking call costs in threads. And §20.1 throughout, because a twenty-minute run has all of a benchmark’s measurement problems and one more.
go1.26.1, darwin/amd64, Intel i7-10700K, GOMAXPROCS=16. Immediately before the run pmset -g therm reported CPU_Scheduler_Limit = 100 with no thermal or performance warning recorded — which matters more here than in Chapter 20, because twenty minutes gives a machine far more opportunity to throttle than a benchmark does. Every figure is reproducible with go test against a fixture that ships with the book. The run and its predictions are registered against go1.26.1 and are not re-run here; nothing in Go 1.27 touches the scheduler, the netpoller or the map implementation this chapter measures, and §22.1.6’s absolutes were never expected to reproduce.
22.1 The Experiment, and How to Re-run It
Every figure in the previous twenty-one chapters is reproducible with go test. A chapter whose central artifact is a twenty-minute crawl could easily have broken that, and the design below exists to stop it.
22.1.1 Why the Crawl Is Not of the Internet
A crawl of real websites cannot be re-run by a reader: the targets change, the network changes, and the load is somebody else’s problem. So the target is an in-repo seeded site graph served by httptest, shipped as code/ch22/sitegraph/. It is a real loopback server rather than Go 1.27’s in-memory httptest.NewTestServer on purpose: §22.3.2’s thread count is only evidence if the fetches are real sockets. There is precedent — code/ch18/corpus/ is already a bundled fixture package.
The cost of a fixture is that somebody chose its parameters, and a chosen distribution can manufacture a chosen finding. They are therefore stated, and each is justified against a shape from the world rather than picked.
Every failure in §22.5 was checked against the parameters above before it was reported. One candidate did not survive that check and was removed rather than caveated — §22.4.3 is the withdrawal.
22.1.2 The Nine Series
Sampled at 1 Hz through runtime/metrics and the crawler’s own counters: goroutines, runnable goroutines, OS threads, heap in-use, allocation rate, cumulative GC pause, limiter-map length, in-flight fetches, and frontier depth — plus the counters §17.7.5 asks for.
One detail nearly cost the chapter. The limiter’s wait time was first recorded as a cumulative mean, which is dominated by its earliest samples and therefore cannot show a slope — the entire subject. It was changed to a windowed mean before the run.
22.1.3 The Window, Derived
A slope needs long enough to be a slope. Two constraints fixed the window before anything ran:
Two conditions the run window had to satisfy before it was chosen. First, the key space K must greatly exceed the arrival rate times the window, so that growth stays linear and does not stop for a reason that is not the code’s. Second, the end-to-start delta of a series must be at least six standard deviations of its own sampling noise, or a trend cannot be told from jitter. Twenty minutes at one sample a second gives 1,200 samples.
Both were checked in a twelve-second calibration, which also caught a metric that reads zero: /sched/threads:threads does not exist on go1.26.1 or go1.27 — the Go 1.26 release notes print it that way; the runtime does not. The name is /sched/threads/total:threads, and the first calibration recorded an entire thread series of zeros before anyone noticed.
22.1.4 Three Configurations, and Why All Three
The frontier — the queue of URLs still to fetch — is where the book gives more than one answer, and §22.2.2 is the fork. Rather than choose and report one, all three implementations were built and measured:
The twenty-minute run and its control both use Drop, because §22.2.2 argues it is the correct reading of the book. Grow and Block are measured separately in §22.5.2, and the three results together are the chapter’s most useful finding.
22.1.5 Pre-registration
§20.7.9 says to write the number down before you start. Applied to a system, that means the predictions exist before the run and are published whatever they say.
predictions.md was committed with a timestamp and a SHA-256 before the twenty-minute run. Twelve entries, each carrying four fields — an entry missing any of them is not a prediction:
The file also carries a section headed what no prediction covers, so that a finding could not later be presented as having been anticipated.
Disclosure, because pre-registration without it is worthless. Two calibration runs of ten and twelve seconds preceded the file, to fix metric names and sampling. Twelve seconds cannot show a twenty-minute slope, so nothing in §22.5 was visible at registration time — but the calibration happened, and saying so is part of the method.
22.1.6 Running It Yourself
Twenty minutes, and it writes the nine series to a CSV. CH22_HOSTS=200000 reproduces §22.4.3's control, CH22_SWEEP=off disables §17.7.2's sweeper so you can watch the limiter map grow instead of converge, and CH22_OUT names the output file.
The runs this chapter reports ship too, so no figure here rests on a file you cannot open:
soak_ch22.csvsoak_ch22_control.csvsoak_registered.csvsoak_sustained.csvpredictions.mdTwo of those are independent runs, and they are the evidence for the callout below. soak_registered.csv is the default configuration run again, separately from the one this chapter reports. It stalls at t=564 against t=561, ends at 901,656 URLs seen against 900,219, and excludes 64.0% against 64.2% — while reporting 18 OS threads where the headline run reports 23. The stall instant reproduced to within three seconds and the excluded fraction to within two-tenths of a point; the absolute that §22.3 prints in a table moved by 22%. That is also why P2 was registered as at or below 25 rather than at 23 — a bound survives a move that a value does not, which is §22.4.5 seen from the side where the form was chosen correctly. soak_sustained.csv is the same check from the other direction: a graph on which the frontier never drains, 3,702,167 URLs seen in twenty minutes and 81.3% of them excluded, against the control’s 80.9%.
The rest run in seconds: frontier_variants_test.go is §22.5.2's three configurations, mapdrain_test.go is §22.5.4's two, and gates_test.go is the exercise.
Re-running this will not reproduce 315,945 fetches or 23 threads, and it is not meant to. What reproduces is every ratio and every direction: the excluded fraction rising with graph size, the drop rate turning before the frontier does, the map returning less than half on delete. That is Chapter 20's rule about what a measurement transfers, applied to a chapter made of measurements.
A unit convention, stated once. Throughout this chapter MB means 10⁶ bytes, which is what runtime/metrics reports and what the harness divides by. 72.6 MB and 80.6 bytes an entry are consistent with each other on that basis; read as MiB the second figure would be 84.6.
22.1.7 Common Mistakes
Nobody can re-run your central figure
A seeded fixture; ship its parameters
The first minute dominates the last
Window it, or the slope is invisible
Measured, an entire thread series of zeros
Check metrics.All(); a bad name reads as a value
Everything held
Register them, with a falsifier, and publish the score
A finding that is a property of your generator
State every parameter; check each finding against them
A slope indistinguishable from jitter
Δ ≥ 6σ, computed before the run
The other reading is a different system
Build both; §22.1.4
Summary: The Experiment, and How to Re-run It
The chapter’s central artifact is a twenty-minute time-series, which is the one thing in this book a go test could easily fail to reproduce — so the target is a seeded in-repo graph, every fixture parameter is stated and justified, and the window was derived from two constraints rather than chosen. Twelve predictions were registered in a form that admits falsification, the calibration that preceded them is disclosed, and where the book gave two answers all three implementations were built rather than one being reported.
Key Takeaways
- Reproducibility is a design constraint on a running system, not a property it has
- A fixture’s parameters can author a finding; state them, and check each finding against them
- A cumulative mean cannot show a slope — window it before the run, not after
- Measured,
/sched/threads:threadsdoes not exist on go1.26.1 and reads as a silent zero - Pre-registration means four fields and a falsifier, or it is a story about foresight
- Where a fork exists, build both readings; §22.5.2 is what that bought
An experiment on a running system is only evidence if somebody else can run it, and only honest if the predictions existed first.
Self-Check Questions: The Experiment, and How to Re-run It
Because the chapter’s central figure would then be the one thing in the book nobody could check.
Every measurement in the previous twenty-one chapters is reproducible with go test. A twenty-minute crawl of the live internet is not, and the closing chapter is the worst possible place for the book’s standard to lapse.
The cost of the fixture is real and stated in §22.1.1: a generated graph has a latency distribution somebody chose, and a chosen distribution can produce a chosen finding. That is why the parameters ship, why each is justified against a real-world shape rather than picked, and why §22.4.3 exists — a candidate finding that turned out to be a property of the graph and was removed rather than reported.
There is a second reason, and it is the one that mattered most. A real crawl cannot be re-run with one parameter changed. §22.4.3's control — the same crawler against a graph ten times larger — is what separated a real failure from a fixture artifact, and it took five minutes.
How long, sampled how often, and flat against what.
How long decides whether a slope could have appeared at all. A run shorter than the phenomenon cannot see it, and reports flat, correctly, meaning nothing.
Sampled how often decides whether the series is a series. One reading at the start and one at the end is two points, and two points cannot distinguish a trend from a spike.
Flat against what is the one people skip. §22.1.3's second constraint is Δ ≥ 6σ: a series whose end-to-start delta is inside its own sampling noise is not flat, it is unmeasured. Reporting it as flat is the error §20.1.5 names in a benchmark — a percentage quoted without the spread that bounds it.
And there is a fourth, which is this chapter’s: flat values are compatible with the system having stopped. §22.3 has six of them reading normal through 639 seconds of doing nothing.
By stating every parameter, justifying each against something outside your own judgement, and then re-running the striking results under a second configuration.
The first is bookkeeping: §22.1.1's table gives hosts, pages, fan-out, cross-host share, latency distribution and error rate, so a reader who suspects a result belongs to the generator can look rather than guess. The second is what stops the table being decoration — log-normal with σ = 1.1 is there because published web-latency distributions have a floor and a long right tail, not because it produced a good chart. A parameter you cannot justify is a parameter you tuned.
The third is the one that actually caught something. §22.4.3's control ran the same crawler against a graph ten times larger, and the chapter’s most dramatic finding — a crawler that silently stops — turned out to be the fixture running out of pages. Four minutes, one withdrawn claim.
The general rule: a fixture parameter is a free variable in your experiment, and a free variable you never vary is an assumption you never tested. Vary the one your finding is most likely to depend on, and see whether the finding survives.
Δ ≥ 6σ rather than just “the line went up”?
Because “the line went up” is a claim about a difference, and a difference is only meaningful relative to the noise it sits in.
Take the heap series. Across the crawling phase it moves from 2.2 MB to a peak of 135.8 MB, which looks like an emphatic trend — and its residual around the fitted line has a standard deviation of 13.9 MB with a range of ±33 MB, because GOGC lets the heap run to roughly twice the live set between collections. Any two samples thirty seconds apart can differ by 60 MB for reasons that have nothing to do with your program. Without a noise floor you cannot say whether a rise is signal.
Δ ≥ 6σ is the same discipline §20.1.5 applies to a benchmark, where the ± column bounds what the percentage can claim. A series whose end-to-start delta is inside its own sampling noise is not flat — it is unmeasured, and the honest report is that the window was too short.
It also decides the window before you run, which matters more than it sounds: choosing the window after seeing the data is how you end up with the run length that produced the nicest slope.
22.2 Every Rule the System Was Built From, and What Each One Predicts
This section is the audit trail. It exists so that §22.4 can score the book rather than the author’s foresight, and it is short by design — the rules are already taught, and each row is a citation rather than a lesson.
22.2.1 The Audit Trail
Measured every construction decision incode/ch22, with the section that made it.
GOMAXPROCS(0)NumCPU is host-scoped and cgroup-blinderrgroup.WithContext, components in a groupFifteen decisions, fifteen citations, and the whole of §22.4's meaning rests on that being exactly true rather than approximately true. Two were wrong on the first pass and corrected before the run: the seen-set’s compound update is §11.3.3's check-then-act rather than Chapter 12's sync.Map material, and one citation turned out to have no owner at all (§22.2.3).
22.2.2 The Fork, and Why It Has No Correct Answer
One decision mattered more than the rest, and the book genuinely gives more than one answer.
“The buffer absorbed the burst.”
📋 §5.3 and §17.4, on bounds. §5.3 is titled When NOT to Buffer; §17.4 argues that an unbounded queue is not a fix but a slower failure.
A crawler’s frontier is the case that separates them. Eight links are discovered per page fetched and one page is consumed, so arrivals exceed service by roughly eight to one permanently. That is not a burst, so §5.2's rule does not apply and §5.3's does — the frontier is bounded and drops what does not fit.
That is the correct reading, and §22.5.3 is what it costs. But the reason this fork gets its own subsection is that the third reading is also available and also loses, which §22.5.2 measures. Record the fork here; the arithmetic of who is right is in §22.5.
22.2.3 Where No Rule Applied
Three decisions had no owner, and naming them is the honest half of an audit trail.
Draining the response body. Before Go 1.27 a body not read to completion and closed left the connection unusable, so the transport opened a new one and §17.7.1's connection pool bought nothing; from 1.27 Close drains a bounded amount itself, and a body above that bound still costs the connection. No chapter covers either. It is two lines on which the entire throughput figure depends — and this crawler would read the body anyway, because the links are in it.
What to do with a URL the frontier rejected. §5.3 says drop. Nothing says whether to record the drop, retry it, or treat it as data loss. §22.5.3 is what that silence costs.
Whether the dedup set has a lifetime. Every other structure in this book has one — §12.3's pool is emptied by the collector, §17.7.2's limiter map is swept, contexts are cancelled. The seen-set is the only thing deliberately permanent, and nothing prices it.
All three are decisions about lifetime in a system, and lifetime is exactly what a chapter about a mechanism cannot teach: §5 cannot tell you what happens to a rejected item because §5 does not know your system has a dedup set. A gap that only appears in composition is the one kind a chapter cannot close.
22.2.4 The Twelve Predictions
Registered before the run in the four-field form. Abbreviated to source and claim; tolerances and falsifiers are in predictions.md.
seen × 100 bytesP11 is the only one derived rather than quoted, and it is Little’s Law doing real work. L = λW. The fixture’s latency is log-normal at median 15 ms with σ = 1.1, so its mean is 15·e^(σ²/2) ≈ 27.5 ms — not 15, and the difference is the whole prediction. With sixteen workers each holding one fetch, λ = 16 / 0.0275 ≈ 580 fetches per second.
22.2.5 What No Prediction Covers
Registered alongside the twelve, so that nothing found later could be presented as anticipated:
- What fraction of a corpus a bounded frontier discards, and whether anyone should watch it. P7 predicts that drops happen and that the rate is stable. No chapter says the fraction matters, names a threshold, or puts it on a dashboard.
- Which series moves first when something goes wrong. §17.7.5 says what to instrument and §19.4.6 says what to add; neither orders them.
- Anything whose signal is a slope. That is the chapter’s thesis and the reason for the twenty-minute window.
22.2.6 Common Mistakes
Two of fifteen citations were wrong before the run
Open the section; quote it
The other reading builds a different system
Record it, build both, then choose
You invent a rule and attribute it
Name it as uncovered; §22.2.3
Twelve for twelve
A falsifier per entry, registered first
Little’s Law off by 83%
Log-normal mean is median·e^(σ²/2)
Every finding was anticipated, apparently
Write it down before the run
Summary: Every Rule the System Was Built From
Fifteen decisions, fifteen citations, two of them wrong on the first pass — which is the argument for opening the section rather than recalling it. One fork mattered: §5.2's buffer-absorbs-the-burst against §5.3's and §17.4's bound-it, and a crawler’s frontier is permanently over-fed rather than bursty, so §5.3 wins on the arithmetic. Three decisions had no owner at all, and all three are about lifetime in a system, which is the one thing a chapter about a mechanism structurally cannot teach.
Key Takeaways
- An audit trail is what lets a scorecard grade the book instead of the author
- Where two sections disagree, record the fork — the other reading is a different system, not a worse one
- §5.2 is about bursts; a crawler’s frontier is not a burst, and §5.3 is the applicable rule
- Three decisions had no owner, and each is about lifetime — the gap composition creates
- Derived, Little’s Law needs the log-normal mean,
median·e^(σ²/2), not the median - Register what no prediction covers, or every finding will look anticipated
Building strictly by the book is a constraint that pays twice — it makes the failures the book’s rather than yours, and it makes the gaps visible.
Self-Check Questions: Every Rule the System Was Built From
Compare the arrival rate to the service rate over the window you care about, not at the moment you are looking.
§5.2's case is a burst: arrivals exceed service temporarily, the area under the spike is finite, and the system has time to catch up afterwards. That is what a buffer is for, and §5.2's own diagram says so — normal, spike, normal.
A crawler’s frontier is the other shape. Each fetch discovers eight links and consumes one page, so arrivals exceed service by roughly eight to one for as long as the crawl runs. There is no “afterwards”. A buffer sized for that is not absorbing a burst; it is deferring an unbounded quantity, which is §17.4's slower failure.
The test that separates them is arithmetic you can do before writing anything: is the excess of arrivals over service bounded in total, or only in rate? If bounded in total, buffer it. If not, no buffer size is correct — and §22.5.2 shows that the remaining choice is narrower and more uncomfortable than “bounded or unbounded”.
Because L = λW is a statement about averages, and a log-normal distribution’s mean is not its median.
The fixture’s latency is log-normal with median 15 ms and σ = 1.1. Its mean is 15·e^(1.1²/2) ≈ 27.5 ms — 83% higher. Using the median would have predicted 16 / 0.015 ≈ 1,067 fetches per second; the measured steady rate was 565.
That is not a rounding difference; it is the difference between a prediction that holds and one that fails by nearly a factor of two. The direction is always the same, because a right-skewed distribution’s mean always exceeds its median, and web response times are the canonical example.
The practical form: when you apply §17.1.3 to a real system, the W you need is the mean, and the number your dashboard shows you is usually a median or a p50. They are not interchangeable, and the gap grows with the tail.
No, and the reason is the most useful thing in §22.2.
All three — draining a response body, what to do with a rejected URL, whether the dedup set expires — are decisions about lifetime in a system. §5 cannot tell you what happens to an item its bounded queue rejects, because §5 does not know your system has a dedup set that has already recorded it. §12 can tell you a pool is emptied by the collector and cannot tell you that your seen-set must not be. The information needed to answer any of them lives in the composition, not in any of the parts.
That is a structural limit on what a chapter organised by mechanism can cover, not an omission somebody could have fixed by writing more. It is also why §18.6 exists — bugs that need two mechanisms at once — and why this chapter does: the same gap, one level up, in health rather than in correctness.
The practical form is a habit rather than a rule. When you compose two mechanisms, write down the decisions neither of them made for you. Those are the ones nobody will review, because there is no section to review them against.
22.3 What Happened
Twenty minutes, 1,200 samples, one crawler, one fixture. This section is reportage: what the nine series did, before any of it is interpreted.
22.3.1 The Run, at Seven Instants
Measured the levels the runtime reports.And the work, over the same instants.
And the two sets the system keeps.
The last two rows are identical in every column that counts work.
22.3.2 The Six That Never Moved
Four series that did not move across the run. Goroutines went from 110 to 94, being sixteen workers plus net/http’s own. Threads rose from 15 to 23 and stopped, which is section 21.4's point that sockets go through the netpoller rather than costing a thread each. Runnable goroutines never exceeded one. Cumulative GC pause totalled 0.084 seconds over 1,200, or 0.007 per cent of wall clock. All four are correct, and none of them knows the crawler stopped at t equals 562.
Threads settling at 23 is §21.4.1 arriving exactly. That section measured 500 goroutines blocked on sockets costing six threads, against 64 in a genuinely blocking syscall costing 67, with the netpoller as the difference. Every fetch here is a socket.
22.3.3 The Two Rates, and the One That Warned
Fetched and dropped are counters, so their levels rise by construction. Their rates are the series worth reading.
Two rates through the run, drawn as bars. The fetch rate holds between 547 and 597 per second from t equals 61 through t equals 555, then goes to zero at t equals 562. The drop rate starts at 1,880 per second and falls through 1,204, 780, 544 and 256, reaching zero around t equals 503. The left column is flat until the instant it is zero; the right one had been falling for three minutes.
The fetch rate is what a throughput dashboard shows. It read 551 per second at t=555, 520 at t=558, and zero at t=562. Put a threshold under it and you get two samples of notice, both of which look like ordinary variance against a series that has been between 547 and 597 all run — which is why §22.6.1 scores it as no warning at all. The drop rate had been falling since t=361.
22.3.4 The One That Converged
The keyed limiter did exactly what §17.7.2 promised, and it is the clearest held prediction in the chapter.
The map peaked at 7,006 keys against a key space of 20,000 hosts, held a plateau near 5,000 while the crawl was active, and emptied when the work stopped. 46,827 evictions, and the sweeper’s two-phase discipline never appeared in any latency series.
“An idle limiter is indistinguishable from a fresh one.” A token bucket’s whole state is a count and a timestamp; after burst / rate seconds of idleness it has refilled to capacity, and a new limiter also starts at capacity, so deleting it discards nothing. This is the section of the book that most clearly earned its place: it named a leak, prescribed a fix, explained why the fix is free, and the fix worked unmodified in a system that section had never seen.
22.3.5 The Heap, and the Column That Explains It
Heap in-use is the noisiest series and the easiest to misread: 2.2 MB at the start, a peak of 135.8 MB, 72.6 MB across the final third. Read against the collector it means nothing. Read against the dedup set it is arithmetic — and §22.4.4 is what happens when you try to state that arithmetic as a band on a value.
Derived 900,219 URLs in the seen-set against 72.6 MB resident gives 80.6 bytes per entry at the endpoint. Across the crawling phase the fit isheap = 7.5 MB + 105.7 bytes × seen.
The number that matters is not the total. It is that two-thirds of those entries are URLs the crawler never fetched, and that after the crawl stopped the 72.6 MB did not come back for 639 seconds.
22.3.6 Common Mistakes
Fetched rises forever, including after work stops
Differentiate; the level is monotone by construction
Measured, 551/s five seconds before zero
It is the last series to move, not the first
It is identical to a dead system
Pair it with a rate; §22.6.2
A noisy series with no meaning
Read it against the structure that owns it
They track blocking syscalls
§21.4.3; sockets are the netpoller’s
Six flat signals through 639 idle seconds
At least one series must be a rate
Summary: What Happened
The crawler ran at 565 fetches per second for nine minutes, discovered 900,219 URLs, fetched 315,945, dropped 577,780, and then stopped and ran for a further 639 seconds doing nothing. Six health signals stayed flat throughout — goroutines, threads, runnable, GC pause, error rate and heap — all six correct and none aware. The keyed limiter converged and emptied exactly as §17.7.2 said, with 46,827 evictions and no visible cost. The heap is explained entirely by the dedup set, and two-thirds of that set is URLs never fetched.
Key Takeaways
- Measured, six health signals stayed flat through 639 seconds of a stopped system
- Measured, threads settled at 23 and never grew — §21.4.1's netpoller arithmetic, unmodified
- Measured, the limiter map peaked at 7,006, evicted 46,827 keys and emptied
- Measured, the fetch rate was 551/s five seconds before it was zero, while the drop rate had fallen for three minutes
- Derived, heap fits
7.5 MB + 105.7 B × seen, and two-thirds ofseenwas never fetched - A counter’s level is monotone by construction; only its rate carries information
Every number the system reported was correct, and none of them was about whether it was working.
Self-Check Questions: What Happened
Because all six measure the capacity the program is using, and a program that has stopped is using its capacity perfectly.
Sixteen workers parked on an empty channel are sixteen goroutines, exactly as when they were fetching. Threads are §21.4's in-flight blocking-syscall count, and zero fetches means zero syscalls — but the pool’s floor is GOMAXPROCS plus a handful, so it does not fall either. Runnable is zero because nothing waits for a processor, the healthiest possible reading and also what a dead process reports. The collector has nothing to collect. The error rate is a ratio of two counters that both stopped. And heap is a level, and the level is correct.
The common property is that each is a level, and a level describes a state rather than a change. The system’s state after it stopped was genuinely fine — the failure was in what it was no longer doing, and a level cannot express “no longer”.
The corollary is the chapter’s practical rule: at least one thing on any dashboard must be a rate, because a rate is the only kind of series whose zero means “stopped” rather than “idle”.
Because the sweeper is not bounded by the key space, it is bounded by the active key set, and those are very different numbers.
§17.7.2's sweep deletes any entry idle longer than the configured window, which here was 30 seconds. So the map’s steady-state size is however many distinct hosts the crawler touched in any 30-second window, not however many exist. At 565 fetches per second with 15% of links crossing hosts, that was about 5,000, with a peak of 7,006 during the early phase when discovery was fastest.
This is why §17.7.2 calls the fix free rather than a trade. Bounding by capacity — an LRU, the section’s second option — would have to be sized above the active set and would hand an evicted key a fresh full bucket. Sweeping by idleness discards only entries that have already refilled, so a swept key and a fresh key are the same object.
The number to take away is not 7,006, which is this workload’s. It is that the map’s size is a property of your traffic’s key locality over the sweep window, and that is a quantity you can estimate before you write the sweeper.
Because it is a ratio of two counters, and both of them stopped.
The error rate here is cumulative errors over cumulative fetches. At t=562 both froze — 6,494 and 315,945 — so the ratio froze with them at exactly the value it had when work stopped. It will read 2.06% for as long as the process lives, and it is correct the whole time: of the requests the crawler made, 2.06% failed.
That is the most deceptive signal on the dashboard, because a ratio of counters carries no information about whether either counter is still moving. A gauge at least has a value that could change. A frozen ratio looks exactly like a stable, healthy system, and the more decimal places you give it the more authoritative it looks.
The fix is the chapter’s rule again, applied to ratios: report the denominator’s rate alongside the ratio. “2.06% of 565 per second” and “2.06% of zero per second” are different statements, and only the second one is an incident.
Neither, and the question is the wrong shape — which is §22.4.4's point arriving early.
72.6 MB is a level, and a level of a series with a sawtooth on it. §22.5.5 decomposes it: the live set, plus garbage not yet collected, plus a doubling map’s old bucket array. Two of those three are the runtime’s timing rather than your program’s, they cancel over a window and dominate at any instant, and the residual around the fitted line spans ±33 MB. Asking whether one sample is the leak is asking a question the sample cannot answer.
What the series does support is a slope: heap = 7.5 MB + 105.7 bytes × seen, at R² 0.796. That says the heap is the dedup set and nothing else, with a coefficient you can check against a URL string plus map overhead, and it is refutable in a way a single reading is not.
So the useful reading of 72.6 MB is not “leak or baseline” but “900,219 remembered URLs, two-thirds of them for pages this crawler will never fetch” — which is a statement about the program rather than about the collector, and is the one that would survive being run with a different GOGC.
22.4 What Held, and What Did Not
Twelve predictions, registered with a falsifier each. This section publishes the score whatever it says, which was the condition of registering it.
22.4.1 The Scorecard
seen × 100 BNine held, three failed. P6 and P10 are one event — the crawl stopping at t=562 — observed in two series. P4 is separate and is the interesting one, and P7 held for a reason worth its own subsection.
22.4.2 What Eight Out of Twelve Establishes
The temptation is to read a high score as a victory lap. It is worth being precise.
It establishes that the book’s mechanism-level advice composes. Nine predictions were derived from eight different chapters and the fixture’s own configuration, applied to a system none of them had seen, and came true within tolerances registered in advance. §21.4's thread arithmetic, §17.7.2's sweep, §17.1.3's Little’s Law and §20.6's allocation model all transferred without adjustment. The common failure of a book of rules is that each is true and the set is jointly useless; that is not what happened.
It does not establish that the system worked. It fetched 35% of what it found and stopped. Every prediction that held was about a mechanism, and the mechanisms were fine — which is exactly the chapter’s problem, because the failures were not in any mechanism.
Eight sections predicted their own behaviour correctly inside a system that was, by the end, doing nothing. That is not a paradox; it is what it means for a failure to live in composition. §18.6 makes the same point about bugs. This is the same point about health.
22.4.3 The One That Was Withdrawn
P6 predicted the frontier would stay saturated. It drained at t=562 and the crawler stopped.
The tempting report is that a crawler built by the book silently dies. Before writing that, one question had to be answered: the graph has a million pages and the crawler had seen 900,219 of them. Was the stop a failure, or the fixture running out?
Measured the same crawler, the same code, against a graph ten times larger — 200,000 hosts, ten million pages — for five minutes.The frontier never drained and the crawler never stopped. The termination was the fixture, not the crawler, and the finding is withdrawn.
That is the most useful five minutes in this chapter. A control took a dramatic result away — and in the same table made the surviving finding worse, because exclusion rose from 64% to 81% as the graph grew.
A twenty-minute run that produces a striking failure is exactly the moment nobody wants to spend four more minutes checking whether the failure belongs to the harness. §20.1's argument is that the workload must contain the thing you meant to measure; here the workload contained something extra, and only a second configuration could tell them apart.
22.4.4 P4, Which Failed for the Interesting Reason
P4 is the one worth its own subsection, and this chapter’s first pass scored it held — by checking the endpoint rather than the samples. On the stricter reading its own falsifier registered, it fails.
Heap in-use is explained by the dedup set and nothing else. (c) heap_inuse stays within ±50% of seen × 100 bytes. (d) Falsified by heap outside that band, or by heap rising while seen is flat.
And the claim inside it is correct.
Prediction P4 scored twice against one dataset. Stated as a value, that heap stays within plus or minus 50 per cent of seen times 100 bytes, 370 of 1,200 samples fall outside and it is falsified. Stated as a slope, heap equals 14.1 megabytes plus 122.8 bytes times seen with R squared 0.861 and the coefficient inside the predicted range, and it is confirmed. One hypothesis, one dataset, two verdicts.
GOGC=100 letting the heap run to roughly twice the live set between collections. §20.6.6 measured that dial; here it is the reason a correct hypothesis about a live set cannot be stated as a band on an instantaneous heap reading.
P4 was written the way a prediction is normally written — a tolerance on a value — and that form cannot survive a sawtooth. Written as a claim about a slope, the identical hypothesis is confirmed at R² 0.796. The failure is in the shape of the assertion, not in the belief behind it. §22.5.5 is what that generalises to.
22.4.5 P7 Held, and Its Falsifier Was Wrong
P7 was scored Half on this chapter’s first pass, and there is no such verdict. Inventing a third category after the run is exactly what pre-registration exists to prevent, so it is worth doing properly.
Drops exceed fetches, and the drop rate is stable. (c) Cumulative dropped exceeds fetched within the first 60 seconds, and the per-second drop rate is flat across the final third. (d) Falsified if drops stay below fetches, or if the drop rate has a positive slope.
And the prediction was nearly useless, because the falsifier only looked one way.
The four things the drop rate could have done, against what prediction P7's falsifier said about each. A rising drop rate would have falsified it, which was anticipated. A flat drop rate holds, also anticipated. A falling drop rate holds, and was not considered. A drop rate of zero holds, was not considered, and is the failure itself. The series that gave 201 seconds of warning is the one this prediction was blind to.
P4 was stated in the wrong shape — a band where a slope was needed. P7 was stated in the wrong direction — a one-sided falsifier for a two-sided quantity. Both were written carefully, by somebody who had read the chapter each came from, and both would have been improved by a question that costs nothing to ask: what would the opposite of my worry look like, and would I catch it?
22.4.6 What the Scorecard Cannot Tell You
Three things survived every check, and none of them was predicted, because no chapter is about them:
- The exclusion is real and scales the wrong way. 64% of found pages never fetched at a million; 81% at ten million.
- The dedup set is real. 900,219 entries, 72.6 MB, retained 639 seconds after the last fetch.
- The instrumentation gap is real. In both runs the error rate reads 2.06% and §17.7.5's Four Numbers are healthy while most of the work is being discarded.
22.4.7 Common Mistakes
Nine held; the crawler fetched 35% and stopped
Mechanism predictions do not predict health
A chapter that cannot lose
Register the falsifier; publish the score
A fixture artifact presented as a finding
Measured, five minutes withdrew one
Three failed predictions, one cause
Ask what changed, not how many series moved
P4 read as held; 17% of samples say otherwise
Score every sample, or state that you scored one
Measured, exclusion rose from 64% to 81%
Scale the control and check the direction
Summary: What Held, and What Did Not
Nine of twelve predictions held, drawn from eight different chapters and the fixture and applied to a system none had seen — §21.4's thread arithmetic, §17.7.2's sweep, §17.1.3's Little’s Law and §20.6's allocation model all transferring unmodified. Two of the three failures were one event, and a five-minute control showed that event was the fixture exhausting rather than the crawler failing, so the finding was withdrawn — while the same comparison made the surviving one worse, from 64% to 81%. The fourth failure is the chapter’s thesis arriving inside its own scorecard: a correct hypothesis, refuted because it was stated as a band on a value and confirmed at R² 0.796 when stated as a slope.
Key Takeaways
- Measured, 9 of 12 registered predictions held; the book’s mechanism advice composes
- A high mechanism score is compatible with a system that fetched 35% of its work and stopped
- Measured, a five-minute control withdrew the chapter’s most dramatic finding
- Measured, the surviving finding scales the wrong way: 64% excluded at 1 M pages, 81% at 10 M
- Measured, P4 fails as a band on a value (17% of samples outside) and holds as a slope (R² 0.796, coefficient 105.7 B)
- Publishing the score whatever it says is the condition that makes registering it worth anything
The book was right about nearly everything it had told you, and the system still did not work, because nothing it had told you was about whether the system was working.
Self-Check Questions: What Held, and What Did Not
Yes, and it is a smaller claim than it sounds.
What it establishes is that mechanism-level advice transfers into composition. Nine predictions came from eight different chapters, were applied to a system none of them had seen, and came true inside tolerances written before the run. §21.4's netpoller arithmetic put threads at 23. §17.7.2's sweep converged and emptied. §17.1.3's Little’s Law predicted 580 fetches per second against 565 measured. The usual failure of a book of rules is that each rule is true and the set is jointly useless; that is not what happened.
What it does not establish is that the system worked, and the two are almost unrelated. Every held prediction was about a mechanism, and every mechanism was fine. The crawler fetched 35% of what it discovered, permanently excluded the rest, and then sat idle for ten minutes — none of which any prediction was about, because none of the chapters is about it.
A reader who takes the score as reassurance has read it exactly backwards.
Because the caveat would have been doing the control’s work, and readers do not run controls.
“A crawler built by the book stopped silently after nine minutes” is a striking sentence, and a footnote saying the fixture might have been exhausted does not undo it — it moves the burden onto the reader. The control answered it in five minutes: with a graph ten times larger the frontier never drained and the crawler never stopped. Reporting the stop as a property of the crawler would have been false.
The general form is §20.1's rule one level up. §20.1 says the benchmark must contain the thing you meant to measure. Here the harness contained something extra — a finite graph — and no amount of care in the crawler could have separated the two. Only a second configuration could.
The outcome is worth noting because it is not the usual reward for being careful: the control did not merely remove a finding, it strengthened the one that remained, since the same comparison showed exclusion rising from 64% to 81%. Withdrawing the dramatic claim made the durable claim harder.
The prediction was stated in a shape the quantity could not satisfy.
P4 asserted a relationship between two growing quantities — retained heap and remembered URLs — and stated it as a band on an instantaneous value. Measured, 17% of samples fell outside the band and the ratio spanned 0.75× to 10.44×, so the falsifier fired. Fitted as a slope, the same data give heap = 7.5 MB + 105.7 bytes × seen at R² 0.796, with the coefficient inside the predicted range. The belief was right.
The reason is that heap_inuse is three things added together: the live set, garbage not yet collected, and a doubling map’s old bucket array. Only the first is the program’s; the other two are the runtime’s timing. Over a window they cancel, and at any instant they dominate — so a band on the instant is mostly a test of GOGC, and it fails for reasons unrelated to the claim.
The rule that survives: state a claim about accumulation as a slope with a coefficient. “Heap stays under 400 MB” is a claim about the collector. “Retention is 105 ± 50 bytes per remembered URL” is a claim about your data structure, it is refutable, and it transfers to a machine with a different GOGC.
A slope with a coefficient, and a falsifier that looks in both directions.
P4 and P7 failed in the two available ways and neither was a failure of belief. P4 was the wrong shape: it stated a claim about accumulation as a band on an instantaneous value, and instantaneous heap carries two terms belonging to the runtime rather than to the program, so 17% of samples fell outside a band whose underlying claim holds at R² 0.796. P7 was the wrong direction: its falsifier fired only if the drop rate rose, and the failure was the rate collapsing — the one series that gave 201 seconds of warning was the one the prediction could not notice.
So: “the queue grows at 5 ± 2 entries per item completed; falsified if the fitted coefficient is outside that range, or if it is not distinguishable from zero, or if it goes negative.” That is a slope, it has a tolerance on the coefficient rather than on a reading, and it names what “the opposite of my worry” would look like.
The general question, which costs nothing to ask and would have caught both: what would the opposite of my worry look like, and would this falsifier notice it?
22.5 The Failures That Only Exist in Time
Everything that survived the control has one shape, and it is the shape no instrument in this book can see. A benchmark measures a function. A profile samples an instant. Neither can express a quantity that is fine at every moment and wrong across an hour.
22.5.1 The Queue That Feeds Itself
The frontier is a queue whose producers are its own consumers: every fetch removes one URL and adds up to eight. Its steady state is growth proportional to the fan-out minus one, permanently.
What makes that a finding rather than an obvious consequence is that the book tells you to bound it in three separate places, and every one of those places assumes something this queue does not have.
Three rules the book gives for bounding a queue, each with the precondition it assumes. Section 5.1's unbuffered channel gives backpressure, but assumes the producer can be made to wait. Chapter 7's pipeline is bounded by construction, but assumes a directed acyclic graph in which no stage feeds one behind it. Section 17.5's capacity equals deadline times drain rate, but assumes a caller with a deadline. For a crawler’s frontier the producer is the consumer, the graph is a cycle, and there is no caller at all, so all three preconditions fail at once.
Backpressure needs a producer you can block, and here blocking the producer blocks the consumer, because they are the same goroutine. A pipeline is bounded because work flows one way; this graph is a cycle. And §17.5's sizing formula needs a deadline owned by somebody outside the system, and a crawler has no such caller.
22.5.2 Drop, Grow, or Deadlock — There Is No Fourth
Three implementations, all built, all measured.
Block. Remove the default: branch so a full frontier makes the sender wait. Measured — TestBlockingFrontierStalls, eight workers, capacity 64, fan-out 6:
Fourteen items on that run, fifteen on the next, and on every run fewer than one queue’s worth of a frontier holding 64 — and then nothing, for as long as you leave it running. Every worker is blocked on a send into the queue it is the only consumer of, and nothing drains it because everything that could drain it is blocked filling it. This is §10's circular wait with one participant class rather than two, and go vet and -race are both silent throughout — the goroutines are correctly synchronised and permanently stuck.
Grow. Keep an unbounded frontier. Measured — TestGrowingFrontierIsUnbounded, the same shape with a slice instead of a channel, for half a second:
The queue holds five entries for every item completed, and five is fanout − 1. That ratio is the part that travels: it came out at 5.0 to four figures on every run here, and on a slower machine that reached 6.7 million entries instead of 9.7 million it was still exactly 5.0. It does not converge, because nothing in the loop removes more than it adds. The absolute is not structural — it is whatever your machine completes in half a second — but the growth rate is (fanout − 1) × throughput whatever that number is, and at roughly 100 bytes an entry a machine of this speed reaches a gigabyte inside a minute. §17.4's slower failure, arriving exactly on the schedule the arithmetic predicts.
Drop. Bound it and discard the overflow. That is what this crawler does, and §22.5.3 is the bill.
The three ways to implement a self-feeding queue and what each one costs. Grow, an unbounded queue, costs memory without limit. Drop, bounded with the overflow discarded, costs 64 to 82 per cent of the corpus. Block, bounded with the sender waiting, deadlocks after fourteen items. The book recommends bounding the queue, and two of the three ways to do that lose.
The reusable form: when a queue’s producers are its own consumers, the choice is not bounded or unbounded. It is drop, or grow. Blocking is not a bound — it is a deadlock with a capacity argument — and whichever of the other two you pick has to be counted.
22.5.3 The Ratio Nobody Exports
Measured, this run: 900,219 URLs discovered, 315,945 fetched, 577,780 dropped. 64% of everything the crawler found, it recorded permanently and fetched never — and 81% on the ten-million-page control.
The mechanism is two correct rules meeting, and it is six lines long.
// Illustrative snippet — not a complete program
func (c *Crawler) enqueue(u string) {
c.mu.Lock()
if _, ok := c.seen[u]; ok {
c.mu.Unlock()
return
}
c.seen[u] = struct{}{} // recorded here
c.mu.Unlock()
select {
case c.frontier <- u:
c.stats.Enqueued.Add(1)
default:
c.stats.Dropped.Add(1) // and dropped here
}
}
§5.3 says bound the queue, and it is bounded. §11.3.3 says the check and the insert are one compound operation under one hold of the lock, and they are. Both rules are followed and neither is wrong.
The consequence lives in the gap between the two statements. A URL is written into seen before anything knows whether the frontier has room for it, so a URL discovered while the frontier is full is recorded permanently and never queued. It cannot be rediscovered, because the dedup set will reject it the next time a page links to it. The crawler has silently decided never to visit two-thirds of the graph, and the line that decided it is an unlock.
At every instant the system behaves exactly as designed: the queue is full, so it sheds, which is what §5.3 asked for. There is no incorrect value anywhere. The failure is the integral — what fraction of the corpus was excluded over the run — and an integral has no instant at which it is wrong.
The repairs all trade something, and the book supplies none of them because each is about lifetime rather than mechanism. Mark on dequeue instead of enqueue, and the frontier holds duplicates. Keep a rejected set, and it is a second unbounded structure. Block, and §22.5.2 measured what that does.
22.5.4 The State You Cannot Delete
The dedup set is the one structure in the system with no lifetime, and that is not an oversight. It is what deduplication is: a set that forgets is a set that lets the crawler revisit.
Everything else here expires. §12.3's pool is emptied by the collector. §17.7.2's limiter map is swept — 46,827 evictions. Contexts are cancelled. The seen-set is deliberately permanent, and nothing in 546,000 words prices it.
Measured 900,219 entries, 72.6 MB, 80.6 bytes per entry, sized by what the crawler discovered rather than what it fetched. Two-thirds of that memory is bookkeeping for URLs that will never be fetched, and after the crawl stopped it stayed resident for 639 seconds.And if you do decide to evict, one more measured surprise. Measured — TestMapDoesNotShrinkOnDelete and TestReplacingTheMapReturnsAllOfIt: two million string keys inserted into a map[string]struct{}, then either every key deleted or the map itself replaced, with forced collections between readings.
len(m) is 0 after the deletes. The map holds nothing. It is still 112.0 MB, and deleting every key returned 45.8% of what the map cost. Assigning a fresh map over it returns all of it.
A Go map does not shrink. Its buckets are allocated as it grows and are not released when entries are removed; only replacing the map returns the memory. So an eviction policy that deletes from the map it is bounding does not bound memory — it bounds len, which is not the number anyone cares about.
Both rows ship as tests, and getting the second to measure anything took §20.1.2's rung 4. Without an observation of the map between filling it and reading the heap, the collector is entitled to take it first — the “before” reading came back at 0.5 MB, and the experiment measured an empty program twice and reported 0% returned. Chapter 20's dead-code trap has a memory-profiling twin, and it is the quieter of the two, because a suspiciously fast benchmark at least looks wrong.
§21.4.1 measured that OS threads are cached rather than destroyed, so thread count is a high-water mark wearing the clothes of a current value. A map’s memory is the same shape. The fix is the same shape too: replace, do not delete. Keep two generations of the set, drop the older one whole when the live one fills, and check membership against both — which returns all of the memory instead of 45.8%, at the cost of a bounded, stated re-fetch rate.
22.5.5 A Value Can Refute What a Slope Confirms
§22.4.4 has the measurement; this is what it generalises to.
P4 asserted a relationship between two growing quantities and stated it as a band on an instantaneous value. Across the crawling phase the band was violated in 17% of samples, and the relationship it asserted is true at R² 0.796 with a coefficient inside the predicted range.
Heap in-use is the sum of three terms: the live set, garbage not yet collected which is governed by GOGC, and a doubling map’s old bucket array which belongs to the runtime. The second and third terms are amplitude rather than trend: they average to nothing across the window and are plus or minus 140 megabytes at any instant. A band on the instantaneous value therefore tests the amplitude, while a slope tests the claim.
heap_inuse are properties of the runtime’s timing rather than of your program. Over a window they cancel; at any instant they dominate. So a prediction about what your program retains, stated as a band on heap_inuse, is mostly a prediction about GOGC — and it fails for reasons that have nothing to do with the belief being tested.
The rule that survives: state a claim about accumulation as a slope with a coefficient. “Heap stays under 400 MB” is a claim about the collector. “Retention is 105 ± 50 bytes per remembered URL” is a claim about your data structure, it is refutable, and it is the one that transfers to a machine with a different GOGC.
22.5.6 Why No Instrument in This Book Can See Any of It
Chapter 19 taught four views and Chapter 20 taught benchmarking. Against this class each fails for a structural reason rather than a fixable one.
Five instruments and the question each answers. A benchmark asks how long a call takes; the call is correct and fast. A CPU profile asks where time is going right now; the system is idle. A heap profile asks what is resident right now; 72.6 megabytes, all of it reachable. A goroutine dump asks where everyone is right now; parked, exactly as designed. The race detector asks whether access is synchronised; it is, and always was. Every one asks about now, and the failure is a quantity accumulated over an hour.
The common property is that every instrument in this book answers a question about an instant, and each of these failures is a property of an interval. That is not a gap in the tooling so much as a category difference, and naming it is the most portable thing in this chapter.
22.5.7 The Shape of the Class, and What to Do
Five failures, one shape:
- A quantity that only accumulates. Excluded URLs. Correct at every instant.
- A quantity that should fall and does not. Retained dedup memory after the work ends — and
deletereturns less than half of it. - A rate whose zero looks like health. Frontier depth zero, in-flight zero.
- A claim whose form cannot survive the runtime’s amplitude. P4 as a band on a value (§22.4.4).
- A claim that could only be wrong in one direction. P7's falsifier, blind to the collapse that was the failure (§22.4.5).
The last two are failures of measurement rather than of the system, and they belong on the list because they arose the same way: an instrument shaped for a value, pointed at an interval.
The diagnostic is a question you can ask of a design before you run it: does this system have a quantity whose correct value depends on the whole run rather than on the moment? If yes, it needs a series, not a gauge.
Three rules follow:
Export at least one rate. A level’s zero is ambiguous between idle and dead. A rate’s zero is not.
Export what you discard. Anything a system drops, sheds, rejects, evicts or times out is work that left without a trace. Here that one series would have given 201 seconds of warning; §17.7.5's four gave 54.
Give every retained structure an owner and a bound, or write down that it has neither. §17.7.2 does exactly that for the limiter map, and it is the one structure here that behaved.
22.5.8 Common Mistakes
Measured, 64–81% permanently excluded
Mark on dequeue, or keep the rejected set
It is not retried and nothing records it
§5.3 says drop; nothing says what drop means
Measured, stalls short of one queue of work, -race silent
Blocking is not a bound; drop or grow
deleteMeasured, len 0 and 112.0 MB resident
Replace the map; do not delete from it
Measured, 17% of samples outside a correct claim
A slope with a coefficient
Zero reads as healthy
At least one series must be a rate
Every byte is live, reachable and correct
Instruments answer about instants; this is an interval
Summary: The Failures That Only Exist in Time
The frontier is a queue whose producers are its own consumers, and all three of the book’s bounding rules assume a precondition it violates: backpressure needs a producer you can block, a pipeline needs a DAG, and §17.5's sizing needs a caller with a deadline. So the choice is drop, grow, or deadlock — and blocking, measured, stalls short of one queue’s worth with -race silent. Dropping costs 64% of the corpus at a million pages and 81% at ten million, because §5.3's bound and §11.3.3's compound check together record a URL before discovering there is no room for it.
The dedup set is the one structure with no lifetime — 900,219 entries, 80.6 bytes each, two-thirds for pages never fetched, still resident 639 seconds later — and measured, deleting every key from a two-million-entry map returns 45.8% of its cost while len reads zero, and replacing the map returns 100%. The fix is to replace rather than delete, which is §21.4.1's cached-threads insight wearing different clothes.
And a correct hypothesis about all of it was refuted because it was stated as a band on a value; as a slope it holds at R² 0.796.
Key Takeaways
- All three of the book’s bounding rules assume a precondition a self-feeding queue violates
- Measured, blocking is not a bound: eight workers stall short of one queue’s worth,
-racesilent - Measured, 64% of found pages permanently excluded at 1 M; 81% at 10 M
- Measured,
deletereturns 45.8% of a 2 M-entry map and replacing it returns 100%;lenreads 0 with 112.0 MB resident - A map’s memory is a high-water mark wearing the clothes of a current value — §21.4.1's thread insight again
- Measured, a claim stated as a band fails at 17% of samples and holds as a slope at R² 0.796
- Every instrument this book teaches answers about an instant; these failures are intervals
A system can be correct at every instant and wrong across an hour, and nothing in this book measures an hour.
Self-Check Questions: The Failures That Only Exist in Time
Unanswerable from the counter. The question you need instead is what a dropped item’s fate is.
Two systems can have identical drop counters. In the first, a dropped request is retried by its caller, so the drop costs latency and nothing else — that is load-shedding working, and §17.4 argues for it. In the second, a dropped item is gone and nobody knows, which is data loss with a healthy dashboard. This crawler is the second, because §11.3.3's compound check marks a URL before §5.3's bounded queue rejects it, and the mark is permanent.
So the diagnostic is not the counter’s level but two questions about the design. Who retries? If the answer is “nobody”, the drop rate is a data-loss rate and should be named that. Is the drop recorded anywhere that survives? A counter that resets on deploy gives you a rate, not which work was lost.
And then the rate rather than the level: measured here, the drop rate turned 201 seconds before the failure while the level had looked high and healthy for six minutes.
Because the entries were never what the memory was.
A Go map’s storage is its bucket array, allocated as the map grows and sized for the high-water mark of insertions. delete removes an entry from a bucket; it does not shrink the array, and the runtime never does. Measured: two million string keys cost 206.4 MB, deleting all of them returned 45.8%, and len(m) reads 0 with 112.0 MB still resident. Replacing the map returns 100%.
The consequence for design is sharper than the fact. An eviction policy that deletes from the map it is bounding bounds len, not memory — and len is not the number anyone was worried about. A cache with a 100,000-entry cap that has ever held ten million entries is a ten-million-entry map holding 100,000 things.
The fix is to replace rather than delete: keep two generations, drop the older whole when the live one fills, and check membership against both. Dropping the map returns all of its memory. The cost is a bounded re-fetch rate for keys that age out of both — which is a stated trade instead of an unbounded set.
§21.4.1 is the same shape one chapter earlier: OS threads are cached rather than destroyed, so the count is a high-water mark wearing the clothes of a current value. Maps do that with bytes.
That the process is alive. Nothing else — and specifically not that it is working.
Measured in §22.3: those readings were identical at minute ten, when the crawler was fetching 565 pages a second, and at minute twenty, after it had done nothing for 639 seconds. Queue depth zero is the sharpest of them because it inverts: on a busy system it means the consumer is keeping up, and on a stopped system it means there is nothing to consume. Same number, opposite meanings, no way to tell from the gauge.
What is missing is a rate. Every one of those four is a level, and a level describes a state. States are compatible with having stopped — a stopped system is in a very healthy state. The one series that distinguishes them is work completed per second, and its zero is unambiguous.
The rule §22.5.7 takes from this: at least one thing on any dashboard must be a rate, and if the system discards work anywhere, the discard rate should be the second — because measured here it led the failure by 201 seconds while every level led by none.
Whether a dropped item is recoverable, and that is a question about code rather than about metrics.
Find where the item is recorded and where it is offered to the queue, and check the order. If it is recorded first — marked seen, marked in-flight, written to a dedupe table — then a drop is permanent, and the queue’s overflow behaviour is a silent data-loss policy that nobody wrote down. This crawler does exactly that in six lines, and it costs 64% of the corpus at one scale and 81% at another.
If it is offered first and recorded only on success, a drop is a deferral and the item comes back. That is a completely different system with the same drop counter, which is why the counter cannot answer the question.
Then two follow-ups. Who retries? If the answer is nobody, name the metric a loss rate rather than a drop rate, because the two words license different decisions. And is the queue self-feeding? If its producers are its own consumers, §22.5.1's three preconditions all fail and the choice is drop or grow — blocking will deadlock, and §22.5.2 measured that: the frontier stalls before it has been filled once.
That it caps len and not memory, and that the cap is the easy half of the decision anyway.
Measured in §22.5.4: a two-million-entry map costs 206.4 MB, and deleting every key returns 45.8% while len reads zero. A Go map’s storage is its bucket array, sized for the high-water mark of insertions, and eviction does not shrink it. So an LRU that evicts by deleting gives you a map that has held ten million entries holding whatever the policy allows — the memory is set by the peak, not the cap. Replacing the map returns 100%, which is why the two-generation shape is the fix: drop the older generation whole rather than draining it.
The harder half is what the cap means. Evicting a URL from a crawler’s dedup set is not like evicting from a cache: a cache miss costs a recomputation, and this miss costs a re-fetch of somebody else’s server, plus a re-discovery of everything that page links to. The eviction policy is therefore a politeness policy, and its parameter is a re-fetch rate you should state rather than discover.
Which is §22.2.3's gap in one sentence: the book can tell you how to bound a structure and cannot tell you what forgetting costs, because forgetting costs whatever your system is for.
22.6 Which Signal Moved First
§17.7.5 names four numbers to instrument. §19.4.6 adds the runtime’s. Neither says which one moves first, and that is the question the person on call actually has. This section does not issue a third prescription; it reports an ordering.
22.6.1 The Ordering, Measured
The run stopped fetching at t=562. Working backwards through the nine recorded series:
How far ahead of the failure each series turned. The drop rate gave 201 seconds. Frontier depth, which is one of section 17.7.5's four numbers, gave 56 seconds. The fetch rate gave none at all. Everything else — goroutines, threads, heap, limiter keys, limiter wait and runnable goroutines — never moved. The longest warning came from a series no chapter asks you to export.
Frontier depth is one of §17.7.5's Four Numbers and it bought 56 seconds, which is that section doing its job. Throughput — the number most services alert on — read 551 per second five seconds before it was zero.
The signal that led by 201 seconds is the drop rate, and it appears on no list in this book. That is not a criticism of §17.7.5: a chapter about rate limiting cannot know that your system discards work somewhere else. It is §22.2.3's gap in its most expensive form.
22.6.2 It Moved the Wrong Way
There is a second, sharper problem with the one signal that did warn.
§17.7.5 argues for queue depth as a way to see a service accumulating an unbounded backlog — the difference between a service at its limit and one falling behind, which have identical admitted-rate graphs. That framing makes a rising queue the signal.
Here it fell. From 4,095 to 0, and the fall was the warning.
Only the last row is a problem, and it is the only one a depth alarm never fires on.
Derived a threshold alarm on queue depth catches the first row and the second. It cannot catch the fourth, because the fourth is the reading a correctly idle service produces. The direction §17.7.5 warns about is the one that is easy to alert on; the direction that ended this run is the one that looks like success.Which is why the four readings above need two numbers rather than one. Depth alone is ambiguous in both directions. Depth paired with a rate resolves all four, and the pairing costs nothing because both numbers are already exported.
22.6.3 What the Other Signals Were Worth
Six of the nine series never moved at all, and it is worth being precise about what that means rather than dismissing them.
They were not useless — they were exonerating. Goroutines flat ruled out a leak in the pool. Threads flat ruled out §21.4's blocking-syscall growth. Runnable at zero ruled out processor starvation, which §19.4.6 pairs with the limiter precisely to distinguish. GC negligible ruled out collector pressure. Each answered a real question, correctly, in the negative.
The mistake is not exporting them. It is treating a set of green exonerations as a positive statement about health. Six correct “not this” readings do not add up to one “working”.
22.6.4 Common Mistakes
Measured, 551/s five seconds before zero
It is the last signal, not the first
Fires on backlog; silent on a stall
Pair depth with a rate; §22.6.2's four readings
The discarded work is invisible
Measured, it led by 201 s
They are exonerations, not confirmations
One positive rate beats six negatives
Measured, the fall was the warning
Both directions of a gauge need a meaning
Summary: Which Signal Moved First
The drop rate turned 201 seconds before the failure; §17.7.5's frontier depth turned 56 seconds before it; throughput turned zero seconds before it, reading 551 per second five seconds out. Six of the nine series never moved at all. The signal with the longest warning is on no list in this book, and the one that did warn moved in the direction nobody alarms on — a falling queue rather than a rising one, which is indistinguishable from spare capacity unless it is paired with a rate.
Key Takeaways
- Measured, warning given: drop rate 201 s, frontier depth 56 s, throughput 0 s
- The longest-warning series is one no chapter asks you to export
- Measured, the queue fell rather than rose; a threshold alarm cannot fire on that
- Depth alone is ambiguous in both directions; depth plus a rate resolves all four readings
- Six flat signals are exonerations, not a statement of health
The earliest warning came from the work the system threw away, and the only signal that did move went the way nobody watches.
Self-Check Questions: Which Signal Moved First
Everything below the threshold, including the case that ended this run.
A depth alarm fires on a rising backlog, which is §17.7.5's case and a real one — it distinguishes a service at its limit from one falling behind, and those have identical admitted-rate graphs. But depth has four readings and the alarm covers two of them:
The fourth row is the failure and it produces the lowest possible depth reading. Measured here, the frontier went 4,095 → 0 and the fall was the only warning that series gave, 56 seconds out.
The fix costs nothing because you already have both numbers: pair depth with a rate. Depth is ambiguous in both directions on its own — full can be healthy saturation or a forming stall, empty can be spare capacity or death — and the rate disambiguates both.
No. They were doing a job — just not the one a dashboard implies.
Each answered a real question in the negative. Goroutines flat ruled out a leak in the worker pool. Threads flat ruled out §21.4's blocking-syscall growth, which is a genuine failure mode this workload could have had. Runnable at zero ruled out processor starvation, which §19.4.6 pairs with the limiter specifically to distinguish from an admission-control problem. GC negligible ruled out collector pressure. Those are four hypotheses eliminated, cheaply, continuously.
The mistake is arithmetic rather than instrumentation: a set of “not this” readings does not sum to “working”. Six correct exonerations tell you which failures are not happening, and the space of failures is larger than six.
So keep them, and add one number that makes a positive claim — work completed per second. One rate that says “this system is doing its job” is worth more than any number of gauges that say “this system is not on fire”, and it is the only series whose zero is unambiguous.
Whatever the system throws away, and a rate to disambiguate the gauges you already have.
The Four Numbers are about admission — admitted, rejected by reason, queue depth, wait p99 — and §19.4.6 adds the runtime underneath them. Both are about work the system accepted or resources it used. Neither covers work that arrived and left without being processed, and in this crawler that was 64% of everything discovered. Measured, the drop rate led the failure by 201 seconds; frontier depth, which is on the list, led by 56.
That gap is structural rather than an oversight. §17.7.5 is a chapter about rate limiting, and a rate limiter cannot know that your system discards work somewhere else entirely — the drop happens in the crawler’s enqueue path, hundreds of lines from anything §17 owns.
The second missing thing is cheaper still: at least one of your existing gauges paired with a rate. §22.6.2's four-reading table shows depth alone is ambiguous in both directions — full can be healthy saturation or a forming stall, empty can be spare capacity or death — and the rate resolves all four for the cost of a number you are already collecting.
22.7 Where This Book Stops
This is the last section of the last chapter, and its job is to say what the preceding 546,000 words do not cover — deliberately, so that the boundary is a decision rather than a gap you discover.
22.7.1 What This Chapter Did Not Test
The experiment is narrow and its limits should be stated before its conclusions travel.
One machine. Every figure is from one Intel i7-10700K at GOMAXPROCS=16. §22.3's absolute numbers are that machine’s; the directions and the ratios are what transfer, which is Chapter 20's rule applying to itself.
One workload shape. A crawler discovers more work than it completes. A system whose work arrives from outside — a server, a consumer — has a different frontier problem, and §22.5.1's three-preconditions analysis is exactly the tool for deciding whether yours is the same shape.
One fixture. §22.1.1 states its parameters precisely so that a reader can judge, and §22.4.3 withdrew a finding that turned out to belong to it. There may be others; the parameters ship so you can look.
Twenty minutes. Long enough for the constraints in §22.1.3, and not long enough for anything whose period is hours. A daily cycle, a weekly cache warm, a monthly key rotation: all invisible here, all real.
22.7.2 The Boundary, Named
The boundary of this book. Chapters 1 to 21 cover one process with many goroutines. On the other side of the boundary are many processes in one cluster, which brings consensus, replication, partial failure and clocks. The second is not more of the first; it is a different subject with different primitives.
Concurrency inside one address space has a memory model, a scheduler and a set of primitives this book has spent its length on. Distributed systems share none of them: there is no happens-before edge you can establish with a mutex, no scheduler you can read, and failure is partial rather than total. A sync.Mutex and a distributed lock share a name and nothing else, and treating the second as the first is one of the more expensive mistakes available in this industry.
This crawler is a good illustration of where the line falls. Every failure in §22.5 is inside one process. Run the same crawler on twenty machines and the dedup set cannot be a map at all — it becomes a distributed set, with a consistency model, a failure mode when a node dies holding uncommitted membership, and a completely different answer to “have we seen this URL”. Nothing in Chapters 1–21 helps with that.
That boundary is where this book stops, and the CoreLabs distributed-systems course is where the other side begins.
22.7.3 What the Appendices Owe You
Two things this chapter deliberately did not do, because they belong elsewhere.
A decision index. Every fork this book established — mutex against channel against atomic, buffered against unbuffered, context against done-channel, errgroup against collect-all, pool against stack — with the section that owns it and the measurement that decides it. That is a reference, not an argument, and putting it in a chapter would make it harder to find rather than easier. It is the appendices' job.
A translation from other languages. What a Go channel is and is not, for somebody arriving from threads and condition variables, or from async/await. Also reference, also the appendices'.
22.7.4 When to Stop
§20.7.9 answered this for optimisation. The system-level version is shorter and it is the last thing this book has to say.
Stop when the next thing you would measure has no decision attached to it. This chapter recorded nine series and used four. The other five were exonerations (§22.6.3) — worth having, not worth extending. A tenth series with no action behind it is a dashboard tile, not an instrument.
Stop when the failures you are finding are properties of your harness. §22.4.3 is the worked example: a striking result, five minutes of control, a withdrawal. When two consecutive findings evaporate under a control, the harness is what you are studying.
Stop when the remaining risk is not in this book’s subject. The crawler’s next real problem is not concurrency; it is politeness, robots.txt, content dedup and storage. Knowing that the concurrency is finished is itself a result, and it is the one this chapter was built to produce.
22.7.5 Common Mistakes
The numbers do not reproduce
Directions and ratios travel; absolutes do not
The preconditions differ
§22.5.1's three tests decide it
Correct code, incorrect system
They share a name and nothing else
A tile nobody acts on
If there is no decision attached, do not add it
Two withdrawals in a row
You are studying the fixture now
Summary: Where This Book Stops
The experiment is one machine, one workload shape, one fixture and twenty minutes, and each of those is a limit on how far its conclusions travel — the directions transfer and the absolutes do not. The boundary of the book is the process: everything here has a memory model, a scheduler and primitives that a cluster does not share, and a sync.Mutex and a distributed lock have a name in common and nothing else. The decision index and the cross-language translation belong to the appendices, deliberately. And the system-level answer to when to stop is that you stop when the next measurement has no decision attached, when your findings have started belonging to your harness, or when the remaining risk is no longer this book’s subject.
Key Takeaways
- One machine, one workload shape, one fixture, twenty minutes — state the limits before the conclusions travel
- A daily or weekly period is invisible to a twenty-minute window and is still real
- The book’s boundary is the process; a distributed set is not a bigger map
- A
sync.Mutexand a distributed lock share a name and nothing else - The decision index is the appendices' job, not a chapter’s
- Stop when the next series has no decision attached, or when your findings belong to the harness
The last useful thing a book can do is say precisely where it stops being true.
Self-Check Questions: Where This Book Stops
Everything inside each process, and nothing between them.
Inside one machine the whole book still holds: §7.3's worker pool, §17.7.2's keyed limiter, §11.3.3's compound check, §21.4's thread arithmetic, and all of §22.5's failures — which do not become milder when there are twenty processes, they become twenty copies.
Between machines, almost none of it transfers, and the dedup set is the clearest case. As a map[string]struct{} it has one authoritative answer, available instantly, at 105.7 bytes an entry. Distributed, it needs a consistency model. Is membership strongly consistent, so two crawlers never fetch the same page and every check costs a round trip? Or eventually consistent, so checks are cheap and pages are occasionally fetched twice? What happens to membership a node had recorded but not replicated when it dies? None of those is a question this book has vocabulary for, because none of them arises when there is one address space and a mutex.
The specific trap: sync.Mutex and a distributed lock share a name and nothing else. A mutex has a memory model behind it — §8's happens-before — and a distributed lock has a lease, a clock and a failure mode where two holders believe they own it. Reaching for the second because you understand the first is one of the more expensive mistakes in this industry.
No — record them, and do not add a tenth.
The five that did not move earned their place by ruling things out (§22.6.3): a pool leak, §21.4's thread growth, processor starvation, collector pressure. Each is a real failure mode this workload could have had, and each was eliminated continuously and for almost nothing. Recording them was how the chapter could say the failures were not in any mechanism, which is half its argument.
The rule §22.7.4 gives is about the next one, not the existing ones: stop when the next thing you would measure has no decision attached to it. A tenth series that would not change what anybody does is a dashboard tile. That is a different test from “did it move”, and it is the one worth applying before adding rather than after.
There is a cost to over-recording and it is not CPU. It is that a wall of green gauges reads as health, which §22.6.3 is precisely about: six correct “not this” readings do not sum to one “working”. If you add a series, add the one that makes a positive claim — and here that was the drop rate, which nothing asked for and which led by 201 seconds.
Run it for the twenty minutes rather than benchmarking it for them, and watch three things.
One rate that makes a positive claim. Work completed per second. Everything else on a dashboard says “not this” — no leak, no starvation, no collector pressure — and §22.6.3's point is that six correct exonerations do not sum to one confirmation. A rate’s zero is the only unambiguous signal in the set.
Everything the system discards. Dropped, shed, rejected, evicted, timed out. That is work which left without a trace, and it was this chapter’s earliest warning by more than three minutes. If nothing in your service discards anything, that is worth knowing too, and takes one grep.
Any structure that only grows. For each, ask who owns it and what bounds it. §17.7.2's limiter map has both and behaved perfectly; the dedup set has neither and is the chapter’s second finding. A structure with no answer is not necessarily wrong — deduplication genuinely cannot forget — but the absence should be written down rather than discovered.
And the twenty minutes matter as much as the three. A benchmark measures a call and a profile samples an instant, and every failure in this chapter is a property of an interval. You cannot see a slope without a window.
Chapter Summary
Twenty-one chapters gave rules. This one composed fifteen of them into a crawler, registered twelve predictions before running anything, ran it for twenty minutes at one sample a second, and published the score.
Nine of the twelve held, drawn from eight different chapters and the fixture and applied to a system none of them had seen. §21.4's netpoller arithmetic put OS threads at 23 and kept them there. §17.7.2's keyed-limiter sweep peaked at 7,006 keys, evicted 46,827 and finished at zero with no cost visible in any latency series — the clearest case in the book of a section that named a problem, prescribed a fix, explained why the fix was free, and was right about all three in a system it had never seen. §17.1.3's Little’s Law predicted 580 fetches a second against 565 measured, once the log-normal mean was used rather than the median. The book’s mechanism-level advice composes, which is not the usual outcome for a book of rules.
Three failures were one event, and it was withdrawn. The crawl stopped at t=562; a five-minute control against a graph ten times larger showed the frontier never draining and the crawler never stopping, so the termination belonged to the fixture. That control is the most useful five minutes here — it removed the chapter’s most dramatic claim and, in the same table, made the surviving one worse, from 64% excluded to 81%.
The fourth failure is the chapter’s thesis arriving inside its own scorecard. P4 claimed the heap was the dedup set and nothing else, and stated it as a band on a value. Seventeen per cent of samples fell outside the band, so it is refuted; fitted as a slope the identical hypothesis holds at R² 0.796 with a coefficient of 105.7 bytes per remembered URL, inside the predicted range. Two of the three terms in heap_inuse are the runtime’s timing rather than your program’s — they cancel over a window and dominate at any instant. State a claim about accumulation as a slope with a coefficient, not as a band on a value.
What survived has one shape. The frontier is a queue whose producers are its own consumers, and all three of the book’s bounding rules assume a precondition it violates: backpressure needs a producer you can block, a pipeline needs a DAG, §17.5's sizing needs a caller with a deadline. So the choice is drop, grow, or deadlock — and blocking, measured, stalls short of one queue’s worth with go vet and -race both silent. Dropping is what this crawler does, and §5.3's bound meeting §11.3.3's compound check records a URL as seen before discovering there is no room for it, so 64% of everything found was permanently excluded at a million pages, and 81% at ten million. The dedup set is the only structure with no lifetime — 900,219 entries at 80.6 bytes, two-thirds of them for pages never fetched, still resident 639 seconds after the last one — and deleting every key from a two-million-entry map returns 45.8% of its cost while len reads zero, and replacing the map returns 100%. A map’s memory is a high-water mark wearing the clothes of a current value, which is §21.4.1's cached-threads insight in different clothes, with the same fix: replace, do not delete.
And the warning came from the work that was thrown away. The drop rate turned 201 seconds before the failure. §17.7.5's frontier depth turned 56 seconds before it — and moved downwards, which is the direction no threshold alarm fires on. Throughput turned zero seconds before it, reading 551 per second five seconds out. Six of the nine series never moved at all; they were exonerations rather than confirmations, and six correct “not this” readings do not sum to one “working”.
No instrument in Chapters 16, 19 or 20 can see any of it, for a structural reason rather than a fixable one. A benchmark measures a call. A profile samples an instant. A dump answers where everyone is now. Each of these failures is a property of an interval: a fraction excluded over a run, a memory that should have fallen and did not, a rate whose zero looks like rest, a claim whose form could not survive the runtime’s amplitude. Every value was correct at every moment.
Three rules survive contact with that. Export at least one rate, because a level’s zero cannot distinguish idle from dead. Export what you discard — dropped, shed, rejected, evicted, timed out — because that is work which left without a trace, and here it was the earliest warning by more than three minutes. Give every retained structure an owner and a bound, or write down that it has neither: §17.7.2 does exactly that for the limiter map, and it is the one structure in this system that behaved.
The last thing worth saying is about the method rather than the crawler. This chapter could have been a victory lap. It was made falsifiable by writing twelve predictions down first with a falsifier each, publishing the score whatever it said, running a control against the one result that was too good to check, and re-scoring a prediction this chapter had first marked as held. That is §20.7.9's rule — write the number down before you start — applied to a book instead of a benchmark, and it is the only honest way to end 546,000 words of advice.
Chapter Connections
GOGC is why §22.4.4's band failed; §20.7.9 is the chapter’s methodThe Measurements in One Place
go1.26.1, darwin/amd64, Intel i7-10700K, GOMAXPROCS=16, unthrottled. Reproduce with CH22_SOAK=1200 go test -run TestSoak in code/ch22.
7.5 MB + 105.7 B × seen, R² 0.796-race silentFinal Checklist
Before you call a concurrent system healthy:
- At least one number on the dashboard is a rate, not a level
- Everything the system discards — dropped, shed, rejected, evicted, timed out — is counted and exported
- You can say what happens to a discarded item: retried by whom, or lost
- Every gauge has a meaning for both directions, or it is paired with a rate
- Every retained structure has an owner and a bound, or a written note that it has neither
- Nothing bounds a map by deleting from it
- No queue whose producers are its own consumers is “bounded” by blocking
- Claims about accumulation are stated as a slope with a coefficient, not a band on a value
- You have run it for longer than the phenomenon you are worried about, sampled often enough to see a slope
- The end-to-start delta of each series clears its own sampling noise (
Δ ≥ 6σ) - Any dramatic finding has been re-run under a second configuration before it is believed
- The predictions were written before the run, with a falsifier each, and the score is published whatever it says
Exercise 22.1 — The Crawler That Cannot Survive Its Own Load Test
The Crawler That Cannot Survive Its Own Load Test
The code is in labs/go-concurrency/code/ch22. It compiles, go vet is clean, go test -race is clean, and every component is individually correct — each built from the section named in its comment.
It also discards most of its work, and nothing in it says so.
crawler.go— the system, with a section citation on every decisionlimiters.go— §17.7.2's keyed limiter and its two-phase sweep. This one is right; leave it alone and read itsitegraph/— the fixture.DefaultConfigis the configuration every figure in this chapter usedgates_test.go— the five gatesfrontier_variants_test.go— §22.5.2's blocking and growing configurationsmapdrain_test.go— §22.5.4's delete-against-replace measurementsoak_ch22.csv,soak_ch22_control.csv— the two runs this chapter reportssolution/— the finished version, after you have tried
The five gates:
TestTheToolchainIsSatisfied.go vet,-race, and a crawl whose counts add up. Green from the start, and it is the gate that makes a broken system look safe.TestTheWorkloadCanExhibitTheFailure. §20.1's rule as a gate: a frontier that never fills cannot drop, and a crawler that never drops cannot show the failure gates 3 and 4 look for. Fix the harness before you trust any number from it.TestTheAbandonedSetDoesNotGrow. The abandoned set is the URLs recorded as seen and fetched never. Fifteen rounds, each comparing a reading late in its window against one early in it — both taken while the crawl is still running, for the reason in the note below; the gate fails if it grew in thirteen or more — an outcome a coin produces less than 1% of the time, which is why it travels to a machine with a different clock speed. The starter fails it 15 of 15.TestCoverageStaysAboveAFloor. Fetched over discovered, against a floor. Note what this gate deliberately does not do: it asserts a level where gate 3 asserts a slope, because a fix can stop the abandoned set growing while leaving coverage stable-but-low — and asserting a downward trend on coverage would fail for the wrong reason or pass by accident. That is §20.1's rule about the workload containing the thing you meant to measure, applied to the gate itself.TestNoPageIsFetchedTwice. Passes today, and constrains every fix. The attractive wrong answer to gates 3 and 4 is to stop recording URLs, or to forget them on a timer: coverage looks perfect and the crawler re-fetches forever, which is the thing the dedup set exists to prevent.
The first is that gate 2 is about the harness and gate 3 is about the result, and they must be fixed in that order. A number taken under a workload that cannot exhibit the effect is not a small number; it is not a number at all.
The second is that the obvious fix deadlocks. Removing the default: branch makes the worker wait for frontier space — and a worker waiting to enqueue is not draining the frontier. §22.5.2 measured it stalling short of one queue’s worth with -race silent throughout, and it ships as TestBlockingFrontierStalls so you can watch it happen.
The third is §22.5.4's, and gate 4 is where it lands. If your fix bounds the dedup set by deleting from it, you have bounded len and not memory — measured, delete returns 45.8% and replacing the map returns 100%.
go vet ./... is clean, and you can say in one sentence what fraction of the crawl the original discarded — with the series that shows it.-race is what made it visible.Gate 3's metric is seen − fetched − queued − errors, and the first version read it early in the window and again after Run returned. Cancelling the context strands every URL a worker has dequeued but not yet fetched — marked seen, off the frontier, never counted — which is indistinguishable from the leak the gate is hunting. Measured, the correct solution then read as growth in 13 of 15 rounds on one such run, ratios from 1.31× to 11.38×, which is enough to fail the gate about one run in three; -race widens it by stretching the shutdown (§16.3.5's 9× to 26× detector overhead). The fix is in the harness, not the crawler: take both samples while the crawl is still running, so the two readings come from the same regime. The same solution then grew in 2 to 7 rounds of 15 across four runs — at or below what a coin gives, which is what the 13-of-15 threshold was designed against, and the gate has since passed twelve consecutive runs under -race. A trend gate whose two samples straddle a state change is measuring the state change.
Further Reading
On the method
- The pre-registration literature in experimental science — the practice §22.1.5 borrows, and the reason it requires publishing the score whatever it says.
predictions.mdships incode/ch22with its timestamp and hash - Kent Beck, Test-Driven Development (Addison-Wesley, 2002) — writing the check before the code, which is the same idea one level down
- Ronald Fisher, The Design of Experiments (Oliver & Boyd, 1935) — where the control run in §22.4.3 comes from, and why a result you did not try to break is not a result
On systems that fail slowly
- Brendan Gregg, Systems Performance, 2nd ed. (Addison-Wesley, 2020) — the USE method, and the argument for measuring saturation rather than utilisation. §22.5.7's rule about rates is a special case
- Gil Tene, “How NOT to Measure Latency” — coordinated omission, which is the same category error as §22.5.5: measuring the instant you were present for rather than the interval you care about
- The Google SRE Book’s chapter on monitoring — the four golden signals, and the reason none of them is “work discarded”
On the Go specifics
$GOROOT/src/internal/runtime/maps— why a map does not shrink, which §22.5.4 measures at 45.8% returned- A Guide to the Go Garbage Collector — the
GOGCbehaviour that is two of the three terms in §22.5.5'sheap_inuse runtime/metrics— the nine series, andmetrics.All(), which is how you avoid §22.1.3's silent zero
On what this book did not cover
labs/distributed-systems/— the course this book hands off to (§22.7.2). Every failure in this chapter lives inside one process; a crawler on twenty machines starts with the fact that its dedup set cannot be a map- Ilya Grigorik, High Performance Browser Networking (O’Reilly, 2013) — the HTTP behaviour §22.2.3's body-draining decision depends on, which no chapter of this book covers
You have now seen this book's advice composed into one system and scored against predictions written before the run: nine of twelve held, the three that did not were one event, and a four-minute control withdrew it. What survived is a class of failure no instrument here can see — correct at every instant, wrong across an hour. That is the end of the book: export a rate, export what you discard, and give every retained structure a bound or a note that it has none.