carlos / platform Public

edge: pin the two multi-upstream fallback contracts, throttle the reload complaint

Review found both non-happy-path branches of Reload's addr arm untested:

  - unparseable health column -> the route must lose its PROBING, never its
    service. Test: Health "{not json" + a live backend answers 200 and holds
    no prober entry.
  - addrs set with no health config -> serves addrs[0] unprobed. This shape
    was served by NOTHING before the switch changed (the old arm gated on the
    legacy Addr column, which it leaves empty), so the behavior is new by
    construction: the test pins that upstream one serves every request, that
    the sibling upstream is never used, and that a dead first upstream keeps
    the legacy bare 502 rather than borrowing the probed path's 503.

Also from review: the balance assertion is exact 10/10 rather than "both were
used" (which would sit through a 19/1 split); the WebSocket test asserts the
route is probed, so it cannot pass while silently exercising tcpProxy; and
prober.go's claim that Reload's `hib` gate is the no-hibernate door for addr
routes is corrected — it reads Kind/Hibernate/viaTunnel and says nothing about
addrs, so the actual door is provision.ValidateShape's addr+hibernate refusal.

The unparseable-health line is a STANDING condition, printing on every reload
for as long as the bad row exists, so it now goes through logThrottled —
logPurgeIssue's first-then-every-300th counter, generalized out of
purge_header.go now that it has a second caller.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 9a3c525958d61c27b37d9c2dffc6f39fbd5bad0d parent a4e2e7f
4 files changed, +180 −33
  • internal/edge/edge.go +44 −11
  • internal/edge/edge_test.go +121 −5
  • internal/edge/prober.go +14 −5
  • internal/edge/purge_header.go +1 −12
