carlos / platform Public

edge asset cache task 9: every edge parses assets; AssetsFor

The home map reads the per-host assets block, carries it whole with the
dial winner through foldHomes, and answers AssetsFor(host, now): false
until the first successful hosts read, exact hosts only, owner and block
present, at within an hour. TestParseKeepsAssets pins the edgesfile reader
to the writer's golden entry.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev effc9cfc84d78c55d4fec55da3bbd017e424e24d parent 41b8dd1
3 files changed, +352 −3
  • internal/edge/homemap.go +70 −2
  • internal/edge/homemap_assets_test.go +246 −0
  • internal/edgesfile/edgesfile_test.go +36 −1
diff --git a/internal/edge/homemap.go b/internal/edge/homemap.go
index da513e36c..a987ccc4b 100644
--- a/internal/edge/homemap.go
+++ b/internal/edge/homemap.go
@@ -195,6 +195,19 @@ type edgesHostView struct {
App string `json:"app,omitempty"`
// Stamp orders two boxes' claims on one host: see foldHomes.
Stamp int64 `json:"stamp,omitempty"`
+ // Assets is the host's asset-cache block (edge asset cache design §4.1),
+ // in the shape internal/host writes and internal/edgesfile reads, pinned
+ // by edgesfile/testdata/assets-host.golden.json. Absent on a file from an
+ // unarmed box or a publisher that predates it, which reads as no block.
+ Assets *edgesAssetsView `json:"assets,omitempty"`
+}
+
+// edgesAssetsView is the wire shape of the assets block: path prefixes, the
+// instance's asset generation, and the host's authority time in Unix seconds.
+type edgesAssetsView struct {
+ Paths []string `json:"paths"`
+ Gen string `json:"gen"`
+ At int64 `json:"at"`
}
// homeEntry is one folded answer: where to dial, and which box that is.
@@ -217,6 +230,11 @@ type homeEntry struct {
account, app string
// stamp is the dial winner's installation stamp, 0 when unstamped.
stamp int64
+ // assets is the dial winner's asset block, nil when it published none.
+ // Like account and app it travels whole with the winner and is never
+ // merged across entries; it points into the parsed file and is never
+ // written after the fold.
+ assets *edgesAssetsView
}
// homeSnapshot is one poll's result, swapped in whole and immutable once
@@ -229,6 +247,9 @@ type homeSnapshot struct {
files map[string]edgesFileView
// hosts is the fold: host → home, own box already excluded.
hosts map[string]homeEntry
+ // read is set once the snapshot comes from a successful hosts refresh:
+ // the empty snapshot a restart starts from is not an answer (AssetsFor).
+ read bool
}
var emptyHomeSnapshot = &homeSnapshot{files: map[string]edgesFileView{}, hosts: map[string]homeEntry{}}
@@ -488,7 +509,7 @@ func (m *homeMap) refreshHosts(ctx context.Context) error {
// deterministic; say so once a refresh.
log.Printf("edge: home map: %d host(s) published by two boxes at the same installation stamp, e.g. %s", len(tied), tied[0])
}
- snap := &homeSnapshot{files: next, hosts: hosts}
+ snap := &homeSnapshot{files: next, hosts: hosts, read: true}
m.mu.Lock()
m.snap = snap
wake := m.hostsWake
@@ -749,7 +770,7 @@ func foldHomes(files map[string]edgesFileView, self string, privateDial bool) (m
continue
}
in := homeEntry{eip: addr, box: f.Box, platform: h.Platform && !h.Alias && !h.Wildcard, alias: h.Alias, wildcard: h.Wildcard, dial: h.Role == edgeRoleHome,
- account: h.Account, app: h.App, stamp: h.Stamp}
+ account: h.Account, app: h.App, stamp: h.Stamp, assets: h.Assets}
prev, dup := hosts[host]
if !dup {
hosts[host] = in
@@ -770,6 +791,7 @@ func foldHomes(files map[string]edgesFileView, self string, privateDial bool) (m
if dialBeats(in, prev) {
merged.eip, merged.box, merged.alias, merged.dial = in.eip, in.box, in.alias, true
merged.account, merged.app, merged.stamp = in.account, in.app, in.stamp
+ merged.assets = in.assets
}
hosts[host] = merged
}
@@ -926,6 +948,52 @@ func (m *homeMap) Owner(host string) (account, app, eip, box string, ok bool) {
return h.account, h.app, h.eip, h.box, true
}
+// assetsFreshFor is how old a host's authority time may be and still be
+// served against (design §4.2). A constant, not a knob.
+const assetsFreshFor = time.Hour
+
+// AssetsFor answers the asset-cache declaration another box published for
+// host, for the far-edge cache (plan 3) to consult before it serves or fills
+// anything. It is the winning home entry's block, from one snapshot read, and
+// it answers only when every condition of design §4.2 and §7 holds:
+//
+// - the home map has completed one successful hosts read since this process
+// started, so an entry is never served against a generation or owner the
+// edge has not checked;
+// - host is an EXACT key. Unlike Home there is no one-label wildcard
+// fallback: a catch-all key never carries a declaration, and an invented
+// subdomain must not borrow one;
+// - the entry is a dial target that published an owner (account and app)
+// and a block;
+// - now is within an hour of the block's at. A file that cannot be read
+// keeps its previous parse (refreshHosts), and that parse's at ages out
+// like any other.
+//
+// This box's own hosts are not in the fold; the host package's LocalAssets
+// answers for those. A nil map, or one with no self label, answers nothing,
+// as Home does. The returned Paths are the caller's to keep.
+func (m *homeMap) AssetsFor(host string, now time.Time) (HostAssets, bool) {
+ if m == nil || m.self == "" {
+ return HostAssets{}, false
+ }
+ snap := m.snapshot()
+ if !snap.read {
+ return HostAssets{}, false
+ }
+ h, ok := snap.hosts[host]
+ if !ok || !h.dial || h.account == "" || h.app == "" || h.assets == nil {
+ return HostAssets{}, false
+ }
+ at := time.Unix(h.assets.At, 0)
+ if now.Sub(at) > assetsFreshFor {
+ return HostAssets{}, false
+ }
+ return HostAssets{
+ Account: h.account, App: h.app,
+ Paths: slices.Clone(h.assets.Paths), Gen: h.assets.Gen, At: at,
+ }, true
+}
+
// KnownHost reports whether host is an EXACT key in the mirrored home map — a
// name some OTHER box in this deployment homes, spelled precisely.
//
diff --git a/internal/edge/homemap_assets_test.go b/internal/edge/homemap_assets_test.go
new file mode 100644
index 000000000..f98f2726e
--- /dev/null
+++ b/internal/edge/homemap_assets_test.go
@@ -0,0 +1,246 @@
+package edge
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "slices"
+ "testing"
+ "time"
+)
+
+// assetsGolden is the host entry the real edges-file writer produced, read
+// from the file internal/host's TestPublishesAssetsForAnArmedHome asserts
+// against. The home map's reader is tested on it, not on a hand copy.
+func assetsGolden(t *testing.T) string {
+ t.Helper()
+ b, err := os.ReadFile(filepath.Join("..", "edgesfile", "testdata", "assets-host.golden.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ return string(b)
+}
+
+// goldenAt is the golden entry's "at".
+var goldenAt = time.Unix(1792832400, 0)
+
+// putAssetsFile lands one box's file whose only host is host with the given
+// entry body.
+func putAssetsFile(t *testing.T, m *homeMap, box, eip, host, entry string) {
+ t.Helper()
+ putJSON(t, m.s, "control/edges/"+box+".json", fmt.Sprintf(
+ `{"v":1,"box":%q,"eip":%q,"hosts":{%q:%s}}`, box, eip, host, entry))
+}
+
+func TestHomeMapParsesAssets(t *testing.T) {
+ m := newHomeMap(meshTestStore(t), "annie")
+ putAssetsFile(t, m, "betty", "2.2.2.2", "shop.example.com", assetsGolden(t))
+ refreshHomeMap(t, m)
+
+ got, ok := m.AssetsFor("shop.example.com", goldenAt.Add(time.Minute))
+ if !ok {
+ t.Fatal("AssetsFor = false for a fresh published block")
+ }
+ want := HostAssets{Account: "abc", App: "shop", Paths: []string{"/_app/immutable/", "/assets/"}, Gen: "0123456789abcdef", At: goldenAt}
+ if got.Account != want.Account || got.App != want.App || got.Gen != want.Gen ||
+ !got.At.Equal(want.At) || !slices.Equal(got.Paths, want.Paths) {
+ t.Fatalf("AssetsFor = %+v, want %+v", got, want)
+ }
+ // The answer is the caller's: scribbling on it leaves the map alone.
+ got.Paths[0] = "/scribbled/"
+ again, _ := m.AssetsFor("shop.example.com", goldenAt)
+ if again.Paths[0] != "/_app/immutable/" {
+ t.Fatalf("AssetsFor shares its path slice with the snapshot: %v", again.Paths)
+ }
+}
+
+func TestAssetsForFalseBeforeFirstRead(t *testing.T) {
+ inner := meshTestStore(t)
+ s := &listFailingStore{Store: inner}
+ m := newHomeMap(s, "annie")
+ putAssetsFile(t, m, "betty", "2.2.2.2", "shop.example.com", assetsGolden(t))
+ now := goldenAt.Add(time.Minute)
+
+ if _, ok := m.AssetsFor("shop.example.com", now); ok {
+ t.Fatal("answered before any read")
+ }
+ s.setFail(true)
+ if err := m.Refresh(context.Background()); err == nil {
+ t.Fatal("precondition: the failing list must be reported")
+ }
+ if _, ok := m.AssetsFor("shop.example.com", now); ok {
+ t.Fatal("answered after a hosts read that failed")
+ }
+ s.setFail(false)
+ refreshHomeMap(t, m)
+ if _, ok := m.AssetsFor("shop.example.com", now); !ok {
+ t.Fatal("no answer after the first successful read")
+ }
+ var nilMap *homeMap
+ if _, ok := nilMap.AssetsFor("shop.example.com", now); ok {
+ t.Fatal("a nil home map answered")
+ }
+}
+
+func TestAssetsForRefusesAStaleAt(t *testing.T) {
+ m := newHomeMap(meshTestStore(t), "annie")
+ putAssetsFile(t, m, "betty", "2.2.2.2", "shop.example.com", assetsGolden(t))
+ refreshHomeMap(t, m)
+ for _, tc := range []struct {
+ age time.Duration
+ want bool
+ }{
+ {0, true}, {59 * time.Minute, true}, {time.Hour, true},
+ {time.Hour + time.Second, false}, {61 * time.Minute, false}, {24 * time.Hour, false},
+ {-5 * time.Minute, true}, // a reader clock behind the publisher's is not stale
+ } {
+ if _, ok := m.AssetsFor("shop.example.com", goldenAt.Add(tc.age)); ok != tc.want {
+ t.Errorf("age %v: ok = %v, want %v", tc.age, ok, tc.want)
+ }
+ }
+}
+
+// An unreadable file keeps its previous parse (refreshHosts), and the kept
+// block does not outlive its time: the at it carries still ages out.
+func TestAssetsForKeepsAStaleParseStale(t *testing.T) {
+ inner := meshTestStore(t)
+ s := &oneKeyFailingStore{Store: inner}
+ m := newHomeMap(s, "annie")
+ putAssetsFile(t, m, "betty", "2.2.2.2", "shop.example.com", assetsGolden(t))
+ // A second readable file, so betty's is one bad object and not a bucket
+ // fault (refreshHosts returns an error when nothing at all can be read).
+ putAssetsFile(t, m, "carol", "3.3.3.3", "other.example.com", `{"role":"home"}`)
+ refreshHomeMap(t, m)
+
+ s.failKey("control/edges/betty.json")
+ refreshHomeMap(t, m)
+ if _, ok := m.AssetsFor("shop.example.com", goldenAt.Add(30*time.Minute)); !ok {
+ t.Fatal("an unreadable file must keep serving its last parse while it is fresh")
+ }
+ if _, ok := m.AssetsFor("shop.example.com", goldenAt.Add(61*time.Minute)); ok {
+ t.Fatal("a kept parse served past its hour")
+ }
+ // A corrupt body is the same class of fault.
+ putJSON(t, inner, "control/edges/betty.json", `{"v":1,"box":"betty",`)
+ s.failKey("")
+ refreshHomeMap(t, m)
+ if _, ok := m.AssetsFor("shop.example.com", goldenAt.Add(30*time.Minute)); !ok {
+ t.Fatal("a corrupt file must keep serving its last parse while it is fresh")
+ }
+ if _, ok := m.AssetsFor("shop.example.com", goldenAt.Add(61*time.Minute)); ok {
+ t.Fatal("a kept parse of a corrupt file served past its hour")
+ }
+}
+
+func TestAssetsForNeverMatchesAWildcard(t *testing.T) {
+ m := newHomeMap(meshTestStore(t), "annie")
+ // A catch-all key carrying a block (no writer does this today).
+ putJSON(t, m.s, "control/edges/betty.json", fmt.Sprintf(
+ `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"*.example.com":%s}}`, assetsGolden(t)))
+ refreshHomeMap(t, m)
+ now := goldenAt.Add(time.Minute)
+
+ if _, _, ok := m.Home("shop.example.com"); !ok {
+ t.Fatal("precondition: Home does match one label under the catch-all")
+ }
+ if _, ok := m.AssetsFor("shop.example.com", now); ok {
+ t.Fatal("AssetsFor matched a name through a wildcard key")
+ }
+ if _, ok := m.AssetsFor("a.b.example.com", now); ok {
+ t.Fatal("AssetsFor matched two labels through a wildcard key")
+ }
+}
+
+func TestAssetsForNeedsADialTargetAnOwnerAndASelf(t *testing.T) {
+ now := goldenAt.Add(time.Minute)
+ noOwner := `{"role":"home","assets":{"paths":["/assets/"],"gen":"0123456789abcdef","at":1792832400}}`
+ certOnly := `{"role":"wildcard","wildcard":true,"account":"abc","app":"shop","assets":{"paths":["/assets/"],"gen":"0123456789abcdef","at":1792832400}}`
+
+ m := newHomeMap(meshTestStore(t), "annie")
+ putAssetsFile(t, m, "betty", "2.2.2.2", "shop.example.com", noOwner)
+ refreshHomeMap(t, m)
+ if _, ok := m.AssetsFor("shop.example.com", now); ok {
+ t.Fatal("a block with no published owner was served")
+ }
+
+ m = newHomeMap(meshTestStore(t), "annie")
+ putAssetsFile(t, m, "betty", "2.2.2.2", "*.example.com", certOnly)
+ refreshHomeMap(t, m)
+ if _, ok := m.AssetsFor("*.example.com", now); ok {
+ t.Fatal("a cert-only entry is not a dial target and carries no assets")
+ }
+
+ // Own box: the fold excludes it, so the local path (LocalAssets) answers.
+ m = newHomeMap(meshTestStore(t), "annie")
+ putAssetsFile(t, m, "annie", "1.1.1.1", "shop.example.com", assetsGolden(t))
+ refreshHomeMap(t, m)
+ if _, ok := m.AssetsFor("shop.example.com", now); ok {
+ t.Fatal("the home map answered for this box's own host")
+ }
+
+ // No self label: fail closed, as Home does.
+ m = newHomeMap(meshTestStore(t), "")
+ putAssetsFile(t, m, "betty", "2.2.2.2", "shop.example.com", assetsGolden(t))
+ refreshHomeMap(t, m)
+ if _, ok := m.AssetsFor("shop.example.com", now); ok {
+ t.Fatal("a map with no self label answered")
+ }
+}
+
+// Two boxes claim a host. The dial winner's whole entry is the one: its
+// block, and no one else's, even when the loser's block is newer.
+func TestAssetsForFollowsTheWinningEntry(t *testing.T) {
+ const old = `{"role":"home","stamp":5,"account":"abc","app":"shop","assets":{"paths":["/old/"],"gen":"aaaaaaaaaaaaaaaa","at":1792832400}}`
+ const fresh = `{"role":"home","stamp":9,"account":"abc","app":"shop","assets":{"paths":["/new/"],"gen":"bbbbbbbbbbbbbbbb","at":1792832400}}`
+ const bare = `{"role":"home","stamp":99,"account":"abc","app":"shop"}`
+ now := goldenAt.Add(time.Minute)
+
+ for _, tc := range []struct {
+ name, annie, betty, wantGen string
+ wantOK bool
+ }{
+ {"higher stamp wins, from the later file", old, fresh, "bbbbbbbbbbbbbbbb", true},
+ {"higher stamp wins, from the earlier file", fresh, old, "bbbbbbbbbbbbbbbb", true},
+ {"winner without a block hides the loser's", old, bare, "", false},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ m := newHomeMap(meshTestStore(t), "carol")
+ putAssetsFile(t, m, "annie", "1.1.1.1", "shop.example.com", tc.annie)
+ putAssetsFile(t, m, "betty", "2.2.2.2", "shop.example.com", tc.betty)
+ refreshHomeMap(t, m)
+ got, ok := m.AssetsFor("shop.example.com", now)
+ if ok != tc.wantOK || got.Gen != tc.wantGen {
+ t.Fatalf("AssetsFor = %+v %v, want gen %q ok %v", got, ok, tc.wantGen, tc.wantOK)
+ }
+ })
+ }
+
+ // A route beats an alias whatever the order, and the block follows.
+ m := newHomeMap(meshTestStore(t), "carol")
+ putAssetsFile(t, m, "annie", "1.1.1.1", "shop.example.com",
+ `{"role":"home","alias":true,"account":"abc","app":"shop","assets":{"paths":["/alias/"],"gen":"cccccccccccccccc","at":1792832400}}`)
+ putAssetsFile(t, m, "betty", "2.2.2.2", "shop.example.com", fresh)
+ refreshHomeMap(t, m)
+ if got, ok := m.AssetsFor("shop.example.com", now); !ok || got.Gen != "bbbbbbbbbbbbbbbb" {
+ t.Fatalf("AssetsFor = %+v %v, want the route's block", got, ok)
+ }
+}
+
+// A file written before assets existed parses exactly as before and yields
+// no block; Home and Owner are unaffected.
+func TestOldFileWithoutAssetsParses(t *testing.T) {
+ m := newHomeMap(meshTestStore(t), "annie")
+ putAssetsFile(t, m, "betty", "2.2.2.2", "shop.example.com",
+ `{"role":"home","platform":true,"account":"abc","app":"shop","stamp":3}`)
+ refreshHomeMap(t, m)
+ if eip, box, ok := m.Home("shop.example.com"); !ok || eip != "2.2.2.2" || box != "betty" {
+ t.Fatalf("Home = %s %s %v", eip, box, ok)
+ }
+ if acct, app, _, _, ok := m.Owner("shop.example.com"); !ok || acct != "abc" || app != "shop" {
+ t.Fatalf("Owner = %s %s %v", acct, app, ok)
+ }
+ if _, ok := m.AssetsFor("shop.example.com", goldenAt); ok {
+ t.Fatal("a host with no block answered")
+ }
+}
diff --git a/internal/edgesfile/edgesfile_test.go b/internal/edgesfile/edgesfile_test.go
index 2504b0ed1..0da16edfa 100644
--- a/internal/edgesfile/edgesfile_test.go
+++ b/internal/edgesfile/edgesfile_test.go
@@ -1,6 +1,41 @@
package edgesfile
-import "testing"
+import (
+ "os"
+ "path/filepath"
+ "slices"
+ "testing"
+)
+
+// TestParseKeepsAssets reads the golden host entry the real writer produced
+// (internal/host TestPublishesAssetsForAnArmedHome pins the same bytes), so
+// the writer and this reader share one shape. A file without the block, which
+// is every file an unarmed box writes, parses with a nil Assets.
+func TestParseKeepsAssets(t *testing.T) {
+ golden, err := os.ReadFile(filepath.Join("testdata", "assets-host.golden.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ body := `{"v":1,"box":"annie","eip":"1.1.1.1","hosts":{"shop.example.com":` + string(golden) +
+ `,"plain.example.com":{"role":"home","platform":false}}}`
+ f, err := Parse([]byte(body))
+ if err != nil {
+ t.Fatalf("parse: %v", err)
+ }
+ a := f.Hosts["shop.example.com"].Assets
+ if a == nil {
+ t.Fatal("shop.example.com lost its assets block")
+ }
+ if want := []string{"/_app/immutable/", "/assets/"}; !slices.Equal(a.Paths, want) {
+ t.Fatalf("paths = %v, want %v", a.Paths, want)
+ }
+ if a.Gen != "0123456789abcdef" || a.At != 1792832400 {
+ t.Fatalf("assets = %+v", *a)
+ }
+ if p := f.Hosts["plain.example.com"].Assets; p != nil {
+ t.Fatalf("a host without the block parsed %+v", *p)
+ }
+}
func TestParseReadsTheWireShape(t *testing.T) {
f, err := Parse([]byte(`{"v":1,"box":"annie","eip":"1.1.1.1","region":"eu-west-1",` +