region HA 2a: the placement policy over the eligible set
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7 files changed,
+279
−14
internal/boxchoice/boxchoice.go+20 −9internal/boxchoice/policy.go+106 −0internal/boxchoice/policy_test.go+111 −0internal/edgesfile/snapshot.go+13 −5internal/edgesfile/snapshot_headroom_test.go+25 −0internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv+3 −0region_ha_boundary_test.go+1 −0
diff --git a/internal/boxchoice/boxchoice.go b/internal/boxchoice/boxchoice.go| index 10a7eccf9..00eab928f 100644 |
| --- a/internal/boxchoice/boxchoice.go |
| +++ b/internal/boxchoice/boxchoice.go |
| @@ -21,11 +21,15 @@ import ( |
| // region HA executor. |
| const Capability = "region-ha-1" |
| -// Headroom is what a box reports it has free. |
| +// Headroom is what a box reports it has free, and (region HA spec 2 §7) |
| +// the totals the free figures are fractions of. A total of 0 is a writer |
| +// that predates it, or a reading that failed. |
| type Headroom struct { |
| - MemFreeMiB int64 `json:"mem_free_mib"` |
| - DiskFreeMiB int64 `json:"disk_free_mib"` |
| - Awake int `json:"awake"` |
| + MemFreeMiB int64 `json:"mem_free_mib"` |
| + DiskFreeMiB int64 `json:"disk_free_mib"` |
| + MemTotalMiB int64 `json:"mem_total_mib"` |
| + DiskTotalMiB int64 `json:"disk_total_mib"` |
| + Awake int `json:"awake"` |
| } |
| // Box is one box's edges file (control/edges/<box>.json, written by |
| @@ -48,6 +52,9 @@ type Box struct { |
| Capabilities []string `json:"capabilities"` |
| Draining bool `json:"draining"` |
| Headroom *Headroom `json:"headroom"` |
| + // Generation is when the box last wrote the file (RFC 3339). Spec 2's |
| + // policy trusts headroom only from a recent file (Placeable). |
| + Generation string `json:"generation"` |
| // Fresh is the caller's verdict that the box's own status snapshot is |
| // still fresh. An edges file outlives its box, so the file alone is |
| // not liveness. |
| @@ -76,10 +83,13 @@ type RosterBox struct { |
| // Candidate is one box that may be named as a host's executor (region HA |
| // spec 1 §2, "eligible box"). |
| type Candidate struct { |
| - BoxID string |
| - Region string |
| - MemFreeMiB, DiskFreeMiB int64 |
| - Awake int |
| + BoxID string |
| + Region string |
| + MemFreeMiB, DiskFreeMiB int64 |
| + MemTotalMiB, DiskTotalMiB int64 |
| + Awake int |
| + // Generation is the box's edges file's (Box.Generation). |
| + Generation string |
| } |
| // Load is what claims already place on a box. |
| @@ -132,9 +142,10 @@ func Eligible(snap Snapshot, roster map[string]RosterBox, placement, awsRegion, |
| case !slices.Contains(f.Capabilities, Capability): |
| case f.Draining, lost[f.BoxID]: |
| default: |
| - c := Candidate{BoxID: f.BoxID, Region: rb.Cloud.Region} |
| + c := Candidate{BoxID: f.BoxID, Region: rb.Cloud.Region, Generation: f.Generation} |
| if f.Headroom != nil { |
| c.MemFreeMiB, c.DiskFreeMiB, c.Awake = f.Headroom.MemFreeMiB, f.Headroom.DiskFreeMiB, f.Headroom.Awake |
| + c.MemTotalMiB, c.DiskTotalMiB = f.Headroom.MemTotalMiB, f.Headroom.DiskTotalMiB |
| } |
| out = append(out, c) |
| } |
diff --git a/internal/boxchoice/policy.go b/internal/boxchoice/policy.go| new file mode 100644 |
| index 000000000..7f704b7f1 |
| --- /dev/null |
| +++ b/internal/boxchoice/policy.go |
| @@ -0,0 +1,106 @@ |
| +package boxchoice |
| + |
| +import ( |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "sort" |
| + "time" |
| +) |
| + |
| +// Region HA spec 2's placement policy (§7). Its rules run over candidates |
| +// Eligible has already filtered for identity, placement and configuration |
| +// (§25): a provider-neutral box identity (#299) changes only Eligible, |
| +// never these. |
| + |
| +// The thresholds of §7. |
| +const ( |
| + // MinMemPercent: a box is placeable only while its free memory is at |
| + // least this share of its total, the memory alarm's own line. |
| + MinMemPercent = 15 |
| + // MinDiskPercent: and its data volume's free space at least this |
| + // share of the volume. |
| + MinDiskPercent = 10 |
| + // MaxEdgesAge: and its edges file, which carries the headroom, is no |
| + // older than this. Files republish every 5 minutes plus up to a |
| + // minute of jitter, so a healthy box always has one within two |
| + // intervals and a margin (A1-8). |
| + MaxEdgesAge = 15 * time.Minute |
| + // maxEdgesSkew is how far in the future a file's generation may be |
| + // before it is not believed: box clocks are synchronised, not equal. |
| + maxEdgesSkew = time.Minute |
| +) |
| + |
| +// ExcludeRoles drops every candidate whose roster role is not the default |
| +// one (roles is box id -> role, "" for the default). Legacy and platform |
| +// boxes, and any role this build does not know, take no new instances. |
| +// The order of cands is kept. |
| +func ExcludeRoles(cands []Candidate, roles map[string]string) []Candidate { |
| + var out []Candidate |
| + for _, c := range cands { |
| + if roles[c.BoxID] == "" { |
| + out = append(out, c) |
| + } |
| + } |
| + return out |
| +} |
| + |
| +// headroomFresh reports whether a candidate's edges file is recent enough |
| +// at now for its headroom to be trusted. |
| +func headroomFresh(generation string, now time.Time) bool { |
| + at, err := time.Parse(time.RFC3339, generation) |
| + if err != nil { |
| + return false |
| + } |
| + age := now.Sub(at) |
| + return age <= MaxEdgesAge && age >= -maxEdgesSkew |
| +} |
| + |
| +// Placeable is §7's eligibility on top of Eligible's: the default role, |
| +// free memory at least MinMemPercent of the total, free disk at least |
| +// MinDiskPercent of the volume, and an edges file no older than |
| +// MaxEdgesAge at now. A box that publishes no totals is not placeable: its |
| +// headroom cannot be judged. The order of cands is kept. |
| +func Placeable(cands []Candidate, roles map[string]string, now time.Time) []Candidate { |
| + var out []Candidate |
| + for _, c := range ExcludeRoles(cands, roles) { |
| + switch { |
| + case c.MemTotalMiB <= 0 || c.MemFreeMiB*100 < c.MemTotalMiB*MinMemPercent: |
| + case c.DiskTotalMiB <= 0 || c.DiskFreeMiB*100 < c.DiskTotalMiB*MinDiskPercent: |
| + case !headroomFresh(c.Generation, now): |
| + default: |
| + out = append(out, c) |
| + } |
| + } |
| + return out |
| +} |
| + |
| +// ChooseRoomiest is §7's choice among placeable candidates: prefer a box |
| +// running no instance of app (soft spread: it never refuses), then the |
| +// most free memory, then the greatest hexadecimal SHA-256 of host + "\x00" |
| +// + box id (claim P5-D2), then the smallest box id. The answer does not |
| +// depend on the order of cands. The bool is false only when cands is |
| +// empty. |
| +func ChooseRoomiest(host string, cands []Candidate, running map[string]Load, app string) (Candidate, bool) { |
| + if len(cands) == 0 { |
| + return Candidate{}, false |
| + } |
| + tie := func(ref string) string { |
| + sum := sha256.Sum256([]byte(host + "\x00" + ref)) |
| + return hex.EncodeToString(sum[:]) |
| + } |
| + sorted := append([]Candidate(nil), cands...) |
| + sort.SliceStable(sorted, func(i, j int) bool { |
| + a, b := sorted[i], sorted[j] |
| + aRuns, bRuns := running[a.BoxID].Apps[app] > 0, running[b.BoxID].Apps[app] > 0 |
| + switch { |
| + case aRuns != bRuns: |
| + return !aRuns |
| + case a.MemFreeMiB != b.MemFreeMiB: |
| + return a.MemFreeMiB > b.MemFreeMiB |
| + case tie(a.BoxID) != tie(b.BoxID): |
| + return tie(a.BoxID) > tie(b.BoxID) |
| + } |
| + return a.BoxID < b.BoxID |
| + }) |
| + return sorted[0], true |
| +} |
diff --git a/internal/boxchoice/policy_test.go b/internal/boxchoice/policy_test.go| new file mode 100644 |
| index 000000000..e33bc4faf |
| --- /dev/null |
| +++ b/internal/boxchoice/policy_test.go |
| @@ -0,0 +1,111 @@ |
| +package boxchoice |
| + |
| +import ( |
| + "reflect" |
| + "testing" |
| + "time" |
| +) |
| + |
| +var policyNow = time.Date(2026, 10, 16, 12, 0, 0, 0, time.UTC) |
| + |
| +// roomy is a candidate every §7 rule passes at policyNow. |
| +func roomy(id string, memFree int64) Candidate { |
| + return Candidate{BoxID: id, Region: "eu-west-2", MemFreeMiB: memFree, MemTotalMiB: 8000, DiskFreeMiB: 50000, DiskTotalMiB: 100000, |
| + Generation: policyNow.Add(-5 * time.Minute).Format(time.RFC3339)} |
| +} |
| + |
| +func TestPlaceableAppliesEachRule(t *testing.T) { |
| + gen := func(d time.Duration) string { return policyNow.Add(d).Format(time.RFC3339) } |
| + cases := []struct { |
| + name string |
| + mut func(*Candidate) |
| + role string |
| + want bool |
| + }{ |
| + {"every rule holds", nil, "", true}, |
| + {"memory exactly 15%", func(c *Candidate) { c.MemFreeMiB = 1200 }, "", true}, |
| + {"memory under 15%", func(c *Candidate) { c.MemFreeMiB = 1199 }, "", false}, |
| + {"no memory total", func(c *Candidate) { c.MemTotalMiB = 0 }, "", false}, |
| + {"disk exactly 10%", func(c *Candidate) { c.DiskFreeMiB = 10000 }, "", true}, |
| + {"disk under 10%", func(c *Candidate) { c.DiskFreeMiB = 9999 }, "", false}, |
| + {"no disk total", func(c *Candidate) { c.DiskTotalMiB = 0 }, "", false}, |
| + {"edges file exactly 15 minutes old", func(c *Candidate) { c.Generation = gen(-15 * time.Minute) }, "", true}, |
| + {"edges file older than 15 minutes", func(c *Candidate) { c.Generation = gen(-15*time.Minute - time.Second) }, "", false}, |
| + {"edges file a minute in the future", func(c *Candidate) { c.Generation = gen(time.Minute) }, "", true}, |
| + {"edges file further in the future", func(c *Candidate) { c.Generation = gen(time.Minute + time.Second) }, "", false}, |
| + {"no generation", func(c *Candidate) { c.Generation = "" }, "", false}, |
| + {"unreadable generation", func(c *Candidate) { c.Generation = "yesterday" }, "", false}, |
| + {"legacy role", nil, "legacy", false}, |
| + {"platform role", nil, "platform", false}, |
| + {"a role this build does not know", nil, "draining", false}, |
| + } |
| + for _, tc := range cases { |
| + t.Run(tc.name, func(t *testing.T) { |
| + c := roomy("i-0aaa", 4000) |
| + if tc.mut != nil { |
| + tc.mut(&c) |
| + } |
| + got := Placeable([]Candidate{c}, map[string]string{"i-0aaa": tc.role}, policyNow) |
| + if (len(got) == 1) != tc.want { |
| + t.Fatalf("placeable = %v, want %v", ids(got), tc.want) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestExcludeRolesKeepsOrderAndDefaults(t *testing.T) { |
| + cands := []Candidate{roomy("c", 1), roomy("a", 1), roomy("b", 1)} |
| + got := ExcludeRoles(cands, map[string]string{"a": "legacy", "b": ""}) |
| + if !reflect.DeepEqual(ids(got), []string{"c", "b"}) { |
| + t.Fatalf("got %v", ids(got)) |
| + } |
| + if got := ExcludeRoles(cands, nil); len(got) != 3 { |
| + t.Fatalf("no roles excluded %v", ids(got)) |
| + } |
| +} |
| + |
| +func TestChooseRoomiestSpreadsThenMostMemoryThenHash(t *testing.T) { |
| + running := map[string]Load{"a": {Hosts: 1, Apps: map[string]int{"bab/jam": 1}}} |
| + // a has the most memory but already runs the app: spread wins. |
| + got, ok := ChooseRoomiest("h.example", []Candidate{roomy("a", 7000), roomy("b", 2000), roomy("c", 3000)}, running, "bab/jam") |
| + if !ok || got.BoxID != "c" { |
| + t.Fatalf("spread then memory: got %q", got.BoxID) |
| + } |
| + // Spread never refuses: every box runs the app, so memory decides. |
| + all := map[string]Load{"a": running["a"], "b": running["a"]} |
| + got, _ = ChooseRoomiest("h.example", []Candidate{roomy("a", 1000), roomy("b", 2000)}, all, "bab/jam") |
| + if got.BoxID != "b" { |
| + t.Fatalf("all run the app: got %q", got.BoxID) |
| + } |
| + // Fewer hosts is no longer a rule: memory beats it. |
| + busy := map[string]Load{"a": {Hosts: 50}, "b": {Hosts: 1}} |
| + got, _ = ChooseRoomiest("h.example", []Candidate{roomy("a", 5000), roomy("b", 4000)}, busy, "bab/jam") |
| + if got.BoxID != "a" { |
| + t.Fatalf("memory over host count: got %q", got.BoxID) |
| + } |
| + // Equal memory: the P5-D2 hash, whatever the order. |
| + one, _ := ChooseRoomiest("h.example", []Candidate{roomy("a", 1000), roomy("b", 1000)}, nil, "bab/jam") |
| + two, _ := ChooseRoomiest("h.example", []Candidate{roomy("b", 1000), roomy("a", 1000)}, nil, "bab/jam") |
| + if one.BoxID != two.BoxID { |
| + t.Fatalf("order changed the answer: %q %q", one.BoxID, two.BoxID) |
| + } |
| + want, _ := Choose("h.example", []Candidate{roomy("a", 1000), roomy("b", 1000)}, nil, "bab/jam") |
| + if one.BoxID != want.BoxID { |
| + t.Fatalf("hash tie-break differs from spec 1's: %q, want %q", one.BoxID, want.BoxID) |
| + } |
| + if _, ok := ChooseRoomiest("h.example", nil, nil, "bab/jam"); ok { |
| + t.Fatal("chose from nothing") |
| + } |
| +} |
| + |
| +func TestEligibleCarriesTotalsAndGeneration(t *testing.T) { |
| + body := haBox("i-0aaa", func(m map[string]any) { |
| + m["headroom"] = map[string]any{"mem_free_mib": 1000, "disk_free_mib": 2000, "mem_total_mib": 8000, "disk_total_mib": 40000, "awake": 1} |
| + m["generation"] = "2026-10-16T11:55:00Z" |
| + }) |
| + got := Eligible(views(t, body), rosterFor("i-0aaa"), "uk", "eu-west-2", "ie", testKeyFP, nil) |
| + want := Candidate{BoxID: "i-0aaa", Region: "eu-west-2", MemFreeMiB: 1000, DiskFreeMiB: 2000, MemTotalMiB: 8000, DiskTotalMiB: 40000, Awake: 1, Generation: "2026-10-16T11:55:00Z"} |
| + if len(got) != 1 || got[0] != want { |
| + t.Fatalf("got %+v, want %+v", got, want) |
| + } |
| +} |
diff --git a/internal/edgesfile/snapshot.go b/internal/edgesfile/snapshot.go| index 10308ccbb..79be51eff 100644 |
| --- a/internal/edgesfile/snapshot.go |
| +++ b/internal/edgesfile/snapshot.go |
| @@ -58,11 +58,18 @@ type PlacementView struct { |
| Capabilities []string `json:"capabilities"` |
| Draining bool `json:"draining"` |
| Headroom *struct { |
| - MemFreeMiB int64 `json:"mem_free_mib"` |
| - DiskFreeMiB int64 `json:"disk_free_mib"` |
| - Awake int `json:"awake"` |
| + MemFreeMiB int64 `json:"mem_free_mib"` |
| + DiskFreeMiB int64 `json:"disk_free_mib"` |
| + MemTotalMiB int64 `json:"mem_total_mib"` |
| + DiskTotalMiB int64 `json:"disk_total_mib"` |
| + Awake int `json:"awake"` |
| } `json:"headroom"` |
| Hosts map[string]json.RawMessage `json:"hosts"` |
| + |
| + // Generation is when the box last wrote the file (RFC 3339), which |
| + // region HA spec 2's placement policy reads as headroom's age. |
| + Generation string `json:"generation"` |
| + |
| // The moves protocol (moves spec §11), read only by the Phase 1 move |
| // door's floor check (selectMoveBoxes); Task 13 adds the writer's side. |
| // MovesProtocol is the protocol the box speaks (absent: 0). |
| @@ -246,10 +253,11 @@ func (s Snapshot) BoxChoice() boxchoice.Snapshot { |
| Box: f.Box, BoxID: f.BoxID, EIP: f.EIP, Region: f.Region, Placement: f.Placement, |
| HibernationConfigured: f.HibernationConfigured, ServesSharedPool: f.ServesSharedPool, |
| AcceptsInbound: f.AcceptsInbound, SecretsKeyFP: f.SecretsKeyFP, Capabilities: f.Capabilities, |
| - Draining: f.Draining, Fresh: f.Fresh, |
| + Draining: f.Draining, Generation: f.Generation, Fresh: f.Fresh, |
| } |
| if f.Headroom != nil { |
| - b.Headroom = &boxchoice.Headroom{MemFreeMiB: f.Headroom.MemFreeMiB, DiskFreeMiB: f.Headroom.DiskFreeMiB, Awake: f.Headroom.Awake} |
| + b.Headroom = &boxchoice.Headroom{MemFreeMiB: f.Headroom.MemFreeMiB, DiskFreeMiB: f.Headroom.DiskFreeMiB, |
| + MemTotalMiB: f.Headroom.MemTotalMiB, DiskTotalMiB: f.Headroom.DiskTotalMiB, Awake: f.Headroom.Awake} |
| } |
| out.Boxes = append(out.Boxes, b) |
| } |
diff --git a/internal/edgesfile/snapshot_headroom_test.go b/internal/edgesfile/snapshot_headroom_test.go| new file mode 100644 |
| index 000000000..93764dc9b |
| --- /dev/null |
| +++ b/internal/edgesfile/snapshot_headroom_test.go |
| @@ -0,0 +1,25 @@ |
| +package edgesfile |
| + |
| +import ( |
| + "encoding/json" |
| + "testing" |
| +) |
| + |
| +// Region HA spec 2 §7: the placement policy reads the headroom totals and |
| +// the file's generation, so BoxChoice carries both. |
| +func TestBoxChoiceCarriesHeadroomTotalsAndGeneration(t *testing.T) { |
| + var f PlacementView |
| + body := `{"v":1,"box":"box-a","box_id":"i-a","generation":"2026-10-16T11:55:00Z","headroom":{"mem_free_mib":512,"disk_free_mib":2048,"mem_total_mib":4096,"disk_total_mib":20480,"awake":3},"hosts":{}}` |
| + if err := json.Unmarshal([]byte(body), &f); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.Fresh = true |
| + got := Snapshot{Files: []PlacementView{f}, Complete: true, BoxesComplete: true}.BoxChoice() |
| + if len(got.Boxes) != 1 { |
| + t.Fatalf("boxes %+v", got.Boxes) |
| + } |
| + b := got.Boxes[0] |
| + if b.Generation != "2026-10-16T11:55:00Z" || b.Headroom == nil || b.Headroom.MemTotalMiB != 4096 || b.Headroom.DiskTotalMiB != 20480 || b.Headroom.MemFreeMiB != 512 || b.Headroom.DiskFreeMiB != 2048 || b.Headroom.Awake != 3 { |
| + t.Fatalf("box lost a field: %+v %+v", b, b.Headroom) |
| + } |
| +} |
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 34d954cab..7edff1385 100644 |
| --- a/internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv |
| +++ b/internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv |
| @@ -1,5 +1,8 @@ |
| # Plan 14 census overlay region-ha-2a (region HA spec 2, plan 2a: docs/superpowers/plans/2026-10-16-region-ha-2a-core.md), loaded last. |
| # Task 2: the new internal/lbhealth package (the load balancer health state). Envelope-only: no root-read unit changed or was added, nothing runs as root that did not before, and no registry, /data, release or claim read was added, so the legacy seal stands. |
| # Task 3: box and group roles in the signed roster (internal/hostactors roles.go; Box.Role and Box.Group, Region.Role, Replace lets the role change, automatic enrollment inherits the group's role). Envelope-only: Directory.Replace, Roster.Validate and Directory.enroll are not root-read units, no root-read unit changed or was added, and the legacy seal stands. |
| +# Task 4: the placement policy (internal/boxchoice policy.go; headroom totals and the edges file's generation carried through Eligible and edgesfile.Snapshot.BoxChoice). Envelope-only: no root-read unit changed or was added, and the legacy seal stands. |
| @coverage internal/lbhealth ad1211a01a4eb3d02cd8ed95cbff0f8b17b3976ce9c0279ebd2cae2f20c07124 |
| @coverage internal/hostactors 6736698380a247764ada687f2476f89382fc32a1183a50c06afe75967d716eab |
| +@coverage internal/boxchoice 1bfd561bcb48b51e60e63f99e086fd44d3b1be2160e28b3ed0253a14bbb9ff66 |
| +@coverage internal/edgesfile da42c799d62e065817b5cce4f9a3ead1308dbd379615a85a7b14533c16d7e804 |
diff --git a/region_ha_boundary_test.go b/region_ha_boundary_test.go| index 7f68ca3fc..6f0ad3804 100644 |
| --- a/region_ha_boundary_test.go |
| +++ b/region_ha_boundary_test.go |
| @@ -21,6 +21,7 @@ import ( |
| var regionHACoreFiles = []string{ |
| "internal/lbhealth/lbhealth.go", |
| "internal/hostactors/roles.go", |
| + "internal/boxchoice/policy.go", |
| } |
| // regionHAAdapterFiles are spec 2's AWS adapters. Each is named for AWS so |