carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package edge
2
3// prober.go — active health probing for multi-upstream addr routes (HA design
4// Part II, issue #152). One goroutine per probed host GETs each of its
5// upstreams on a fixed cadence and maintains the LIVE SET the request path
6// (multiProxy) picks from; the same table is the dashboard's per-upstream
7// state.
8//
9// Two rules shape everything below:
10//
11// 1. **Optimistic start.** Every upstream of a newly synced host starts LIVE.
12// A reload happens on any registry fingerprint move — an unrelated route
13// changing must never take a healthy fleet off the air for the first
14// fail_after × interval while probes catch up. Probing only ever REMOVES
15// confidence; it never withholds it.
16//
17// 2. **Probes dial upstreams directly**, never the edge's own public host, so
18// a probe cannot enter the activator and cannot wake a hibernating tenant
19// — the wake hazard that would otherwise make active probing incompatible
20// with hibernation.
21//
22// The second half of that guarantee is a MINT-SIDE door, not Reload's:
23// provision.ValidateShape refuses `addrs` together with `hibernate: true`
24// outright (internal/provision/provision.go, "addr with hibernate"), so a
25// probed route is never activator-managed in the first place. Reload's
26// `hib` gate is NOT that door — it reads `Kind == "instance" && Hibernate
27// && !viaTunnel` and says nothing about addrs — so a hand-written registry
28// row could still pair the two, and would then hibernate a process whose
29// upstream the probe keeps marking down. Fixing that means widening the
30// gate, not trusting this comment.
31
32import (
33 "context"
34 "fmt"
35 "io"
36 "log"
37 "net/http"
38 "slices"
39 "sync"
40 "time"
41
42 "github.com/carlosframework/platform/internal/provision"
43 "github.com/carlosframework/platform/internal/status"
44)
45
46// probeTimeout bounds a single probe request end to end (connect through
47// body). Held at 2s per the spec: long enough that a loaded-but-alive
48// upstream still answers, short enough that a black-holed one cannot stall a
49// tick past the default 2s interval.
50const probeTimeout = 2 * time.Second
51
52// Defensive fallbacks for a spec that reaches the prober with a non-positive
53// cadence or count. Sync runs every incoming spec through
54// provision.Health.WithDefaults, which covers the ZEROES; these clamps exist
55// for what WithDefaults lets through — NEGATIVES — because mint is not the only
56// writer of a route's health JSON (registry.SetUpstreams writes the column
57// directly, and the route PATCH endpoint will too), and neither hand-edited
58// JSON nor a future writer is obliged to be sane.
59//
60// The interval clamp is the load-bearing one: time.NewTicker PANICS on a
61// non-positive duration, so an unclamped interval_ms of -1 (or 0, on any path
62// that reached newHostProber without normalization) would take the whole edge
63// process down — every route on the box, not just the misconfigured one. A
64// non-positive fail_after must likewise not fail an upstream away on a single
65// blip, nor a non-positive rise_after rejoin it on a single pass.
66//
67// Only NON-POSITIVE values are replaced. A deliberately fast interval is the
68// operator's call (and the tests' — they probe at 20ms).
69const (
70 fallbackProbeInterval = 2000 * time.Millisecond
71 fallbackFailAfter = 3
72 fallbackRiseAfter = 2
73)
74
75// UpstreamState is the dashboard-facing view of one upstream: what the probe
76// currently believes, when that belief last changed, and why.
77//
78// An ALIAS, not a declaration: the struct itself lives in internal/status
79// (Task 6), because it is a wire type on status/boxes/<label>.json and its
80// readers — the console, operators — must be able to decode it without
81// importing the edge. See status.UpstreamState's own doc comment for the full
82// argument. The alias keeps this package writing plain UpstreamState with no
83// conversion where the prober fills it in.
84type UpstreamState = status.UpstreamState
85
86// probeSpec is one route's probing contract, parsed from the registry's health
87// JSON once per Reload. Health need not have been through WithDefaults: Sync
88// normalizes it, so a caller may hand over the raw parse.
89type probeSpec struct {
90 Host string // route host — the probe's Host header default
91 Addrs []string
92 Health provision.Health
93}
94
95// equal reports whether two specs describe the same probing job. Sync uses it
96// to decide restart-or-leave-alone, so it must cover every field the running
97// goroutine captured: host (the default Host header), the addr list INCLUDING
98// order (round-robin walks it), and the whole health object. Sync compares
99// NORMALIZED specs, so Health{} and Health{}.WithDefaults() are equal here.
100func (s probeSpec) equal(o probeSpec) bool {
101 return s.Host == o.Host && s.Health == o.Health && slices.Equal(s.Addrs, o.Addrs)
102}
103
104// proberSet owns one prober goroutine per probed host plus the live-state
105// table both the request path and the status snapshot read.
106//
107// Lifecycle mirrors Activator.RetireExcept: Reload computes the desired specs
108// and calls Sync, which starts probers for new hosts, stops those whose route
109// vanished, and replaces those whose spec changed. It is the only other thing
110// in this package that owns per-route goroutines across a reload.
111//
112// A *proberSet must come from newProberSet; the zero value is not usable and a
113// nil receiver panics rather than quietly reporting "no live upstreams" — a
114// silent nil would turn a wiring mistake into fleet-wide 503s instead of a
115// loud failure in the first test that hits it.
116type proberSet struct {
117 // mu guards hosts only. The per-host state table lives on hostProber
118 // under its own lock, so a probe tick writing state never contends with
119 // Sync, and Sync never has to hold this lock while waiting for a
120 // goroutine to exit (which would stall Live on the request hot path).
121 mu sync.Mutex
122 hosts map[string]*hostProber
123 client *http.Client
124}
125
126func newProberSet() *proberSet {
127 return &proberSet{
128 hosts: make(map[string]*hostProber),
129 client: &http.Client{
130 Timeout: probeTimeout,
131 // A redirect is not health: an upstream answering 302 has not
132 // served the configured status, and following the hop would
133 // probe something the operator never named.
134 CheckRedirect: func(*http.Request, []*http.Request) error {
135 return http.ErrUseLastResponse
136 },
137 Transport: &http.Transport{
138 // Probes are the same tiny request to the same handful of
139 // addrs forever, so idle connections are worth keeping —
140 // bounded tightly, since every probed upstream holds one.
141 MaxIdleConnsPerHost: 1,
142 IdleConnTimeout: 90 * time.Second,
143 ResponseHeaderTimeout: probeTimeout,
144 },
145 },
146 }
147}
148
149// hostProber is one host's probing job: its spec, the clamped cadence derived
150// from it, its goroutine's stop handle, and the per-addr state that goroutine
151// maintains.
152//
153// The state table is OWNED BY THIS STRUCT, not by the set — that is what makes
154// Sync's replace safe. A replaced prober's goroutine can still be mid-probe
155// when the new one is installed, and whatever it writes lands in its own
156// retired table, which nothing reads any more. (Sync additionally waits for
157// the old goroutine to observe its stop before starting the new one, so even
158// the log lines stay ordered.)
159type hostProber struct {
160 spec probeSpec
161 client *http.Client
162 path string
163 wantCode int
164 hostHdr string
165 interval time.Duration
166 failAt int
167 riseAt int
168
169 cancel context.CancelFunc
170 ctx context.Context
171 done chan struct{}
172
173 mu sync.Mutex
174 order []string // addrs in spec order — Live/State iterate this, not the map
175 state map[string]*upstreamState
176}
177
178// upstreamState is the mutable half of UpstreamState plus the pass counter,
179// which is bookkeeping the dashboard has no use for.
180type upstreamState struct {
181 up bool
182 since time.Time
183 fails int
184 passes int
185 lastError string
186}
187
188func newHostProber(spec probeSpec, client *http.Client) *hostProber {
189 h := spec.Health
190 hp := &hostProber{
191 spec: spec,
192 client: client,
193 path: h.Path,
194 wantCode: h.Status,
195 hostHdr: h.HostHeader,
196 interval: time.Duration(h.IntervalMS) * time.Millisecond,
197 failAt: h.FailAfter,
198 riseAt: h.RiseAfter,
199 done: make(chan struct{}),
200 state: make(map[string]*upstreamState, len(spec.Addrs)),
201 }
202 if hp.path == "" {
203 hp.path = "/"
204 }
205 if hp.path[0] != '/' {
206 hp.path = "/" + hp.path
207 }
208 if hp.wantCode == 0 {
209 hp.wantCode = http.StatusOK
210 }
211 if hp.hostHdr == "" {
212 // WithDefaults deliberately leaves HostHeader empty so this
213 // substitution can see "unset" rather than a value it invented.
214 hp.hostHdr = spec.Host
215 }
216 if h.IntervalMS <= 0 {
217 hp.interval = fallbackProbeInterval
218 }
219 if hp.failAt <= 0 {
220 hp.failAt = fallbackFailAfter
221 }
222 if hp.riseAt <= 0 {
223 hp.riseAt = fallbackRiseAfter
224 }
225 now := time.Now()
226 for _, addr := range spec.Addrs {
227 if addr == "" || hp.state[addr] != nil {
228 continue // validation refuses both; be inert if one slips through
229 }
230 hp.order = append(hp.order, addr)
231 // Rule 1: everything starts live.
232 hp.state[addr] = &upstreamState{up: true, since: now}
233 }
234 hp.ctx, hp.cancel = context.WithCancel(context.Background())
235 return hp
236}
237
238// stop cancels the goroutine's context — aborting any in-flight probe at once,
239// so this does not wait out the 2s client timeout — and blocks until it has
240// exited.
241func (hp *hostProber) stop() {
242 hp.cancel()
243 <-hp.done
244}
245
246// Sync starts, stops and replaces probers so the running set matches want.
247// Called from Reload after the upstream map swap, beside RetireExcept.
248//
249// Specs with no addrs are not probed (nothing to probe); such a host holds no
250// entry, and Live/State report it as absent.
251//
252// Concurrent callers are safe (the set lock serializes the diff), but be aware
253// that a call can BLOCK on stop()ing a retired prober — including one a peer
254// call has installed but not yet started, since the goroutine launch happens
255// after the lock is released. Reload is the single caller today, so this is a
256// note for whoever adds the second one, not a live hazard.
257func (p *proberSet) Sync(want map[string]probeSpec) {
258 // Normalize BEFORE both the compare and the construction. A caller that
259 // hands over a raw Health{} on one Reload and a WithDefaults()ed one on the
260 // next is describing the same probing job; comparing them un-normalized
261 // would call that a change and restart every prober on the box — resetting
262 // every fail/rise run — on each Reload a mixed-path writer triggers.
263 norm := make(map[string]probeSpec, len(want))
264 for host, w := range want {
265 w.Health = w.Health.WithDefaults()
266 norm[host] = w
267 }
268 want = norm
269
270 var retired, started []*hostProber
271
272 p.mu.Lock()
273 for host, hp := range p.hosts {
274 w, ok := want[host]
275 if ok && len(w.Addrs) > 0 && hp.spec.equal(w) {
276 continue // unchanged: leave the goroutine and its counters alone
277 }
278 retired = append(retired, hp)
279 delete(p.hosts, host)
280 }
281 for host, w := range want {
282 if len(w.Addrs) == 0 {
283 continue
284 }
285 if _, ok := p.hosts[host]; ok {
286 continue
287 }
288 // Built (and therefore all-live) INSIDE the same critical section
289 // that removed the outgoing prober, so a concurrent Live never
290 // observes a replaced host as having no upstreams — a gap there
291 // would be a 503 for a route that is perfectly healthy.
292 hp := newHostProber(w, p.client)
293 p.hosts[host] = hp
294 started = append(started, hp)
295 }
296 p.mu.Unlock()
297
298 // Outside the lock, and before the replacements run: retiring waits for
299 // each old goroutine to exit, and blocking Live (request hot path) for
300 // that wait would be exactly the stall this whole feature exists to
301 // avoid.
302 for _, hp := range retired {
303 hp.stop()
304 }
305 for _, hp := range started {
306 go hp.run()
307 }
308}
309
310// Live reports the currently-live upstreams for host, in the route's addr
311// order, as a fresh slice the caller may keep. Unknown host (or one with no
312// live upstream) → empty: the request path answers 503 + Retry-After rather
313// than dialing something the probe says is dead.
314func (p *proberSet) Live(host string) []string {
315 p.mu.Lock()
316 hp := p.hosts[host]
317 p.mu.Unlock()
318 if hp == nil {
319 return nil
320 }
321 hp.mu.Lock()
322 defer hp.mu.Unlock()
323 out := make([]string, 0, len(hp.order))
324 for _, addr := range hp.order {
325 if hp.state[addr].up {
326 out = append(out, addr)
327 }
328 }
329 return out
330}
331
332// Eject drops addr from host's live set immediately — the in-request
333// dial-failure fast path, so a box that died between probe ticks costs one
334// retry rather than up to fail_after × interval of 502s. Fails is set to
335// failAfter so the upstream is exactly as down as a probe-failed one; probe
336// passes rejoin it after rise_after, the same as any other recovery.
337//
338// A host or addr this set does not know is a no-op: the request path can
339// legitimately race a Reload that just dropped either.
340func (p *proberSet) Eject(host, addr string) {
341 p.mu.Lock()
342 hp := p.hosts[host]
343 p.mu.Unlock()
344 if hp == nil {
345 return
346 }
347 hp.mu.Lock()
348 st := hp.state[addr]
349 if st == nil {
350 hp.mu.Unlock()
351 return
352 }
353 const reason = "dial failed on a live request"
354 was := st.up
355 st.up = false
356 st.passes = 0
357 st.fails = hp.failAt
358 st.lastError = reason
359 if was {
360 st.since = time.Now()
361 }
362 hp.mu.Unlock()
363 if was {
364 log.Printf("edge: probe %s %s down (%s)", host, addr, reason)
365 }
366}
367
368// State returns the probe's view of every probed host, keyed by host, each
369// upstream in the route's addr order. Everything returned is a copy: Task 6's
370// snapshot serializes this while probes keep running.
371func (p *proberSet) State() map[string][]UpstreamState {
372 p.mu.Lock()
373 hps := make(map[string]*hostProber, len(p.hosts))
374 for host, hp := range p.hosts {
375 hps[host] = hp
376 }
377 p.mu.Unlock()
378
379 out := make(map[string][]UpstreamState, len(hps))
380 for host, hp := range hps {
381 hp.mu.Lock()
382 states := make([]UpstreamState, 0, len(hp.order))
383 for _, addr := range hp.order {
384 st := hp.state[addr]
385 states = append(states, UpstreamState{
386 Addr: addr,
387 Up: st.up,
388 Since: st.since,
389 Fails: st.fails,
390 LastError: st.lastError,
391 })
392 }
393 hp.mu.Unlock()
394 out[host] = states
395 }
396 return out
397}
398
399// Stop stops every prober and empties the set (tests and shutdown). Sync may
400// be called again afterwards; it simply starts fresh probers.
401func (p *proberSet) Stop() {
402 p.mu.Lock()
403 hps := make([]*hostProber, 0, len(p.hosts))
404 for host, hp := range p.hosts {
405 hps = append(hps, hp)
406 delete(p.hosts, host)
407 }
408 p.mu.Unlock()
409 for _, hp := range hps {
410 hp.stop()
411 }
412 // Every probed upstream held an idle connection (MaxIdleConnsPerHost 1);
413 // with no prober left to reuse them they are just sockets on a box that was
414 // told to stop. A later Sync gets fresh ones.
415 p.client.CloseIdleConnections()
416}
417
418// run is one host's probe loop: probe every upstream, wait out the interval,
419// repeat. The first pass runs immediately so a dead upstream is caught within
420// fail_after × interval of the reload rather than one interval later.
421func (hp *hostProber) run() {
422 defer close(hp.done)
423 t := time.NewTicker(hp.interval)
424 defer t.Stop()
425 for {
426 hp.tick()
427 select {
428 case <-hp.ctx.Done():
429 return
430 case <-t.C:
431 }
432 }
433}
434
435// tick probes each upstream once, sequentially. Sequential is deliberate at
436// these counts (a route has a handful of upstreams, not hundreds): it bounds
437// the goroutine's own concurrency at one in-flight request and keeps the log
438// lines of a whole-fleet outage in addr order. The ctx checks are what keep
439// stop() prompt on a route with several dead addrs.
440//
441// Under a TOTAL outage a tick can outlast the interval: N black-holed addrs ×
442// the 2s probe timeout against, say, a 2s cadence. time.Ticker has a one-slot
443// buffer, so the missed ticks collapse into one and the loop simply runs
444// back-to-back rather than queueing a backlog. That is the intended
445// degradation — the probe rate self-limits to "as fast as the timeouts allow"
446// and returns to cadence the moment upstreams answer again.
447//
448// hp.order is read without the lock on purpose: it is fixed by
449// newHostProber before this goroutine is started (an addr set change is a new
450// hostProber, never a mutation), so the `go hp.run()` in Sync is the
451// happens-before edge. Live/State still take the lock because they share it
452// with the state map.
453func (hp *hostProber) tick() {
454 for _, addr := range hp.order {
455 if hp.ctx.Err() != nil {
456 return
457 }
458 err := hp.probe(addr)
459 // A probe cancelled by our own stop says nothing about the upstream.
460 // Recording it would let a retiring prober log a "down" transition
461 // (and stamp a LastError) for a host that was merely reloaded.
462 if hp.ctx.Err() != nil {
463 return
464 }
465 hp.record(addr, err)
466 }
467}
468
469// probe performs one health request. A nil error means the upstream answered
470// the configured status; anything else is the reason string that reaches both
471// the transition log and the dashboard's LastError.
472func (hp *hostProber) probe(addr string) error {
473 req, err := http.NewRequestWithContext(hp.ctx, http.MethodGet, "http://"+addr+hp.path, nil)
474 if err != nil {
475 return fmt.Errorf("bad probe target: %v", err)
476 }
477 // Host, not the header map: net/http reads Request.Host for the wire
478 // Host line, and an upstream that vhosts (or redirects on an unexpected
479 // name) must see the name the operator configured.
480 req.Host = hp.hostHdr
481 resp, err := hp.client.Do(req)
482 if err != nil {
483 // Connect refused, timeout, reset, TLS-less port answering garbage:
484 // from the edge's side these are one thing — no usable response
485 // arrived — and the spec names that class "dial".
486 return fmt.Errorf("dial: %v", err)
487 }
488 // Drain a bounded amount so the connection can be reused; a probe body is
489 // expected to be tiny and is never inspected.
490 io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10))
491 resp.Body.Close()
492 if resp.StatusCode != hp.wantCode {
493 return fmt.Errorf("status %d (want %d)", resp.StatusCode, hp.wantCode)
494 }
495 return nil
496}
497
498// record folds one probe result into an upstream's state and logs transitions
499// with their reason. Consecutive counts only: a single pass clears the failure
500// run and a single failure clears the recovery run, which is what makes
501// fail_after/rise_after mean "sustained", not "cumulative".
502//
503// Fails and LastError are the DOWN upstream's explanation, so they survive the
504// passing probes of a recovery and are cleared only on the up transition. Zero
505// them on the first pass instead and the dashboard shows the mid-recovery
506// upstream as `Up:false, Fails:0, LastError:""` — down for no stated reason,
507// which is the one thing an operator staring at a failover must not see.
508func (hp *hostProber) record(addr string, probeErr error) {
509 hp.mu.Lock()
510 st := hp.state[addr]
511 if st == nil { // addr vanished from this prober — impossible today
512 hp.mu.Unlock()
513 return
514 }
515 var transition, reason string
516 switch {
517 case probeErr == nil:
518 if st.up {
519 // Healthy and staying healthy: the failure run ends here.
520 st.fails = 0
521 st.lastError = ""
522 break
523 }
524 st.passes++
525 if st.passes >= hp.riseAt {
526 st.up = true
527 st.passes = 0
528 st.fails = 0
529 st.lastError = ""
530 st.since = time.Now()
531 transition = "up"
532 }
533 default:
534 st.passes = 0
535 st.fails++
536 st.lastError = probeErr.Error()
537 if st.up && st.fails >= hp.failAt {
538 st.up = false
539 st.since = time.Now()
540 transition, reason = "down", st.lastError
541 }
542 }
543 hp.mu.Unlock()
544
545 switch transition {
546 case "down":
547 log.Printf("edge: probe %s %s down (%s)", hp.spec.Host, addr, reason)
548 case "up":
549 log.Printf("edge: probe %s %s up", hp.spec.Host, addr)
550 }
551}
552