carlos / platform Public

region HA 2a: the edge beat probes the serving path; refresh timing and negative tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev ff18a4582e1895c10062fc49c00697d840377a92 parent cfb2374
4 files changed, +177 −5
  • internal/edge/edge.go +10 −3
  • internal/edge/lbhealth.go +37 −0
  • internal/edge/lbhealth_test.go +129 −1
  • internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv +1 −1
diff --git a/internal/edge/edge.go b/internal/edge/edge.go
index 3b3a2758e..06c60b869 100644
--- a/internal/edge/edge.go
+++ b/internal/edge/edge.go
@@ -136,6 +136,10 @@ type Edge struct {
// lbhealth.Default with CARLOS_REGION_HA on, nil and inert with it
// off. Set once in New.
lbHealth *lbhealth.State
+ // probing is set while a serving-path probe (servingPathLive) is
+ // waiting on a lock, so a wedged lock costs one goroutine, not one a
+ // tick.
+ probing atomic.Bool
// appsDomain is the platform apps domain (PLATFORM_APPS_DOMAIN,
// normalized), read once in New; cookieLogged is the set of route hosts
// whose cookie rewrite has been logged. Both serve cookiescope.go. ""
@@ -2317,9 +2321,12 @@ func (e *Edge) Watch(stop <-chan struct{}) {
continue
}
errs = 0
- // The edge's own loop is alive and its routing table readable
- // (region HA spec 2 §6). A wedged poll stops beating.
- e.lbHealth.EdgeBeat(time.Now())
+ // The edge's own loop is alive, its routing table readable, and the
+ // :443 path's shared lock free (region HA spec 2 §6, servingPathLive).
+ // A wedged poll or a wedged request path stops beating.
+ if e.lbHealth != nil && e.servingPathLive() {
+ e.lbHealth.EdgeBeat(time.Now())
+ }
if fp == last {
continue
}
diff --git a/internal/edge/lbhealth.go b/internal/edge/lbhealth.go
index bbb6a6565..495ee675c 100644
--- a/internal/edge/lbhealth.go
+++ b/internal/edge/lbhealth.go
@@ -37,3 +37,40 @@ func (e *Edge) lbHealthAnswer(w http.ResponseWriter, r *http.Request) bool {
}
return lbhealth.Serve(w, r, e.lbHealth, time.Now())
}
+
+// servingProbeTimeout bounds servingPathLive's wait for the lock.
+var servingProbeTimeout = time.Second
+
+// servingPathLive is the edge beat's proof that the :443 request path is not
+// wedged. Watch's registry poll shares no lock with ServeHTTP (the route
+// table is an atomic pointer, read without one), so a poll alone would keep
+// beating over a deadlocked request path. The one lock every cacheable GET
+// and HEAD on :443 takes is the response cache's (GetFor, maybeStore), so
+// this takes and releases it, and answers false if that takes longer than
+// servingProbeTimeout. A probe still waiting from an earlier tick is not
+// stacked: the answer stays false until it lands. An edge with no cache has
+// no such lock, and the probe passes. Nothing is added to ServeHTTP.
+func (e *Edge) servingPathLive() bool {
+ c := e.cache
+ if c == nil {
+ return true
+ }
+ if !e.probing.CompareAndSwap(false, true) {
+ return false
+ }
+ done := make(chan struct{})
+ go func() {
+ c.mu.Lock()
+ c.mu.Unlock() //nolint:staticcheck // take and release is the probe
+ e.probing.Store(false)
+ close(done)
+ }()
+ t := time.NewTimer(servingProbeTimeout)
+ defer t.Stop()
+ select {
+ case <-done:
+ return true
+ case <-t.C:
+ return false
+ }
+}
diff --git a/internal/edge/lbhealth_test.go b/internal/edge/lbhealth_test.go
index fdaee8be5..58355ab35 100644
--- a/internal/edge/lbhealth_test.go
+++ b/internal/edge/lbhealth_test.go
@@ -5,6 +5,8 @@ import (
"errors"
"net/http"
"net/http/httptest"
+ "os"
+ "path/filepath"
"strings"
"testing"
"time"
@@ -12,6 +14,7 @@ import (
"golang.org/x/crypto/acme/autocert"
"github.com/carlosframework/platform/internal/lbhealth"
+ "github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
)
@@ -166,12 +169,20 @@ func TestHomeMapFeedsTheHealthState(t *testing.T) {
t.Fatalf("a failed attempt dropped forwarding or went unstamped: %s", got)
}
- // A stuck attempt stamps nothing.
+ // A stuck attempt stamps nothing, at entry or otherwise: the last stamp
+ // is the failed attempt above, taken more than a second before t0, so
+ // a minute and a bit short of the window after t0 it is already stale.
+ // A stamp taken at entry would be fresh here.
s.down = false
s.block, s.entered = make(chan struct{}), make(chan struct{}, 1)
+ time.Sleep(1200 * time.Millisecond)
+ t0 := time.Now()
done := make(chan struct{})
go func() { _ = m.Refresh(context.Background()); close(done) }()
<-s.entered
+ if got := strings.Join(h.Failing(t0.Add(lbhealth.RefreshWindow-time.Second)), ","); !strings.Contains(got, lbhealth.CondRefresh) {
+ t.Fatalf("a refresh stuck inside its attempt was stamped at entry: %s", got)
+ }
stuckAt := time.Now()
if got := strings.Join(h.Failing(stuckAt.Add(lbhealth.RefreshWindow+time.Second)), ","); !strings.Contains(got, lbhealth.CondRefresh) {
t.Fatalf("a refresh stuck for longer than the window still counts: %s", got)
@@ -229,3 +240,120 @@ func TestRegistryWatchBeats(t *testing.T) {
time.Sleep(50 * time.Millisecond)
}
}
+
+// A certificate mirror whose first list fails has loaded nothing: the
+// certificate condition stays unlatched, and a later good refresh latches it.
+func TestCertMirrorFailedRefreshLeavesCertsUnlatched(t *testing.T) {
+ inner := meshTestStore(t)
+ s := &blockingStore{Store: inner, down: true}
+ h := &lbhealth.State{}
+ m := newCertMirror(s, autocert.DirCache(t.TempDir()))
+ m.health = h
+ if err := m.Refresh(context.Background()); err == nil {
+ t.Fatal("refresh against a down bucket succeeded")
+ }
+ if !strings.Contains(strings.Join(h.Failing(time.Now()), ","), lbhealth.CondCerts) {
+ t.Fatal("a failed refresh latched the certificate condition")
+ }
+ s.down = false
+ if err := m.Refresh(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(strings.Join(h.Failing(time.Now()), ","), lbhealth.CondCerts) {
+ t.Fatal("the next good refresh did not latch")
+ }
+}
+
+// failingFingerprint is a registry whose file stops being a database after
+// it opened (a directory stands where it was), so Fingerprint errors on every call.
+func failingFingerprint(t *testing.T) *registry.Registry {
+ t.Helper()
+ path := filepath.Join(t.TempDir(), "registry.db")
+ reg, err := registry.Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, f := range []string{path, path + "-wal", path + "-shm"} {
+ _ = os.Remove(f)
+ }
+ if err := os.Mkdir(path, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := reg.Fingerprint(); err == nil {
+ t.Fatal("setup: Fingerprint still works")
+ }
+ return reg
+}
+
+// A registry poll that fails does not beat.
+func TestRegistryWatchDoesNotBeatOnFingerprintError(t *testing.T) {
+ e, err := New(openReg(t), nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(e.probers.Stop)
+ e.reg = failingFingerprint(t)
+ h := &lbhealth.State{}
+ e.lbHealth = h
+ stop := make(chan struct{})
+ t.Cleanup(func() { close(stop) })
+ go e.Watch(stop)
+ time.Sleep(5 * time.Second)
+ if !strings.Contains(strings.Join(h.Failing(time.Now()), ","), lbhealth.CondEdge) {
+ t.Fatal("a failing Fingerprint beat")
+ }
+}
+
+// wedgedProbe runs an edge over a state that is healthy at setup (every beat
+// taken then), so only the edge's beat can still move, and reports what is
+// failing a little before the window ends.
+func wedgedProbe(t *testing.T, wedge bool) (failing string) {
+ t.Helper()
+ old := servingProbeTimeout
+ servingProbeTimeout = 100 * time.Millisecond
+ t.Cleanup(func() { servingProbeTimeout = old })
+ e, err := New(openReg(t), nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(e.probers.Stop)
+ e.EnableCache(NewCache(10, 1<<20))
+ h := healthyState()
+ h.SetActivator(time.Now) // the activator is not what this test wedges
+ e.lbHealth = h
+ if wedge {
+ // A request path deadlocked inside the cache: every cacheable GET
+ // takes this lock. Released at cleanup so the probe goroutines end.
+ e.cache.mu.Lock()
+ t.Cleanup(e.cache.mu.Unlock)
+ }
+ stop := make(chan struct{})
+ t.Cleanup(func() { close(stop) })
+ go e.Watch(stop)
+ time.Sleep(5 * time.Second)
+ // Set the clock 57s ahead: a beat from the last tick or two is still
+ // inside the 60s window, one from before the wedge (setup, 5s+ ago) is not.
+ now := time.Now().Add(lbhealth.HeartbeatWindow - 3*time.Second)
+ got := h.Failing(now)
+ w := httptest.NewRecorder()
+ lbhealth.Serve(w, httptest.NewRequest(http.MethodGet, lbhealth.Path, nil), h, now)
+ if wedge != (w.Code == http.StatusServiceUnavailable) {
+ t.Fatalf("wedge=%v: the health answer was %d %q", wedge, w.Code, w.Body.String())
+ }
+ return strings.Join(got, ",")
+}
+
+// A deadlocked request path keeps the registry poll running, but the edge
+// stops beating, and the health answer turns 503 naming the edge once the
+// last beat ages out.
+func TestEdgeBeatStopsWhenTheServingPathIsWedged(t *testing.T) {
+ if got := wedgedProbe(t, true); !strings.Contains(got, lbhealth.CondEdge) {
+ t.Fatalf("a wedged serving path kept beating: %q", got)
+ }
+}
+
+func TestEdgeBeatContinuesWhenTheServingPathIsFree(t *testing.T) {
+ if got := wedgedProbe(t, false); strings.Contains(got, lbhealth.CondEdge) {
+ t.Fatalf("a free serving path stopped beating: %q", got)
+ }
+}
diff --git a/internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv b/internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv
index b4f264e64..98c76e40f 100644
--- a/internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv
+++ b/internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv
@@ -14,6 +14,6 @@ internal/activator/activator.go type Service authority B,C,D,F,J4 rev3=PROPOSED-
@coverage internal/edgesfile f62c3973bb48fa36f561d26f496c0ba9a14fe882fe5843867e9f7a9eaf0abbdf
@coverage internal/host 4ad9bdcd8b94516a6ccdad3bc900b166833faf1f0334ac83478be5b04de47069
@coverage internal/hostops 3dc23aaff7bdae0341a8c9a6fbac076a2f57ca1e4676d83543433ff9ad7d1c79
-@coverage internal/edge 8e47251c54e3c5ee9cb6e8b9b1ad3ae87da528436c6a870cadb07ca3a8ac3146
+@coverage internal/edge 20177c9e7dc961937677bbf2763c73004d7c95f68512591800f6fe841c36152f
internal/host/edgesfile.go edgesFileWriteClaims authority F,J1 root-route;kept=F;scope=protected-decision-only registry:Account@10835,registry:Alias@5297,registry:Alias@5332,registry:Alias@5754,registry:Alias@5911,registry:Alias@5981,registry:Alias@6013,registry:Alias@6482,registry:Alias@8772,registry:App@10847,registry:DNSStatus@5429,registry:DNSStatus@5920,registry:Host@10480,registry:Host@2878,registry:Host@5389,registry:Host@6234,registry:Host@6567,registry:Host@8819,registry:Host@8866,registry:Host@8916,registry:Host@8951,registry:Host@9000,registry:Platform@2921,registry:Provisioned@2935,registry:Replica@2959,registry:Status@877,whole:Route@3212,whole:Route@323,whole:Route@3505,whole:Route@351,whole:RouteAlias@5282,whole:RouteAlias@8725,whole:RoutePath@3520,whole:WildcardDomain@8905 8c7cdffba66886b92f9a2ac663a86dbdd5f80699afd2e6afb4571da916c727ad
@coverage internal/activator fd9cf14712f55895541bb8bfbf8137cfd446298bcaa8634b0aba7b8344c29ec3