edges: claim reads have a 20s per-pass deadline, throttled failure logging, and never read a host hostclaim.Key rejects
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 files changed,
+148
−3
internal/host/edgesclaims.go+54 −3internal/host/edgesclaims_test.go+94 −0
diff --git a/internal/host/edgesclaims.go b/internal/host/edgesclaims.go| index fda76f13f..35a38250f 100644 |
| --- a/internal/host/edgesclaims.go |
| +++ b/internal/host/edgesclaims.go |
| @@ -2,6 +2,7 @@ package host |
| import ( |
| "context" |
| + "log" |
| "math" |
| "sort" |
| "time" |
| @@ -33,6 +34,13 @@ const ( |
| // passes every host is re-read within about edgesClaimTTL plus four |
| // minutes. |
| edgesClaimPassEvery = 30 * time.Second |
| + // edgesClaimPassDeadline bounds one pass's claim reads, so a store that |
| + // stops answering cannot stall the edges file. A read the deadline cuts |
| + // short is a failed read: the host keeps its last decision. |
| + edgesClaimPassDeadline = 20 * time.Second |
| + // edgesClaimLogEvery: a run of passes with failed claim reads is logged |
| + // on its first pass and every this many after. |
| + edgesClaimLogEvery = 60 |
| ) |
| // edgesDecision is what one claim says about this box and one host. |
| @@ -59,10 +67,15 @@ type edgesClaims struct { |
| now func() time.Time |
| entries map[string]edgesClaimEntry |
| hosts []string // the relocatable hosts of the last pass |
| + // passDeadline bounds one pass's claim reads (edgesClaimPassDeadline). |
| + passDeadline time.Duration |
| + logf func(format string, a ...any) |
| + failingRun int // consecutive passes with a failed claim read |
| } |
| func newEdgesClaims(self string, read func(context.Context, string) (hostclaim.Observed, error)) *edgesClaims { |
| - return &edgesClaims{self: self, read: read, now: time.Now, entries: map[string]edgesClaimEntry{}} |
| + return &edgesClaims{self: self, read: read, now: time.Now, entries: map[string]edgesClaimEntry{}, |
| + passDeadline: edgesClaimPassDeadline, logf: log.Printf} |
| } |
| // relocatableRow is spec 1 §2's relocatable host as a registry row shows |
| @@ -111,7 +124,8 @@ func (c *edgesClaims) backlog(now time.Time) bool { |
| // attempt is older than edgesClaimTTL are read, never-attempted first and |
| // then oldest attempt first, up to edgesClaimReadsPerPass. A read that |
| // fails keeps the previous decision; a host with no successful read has |
| -// none, and publishes as before. |
| +// none, and publishes as before. Once ctx ends no further read is made: |
| +// the hosts not reached stay due, in their place, for a later pass. |
| func (c *edgesClaims) decide(ctx context.Context, hosts []string) map[string]edgesDecision { |
| now := c.now() |
| c.hosts = append(c.hosts[:0], hosts...) |
| @@ -142,14 +156,27 @@ func (c *edgesClaims) decide(ctx context.Context, hosts []string) map[string]edg |
| if len(due) > edgesClaimReadsPerPass { |
| due = due[:edgesClaimReadsPerPass] |
| } |
| + var failed, made int |
| + var firstErr error |
| + var firstHost string |
| for _, h := range due { |
| + if ctx.Err() != nil { |
| + break |
| + } |
| e := c.entries[h] |
| e.attempted = now |
| + made++ |
| if obs, err := c.read(ctx, h); err == nil { |
| e.decision, e.decided = c.decideClaim(obs), true |
| + } else { |
| + if failed == 0 { |
| + firstErr, firstHost = err, h |
| + } |
| + failed++ |
| } |
| c.entries[h] = e |
| } |
| + c.logReads(made, failed, firstHost, firstErr) |
| out := make(map[string]edgesDecision, len(c.entries)) |
| for h, e := range c.entries { |
| if e.decided { |
| @@ -159,10 +186,29 @@ func (c *edgesClaims) decide(ctx context.Context, hosts []string) map[string]edg |
| return out |
| } |
| +// logReads logs a pass's failed claim reads, throttled: the first pass of |
| +// a failing run and every edgesClaimLogEvery-th after it, then once when a |
| +// pass's reads all succeed again. |
| +func (c *edgesClaims) logReads(made, failed int, host string, err error) { |
| + if failed > 0 { |
| + c.failingRun++ |
| + if c.failingRun == 1 || c.failingRun%edgesClaimLogEvery == 0 { |
| + c.logf("agent: edges file: %d of %d claim reads failed (failing pass #%d), first %s: %v; those hosts keep their last decision", failed, made, c.failingRun, host, err) |
| + } |
| + return |
| + } |
| + if made > 0 && c.failingRun > 0 { |
| + c.logf("agent: edges file: claim reads recovered after %d failing passes", c.failingRun) |
| + c.failingRun = 0 |
| + } |
| +} |
| + |
| // applyEdgesClaims withholds or stamps every relocatable row in hosts |
| // (edgesFileWrite's served map, before aliases join it, so an alias of a |
| // withheld host is withheld with it and an alias of a stamped host takes |
| -// its stamp). claims nil leaves hosts untouched. |
| +// its stamp). claims nil leaves hosts untouched. A host hostclaim.Key |
| +// rejects has no claim to read and publishes as before. The pass's reads |
| +// share one deadline, claims.passDeadline. |
| func applyEdgesClaims(ctx context.Context, claims *edgesClaims, routes []registry.Route, hosts map[string]edgeHost) { |
| if claims == nil { |
| return |
| @@ -170,9 +216,14 @@ func applyEdgesClaims(ctx context.Context, claims *edgesClaims, routes []registr |
| var reloc []string |
| for _, r := range routes { |
| if _, served := hosts[r.Host]; served && relocatableRow(r) { |
| + if _, err := hostclaim.Key(r.Host); err != nil { |
| + continue |
| + } |
| reloc = append(reloc, r.Host) |
| } |
| } |
| + ctx, cancel := context.WithTimeout(ctx, claims.passDeadline) |
| + defer cancel() |
| for host, d := range claims.decide(ctx, reloc) { |
| h, ok := hosts[host] |
| if !ok { |
diff --git a/internal/host/edgesclaims_test.go b/internal/host/edgesclaims_test.go| index 8b36e9aee..5cdbff15d 100644 |
| --- a/internal/host/edgesclaims_test.go |
| +++ b/internal/host/edgesclaims_test.go |
| @@ -325,3 +325,97 @@ func TestEdgesLoopClaimPassesObeyTheRetryBackoff(t *testing.T) { |
| t.Fatalf("passes after writes recovered = %v, want the 30-second claim passes back within eight minutes", after) |
| } |
| } |
| + |
| +// Final review 4: a store that never answers cannot stall the edges file. |
| +// The pass has a deadline; a read it cuts short keeps the last decision as |
| +// any failed read does, and the reads not yet made wait for a later pass. |
| +func TestEdgesClaimsPassHasADeadline(t *testing.T) { |
| + book := &claimsBook{claims: map[string]*hostclaim.Claim{"mine.bab.test": serving("annie", 41), "theirs.bab.test": serving("bert", 42)}} |
| + hang := false |
| + read := func(ctx context.Context, host string) (hostclaim.Observed, error) { |
| + if hang { |
| + select { // a Get that blocks until the context ends |
| + case <-ctx.Done(): |
| + return hostclaim.Observed{}, ctx.Err() |
| + case <-time.After(2 * time.Second): // so a missing deadline fails rather than hangs |
| + return hostclaim.Observed{}, errors.New("no deadline") |
| + } |
| + } |
| + return book.read(ctx, host) |
| + } |
| + clock := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC) |
| + c := newEdgesClaims("annie", read) |
| + c.now = func() time.Time { return clock } |
| + c.passDeadline = 100 * time.Millisecond |
| + c.logf = func(string, ...any) {} |
| + routes := []registry.Route{ |
| + {Host: "mine.bab.test", Kind: "instance", Socket: "/run/carlos/mine.sock", Provisioned: true}, |
| + {Host: "theirs.bab.test", Kind: "instance", Socket: "/run/carlos/theirs.sock", Provisioned: true}, |
| + } |
| + served := func() map[string]edgeHost { |
| + return map[string]edgeHost{"mine.bab.test": {Role: "home"}, "theirs.bab.test": {Role: "home"}} |
| + } |
| + applyEdgesClaims(context.Background(), c, routes, served()) |
| + clock = clock.Add(edgesClaimTTL) |
| + hang = true |
| + hosts := served() |
| + start := time.Now() |
| + applyEdgesClaims(context.Background(), c, routes, hosts) |
| + if took := time.Since(start); took > time.Second { |
| + t.Fatalf("the pass took %s against a store that never answers, want about its 100ms deadline", took) |
| + } |
| + if h, ok := hosts["mine.bab.test"]; !ok || h.Stamp != 41 { |
| + t.Fatalf("mine = %+v, %v: a read cut short by the deadline moved a served host", h, ok) |
| + } |
| + if _, ok := hosts["theirs.bab.test"]; ok { |
| + t.Fatal("a read cut short by the deadline published a host this box was told not to") |
| + } |
| + if d := newEdgesClaims("annie", read).passDeadline; d != 20*time.Second { |
| + t.Fatalf("the default pass deadline is %s, want 20s", d) |
| + } |
| +} |
| + |
| +// Final review 4: a host hostclaim.Key rejects is never read; it publishes |
| +// as it did before claims. |
| +func TestEdgesClaimsNeverReadAHostKeyRejects(t *testing.T) { |
| + const bad = "under_score.bab.test" |
| + if _, err := hostclaim.Key(bad); err == nil { |
| + t.Fatalf("hostclaim.Key accepts %q; the test needs a host it rejects", bad) |
| + } |
| + book := &claimsBook{claims: map[string]*hostclaim.Claim{bad: serving("bert", 42)}} |
| + c := newEdgesClaims("annie", book.read) |
| + routes := []registry.Route{{Host: bad, Kind: "instance", Socket: "/run/carlos/x.sock", Provisioned: true}} |
| + hosts := map[string]edgeHost{bad: {Role: "home"}} |
| + applyEdgesClaims(context.Background(), c, routes, hosts) |
| + if len(book.reads) != 0 { |
| + t.Fatalf("read claims for %v", book.reads) |
| + } |
| + if h, ok := hosts[bad]; !ok || h.Stamp != 0 { |
| + t.Fatalf("%s = %+v, %v; want home, unstamped, as before", bad, h, ok) |
| + } |
| +} |
| + |
| +// Final review 4: claim read failures are logged on the first failing |
| +// pass and every 60th after it, and once when reads recover. |
| +func TestEdgesClaimsLogReadFailuresThrottled(t *testing.T) { |
| + book := &claimsBook{claims: map[string]*hostclaim.Claim{}, errs: map[string]bool{"a.bab.test": true}} |
| + clock := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC) |
| + c := newEdgesClaims("annie", book.read) |
| + c.now = func() time.Time { return clock } |
| + var lines []string |
| + c.logf = func(format string, a ...any) { lines = append(lines, fmt.Sprintf(format, a...)) } |
| + for i := 0; i < 120; i++ { |
| + c.decide(context.Background(), []string{"a.bab.test"}) |
| + clock = clock.Add(edgesClaimTTL) |
| + } |
| + if len(lines) != 3 { // passes 1, 60 and 120 |
| + t.Fatalf("%d lines for 120 failing passes, want 3: %q", len(lines), lines) |
| + } |
| + book.errs = nil |
| + c.decide(context.Background(), []string{"a.bab.test"}) |
| + clock = clock.Add(edgesClaimTTL) |
| + c.decide(context.Background(), []string{"a.bab.test"}) |
| + if len(lines) != 4 { |
| + t.Fatalf("lines after recovery = %q, want one recovery line", lines) |
| + } |
| +} |