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:
48
go/main.go
48
go/main.go
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user