carlos / platform Public

edge: upstream prober — live set, fail-away/fail-back, RetireExcept-shaped lifecycle

One goroutine per probed host GETs each upstream on the route's cadence
and maintains the live set the request path (Task 5's multiProxy) picks
from; the same table is the dashboard's per-upstream state (Task 6).
Lifecycle mirrors Activator.RetireExcept: Reload computes the desired
specs, Sync starts/stops/replaces, unchanged specs are left alone so an
unrelated route's fingerprint move never resets a fleet's counters.

Two invariants shape it. Every upstream of a newly synced host starts
LIVE — probing only ever removes confidence, so a reload can never 503 a
healthy fleet while the first probes run. And the per-addr state table is
owned by the hostProber, not the set: a replaced prober's goroutine can
still be mid-probe when its successor is installed, and whatever it
writes lands in its own retired table. Sync additionally waits for each
retired goroutine to observe its stop (context cancel, so no waiting out
the 2s client timeout) before starting replacements, and builds the
replacement inside the same critical section that removed the outgoing
one — so a concurrent Live never sees a replaced host as upstream-less.

Eject is the in-request dial-failure fast path: down immediately with
Fails=FailAfter, rejoining on RiseAfter probe passes like any other
recovery. Unknown host or addr is a no-op — the request path can race a
Reload that dropped either.

Defensive fallbacks (controller amendment, Task 1's review): a spec
reaching the prober with interval_ms/fail_after/rise_after <= 0 gets
2000ms/3/2 here, independently of mint-time WithDefaults. Mint is not the
only writer of a route's health JSON — registry.SetUpstreams writes the
column directly and the route PATCH endpoint will too — and a zero
interval must cost one 2s-cadence prober, never a tight loop against a
customer's upstream. Only non-positive values are replaced; a
deliberately fast interval stays the operator's call.

Tests: fail-away/fail-back across two httptest backends, configured vs
defaulted Host header (and probe path), Sync stopping a vanished host
(hit count frozen, no settle window needed — stop waits) and leaving an
unchanged spec's counters alone, Eject's immediacy plus rejoin, both
fallback clamps, and State/Live returning copies. Backends flip their
answer through an atomic flag rather than reassigning Config.Handler,
which net/http reads per connection and -race rightly flags.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 8c97a9d800fd387b928847d236365fa70459aafc parent 6154a7c
2 files changed, +858 −0
  • internal/edge/prober.go +495 −0
  • internal/edge/prober_test.go +363 −0
diff --git a/internal/edge/prober.go b/internal/edge/prober.go
new file mode 100644
index 000000000..b6b058648
--- /dev/null
+++ b/internal/edge/prober.go
@@ -0,0 +1,495 @@
+package edge
+
+// prober.go — active health probing for multi-upstream addr routes (HA design
+// Part II, issue #152). One goroutine per probed host GETs each of its
+// upstreams on a fixed cadence and maintains the LIVE SET the request path
+// (multiProxy) picks from; the same table is the dashboard's per-upstream
+// state.
+//
+// Two rules shape everything below:
+//
+// 1. **Optimistic start.** Every upstream of a newly synced host starts LIVE.
+// A reload happens on any registry fingerprint move — an unrelated route
+// changing must never take a healthy fleet off the air for the first
+// fail_after × interval while probes catch up. Probing only ever REMOVES
+// confidence; it never withholds it.
+//
+// 2. **Probes dial upstreams directly**, never the edge's own public host.
+// Combined with the no-hibernate invariant for addr routes (Reload's `hib`
+// gate), a probe can therefore never wake a hibernating tenant — the wake
+// hazard that would otherwise make active probing incompatible with
+// hibernation simply does not exist here.
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "slices"
+ "sync"
+ "time"
+
+ "github.com/carlosframework/platform/internal/provision"
+)
+
+// probeTimeout bounds a single probe request end to end (connect through
+// body). Held at 2s per the spec: long enough that a loaded-but-alive
+// upstream still answers, short enough that a black-holed one cannot stall a
+// tick past the default 2s interval.
+const probeTimeout = 2 * time.Second
+
+// Defensive fallbacks for a spec that reaches the prober with a non-positive
+// cadence or count. These duplicate provision.Health.WithDefaults on purpose:
+// WithDefaults runs at MINT time, and mint is not the only writer of a route's
+// health JSON — registry.SetUpstreams writes the column directly, and the
+// route PATCH endpoint will too. A zero interval arriving from any of those
+// must cost one 2s-cadence prober, not an unbounded tight loop against a
+// customer's upstream; a zero fail_after must not fail an upstream away on a
+// single blip, nor a zero rise_after rejoin it on a single pass.
+//
+// Only NON-POSITIVE values are replaced. A deliberately fast interval is the
+// operator's call (and the tests' — they probe at 20ms).
+const (
+ fallbackProbeInterval = 2000 * time.Millisecond
+ fallbackFailAfter = 3
+ fallbackRiseAfter = 2
+)
+
+// UpstreamState is the dashboard-facing view of one upstream: what the probe
+// currently believes, when that belief last changed, and why. Task 6 carries
+// this into the box status snapshot, so the JSON tags are wire shape.
+type UpstreamState struct {
+ Addr string `json:"addr"`
+ Up bool `json:"up"`
+ Since time.Time `json:"since"` // last transition (or the sync that started this prober)
+ Fails int `json:"fails"` // consecutive probe failures
+ LastError string `json:"last_error,omitempty"` // dial vs status, for the log/dashboard
+}
+
+// probeSpec is one route's probing contract, parsed from the registry's health
+// JSON once per Reload. Health is expected (but not required) to have been
+// through WithDefaults — see the clamps above for why "not required".
+type probeSpec struct {
+ Host string // route host — the probe's Host header default
+ Addrs []string
+ Health provision.Health
+}
+
+// equal reports whether two specs describe the same probing job. Sync uses it
+// to decide restart-or-leave-alone, so it must cover every field the running
+// goroutine captured: host (the default Host header), the addr list INCLUDING
+// order (round-robin walks it), and the whole health object.
+func (s probeSpec) equal(o probeSpec) bool {
+ return s.Host == o.Host && s.Health == o.Health && slices.Equal(s.Addrs, o.Addrs)
+}
+
+// proberSet owns one prober goroutine per probed host plus the live-state
+// table both the request path and the status snapshot read.
+//
+// Lifecycle mirrors Activator.RetireExcept: Reload computes the desired specs
+// and calls Sync, which starts probers for new hosts, stops those whose route
+// vanished, and replaces those whose spec changed. It is the only other thing
+// in this package that owns per-route goroutines across a reload.
+//
+// A *proberSet must come from newProberSet; the zero value is not usable and a
+// nil receiver panics rather than quietly reporting "no live upstreams" — a
+// silent nil would turn a wiring mistake into fleet-wide 503s instead of a
+// loud failure in the first test that hits it.
+type proberSet struct {
+ // mu guards hosts only. The per-host state table lives on hostProber
+ // under its own lock, so a probe tick writing state never contends with
+ // Sync, and Sync never has to hold this lock while waiting for a
+ // goroutine to exit (which would stall Live on the request hot path).
+ mu sync.Mutex
+ hosts map[string]*hostProber
+ client *http.Client
+}
+
+func newProberSet() *proberSet {
+ return &proberSet{
+ hosts: make(map[string]*hostProber),
+ client: &http.Client{
+ Timeout: probeTimeout,
+ // A redirect is not health: an upstream answering 302 has not
+ // served the configured status, and following the hop would
+ // probe something the operator never named.
+ CheckRedirect: func(*http.Request, []*http.Request) error {
+ return http.ErrUseLastResponse
+ },
+ Transport: &http.Transport{
+ // Probes are the same tiny request to the same handful of
+ // addrs forever, so idle connections are worth keeping —
+ // bounded tightly, since every probed upstream holds one.
+ MaxIdleConnsPerHost: 1,
+ IdleConnTimeout: 90 * time.Second,
+ ResponseHeaderTimeout: probeTimeout,
+ },
+ },
+ }
+}
+
+// hostProber is one host's probing job: its spec, the clamped cadence derived
+// from it, its goroutine's stop handle, and the per-addr state that goroutine
+// maintains.
+//
+// The state table is OWNED BY THIS STRUCT, not by the set — that is what makes
+// Sync's replace safe. A replaced prober's goroutine can still be mid-probe
+// when the new one is installed, and whatever it writes lands in its own
+// retired table, which nothing reads any more. (Sync additionally waits for
+// the old goroutine to observe its stop before starting the new one, so even
+// the log lines stay ordered.)
+type hostProber struct {
+ spec probeSpec
+ client *http.Client
+ path string
+ wantCode int
+ hostHdr string
+ interval time.Duration
+ failAt int
+ riseAt int
+
+ cancel context.CancelFunc
+ ctx context.Context
+ done chan struct{}
+
+ mu sync.Mutex
+ order []string // addrs in spec order — Live/State iterate this, not the map
+ state map[string]*upstreamState
+}
+
+// upstreamState is the mutable half of UpstreamState plus the pass counter,
+// which is bookkeeping the dashboard has no use for.
+type upstreamState struct {
+ up bool
+ since time.Time
+ fails int
+ passes int
+ lastError string
+}
+
+func newHostProber(spec probeSpec, client *http.Client) *hostProber {
+ h := spec.Health
+ hp := &hostProber{
+ spec: spec,
+ client: client,
+ path: h.Path,
+ wantCode: h.Status,
+ hostHdr: h.HostHeader,
+ interval: time.Duration(h.IntervalMS) * time.Millisecond,
+ failAt: h.FailAfter,
+ riseAt: h.RiseAfter,
+ done: make(chan struct{}),
+ state: make(map[string]*upstreamState, len(spec.Addrs)),
+ }
+ if hp.path == "" {
+ hp.path = "/"
+ }
+ if hp.path[0] != '/' {
+ hp.path = "/" + hp.path
+ }
+ if hp.wantCode == 0 {
+ hp.wantCode = http.StatusOK
+ }
+ if hp.hostHdr == "" {
+ // WithDefaults deliberately leaves HostHeader empty so this
+ // substitution can see "unset" rather than a value it invented.
+ hp.hostHdr = spec.Host
+ }
+ if h.IntervalMS <= 0 {
+ hp.interval = fallbackProbeInterval
+ }
+ if hp.failAt <= 0 {
+ hp.failAt = fallbackFailAfter
+ }
+ if hp.riseAt <= 0 {
+ hp.riseAt = fallbackRiseAfter
+ }
+ now := time.Now()
+ for _, addr := range spec.Addrs {
+ if addr == "" || hp.state[addr] != nil {
+ continue // validation refuses both; be inert if one slips through
+ }
+ hp.order = append(hp.order, addr)
+ // Rule 1: everything starts live.
+ hp.state[addr] = &upstreamState{up: true, since: now}
+ }
+ hp.ctx, hp.cancel = context.WithCancel(context.Background())
+ return hp
+}
+
+// stop cancels the goroutine's context — aborting any in-flight probe at once,
+// so this does not wait out the 2s client timeout — and blocks until it has
+// exited.
+func (hp *hostProber) stop() {
+ hp.cancel()
+ <-hp.done
+}
+
+// Sync starts, stops and replaces probers so the running set matches want.
+// Called from Reload after the upstream map swap, beside RetireExcept.
+//
+// Specs with no addrs are not probed (nothing to probe); such a host holds no
+// entry, and Live/State report it as absent.
+func (p *proberSet) Sync(want map[string]probeSpec) {
+ var retired, started []*hostProber
+
+ p.mu.Lock()
+ for host, hp := range p.hosts {
+ w, ok := want[host]
+ if ok && len(w.Addrs) > 0 && hp.spec.equal(w) {
+ continue // unchanged: leave the goroutine and its counters alone
+ }
+ retired = append(retired, hp)
+ delete(p.hosts, host)
+ }
+ for host, w := range want {
+ if len(w.Addrs) == 0 {
+ continue
+ }
+ if _, ok := p.hosts[host]; ok {
+ continue
+ }
+ // Built (and therefore all-live) INSIDE the same critical section
+ // that removed the outgoing prober, so a concurrent Live never
+ // observes a replaced host as having no upstreams — a gap there
+ // would be a 503 for a route that is perfectly healthy.
+ hp := newHostProber(w, p.client)
+ p.hosts[host] = hp
+ started = append(started, hp)
+ }
+ p.mu.Unlock()
+
+ // Outside the lock, and before the replacements run: retiring waits for
+ // each old goroutine to exit, and blocking Live (request hot path) for
+ // that wait would be exactly the stall this whole feature exists to
+ // avoid.
+ for _, hp := range retired {
+ hp.stop()
+ }
+ for _, hp := range started {
+ go hp.run()
+ }
+}
+
+// Live reports the currently-live upstreams for host, in the route's addr
+// order, as a fresh slice the caller may keep. Unknown host (or one with no
+// live upstream) → empty: the request path answers 503 + Retry-After rather
+// than dialing something the probe says is dead.
+func (p *proberSet) Live(host string) []string {
+ p.mu.Lock()
+ hp := p.hosts[host]
+ p.mu.Unlock()
+ if hp == nil {
+ return nil
+ }
+ hp.mu.Lock()
+ defer hp.mu.Unlock()
+ out := make([]string, 0, len(hp.order))
+ for _, addr := range hp.order {
+ if hp.state[addr].up {
+ out = append(out, addr)
+ }
+ }
+ return out
+}
+
+// Eject drops addr from host's live set immediately — the in-request
+// dial-failure fast path, so a box that died between probe ticks costs one
+// retry rather than up to fail_after × interval of 502s. Fails is set to
+// failAfter so the upstream is exactly as down as a probe-failed one; probe
+// passes rejoin it after rise_after, the same as any other recovery.
+//
+// A host or addr this set does not know is a no-op: the request path can
+// legitimately race a Reload that just dropped either.
+func (p *proberSet) Eject(host, addr string) {
+ p.mu.Lock()
+ hp := p.hosts[host]
+ p.mu.Unlock()
+ if hp == nil {
+ return
+ }
+ hp.mu.Lock()
+ st := hp.state[addr]
+ if st == nil {
+ hp.mu.Unlock()
+ return
+ }
+ const reason = "dial failed on a live request"
+ was := st.up
+ st.up = false
+ st.passes = 0
+ st.fails = hp.failAt
+ st.lastError = reason
+ if was {
+ st.since = time.Now()
+ }
+ hp.mu.Unlock()
+ if was {
+ log.Printf("edge: probe %s %s down (%s)", host, addr, reason)
+ }
+}
+
+// State returns the probe's view of every probed host, keyed by host, each
+// upstream in the route's addr order. Everything returned is a copy: Task 6's
+// snapshot serializes this while probes keep running.
+func (p *proberSet) State() map[string][]UpstreamState {
+ p.mu.Lock()
+ hps := make(map[string]*hostProber, len(p.hosts))
+ for host, hp := range p.hosts {
+ hps[host] = hp
+ }
+ p.mu.Unlock()
+
+ out := make(map[string][]UpstreamState, len(hps))
+ for host, hp := range hps {
+ hp.mu.Lock()
+ states := make([]UpstreamState, 0, len(hp.order))
+ for _, addr := range hp.order {
+ st := hp.state[addr]
+ states = append(states, UpstreamState{
+ Addr: addr,
+ Up: st.up,
+ Since: st.since,
+ Fails: st.fails,
+ LastError: st.lastError,
+ })
+ }
+ hp.mu.Unlock()
+ out[host] = states
+ }
+ return out
+}
+
+// Stop stops every prober and empties the set (tests and shutdown). Sync may
+// be called again afterwards; it simply starts fresh probers.
+func (p *proberSet) Stop() {
+ p.mu.Lock()
+ hps := make([]*hostProber, 0, len(p.hosts))
+ for host, hp := range p.hosts {
+ hps = append(hps, hp)
+ delete(p.hosts, host)
+ }
+ p.mu.Unlock()
+ for _, hp := range hps {
+ hp.stop()
+ }
+}
+
+// run is one host's probe loop: probe every upstream, wait out the interval,
+// repeat. The first pass runs immediately so a dead upstream is caught within
+// fail_after × interval of the reload rather than one interval later.
+func (hp *hostProber) run() {
+ defer close(hp.done)
+ t := time.NewTicker(hp.interval)
+ defer t.Stop()
+ for {
+ hp.tick()
+ select {
+ case <-hp.ctx.Done():
+ return
+ case <-t.C:
+ }
+ }
+}
+
+// tick probes each upstream once, sequentially. Sequential is deliberate at
+// these counts (a route has a handful of upstreams, not hundreds): it bounds
+// the goroutine's own concurrency at one in-flight request and keeps the log
+// lines of a whole-fleet outage in addr order. The ctx checks are what keep
+// stop() prompt on a route with several dead addrs.
+//
+// hp.order is read without the lock on purpose: it is fixed by
+// newHostProber before this goroutine is started (an addr set change is a new
+// hostProber, never a mutation), so the `go hp.run()` in Sync is the
+// happens-before edge. Live/State still take the lock because they share it
+// with the state map.
+func (hp *hostProber) tick() {
+ for _, addr := range hp.order {
+ if hp.ctx.Err() != nil {
+ return
+ }
+ err := hp.probe(addr)
+ // A probe cancelled by our own stop says nothing about the upstream.
+ // Recording it would let a retiring prober log a "down" transition
+ // (and stamp a LastError) for a host that was merely reloaded.
+ if hp.ctx.Err() != nil {
+ return
+ }
+ hp.record(addr, err)
+ }
+}
+
+// probe performs one health request. A nil error means the upstream answered
+// the configured status; anything else is the reason string that reaches both
+// the transition log and the dashboard's LastError.
+func (hp *hostProber) probe(addr string) error {
+ req, err := http.NewRequestWithContext(hp.ctx, http.MethodGet, "http://"+addr+hp.path, nil)
+ if err != nil {
+ return fmt.Errorf("bad probe target: %v", err)
+ }
+ // Host, not the header map: net/http reads Request.Host for the wire
+ // Host line, and an upstream that vhosts (or redirects on an unexpected
+ // name) must see the name the operator configured.
+ req.Host = hp.hostHdr
+ resp, err := hp.client.Do(req)
+ if err != nil {
+ // Connect refused, timeout, reset, TLS-less port answering garbage:
+ // from the edge's side these are one thing — no usable response
+ // arrived — and the spec names that class "dial".
+ return fmt.Errorf("dial: %v", err)
+ }
+ // Drain a bounded amount so the connection can be reused; a probe body is
+ // expected to be tiny and is never inspected.
+ io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10))
+ resp.Body.Close()
+ if resp.StatusCode != hp.wantCode {
+ return fmt.Errorf("status %d (want %d)", resp.StatusCode, hp.wantCode)
+ }
+ return nil
+}
+
+// record folds one probe result into an upstream's state and logs transitions
+// with their reason. Consecutive counts only: a single pass clears the failure
+// run and a single failure clears the recovery run, which is what makes
+// fail_after/rise_after mean "sustained", not "cumulative".
+func (hp *hostProber) record(addr string, probeErr error) {
+ hp.mu.Lock()
+ st := hp.state[addr]
+ if st == nil { // addr vanished from this prober — impossible today
+ hp.mu.Unlock()
+ return
+ }
+ var transition, reason string
+ switch {
+ case probeErr == nil:
+ st.fails = 0
+ st.lastError = ""
+ if !st.up {
+ st.passes++
+ if st.passes >= hp.riseAt {
+ st.up = true
+ st.passes = 0
+ st.since = time.Now()
+ transition = "up"
+ }
+ }
+ default:
+ st.passes = 0
+ st.fails++
+ st.lastError = probeErr.Error()
+ if st.up && st.fails >= hp.failAt {
+ st.up = false
+ st.since = time.Now()
+ transition, reason = "down", st.lastError
+ }
+ }
+ hp.mu.Unlock()
+
+ switch transition {
+ case "down":
+ log.Printf("edge: probe %s %s down (%s)", hp.spec.Host, addr, reason)
+ case "up":
+ log.Printf("edge: probe %s %s up", hp.spec.Host, addr)
+ }
+}
diff --git a/internal/edge/prober_test.go b/internal/edge/prober_test.go
new file mode 100644
index 000000000..962ea780b
--- /dev/null
+++ b/internal/edge/prober_test.go
@@ -0,0 +1,363 @@
+package edge
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/provision"
+)
+
+// waitFor polls cond until it holds or within elapses. Every prober assertion
+// is time-based (a probe tick has to land before state can move), so the
+// windows here are deliberately generous — an order of magnitude above the
+// tick they wait on — rather than exact: a loaded CI box must not turn a
+// correct prober into a red build.
+func waitFor(t *testing.T, within time.Duration, what string, cond func() bool) {
+ t.Helper()
+ deadline := time.Now().Add(within)
+ for time.Now().Before(deadline) {
+ if cond() {
+ return
+ }
+ time.Sleep(5 * time.Millisecond)
+ }
+ if !cond() {
+ t.Fatalf("timed out after %s waiting for %s", within, what)
+ }
+}
+
+// switchableBackend is an httptest server whose answer flips between the
+// configured status and 200 under an atomic flag, plus a hit counter.
+//
+// The flag exists because the obvious alternative — reassigning
+// srv.Config.Handler once the server is live — is a data race: net/http reads
+// Server.Handler on every connection it serves, so mutating it while the
+// prober is mid-tick is exactly the kind of race `-race` exists to catch.
+func switchableBackend(t *testing.T, downStatus int) (*httptest.Server, *atomic.Bool, *atomic.Int64) {
+ t.Helper()
+ ok := new(atomic.Bool)
+ hits := new(atomic.Int64)
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ hits.Add(1)
+ if ok.Load() {
+ w.WriteHeader(http.StatusOK)
+ return
+ }
+ w.WriteHeader(downStatus)
+ }))
+ t.Cleanup(ts.Close)
+ return ts, ok, hits
+}
+
+func TestProberFailsAwayAndBack(t *testing.T) {
+ healthy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ }))
+ t.Cleanup(healthy.Close)
+ flaky, flakyOK, _ := switchableBackend(t, http.StatusInternalServerError)
+
+ healthyAddr := healthy.Listener.Addr().String()
+ flakyAddr := flaky.Listener.Addr().String()
+ spec := probeSpec{
+ Host: "m.example.com",
+ Addrs: []string{healthyAddr, flakyAddr},
+ Health: provision.Health{
+ Path: "/", Status: 200, IntervalMS: 20, FailAfter: 3, RiseAfter: 2,
+ }.WithDefaults(),
+ }
+
+ p := newProberSet()
+ defer p.Stop()
+ // Optimism: a fresh Sync must not 503 a healthy fleet while the first
+ // probes run, so both upstreams are live before any probe has landed.
+ p.Sync(map[string]probeSpec{"m.example.com": spec})
+ if live := p.Live("m.example.com"); len(live) != 2 {
+ t.Fatalf("live immediately after Sync = %v, want both upstreams (all start live)", live)
+ }
+
+ waitFor(t, 2*time.Second, "the 500ing upstream to fail away", func() bool {
+ return len(p.Live("m.example.com")) == 1
+ })
+ if live := p.Live("m.example.com"); live[0] != healthyAddr {
+ t.Fatalf("live = %v, want only the healthy upstream %s", live, healthyAddr)
+ }
+ st := p.State()["m.example.com"]
+ if len(st) != 2 {
+ t.Fatalf("State() = %v, want one entry per upstream", st)
+ }
+ down := st[1]
+ if down.Addr != flakyAddr || down.Up {
+ t.Fatalf("State()[1] = %+v, want %s down", down, flakyAddr)
+ }
+ if down.Fails < 3 {
+ t.Errorf("down upstream Fails = %d, want >= FailAfter (3)", down.Fails)
+ }
+ if down.LastError == "" {
+ t.Error("down upstream LastError is empty, want the wrong-status reason")
+ }
+ if down.Since.IsZero() {
+ t.Error("down upstream Since is zero, want the transition time")
+ }
+
+ // The flaky upstream recovers → rejoins after RiseAfter passes.
+ flakyOK.Store(true)
+ waitFor(t, 2*time.Second, "the recovered upstream to rejoin", func() bool {
+ return len(p.Live("m.example.com")) == 2
+ })
+ if got := p.State()["m.example.com"][1]; !got.Up || got.Fails != 0 || got.LastError != "" {
+ t.Errorf("recovered upstream = %+v, want up with cleared fails/error", got)
+ }
+}
+
+func TestProberSendsConfiguredHostHeader(t *testing.T) {
+ var mu sync.Mutex
+ var seenHost, seenPath string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ mu.Lock()
+ seenHost, seenPath = r.Host, r.URL.Path
+ mu.Unlock()
+ w.WriteHeader(http.StatusOK)
+ }))
+ t.Cleanup(ts.Close)
+ saw := func() (string, string) {
+ mu.Lock()
+ defer mu.Unlock()
+ return seenHost, seenPath
+ }
+
+ // HostHeader set: it wins over the route host, so a shared upstream that
+ // vhosts on Host can be probed on the name it actually answers to.
+ p := newProberSet()
+ p.Sync(map[string]probeSpec{"m.example.com": {
+ Host: "m.example.com",
+ Addrs: []string{ts.Listener.Addr().String()},
+ Health: provision.Health{
+ Path: "/up", Status: 200, HostHeader: "probe.internal", IntervalMS: 20,
+ }.WithDefaults(),
+ }})
+ waitFor(t, 2*time.Second, "the first probe with an explicit host header", func() bool {
+ h, _ := saw()
+ return h != ""
+ })
+ if h, path := saw(); h != "probe.internal" || path != "/up" {
+ t.Fatalf("probe hit Host=%q path=%q, want probe.internal /up", h, path)
+ }
+ p.Stop()
+
+ mu.Lock()
+ seenHost, seenPath = "", ""
+ mu.Unlock()
+
+ // HostHeader unset: the route host is substituted — WithDefaults
+ // deliberately leaves HostHeader empty so the prober can see "unset".
+ p2 := newProberSet()
+ defer p2.Stop()
+ p2.Sync(map[string]probeSpec{"m.example.com": {
+ Host: "m.example.com",
+ Addrs: []string{ts.Listener.Addr().String()},
+ Health: provision.Health{IntervalMS: 20}.WithDefaults(),
+ }})
+ waitFor(t, 2*time.Second, "the first probe with a defaulted host header", func() bool {
+ h, _ := saw()
+ return h != ""
+ })
+ if h, path := saw(); h != "m.example.com" || path != "/" {
+ t.Fatalf("probe hit Host=%q path=%q, want the route host m.example.com and /", h, path)
+ }
+}
+
+func TestSyncStopsVanishedHosts(t *testing.T) {
+ backend, ok, hits := switchableBackend(t, http.StatusInternalServerError)
+ ok.Store(true)
+
+ p := newProberSet()
+ defer p.Stop()
+ spec := probeSpec{
+ Host: "m.example.com",
+ Addrs: []string{backend.Listener.Addr().String()},
+ Health: provision.Health{IntervalMS: 20}.WithDefaults(),
+ }
+ p.Sync(map[string]probeSpec{"m.example.com": spec})
+ waitFor(t, 2*time.Second, "the host to be probed at least twice", func() bool {
+ return hits.Load() >= 2
+ })
+
+ // An unchanged Sync must NOT restart the prober (Reload runs on every
+ // fingerprint move; restarting would reset every counter and re-probe
+ // the fleet from scratch each time an unrelated route changed).
+ before := p.State()["m.example.com"][0].Since
+ p.Sync(map[string]probeSpec{"m.example.com": spec})
+ if after := p.State()["m.example.com"][0].Since; !after.Equal(before) {
+ t.Errorf("unchanged Sync restarted the prober (Since %v -> %v)", before, after)
+ }
+
+ // The host vanishes: its goroutine is stopped before Sync returns, so
+ // the hit count is frozen from here on — no settle window needed.
+ p.Sync(map[string]probeSpec{})
+ if st := p.State(); len(st) != 0 {
+ t.Fatalf("State() = %v, want empty after the host vanished", st)
+ }
+ if live := p.Live("m.example.com"); len(live) != 0 {
+ t.Fatalf("Live() = %v, want empty after the host vanished", live)
+ }
+ frozen := hits.Load()
+ time.Sleep(150 * time.Millisecond) // >> the 20ms interval it was probing at
+ if now := hits.Load(); now != frozen {
+ t.Fatalf("backend hit %d more time(s) after Sync dropped the host — goroutine leaked", now-frozen)
+ }
+}
+
+func TestEjectIsImmediateAndProbeRejoins(t *testing.T) {
+ a := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ }))
+ t.Cleanup(a.Close)
+ b := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ }))
+ t.Cleanup(b.Close)
+ aAddr, bAddr := a.Listener.Addr().String(), b.Listener.Addr().String()
+
+ p := newProberSet()
+ defer p.Stop()
+ p.Sync(map[string]probeSpec{"m.example.com": {
+ Host: "m.example.com",
+ Addrs: []string{aAddr, bAddr},
+ Health: provision.Health{
+ Path: "/", Status: 200, IntervalMS: 20, FailAfter: 3, RiseAfter: 2,
+ }.WithDefaults(),
+ }})
+ waitFor(t, 2*time.Second, "both upstreams live", func() bool {
+ return len(p.Live("m.example.com")) == 2
+ })
+
+ // Eject is the in-request dial-failure fast path: the drop is visible on
+ // the very next Live(), with no probe tick in between.
+ p.Eject("m.example.com", aAddr)
+ live := p.Live("m.example.com")
+ if len(live) != 1 || live[0] != bAddr {
+ t.Fatalf("live right after Eject = %v, want only %s", live, bAddr)
+ }
+ got := p.State()["m.example.com"][0]
+ if got.Addr != aAddr || got.Up {
+ t.Fatalf("ejected upstream state = %+v, want %s down", got, aAddr)
+ }
+ if got.Fails != 3 {
+ t.Errorf("ejected upstream Fails = %d, want FailAfter (3)", got.Fails)
+ }
+ if got.LastError == "" {
+ t.Error("ejected upstream LastError is empty, want the ejection reason")
+ }
+
+ // The upstream was never actually unhealthy, so probe passes rejoin it.
+ waitFor(t, 2*time.Second, "the ejected upstream to rejoin on probe passes", func() bool {
+ return len(p.Live("m.example.com")) == 2
+ })
+
+ // Ejecting an addr this host does not have (or an unknown host) is a
+ // no-op, not a panic: the proxy can race a Reload that dropped either.
+ p.Eject("m.example.com", "203.0.113.9:1")
+ p.Eject("nobody.example.com", aAddr)
+ if len(p.Live("m.example.com")) != 2 {
+ t.Fatalf("stray Eject disturbed the live set: %v", p.Live("m.example.com"))
+ }
+}
+
+// TestProberClampsZeroIntervalToTheDefault is the defensive half of the
+// controller amendment: mint-time defaulting (WithDefaults) is NOT the only
+// writer of a route's health JSON — SetUpstreams (and the PATCH endpoint that
+// follows it) can write the object directly — so a spec that reaches the
+// prober with interval_ms 0 must be clamped here, not tight-loop against a
+// customer's upstream at whatever rate the network allows.
+func TestProberClampsZeroIntervalToTheDefault(t *testing.T) {
+ backend, ok, hits := switchableBackend(t, http.StatusInternalServerError)
+ ok.Store(true)
+
+ p := newProberSet()
+ defer p.Stop()
+ // Deliberately NOT WithDefaults()ed — this is the un-defaulted shape a
+ // direct health-JSON writer can produce.
+ p.Sync(map[string]probeSpec{"m.example.com": {
+ Host: "m.example.com",
+ Addrs: []string{backend.Listener.Addr().String()},
+ Health: provision.Health{Path: "/", Status: 200},
+ }})
+ waitFor(t, 2*time.Second, "the first probe", func() bool { return hits.Load() >= 1 })
+
+ time.Sleep(400 * time.Millisecond)
+ // At the clamped 2000ms cadence this window holds one probe (the
+ // immediate first one) and possibly a second on a slow box. An unclamped
+ // zero interval would be in the hundreds or thousands.
+ if n := hits.Load(); n > 5 {
+ t.Fatalf("backend probed %d times in ~400ms — zero interval was not clamped to 2000ms", n)
+ }
+}
+
+// TestProberClampsZeroFailAndRiseCounts is the other half of the same
+// amendment: a zero fail_after must not fail an upstream away on a single
+// blip, and a zero rise_after must not rejoin it on a single pass.
+func TestProberClampsZeroFailAndRiseCounts(t *testing.T) {
+ backend, ok, hits := switchableBackend(t, http.StatusInternalServerError)
+
+ p := newProberSet()
+ defer p.Stop()
+ p.Sync(map[string]probeSpec{"m.example.com": {
+ Host: "m.example.com",
+ Addrs: []string{backend.Listener.Addr().String()},
+ // Interval is set (the clamp for it has its own test); the counts
+ // are the zeroes under test.
+ Health: provision.Health{Path: "/", Status: 200, IntervalMS: 20},
+ }})
+
+ waitFor(t, 2*time.Second, "the failing upstream to go down", func() bool {
+ return len(p.Live("m.example.com")) == 0
+ })
+ atDown := hits.Load()
+ if atDown < 3 {
+ t.Fatalf("upstream went down after %d probe(s), want >= FailAfter's clamped 3", atDown)
+ }
+
+ ok.Store(true)
+ waitFor(t, 2*time.Second, "the recovered upstream to rejoin", func() bool {
+ return len(p.Live("m.example.com")) == 1
+ })
+ if n := hits.Load(); n < atDown+2 {
+ t.Fatalf("upstream rejoined after %d pass(es), want >= RiseAfter's clamped 2", n-atDown)
+ }
+}
+
+// TestProberStateReturnsCopies pins the Task 6 contract: the status snapshot
+// serializes what State() returns while probes keep running, so the snapshot
+// must never share a slice with the live table.
+func TestProberStateReturnsCopies(t *testing.T) {
+ backend, ok, _ := switchableBackend(t, http.StatusInternalServerError)
+ ok.Store(true)
+
+ p := newProberSet()
+ defer p.Stop()
+ addr := backend.Listener.Addr().String()
+ p.Sync(map[string]probeSpec{"m.example.com": {
+ Host: "m.example.com",
+ Addrs: []string{addr},
+ Health: provision.Health{IntervalMS: 20}.WithDefaults(),
+ }})
+
+ st := p.State()
+ st["m.example.com"][0].Addr = "clobbered"
+ delete(st, "m.example.com")
+ if again := p.State()["m.example.com"]; len(again) != 1 || again[0].Addr != addr {
+ t.Fatalf("State() = %v after a caller mutated its copy, want the live table intact", again)
+ }
+ live := p.Live("m.example.com")
+ if len(live) != 1 {
+ t.Fatalf("Live() = %v, want the single upstream", live)
+ }
+ live[0] = "clobbered"
+ if again := p.Live("m.example.com"); again[0] != addr {
+ t.Fatalf("Live() = %v after a caller mutated its copy, want %s", again, addr)
+ }
+}