diff --git a/internal/edge/edge.go b/internal/edge/edge.go
index 97a411db4..c7b0768c1 100644
--- a/internal/edge/edge.go
+++ b/internal/edge/edge.go
@@ -130,12 +130,14 @@ type Edge struct {
// invoking the hibernation activator or the backend — the mitigation
// this whole feature exists for. nil disables caching entirely.
cache *Cache
- // purgeLogN counts Carlos-Purge complaints per (class, host) so
- // logPurgeIssue can rate-limit them; those lines are driven by app
- // responses on the request hot path and would otherwise be one journal
- // line per request. Lazily created under purgeLogMu.
- purgeLogMu sync.Mutex
- purgeLogN map[string]int
+ // logN counts occurrences per throttle key so logThrottled can rate-limit
+ // a repeating complaint. Two callers today: Carlos-Purge complaints, which
+ // are driven by app responses on the request hot path and would otherwise
+ // be one journal line per request, and Reload's unparseable-health line,
+ // which repeats on every reload for as long as the bad row exists. Lazily
+ // created under logMu.
+ logMu sync.Mutex
+ logN map[string]int
// reloadHook, when set (OnReload), runs after every successful route
// swap — the wildcard manager recomputes its parent set from it. Atomic
// because registration happens in RunWithActivator after Watch may
@@ -398,11 +400,14 @@ func (e *Edge) Reload() error {
var h provision.Health
if err := json.Unmarshal([]byte(t.Health), &h); err != nil {
// Never a dead route: a health column we cannot parse costs
- // the route its probing, not its service. Logged every
- // reload on purpose — it is a config error someone has to
- // fix, and the route silently losing HA is worse than the
- // log line.
- log.Printf("edge: route %s: unparseable health config (%v) — serving %s unprobed", t.Host, err, addrs[0])
+ // the route its probing, not its service. Logged rather
+ // than swallowed — it is a config error someone has to fix,
+ // and a route silently losing its HA is worse than a log
+ // line — but throttled, because a bad row is a STANDING
+ // condition that would otherwise print on every reload for
+ // as long as it exists.
+ e.logThrottled("health-parse "+t.Host, fmt.Sprintf(
+ "route %s: unparseable health config (%v) — serving %s unprobed", t.Host, err, addrs[0]))
p = tcpProxy(addrs[0])
break
}
@@ -551,6 +556,34 @@ func (e *Edge) OnReload(fn func()) {
e.reloadHook.Store(&fn)
}
+// logThrottled logs msg the first time key occurs and every 300th time after
+// that — the same first-then-every-300th shape as the registry watcher below,
+// generalized out of logPurgeIssue (purge_header.go) when Reload gained a
+// second line with the same problem.
+//
+// The problem both share: the condition is a STANDING one, not an event. A
+// malformed Carlos-Purge header repeats on every response the app writes; an
+// unparseable health column repeats on every reload for as long as the row
+// exists. Either would fill a journal with the same sentence. The first
+// occurrence is always logged, so nothing is silently swallowed, and the
+// running count travels with each line so the rate stays visible.
+//
+// Keys must be built from values the box itself controls (a route's own host,
+// a complaint class) — never from remote input, or the map becomes a memory
+// growth vector.
+func (e *Edge) logThrottled(key, msg string) {
+ e.logMu.Lock()
+ if e.logN == nil {
+ e.logN = make(map[string]int)
+ }
+ e.logN[key]++
+ n := e.logN[key]
+ e.logMu.Unlock()
+ if n == 1 || n%300 == 0 {
+ log.Printf("edge: %s (occurrence #%d)", msg, n)
+ }
+}
+
func (e *Edge) current() map[string]upstream {
if p := e.upstreams.Load(); p != nil {
return *p
diff --git a/internal/edge/edge_test.go b/internal/edge/edge_test.go
index 4ada76d02..76ae05b87 100644
--- a/internal/edge/edge_test.go
+++ b/internal/edge/edge_test.go
@@ -1627,13 +1627,15 @@ func TestMultiUpstreamBalancesAcrossLiveBackends(t *testing.T) {
t.Fatalf("request %d: body = %q, want one of the two upstreams", i, body)
}
}
- if hitsA.Load() == 0 || hitsB.Load() == 0 {
- t.Fatalf("upstream hits = A:%d B:%d, want BOTH non-zero (round-robin over the live set)",
+ // Exactly even, not merely "both were used". The cursor is per route and
+ // starts at zero, keep-alives are off so every request dials, and both
+ // upstreams stay live throughout — so 10/10 is the only correct answer
+ // here, and a loose "> 0" assertion would sit quietly through a 19/1 split
+ // that means the rotation has broken.
+ if hitsA.Load() != 10 || hitsB.Load() != 10 {
+ t.Fatalf("upstream hits = A:%d B:%d, want 10/10 (strict round-robin over the live set)",
hitsA.Load(), hitsB.Load())
}
- if total := hitsA.Load() + hitsB.Load(); total != 20 {
- t.Fatalf("upstreams served %d requests, want exactly 20 (no request served twice or dropped)", total)
- }
}
func TestDialFailureRetriesOtherUpstreamNoUserError(t *testing.T) {
@@ -1889,6 +1891,15 @@ func TestMultiUpstreamServesWebSocketUpgrade(t *testing.T) {
srv := httptest.NewServer(e)
defer srv.Close()
+ // Pin that this route really took the multiProxy arm: only a probed route
+ // has a prober, so without this the test would still pass if the switch
+ // had quietly built a plain tcpProxy — and it is tcpProxy's transport,
+ // not multiProxy's keep-alive-free one, that the upgrade path is being
+ // checked against.
+ if live := e.probers.Live("ws.example.com"); len(live) != 1 {
+ t.Fatalf("live set = %v, want one probed upstream — this test must exercise multiProxy, not tcpProxy", live)
+ }
+
c, err := net.Dial("tcp", strings.TrimPrefix(srv.URL, "http://"))
if err != nil {
t.Fatalf("dial edge: %v", err)
@@ -1919,3 +1930,108 @@ func TestMultiUpstreamServesWebSocketUpgrade(t *testing.T) {
t.Fatalf("post-upgrade echo = %q, want %q — bytes must flow both ways after the switch", line, "ws:ping\n")
}
}
+
+// TestUnparseableHealthServesUnprobed pins the parse-failure contract: a
+// health column the edge cannot read costs the route its PROBING, never its
+// SERVICE. The alternative — skipping the route, or building a proxy over an
+// empty live set — turns one bad character in a config column into a dead
+// host, which is the one outcome this branch exists to prevent.
+func TestUnparseableHealthServesUnprobed(t *testing.T) {
+ addr, hits := countingBackend(t, "served anyway")
+
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{
+ Host: "broken.example.com", Kind: "service", Status: "ready",
+ Addrs: addr, Health: "{not json",
+ }); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ defer e.probers.Stop()
+ srv := httptest.NewServer(e)
+ defer srv.Close()
+
+ code, body := getHost(t, srv.URL, "broken.example.com")
+ if code != http.StatusOK || body != "served anyway" {
+ t.Fatalf("= %d %q, want 200 %q — an unreadable health config must not take the host off the air",
+ code, body, "served anyway")
+ }
+ if got := hits.Load(); got != 1 {
+ t.Fatalf("backend served %d requests, want 1", got)
+ }
+ if st := e.probers.State(); len(st) != 0 {
+ t.Fatalf("prober state = %v, want empty — a health config that did not parse cannot become a probing job", st)
+ }
+}
+
+// TestMultiAddrWithoutHealthServesFirstUnprobed covers the row shape nothing
+// mints but the switch must still answer for: several addrs, no health
+// config. Before multi-upstream routing such a row was served by NOTHING (the
+// old arm gated on the legacy Addr column, which this shape leaves empty), so
+// the behavior asserted here is new by construction and needs pinning:
+// upstream ONE is served, unprobed, with the legacy 502 semantics. Balancing
+// is deliberately not offered — without a probe there is no live set, and
+// rotating onto upstreams nothing is checking would be worse than using one.
+func TestMultiAddrWithoutHealthServesFirstUnprobed(t *testing.T) {
+ first, firstHits := countingBackend(t, "first")
+ second, secondHits := countingBackend(t, "second")
+ dead := deadAddr(t)
+
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{
+ Host: "unprobed.example.com", Kind: "service", Status: "ready",
+ Addrs: first + "," + second,
+ }); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ // A second route of the same shape whose first upstream is dead, so the
+ // failure semantics can be asserted alongside the success ones.
+ if err := reg.Upsert(registry.Route{
+ Host: "unprobed-dead.example.com", Kind: "service", Status: "ready",
+ Addrs: dead + "," + second,
+ }); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ defer e.probers.Stop()
+ srv := httptest.NewServer(e)
+ defer srv.Close()
+
+ for i := 0; i < 6; i++ {
+ code, body := getHost(t, srv.URL, "unprobed.example.com")
+ if code != http.StatusOK || body != "first" {
+ t.Fatalf("request %d = %d %q, want 200 %q (addrs[0], unprobed)", i, code, body, "first")
+ }
+ }
+ if got := firstHits.Load(); got != 6 {
+ t.Fatalf("first upstream served %d requests, want all 6", got)
+ }
+ if got := secondHits.Load(); got != 0 {
+ t.Fatalf("second upstream served %d requests, want 0 — an unprobed route must not balance onto upstreams nothing is checking", got)
+ }
+
+ // Dead first upstream: the LEGACY failure shape, not the probed one. No
+ // live set exists to fail away from, so there is nothing to promise a
+ // client about coming back.
+ resp := getHostHeader(t, srv.URL, "unprobed-dead.example.com")
+ if resp.StatusCode != http.StatusBadGateway {
+ t.Fatalf("dead first upstream = %d, want 502 (the unprobed path keeps the stdlib behavior)", resp.StatusCode)
+ }
+ if got := resp.Header.Get("Retry-After"); got != "" {
+ t.Fatalf("Retry-After = %q, want absent — 503 semantics are bought by probing, not by the addr count", got)
+ }
+ if got := secondHits.Load(); got != 0 {
+ t.Fatalf("second upstream served %d requests, want 0 — the unprobed path must not retry onto a sibling upstream", got)
+ }
+ if st := e.probers.State(); len(st) != 0 {
+ t.Fatalf("prober state = %v, want empty — neither route carries a health config", st)
+ }
+}
diff --git a/internal/edge/prober.go b/internal/edge/prober.go
index 6add169eb..9467c0d58 100644
--- a/internal/edge/prober.go
+++ b/internal/edge/prober.go
@@ -14,11 +14,20 @@ package edge
// 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.
+// 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"
diff --git a/internal/edge/purge_header.go b/internal/edge/purge_header.go
index c9afa81a6..bdb75c250 100644
--- a/internal/edge/purge_header.go
+++ b/internal/edge/purge_header.go
@@ -2,7 +2,6 @@ package edge
import (
"fmt"
- "log"
"net/http"
"net/http/httputil"
"net/url"
@@ -525,17 +524,7 @@ func (e *Edge) applyPurgeTargets(host, joined string) {
// table, never from the header, so the map is bounded by the number of routes
// on the box and cannot be grown by a remote party.
func (e *Edge) logPurgeIssue(kind, host, msg string) {
- e.purgeLogMu.Lock()
- if e.purgeLogN == nil {
- e.purgeLogN = make(map[string]int)
- }
- key := kind + " " + host
- e.purgeLogN[key]++
- n := e.purgeLogN[key]
- e.purgeLogMu.Unlock()
- if n == 1 || n%300 == 0 {
- log.Printf("edge: %s (occurrence #%d)", msg, n)
- }
+ e.logThrottled("purge "+kind+" "+host, msg)
}
// purgeTargetKey turns one declared target into the cache-key components it