carlos / platform Public

region HA 2a: edges files carry total memory and disk

Also pins that a quiet region HA box's timed pass rewrites an unchanged
body (so its Generation stays inside MaxEdgesAge) and that a box without
region HA never does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 3d35bbf404d5ac297c349b96384f6926ce51bf97 parent e1ca43c
5 files changed, +110 −2
  • internal/host/edgesfile.go +5 −0
  • internal/host/edgesfile_test.go +34 −0
  • internal/host/headroom.go +17 −1
  • internal/host/headroom_test.go +52 −1
  • internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv +2 −0
diff --git a/internal/host/edgesfile.go b/internal/host/edgesfile.go
index bc848fa75..b0112d9f1 100644
--- a/internal/host/edgesfile.go
+++ b/internal/host/edgesfile.go
@@ -151,6 +151,11 @@ type edgeHeadroom struct {
MemFreeMiB int64 `json:"mem_free_mib"`
DiskFreeMiB int64 `json:"disk_free_mib"`
Awake int `json:"awake"`
+ // MemTotalMiB and DiskTotalMiB are what the free figures are shares
+ // of (region HA spec 2 §7). Additive and omitted when 0, a reading
+ // that failed: a reader takes absent and 0 alike, as unknown.
+ MemTotalMiB int64 `json:"mem_total_mib,omitempty"`
+ DiskTotalMiB int64 `json:"disk_total_mib,omitempty"`
}
const capRegionHA1 = "region-ha-1"
diff --git a/internal/host/edgesfile_test.go b/internal/host/edgesfile_test.go
index 8e771b148..ca4a00771 100644
--- a/internal/host/edgesfile_test.go
+++ b/internal/host/edgesfile_test.go
@@ -1547,3 +1547,37 @@ func TestEdgesFilePublishesMovesProtocolFloorAndPrev(t *testing.T) {
t.Fatalf("no verdict, yet published %s", body)
}
}
+
+// Region HA spec 2 §7 reads a box's edges file as stale after 15 minutes
+// (boxchoice.MaxEdgesAge), and the gate clears Generation, so a quiet box
+// stays placeable only if its timed pass rewrites an UNCHANGED body. With
+// headroom published (region HA on) it does, every timed pass (at most six
+// minutes apart); an untimed pass with nothing changed does not; and with
+// region HA off (no headroom) nothing is ever rewritten.
+func TestEdgesFileQuietBoxStaysFreshOnTimedPassesOnlyWithRegionHA(t *testing.T) {
+ ctx, reg := context.Background(), edgesTestRegistry(t)
+ seedRoute(t, reg, "app.example.com")
+ run := func(ident edgesFile) (untimedPuts, timedPuts int) {
+ st := &putCountingStore{Store: release.NewDirStore(t.TempDir())}
+ var last []byte
+ if err := edgesFileWrite(ctx, st, reg, ident, "1.2.3.4", &last, false); err != nil {
+ t.Fatal(err)
+ }
+ if err := edgesFileWrite(ctx, st, reg, ident, "1.2.3.4", &last, false); err != nil {
+ t.Fatal(err)
+ }
+ untimedPuts = st.puts
+ if err := edgesFileWrite(ctx, st, reg, ident, "1.2.3.4", &last, true); err != nil {
+ t.Fatal(err)
+ }
+ return untimedPuts, st.puts
+ }
+ on := edgesFile{Box: "annie", Capabilities: []string{capRegionHA1},
+ Headroom: &edgeHeadroom{MemFreeMiB: 100, DiskFreeMiB: 100, Awake: 1, MemTotalMiB: 1000, DiskTotalMiB: 1000}}
+ if untimed, timed := run(on); untimed != 1 || timed != 2 {
+ t.Fatalf("region HA on: puts = %d untimed, %d after a timed pass; want 1, 2", untimed, timed)
+ }
+ if untimed, timed := run(edgesFile{Box: "annie"}); untimed != 1 || timed != 1 {
+ t.Fatalf("region HA off: puts = %d untimed, %d after a timed pass; want 1, 1", untimed, timed)
+ }
+}
diff --git a/internal/host/headroom.go b/internal/host/headroom.go
index 756bbf914..405d3b912 100644
--- a/internal/host/headroom.go
+++ b/internal/host/headroom.go
@@ -14,7 +14,23 @@ func headroomFor(caps []string, dataDir string, reg *registry.Registry) *edgeHea
if len(caps) == 0 {
return nil
}
- return readHeadroom(dataDir, awakeCount(reg))
+ h := readHeadroom(dataDir, awakeCount(reg))
+ h.MemTotalMiB, h.DiskTotalMiB = capacityMiB(readMemBytes, func() (uint64, uint64, error) { return diskBytes(dataDir) })
+ return h
+}
+
+// capacityMiB is the box's total memory and its data volume's size, in
+// MiB: the totals region HA spec 2's placement policy judges the free
+// figures against (§7). A reading that fails is 0, which the policy reads
+// as a box it cannot judge, and so does not place on.
+func capacityMiB(mem func() (total, avail *int64), disk func() (total, avail uint64, err error)) (memMiB, diskMiB int64) {
+ if total, _ := mem(); total != nil && *total > 0 {
+ memMiB = *total / (1 << 20)
+ }
+ if total, _, err := disk(); err == nil {
+ diskMiB = int64(total / (1 << 20))
+ }
+ return memMiB, diskMiB
}
// parseMemAvailableMiB returns the MemAvailable line in MiB, or 0.
diff --git a/internal/host/headroom_test.go b/internal/host/headroom_test.go
index 5292dc0ba..738c42155 100644
--- a/internal/host/headroom_test.go
+++ b/internal/host/headroom_test.go
@@ -1,6 +1,13 @@
package host
-import "testing"
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/release"
+)
func TestParseMemAvailable(t *testing.T) {
const meminfo = "MemTotal: 16000000 kB\nMemFree: 500000 kB\nMemAvailable: 2263040 kB\n"
@@ -24,3 +31,47 @@ func TestHeadroomOnlyPublishedWithTheCapability(t *testing.T) {
t.Fatal("flag on: headroom is nil")
}
}
+
+// Region HA spec 2 §7: the totals ride beside the free figures, and a
+// failed reading is 0, never a guess.
+func TestCapacityMiBReadsTotalsAndZeroesFailures(t *testing.T) {
+ gib := int64(8 << 30)
+ mem := func() (*int64, *int64) { return &gib, nil }
+ disk := func() (uint64, uint64, error) { return 100 << 30, 1 << 30, nil }
+ if m, d := capacityMiB(mem, disk); m != 8192 || d != 102400 {
+ t.Fatalf("capacity = %d, %d; want 8192, 102400", m, d)
+ }
+ noMem := func() (*int64, *int64) { return nil, nil }
+ noDisk := func() (uint64, uint64, error) { return 0, 0, errors.New("statfs") }
+ if m, d := capacityMiB(noMem, noDisk); m != 0 || d != 0 {
+ t.Fatalf("failed readings = %d, %d; want 0, 0", m, d)
+ }
+}
+
+func TestHeadroomCarriesTheDataVolumeSize(t *testing.T) {
+ h := headroomFor([]string{capRegionHA1}, t.TempDir(), edgesTestRegistry(t))
+ if h == nil || h.DiskTotalMiB <= 0 {
+ t.Fatalf("headroom %+v: want the data volume's size", h)
+ }
+}
+
+func TestEdgesFileWireCarriesHeadroomTotals(t *testing.T) {
+ s, reg := release.NewDirStore(t.TempDir()), edgesTestRegistry(t)
+ var last []byte
+ ident := edgesFile{Box: "annie", BoxID: "i-0abc", Capabilities: []string{"region-ha-1"},
+ Headroom: &edgeHeadroom{MemFreeMiB: 2210, DiskFreeMiB: 31200, Awake: 14, MemTotalMiB: 7900, DiskTotalMiB: 61000}}
+ if err := edgesFilePass(context.Background(), s, reg, ident, "1.2.3.4", &last); err != nil {
+ t.Fatal(err)
+ }
+ body, _, err := s.Get(context.Background(), "control/edges/annie.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var raw map[string]json.RawMessage
+ if err := json.Unmarshal(body, &raw); err != nil {
+ t.Fatal(err)
+ }
+ if want := `{"mem_free_mib":2210,"disk_free_mib":31200,"awake":14,"mem_total_mib":7900,"disk_total_mib":61000}`; string(raw["headroom"]) != want {
+ t.Fatalf("headroom = %s, want %s", raw["headroom"], want)
+ }
+}
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 7edff1385..f9d0fe7f9 100644
--- a/internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv
+++ b/internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv
@@ -2,7 +2,9 @@
# 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.
+# Task 5: the edges file's headroom carries the box's total memory and data volume size (headroomFor and capacityMiB, internal/host/headroom.go; edgeHeadroom's two additive fields). headroomFor calls the existing readMemBytes and diskBytes and adds no read of its own. Envelope-only: readHeadroom and readMemBytes are unchanged, 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
+@coverage internal/host ac55f3fa73d0e0c6d5979e446b2accdd2863b06a93c54d2006f6b547f840abd0