Add URL-level concurrency (-c flag, Step 7)

Probe multiple URLs in parallel with a bounded worker pool; the N samples
of each URL remain sequential to preserve accurate min/avg/max statistics.
Default auto-concurrency is min(numURLs, 8); -c 1 restores serial mode.
Output is always buffered and printed in original input order. Verified
clean with go test -race.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 01:33:03 +02:00
parent 45583ac2be
commit 9a69ba946f
9 changed files with 394 additions and 22 deletions

View File

@@ -9,6 +9,7 @@ import (
"net/url"
"os"
"strings"
"sync"
"time"
"latprobe/internal/probe"
@@ -20,11 +21,12 @@ Usage:
latprobe [flags] <url> [url ...]
Flags:
-n, --count int Number of requests per URL (default 1)
--timeout duration Request timeout, e.g. 10s, 500ms (default 10s)
--fail Exit non-zero on HTTP status >= 400 (exit code 6)
--json Output results as JSON instead of text
-h, --help Show this help
-n, --count int Number of requests per URL (default 1)
-c, --concurrency int Max URLs probed in parallel, 0 = auto (default min(numURLs,8))
--timeout duration Request timeout, e.g. 10s, 500ms (default 10s)
--fail Exit non-zero on HTTP status >= 400 (exit code 6)
--json Output results as JSON instead of text
-h, --help Show this help
Exit codes:
0 All probes succeeded
@@ -77,6 +79,8 @@ func run(args []string, stdout, stderr io.Writer) int {
count := fs.Int("count", 1, "number of requests per URL")
fs.IntVar(count, "n", 1, "number of requests per URL (shorthand)")
conc := fs.Int("concurrency", 0, "max URLs probed in parallel (0 = auto)")
fs.IntVar(conc, "c", 0, "max URLs probed in parallel (shorthand)")
timeout := fs.Duration("timeout", 10*time.Second, "request timeout per sample")
fail := fs.Bool("fail", false, "exit non-zero on HTTP status >= 400")
jsonOut := fs.Bool("json", false, "output results as JSON instead of text")
@@ -96,11 +100,43 @@ func run(args []string, stdout, stderr io.Writer) int {
}
opts := probe.Options{Timeout: *timeout}
// Resolve effective worker count.
const defaultMaxConc = 8
workers := *conc
if workers <= 0 {
workers = min(len(urls), defaultMaxConc) // auto
}
workers = min(workers, len(urls)) // never more goroutines than work units
workers = max(workers, 1)
// ── Measure phase (concurrent) ────────────────────────────────────────────
// Each goroutine writes only its own indexed slot — no shared mutable state.
type urlResult struct {
succeeded, failed []probe.Result
}
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{s, f}
}(i, rawURL)
}
wg.Wait()
// ── Render phase (sequential, input order) ────────────────────────────────
worstCode := exitOK
var jsonEntries []jsonEntry
for i, rawURL := range urls {
succeeded, failed := runSamples(rawURL, *count, opts)
succeeded := results[i].succeeded
failed := results[i].failed
for _, r := range failed {
if c := failPhaseCode(r.FailPhase); c > worstCode {