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, so // a probe cannot enter the activator and cannot wake a hibernating tenant // — the wake hazard that would otherwise make active probing incompatible // with hibernation. // // The second half of that guarantee is a MINT-SIDE door, not Reload's: // provision.ValidateShape refuses `addrs` together with `hibernate: true` // outright (internal/provision/provision.go, "addr with hibernate"), so a // probed route is never activator-managed in the first place. Reload's // `hib` gate is NOT that door — it reads `Kind == "instance" && Hibernate // && !viaTunnel` and says nothing about addrs — so a hand-written registry // row could still pair the two, and would then hibernate a process whose // upstream the probe keeps marking down. Fixing that means widening the // gate, not trusting this comment. import ( "context" "fmt" "io" "log" "net/http" "slices" "sync" "time" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/status" ) // 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. Sync runs every incoming spec through // provision.Health.WithDefaults, which covers the ZEROES; these clamps exist // for what WithDefaults lets through — NEGATIVES — because 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 neither hand-edited // JSON nor a future writer is obliged to be sane. // // The interval clamp is the load-bearing one: time.NewTicker PANICS on a // non-positive duration, so an unclamped interval_ms of -1 (or 0, on any path // that reached newHostProber without normalization) would take the whole edge // process down — every route on the box, not just the misconfigured one. A // non-positive fail_after must likewise not fail an upstream away on a single // blip, nor a non-positive 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. // // An ALIAS, not a declaration: the struct itself lives in internal/status // (Task 6), because it is a wire type on status/boxes/