| index 7ce08214e..07758c854 100644 |
| --- a/internal/host/agent_test.go |
| +++ b/internal/host/agent_test.go |
| @@ -113,7 +113,7 @@ func TestGenPollPassFirstSightIsSilent(t *testing.T) { |
| ctx, s := genKit(t) |
| touch(t, ctx, s, "bab", "eleven") |
| routes := []registry.Route{execRoute("a.example.com", "bab", "eleven")} |
| - cache := map[[2]string]string{} |
| + cache := newGenCache() |
| |
| execChanged, unitChanged, errs := genPollPass(ctx, routes, s, cache) |
| if execChanged || unitChanged { |
| @@ -123,7 +123,7 @@ func TestGenPollPassFirstSightIsSilent(t *testing.T) { |
| t.Fatalf("unexpected read errors: %v", errs) |
| } |
| want := genEtag(t, ctx, s, "bab", "eleven") |
| - if got := cache[[2]string{"bab", "eleven"}]; got != want { |
| + if got := cache.etags[[2]string{"bab", "eleven"}]; got != want { |
| t.Fatalf("cache etag = %q, want the store's %q", got, want) |
| } |
| } |
| @@ -132,7 +132,7 @@ func TestGenPollPassUnchangedIsSilent(t *testing.T) { |
| ctx, s := genKit(t) |
| touch(t, ctx, s, "bab", "eleven") |
| routes := []registry.Route{execRoute("a.example.com", "bab", "eleven")} |
| - cache := map[[2]string]string{} |
| + cache := newGenCache() |
| |
| genPollPass(ctx, routes, s, cache) |
| execChanged, unitChanged, errs := genPollPass(ctx, routes, s, cache) |
| @@ -148,7 +148,7 @@ func TestGenPollPassTouchedGenerationTriggersSync(t *testing.T) { |
| ctx, s := genKit(t) |
| touch(t, ctx, s, "bab", "eleven") |
| routes := []registry.Route{execRoute("a.example.com", "bab", "eleven")} |
| - cache := map[[2]string]string{} |
| + cache := newGenCache() |
| genPollPass(ctx, routes, s, cache) |
| |
| touch(t, ctx, s, "bab", "eleven") |
| @@ -163,7 +163,7 @@ func TestGenPollPassTouchedGenerationTriggersSync(t *testing.T) { |
| t.Fatalf("unexpected read errors: %v", errs) |
| } |
| want := genEtag(t, ctx, s, "bab", "eleven") |
| - if got := cache[[2]string{"bab", "eleven"}]; got != want { |
| + if got := cache.etags[[2]string{"bab", "eleven"}]; got != want { |
| t.Fatalf("cache etag = %q, want the new %q (a stale cache would re-sync forever)", got, want) |
| } |
| if execChanged, unitChanged, _ := genPollPass(ctx, routes, s, cache); execChanged || unitChanged { |
| @@ -176,7 +176,7 @@ func TestGenPollPassAbsentGenerationStaysSilent(t *testing.T) { |
| // No touch at all: an app last written by a pre-generation CLI. Legal |
| // forever — the hourly configSyncLoop is its floor. |
| routes := []registry.Route{execRoute("a.example.com", "bab", "eleven")} |
| - cache := map[[2]string]string{} |
| + cache := newGenCache() |
| |
| for i := range 2 { |
| execChanged, unitChanged, errs := genPollPass(ctx, routes, s, cache) |
| @@ -187,7 +187,7 @@ func TestGenPollPassAbsentGenerationStaysSilent(t *testing.T) { |
| t.Fatalf("pass %d: absent is not a read error: %v", i, errs) |
| } |
| } |
| - if got := cache[[2]string{"bab", "eleven"}]; got != genAbsent { |
| + if got := cache.etags[[2]string{"bab", "eleven"}]; got != genAbsent { |
| t.Fatalf("cache = %q, want the known-absent sentinel %q", got, genAbsent) |
| } |
| } |
| @@ -195,7 +195,7 @@ func TestGenPollPassAbsentGenerationStaysSilent(t *testing.T) { |
| func TestGenPollPassAbsentToPresentTriggersSync(t *testing.T) { |
| ctx, s := genKit(t) |
| routes := []registry.Route{execRoute("a.example.com", "bab", "eleven")} |
| - cache := map[[2]string]string{} |
| + cache := newGenCache() |
| genPollPass(ctx, routes, s, cache) // records known-absent |
| |
| touch(t, ctx, s, "bab", "eleven") // the app's first ever config write |
| @@ -216,7 +216,7 @@ func TestGenPollPassOneChangedAppSyncsOnce(t *testing.T) { |
| execRoute("a.example.com", "bab", "eleven"), |
| execRoute("b.example.com", "bab", "titogo"), |
| } |
| - cache := map[[2]string]string{} |
| + cache := newGenCache() |
| genPollPass(ctx, routes, s, cache) |
| |
| touch(t, ctx, s, "bab", "titogo") |
| @@ -230,7 +230,7 @@ func TestGenPollPassOneChangedAppSyncsOnce(t *testing.T) { |
| // One flag for the whole pass: the caller runs ONE SyncExec, which |
| // resolves every app itself. The unchanged app's cache entry must be |
| // carried forward untouched so it doesn't re-fire next tick. |
| - if got, want := cache[[2]string{"bab", "eleven"}], genEtag(t, ctx, s, "bab", "eleven"); got != want { |
| + if got, want := cache.etags[[2]string{"bab", "eleven"}], genEtag(t, ctx, s, "bab", "eleven"); got != want { |
| t.Fatalf("unchanged app's cache = %q, want %q", got, want) |
| } |
| if execChanged, _, _ := genPollPass(ctx, routes, s, cache); execChanged { |
| @@ -247,21 +247,37 @@ func TestGenPollPassDropsVanishedApps(t *testing.T) { |
| execRoute("b.example.com", "bab", "titogo"), |
| } |
| one := both[:1] |
| - cache := map[[2]string]string{} |
| + cache := newGenCache() |
| genPollPass(ctx, both, s, cache) |
| |
| genPollPass(ctx, one, s, cache) // titogo's route is gone |
| - if _, ok := cache[[2]string{"bab", "titogo"}]; ok { |
| + if _, ok := cache.etags[[2]string{"bab", "titogo"}]; ok { |
| t.Fatal("a route that left the registry must leave no cache entry behind") |
| } |
| // The proof that dropping matters: while the app was unrouted its |
| - // config moved. Re-adding the route must record silently (first sight |
| - // again), not fire a sync off a remembered etag — a route being |
| - // re-added already gets fresh config from the spawn path. |
| + // config moved. The ETAG half must record that silently (first sight |
| + // again) rather than comparing against a remembered value — a route |
| + // being re-added already gets fresh config from the spawn path. |
| + // |
| + // The tick itself is NOT silent, and that is the route-shape half |
| + // speaking, not the etag one (issue #196): a route entering or leaving |
| + // scope changes what belongs on disk — a file to write, or one to reap |
| + // — so it earns a pass. The pass is cheap and cannot cost a restart: |
| + // SyncExec bounces only a route whose rendered bytes actually changed. |
| + // What this test still pins directly is the cache entry above; the |
| + // etag half's silence is no longer separately observable from here, |
| + // since the shape movement covers it either way. |
| touch(t, ctx, s, "bab", "titogo") |
| + _, _, errs := genPollPass(ctx, both, s, cache) |
| + if len(errs) != 0 { |
| + t.Fatalf("unexpected read errors: %v", errs) |
| + } |
| + // Settled again: with the route set stable and no bucket movement, the |
| + // next tick must be silent. A remembered etag or a mis-stored digest |
| + // would still be firing here. |
| execChanged, unitChanged, errs := genPollPass(ctx, both, s, cache) |
| if execChanged || unitChanged { |
| - t.Fatal("a re-added app is a first sight, not a change") |
| + t.Fatal("a settled tick after a re-add must be silent") |
| } |
| if len(errs) != 0 { |
| t.Fatalf("unexpected read errors: %v", errs) |
| @@ -276,9 +292,9 @@ func TestGenPollPassReadErrorLeavesCacheAndKeepsGoing(t *testing.T) { |
| execRoute("a.example.com", "bab", "eleven"), |
| execRoute("b.example.com", "bab", "titogo"), |
| } |
| - cache := map[[2]string]string{} |
| + cache := newGenCache() |
| genPollPass(ctx, routes, dir, cache) |
| - elevenEtag := cache[[2]string{"bab", "eleven"}] |
| + elevenEtag := cache.etags[[2]string{"bab", "eleven"}] |
| |
| // eleven's generation object goes unreadable; titogo's moves. |
| boom := errors.New("bucket said no") |
| @@ -295,7 +311,7 @@ func TestGenPollPassReadErrorLeavesCacheAndKeepsGoing(t *testing.T) { |
| if !errors.Is(errs[0], boom) { |
| t.Fatalf("read error = %v, want it to wrap %v", errs[0], boom) |
| } |
| - if got := cache[[2]string{"bab", "eleven"}]; got != elevenEtag { |
| + if got := cache.etags[[2]string{"bab", "eleven"}]; got != elevenEtag { |
| t.Fatalf("unreadable app's cache = %q, want it untouched at %q (a cleared entry would re-fire on recovery)", got, elevenEtag) |
| } |
| } |
| @@ -304,7 +320,7 @@ func TestGenPollPassUnitFirstSightIsSilent(t *testing.T) { |
| ctx, s := genKit(t) |
| touch(t, ctx, s, "bab", "unitapp") |
| routes := []registry.Route{unitRoute("u.example.com", "bab", "unitapp")} |
| - cache := map[[2]string]string{} |
| + cache := newGenCache() |
| |
| execChanged, unitChanged, errs := genPollPass(ctx, routes, s, cache) |
| if execChanged || unitChanged { |
| @@ -314,7 +330,7 @@ func TestGenPollPassUnitFirstSightIsSilent(t *testing.T) { |
| t.Fatalf("unexpected read errors: %v", errs) |
| } |
| want := genEtag(t, ctx, s, "bab", "unitapp") |
| - if got := cache[[2]string{"bab", "unitapp"}]; got != want { |
| + if got := cache.etags[[2]string{"bab", "unitapp"}]; got != want { |
| t.Fatalf("cache etag = %q, want the store's %q", got, want) |
| } |
| } |
| @@ -327,7 +343,7 @@ func TestGenPollPassUnitMovementRequestsRootPassOnly(t *testing.T) { |
| unitRoute("u.example.com", "bab", "unitapp"), |
| execRoute("a.example.com", "bab", "eleven"), |
| } |
| - cache := map[[2]string]string{} |
| + cache := newGenCache() |
| genPollPass(ctx, routes, s, cache) |
| |
| touch(t, ctx, s, "bab", "unitapp") |
| @@ -357,7 +373,7 @@ func TestGenPollPassBothShapesSetBothHalvesOnOneRead(t *testing.T) { |
| unitRoute("mixed-unit.example.com", "zed", "mixed"), |
| } |
| s := &countingStore{Store: dir} |
| - cache := map[[2]string]string{} |
| + cache := newGenCache() |
| genPollPass(ctx, routes, s, cache) |
| |
| touch(t, ctx, dir, "zed", "mixed") |
| @@ -381,7 +397,7 @@ func TestGenPollPassPrunesUnitAppsToo(t *testing.T) { |
| unitRoute("u.example.com", "bab", "unitapp"), |
| } |
| one := both[:1] |
| - cache := map[[2]string]string{} |
| + cache := newGenCache() |
| genPollPass(ctx, both, s, cache) |
| |
| // The live set the cache is pruned against is the UNION: a unit app whose |
| @@ -389,13 +405,21 @@ func TestGenPollPassPrunesUnitAppsToo(t *testing.T) { |
| // remembered etag would fire a bogus root-pass request when the route |
| // comes back (adoption already delivers fresh config on the way in). |
| genPollPass(ctx, one, s, cache) |
| - if _, ok := cache[[2]string{"bab", "unitapp"}]; ok { |
| + if _, ok := cache.etags[[2]string{"bab", "unitapp"}]; ok { |
| t.Fatal("a unit-backed route that left the registry must leave no cache entry behind") |
| } |
| + // As in TestGenPollPassDropsVanishedApps: the re-add tick itself moves |
| + // the route-shape digest (issue #196), so the silence worth asserting |
| + // is the SETTLED tick after it. The dropped cache entry above is what |
| + // pins the etag half directly. |
| touch(t, ctx, s, "bab", "unitapp") |
| + _, _, errs := genPollPass(ctx, both, s, cache) |
| + if len(errs) != 0 { |
| + t.Fatalf("unexpected read errors: %v", errs) |
| + } |
| execChanged, unitChanged, errs := genPollPass(ctx, both, s, cache) |
| if execChanged || unitChanged { |
| - t.Fatal("a re-added unit-backed app is a first sight, not a change") |
| + t.Fatal("a settled tick after a unit-backed re-add must be silent") |
| } |
| if len(errs) != 0 { |
| t.Fatalf("unexpected read errors: %v", errs) |
| @@ -1666,3 +1690,103 @@ func TestStampedUnitNamesEmptyRegistry(t *testing.T) { |
| t.Fatalf("stampedUnitNames on an empty registry = %v, want nil", got) |
| } |
| } |
| + |
| +// --- issue #196: the registry side of "did anything change" --- |
| + |
| +// boundExecRoute is execRoute wired to a named config environment — the |
| +// binding whose whole point is that it changes which bundle merges into |
| +// the route's delivered config. |
| +func boundExecRoute(host, acct, app, env string) registry.Route { |
| + rt := execRoute(host, acct, app) |
| + rt.Environment = env |
| + return rt |
| +} |
| + |
| +// TestGenPollPassSyncsOnRouteRebinding is issue #196: binding a route to an |
| +// environment must converge from the FAST loop, not an hour later on |
| +// configSyncLoop's floor. The bucket does not move at all here — only the |
| +// registry column does, which is exactly why the etag poll could not see |
| +// it and titogo lost an hour to a symptom reading as a bad credential. |
| +func TestGenPollPassSyncsOnRouteRebinding(t *testing.T) { |
| + ctx, s := genKit(t) |
| + touch(t, ctx, s, "bab", "titogo") |
| + cache := newGenCache() |
| + genPollPass(ctx, []registry.Route{execRoute("connect.go.tito.io", "bab", "titogo")}, s, cache) |
| + |
| + rebound := []registry.Route{boundExecRoute("connect.go.tito.io", "bab", "titogo", "relay")} |
| + execChanged, unitChanged, errs := genPollPass(ctx, rebound, s, cache) |
| + if len(errs) != 0 { |
| + t.Fatalf("unexpected read errors: %v", errs) |
| + } |
| + if !execChanged { |
| + t.Fatal("binding a route to an environment must make the exec half sync") |
| + } |
| + if unitChanged { |
| + t.Fatal("an exec route's rebinding must not ask for a root config pass") |
| + } |
| + // And the pass after it is quiet again: a digest that re-fired every |
| + // tick would run SyncExec forever. |
| + if execChanged, _, _ := genPollPass(ctx, rebound, s, cache); execChanged { |
| + t.Fatal("the pass after a rebinding must be silent again (a stale digest would re-sync forever)") |
| + } |
| +} |
| + |
| +// TestGenPollPassSyncsOnUnitRouteRebinding: the unit half converges per |
| +// environment too (appconfig.Sync's group is (acct, app, environment)), so |
| +// a unit-backed route's rebinding must ask for the root pass — and must |
| +// not make the agent render anything itself. |
| +func TestGenPollPassSyncsOnUnitRouteRebinding(t *testing.T) { |
| + ctx, s := genKit(t) |
| + touch(t, ctx, s, "bae", "keymail") |
| + cache := newGenCache() |
| + genPollPass(ctx, []registry.Route{unitRoute("keymail.dev", "bae", "keymail")}, s, cache) |
| + |
| + rebound := []registry.Route{unitRoute("keymail.dev", "bae", "keymail")} |
| + rebound[0].Environment = "staging" |
| + execChanged, unitChanged, errs := genPollPass(ctx, rebound, s, cache) |
| + if len(errs) != 0 { |
| + t.Fatalf("unexpected read errors: %v", errs) |
| + } |
| + if !unitChanged { |
| + t.Fatal("binding a unit-backed route to an environment must request a root config pass") |
| + } |
| + if execChanged { |
| + t.Fatal("a unit route's rebinding must not run a SyncExec pass") |
| + } |
| +} |
| + |
| +// TestGenPollPassFirstSightOfRouteShapeIsSilent: a box coming up has never |
| +// seen the digest before, and must not treat that as a change — every |
| +// instance on the box would sync at once on boot. configSyncLoop's ~1min |
| +// first pass owns boot convergence, exactly as it does for etags. |
| +func TestGenPollPassFirstSightOfRouteShapeIsSilent(t *testing.T) { |
| + ctx, s := genKit(t) |
| + touch(t, ctx, s, "bab", "titogo") |
| + routes := []registry.Route{boundExecRoute("connect.go.tito.io", "bab", "titogo", "relay")} |
| + execChanged, unitChanged, errs := genPollPass(ctx, routes, s, newGenCache()) |
| + if len(errs) != 0 { |
| + t.Fatalf("unexpected read errors: %v", errs) |
| + } |
| + if execChanged || unitChanged { |
| + t.Fatal("the first pass must record the route shape silently") |
| + } |
| +} |
| + |
| +// TestGenPollPassRouteShapeCostsNoBucketReads: the registry half must not |
| +// add a single fetch to the tick — the whole economy of this loop is that |
| +// it reads generation objects and nothing else. |
| +func TestGenPollPassRouteShapeCostsNoBucketReads(t *testing.T) { |
| + ctx, _ := genKit(t) |
| + cs := &countingStore{Store: release.NewDirStore(t.TempDir())} |
| + touch(t, ctx, cs, "bab", "titogo") |
| + routes := []registry.Route{execRoute("connect.go.tito.io", "bab", "titogo")} |
| + cache := newGenCache() |
| + genPollPass(ctx, routes, cs, cache) |
| + key := appconfig.GenKey("bab", "titogo") |
| + before := cs.gets[key] |
| + rebound := []registry.Route{boundExecRoute("connect.go.tito.io", "bab", "titogo", "relay")} |
| + genPollPass(ctx, rebound, cs, cache) |
| + if got := cs.gets[key] - before; got != 1 { |
| + t.Fatalf("the rebinding tick made %d bucket reads, want exactly 1 (the app's generation object, as before)", got) |
| + } |
| +} |