# Plan: Concurrency (Go, Step 7) ## Context Large probe runs are slow. The example matrices in `docs/custom-usage-examples.md` are 15 URLs × 10 samples = 150 sequential HTTP requests, dominated by summed network round-trips, so a single run takes minutes. Execution is currently strictly sequential: `run()` loops over URLs and, for each, `runSamples()` loops `-n` times calling `probe.Measure()`. We want to cut wall-clock time by probing multiple **URLs** in parallel, while keeping measurement honest. Concurrency and latency accuracy are in tension: firing many requests at once makes them contend for the local NIC/CPU/resolver and inflates timings. The decision (confirmed with the user) is to parallelize **only across URLs** — the samples of a single URL stay sequential so each URL's min/avg/max remains a clean, self-consistent measurement. Confirmed decisions: - **Granularity:** across URLs only; samples sequential per URL. Unit of work = one URL with its full `-n` sample set. - **Default concurrency:** auto when `-c` is not given → `min(numURLs, 8)`. `-c 1` forces fully sequential (most accurate); `-c N` sets an explicit cap. - **Output:** buffered and printed in original input order (deterministic; identical layout to today, for both text and JSON). ## Files to modify ### `go/main.go` — core change 1. **New flag** (mirror the `-n`/`--count` pattern at lines 78–79): ```go conc := fs.Int("concurrency", 0, "max URLs probed in parallel (0 = auto)") fs.IntVar(conc, "c", 0, "max URLs probed in parallel (shorthand)") ``` Add `-c, --concurrency` to `usageText` (around lines 22–27). 2. **Resolve effective concurrency** after the empty-URL check (line 96): ```go const defaultMaxConc = 8 workers := *conc if workers <= 0 { workers = min(len(urls), defaultMaxConc) // auto } workers = min(workers, len(urls)) // never exceed work units if workers < 1 { workers = 1 } ``` (Go 1.26 has builtin `min`.) 3. **Split `run()` into measure phase (concurrent) + render phase (sequential).** Introduce a small result holder: ```go type urlResult struct { url string succeeded, failed []probe.Result } ``` Measure phase — bounded worker pool via a semaphore channel + `sync.WaitGroup`, each goroutine writing only its own preallocated slot (no shared mutable state, so no mutex / data race): ```go results := make([]urlResult, len(urls)) sem := make(chan struct{}, workers) var wg sync.WaitGroup for i, rawURL := range urls { wg.Add(1) go func(i int, rawURL string) { defer wg.Done() sem <- struct{}{} defer func() { <-sem }() s, f := runSamples(rawURL, *count, opts) results[i] = urlResult{rawURL, s, f} }(i, rawURL) } wg.Wait() ``` Render phase — the existing per-URL loop body (lines 102–127), unchanged in behaviour, now reading from `results[i]` instead of calling `runSamples` inline. `worstCode` accumulation, JSON entry building, and ordered text printing all stay identical. JSON encode block (129–136) and `return worstCode` unchanged. `runSamples`, `printURL`, `buildJSONEntry`, and all output helpers stay as-is. Concurrency safety: `probe.Measure` uses `http.DefaultClient`, which is safe for concurrent use; distinct URLs hit distinct hosts so the shared transport pool is not a correctness concern. Output order is deterministic because rendering reads the indexed `results` slice after `wg.Wait()`. ### `go/run_test.go` — add coverage - `TestRunConcurrentOrder`: spin up several `httptest` servers (reuse existing `statusSrv` helper), pass them with `-c` larger than 1, and assert the printed blocks appear in **input order** and the exit code matches the sequential run. - Add a mixed success/failure case under high `-c` to confirm `worstCode` aggregation is unaffected by parallelism. ### `Makefile` + `docs/usage/makefile.md` - Add `go-test-race`: `go -C $(GO_DIR) test -race ./...` (with `##` help comment, added to `.PHONY`). Document it in the Go targets table. Leave `check` as-is (race run kept as an explicit opt-in target). ### Docs & conventions - `docs/usage/step-7-concurrency.md` (new): explain the `-c`/`--concurrency` flag, the auto default (`min(numURLs, 8)`), the across-URLs-only model, the accuracy trade-off (use `-c 1` for the most precise numbers), and a worked example with before/after timing. - `README.md`: add `-c, --concurrency` to the Usage flags block; add **Step 7 — Concurrency** row (✅) to the Go roadmap table. - `CHANGELOG.md`: new top entry, `2026-07-01 01:23 — Concurrency (Go, Step 7)`. - `docs/custom-usage-examples.md`: note near the top that runs now parallelize across URLs by default and that `-c 1` restores serial timing; revise the Example 2/3/9 runtime estimates to reflect the speedup. - `docs/plans/2026-07-01-01-23-concurrency.md`: copy of this plan (per CLAUDE.md). ## Verification ```sh make build # Auto concurrency (defaults to min(numURLs,8)) — should be much faster than before: time ./go/latprobe -n 5 https://www.google.com https://www.bbc.co.uk \ https://www.lemonde.fr https://www.spiegel.de https://www.abc.net.au # Forced serial for comparison / accuracy: time ./go/latprobe -c 1 -n 5 https://www.google.com https://www.bbc.co.uk \ https://www.lemonde.fr https://www.spiegel.de https://www.abc.net.au # Output order is deterministic regardless of -c: ./go/latprobe -c 8 https://www.google.com https://www.bbc.co.uk https://www.abc.net.au # JSON array order also matches input order: ./go/latprobe --json -c 8 https://www.google.com https://www.bbc.co.uk | jq '.[].url' make test # full suite incl. new ordering tests make go-test-race # data-race detector must report clean make check # fmt + vet + test gate ``` Confirm: identical output (modulo timing numbers) between `-c 1` and default for the same URL set; exit codes unchanged; `-race` clean.