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 {

View File

@@ -250,6 +250,90 @@ func TestJSONDNSFailure(t *testing.T) {
}
}
// ── concurrency tests ─────────────────────────────────────────────────────────
// TestRunConcurrentOrder verifies that with -c > 1 the URL blocks are printed
// in the original input order and exit codes are accumulated correctly.
func TestRunConcurrentOrder(t *testing.T) {
a := statusSrv(200)
defer a.Close()
b := statusSrv(200)
defer b.Close()
c := statusSrv(200)
defer c.Close()
// Run with explicit high concurrency — all three URLs measured in parallel.
stdout, _, code := invoke("-c", "3", a.URL, b.URL, c.URL)
if code != exitOK {
t.Fatalf("exit code = %d, want 0\nstdout:\n%s", code, stdout)
}
// Each URL block must appear and in the correct order.
posA := strings.Index(stdout, a.URL)
posB := strings.Index(stdout, b.URL)
posC := strings.Index(stdout, c.URL)
if posA < 0 || posB < 0 || posC < 0 {
t.Fatalf("one or more URLs missing from stdout:\n%s", stdout)
}
if !(posA < posB && posB < posC) {
t.Errorf("URLs out of order: posA=%d posB=%d posC=%d\nstdout:\n%s",
posA, posB, posC, stdout)
}
}
// TestConcurrentWorstCode confirms that worstCode aggregation is correct when
// both successes and failures run concurrently.
func TestConcurrentWorstCode(t *testing.T) {
good := statusSrv(200)
defer good.Close()
// DNS failure mixed with a successful URL — highest code (2) must win.
stdout, _, code := invoke("-c", "2",
good.URL,
"https://totally.bogus.domain.for.concurrency.test.invalid",
)
if code != exitDNS {
t.Errorf("exit code = %d, want %d (DNS)\nstdout:\n%s", code, exitDNS, stdout)
}
// Both URLs must appear in output (good one before the failing one).
if !strings.Contains(stdout, good.URL) {
t.Errorf("stdout missing good URL\nstdout:\n%s", stdout)
}
if !strings.Contains(stdout, "✗ dns:") {
t.Errorf("stdout missing DNS failure marker\nstdout:\n%s", stdout)
}
}
// TestConcurrentJSONOrder verifies that JSON entries preserve input order under
// parallel execution.
func TestConcurrentJSONOrder(t *testing.T) {
a := statusSrv(200)
defer a.Close()
b := statusSrv(200)
defer b.Close()
stdout, _, code := invoke("--json", "-c", "2", a.URL, b.URL)
if code != exitOK {
t.Fatalf("exit code = %d, want 0\nstdout:\n%s", code, stdout)
}
var results []struct {
URL string `json:"url"`
}
if err := json.Unmarshal([]byte(stdout), &results); err != nil {
t.Fatalf("invalid JSON: %v\nstdout: %s", err, stdout)
}
if len(results) != 2 {
t.Fatalf("expected 2 results, got %d", len(results))
}
if results[0].URL != a.URL {
t.Errorf("results[0].url = %q, want %q", results[0].URL, a.URL)
}
if results[1].URL != b.URL {
t.Errorf("results[1].url = %q, want %q", results[1].URL, b.URL)
}
}
func TestJSONSampling(t *testing.T) {
srv := statusSrv(200)
defer srv.Close()