| index d06440caf..e98768f3f 100644 |
| --- a/internal/host/assetpublish_test.go |
| +++ b/internal/host/assetpublish_test.go |
| @@ -1477,16 +1477,31 @@ func TestAssetDecisionOverlappingAnAliasPassWithholds(t *testing.T) { |
| t.Fatal("still due after a settled pass") |
| } |
| |
| - // For and Publish: the same tear in the other direction, back to blog. |
| - // The old path set (shop's) with the new alias (to blog) would catch. |
| + // For answers from the last settled decision and reads nothing, so the |
| + // tear cannot reach it: the hook armed for the next read stays unfired. |
| + // Publish reads, overlaps the hook's pass (back to blog) and refuses. |
| b.tearTo("blog.example.com") |
| + fired := false |
| b.once(func() { |
| + fired = true |
| if err := aliasPassReporting(ctx, b.doms, b.reg, nil, b.auth); err != nil { |
| t.Error(err) |
| } |
| }) |
| - if got, ok := b.policy.For("www.example.com"); ok { |
| - t.Fatalf("For answered %+v from a torn read", got) |
| + if _, ok := b.policy.For("www.example.com"); ok { |
| + t.Fatal("For caught the alias of a primary with path rows") |
| + } |
| + if _, ok := b.policy.For("blog.example.com"); !ok { |
| + t.Fatal("For refused blog, which the last settled decision catches") |
| + } |
| + if fired { |
| + t.Fatal("For read the registry") |
| + } |
| + if got, ok := b.policy.Publish("www.example.com"); ok { |
| + t.Fatalf("Publish answered %+v from a torn read", got) |
| + } |
| + if !fired { |
| + t.Fatal("Publish did not reach the hook: the fixture tested nothing") |
| } |
| aliases, err := b.reg.ListAliases() |
| if err != nil { |
| @@ -1495,23 +1510,18 @@ func TestAssetDecisionOverlappingAnAliasPassWithholds(t *testing.T) { |
| if !slices.ContainsFunc(aliases, func(a registry.RouteAlias) bool { return a.Alias == "www.example.com" && a.Host == "blog.example.com" }) { |
| t.Fatalf("the hook's pass did not move the alias: %+v", aliases) |
| } |
| - b.tearTo("shop.example.com") |
| - b.once(func() { |
| - if err := aliasPassReporting(ctx, b.doms, b.reg, nil, b.auth); err != nil { |
| - t.Error(err) |
| - } |
| - }) |
| - if got, ok := b.policy.Publish("www.example.com"); ok { |
| - t.Fatalf("Publish answered %+v from a torn read", got) |
| - } |
| - // Settled again, the controls: blog has no path rows now, so blog |
| - // catches, and the alias (on shop, with path rows) does not. |
| + // The next settled writer pass replaces the decision: blog now has path |
| + // rows and shop has none. |
| b.policy.betweenReads = nil |
| - if _, ok := b.policy.For("blog.example.com"); !ok { |
| - t.Fatal("control: For refused blog on a settled read") |
| + b.write() |
| + if _, ok := b.policy.For("blog.example.com"); ok { |
| + t.Fatal("For still caught blog after the settled pass gave it path rows") |
| + } |
| + if _, ok := b.policy.For("shop.example.com"); !ok { |
| + t.Fatal("control: For refused shop on a settled decision") |
| } |
| if _, ok := b.policy.For("www.example.com"); ok { |
| - t.Fatal("For answered for the alias of a primary with path rows") |
| + t.Fatal("For caught the alias of a primary with path rows") |
| } |
| } |
| |
| @@ -1537,13 +1547,9 @@ func TestAssetDecisionOverlappingARecordsPassWithholds(t *testing.T) { |
| if got, ok := b.published("shop.example.com"); !ok || got.At != before.At { |
| t.Fatalf("an overlapped pass published %+v %v, want the last block at %d", got, ok, before.At) |
| } |
| - b.once(func() { |
| - if err := reconcileSerializedAuth(ctx, b.recs, b.reg, b.reconcileConfig(), nil, b.auth); err != nil { |
| - t.Error(err) |
| - } |
| - }) |
| - if _, ok := b.policy.For("shop.example.com"); ok { |
| - t.Fatal("For answered across a records pass") |
| + // For keeps answering from the last settled decision, with its at. |
| + if got, ok := b.policy.For("shop.example.com"); !ok || got.At.Unix() != before.At { |
| + t.Fatalf("For during the overlap = %+v %v, want the last settled block at %d", got, ok, before.At) |
| } |
| b.policy.betweenReads = nil |
| b.write() |
| @@ -1572,15 +1578,17 @@ func TestAssetDecisionWithholdsWhileAPassRuns(t *testing.T) { |
| w.confirm() |
| w.auth.passBegin() |
| wantAssets(t, w.hosts(), "shop.example.com", []string{"/assets/"}, assetGen1, assetT0) |
| - if _, ok := w.policy.For("shop.example.com"); ok { |
| - t.Fatal("For answered while a pass ran") |
| + // The home's own catch keeps the last settled decision, with its at |
| + // (design G4: a hit must not wake the instance because a pass runs). |
| + if got, ok := w.policy.For("shop.example.com"); !ok || !got.At.Equal(assetT0) { |
| + t.Fatalf("For while a pass ran = %+v %v, want the last settled block at %v", got, ok, assetT0) |
| + } |
| + if !w.policy.Declared("shop.example.com", "v1", "/assets/a.js") { |
| + t.Fatal("Declared refused while a pass ran") |
| } |
| if _, ok := w.policy.Publish("shop.example.com"); ok { |
| t.Fatal("Publish answered while a pass ran") |
| } |
| - if w.policy.Declared("shop.example.com", "v1", "/assets/a.js") { |
| - t.Fatal("Declared answered while a pass ran") |
| - } |
| if !w.policy.rewriteDue() { |
| t.Fatal("no retry asked for") |
| } |
| @@ -1600,5 +1608,147 @@ func TestAssetDecisionWithholdsWhileAPassRuns(t *testing.T) { |
| wantNoAssets(t, w2.hosts(), "shop.example.com") |
| } |
| |
| +// TestForAgesOutWhenPassesNeverSettle: a box stuck mid-pass keeps its last |
| +// settled decision, and For stops catching once that decision's at is over |
| +// an hour old. |
| +// |
| +// Not acceptance: the clocks are fed reports directly; the writer and the |
| +// reader are the production ones. |
| +func TestForAgesOutWhenPassesNeverSettle(t *testing.T) { |
| + t.Parallel() |
| + w := newAssetWorld(t) |
| + w.ship("v1", "/assets/") |
| + w.instance("shop.example.com", assetGen1, "v1") |
| + w.confirm() |
| + w.pass() |
| + w.auth.passBegin() // never ends |
| + for range 12 { |
| + w.clock.advance(5 * time.Minute) |
| + w.confirm() |
| + w.pass() |
| + if _, ok := w.policy.For("shop.example.com"); !ok { |
| + t.Fatalf("For refused at %v, inside the hour", w.clock.now()) |
| + } |
| + } |
| + w.clock.advance(time.Minute) |
| + w.pass() |
| + if got, ok := w.policy.For("shop.example.com"); ok { |
| + t.Fatalf("For answered %+v with a decision over an hour old", got) |
| + } |
| + if w.policy.Declared("shop.example.com", "v1", "/assets/a.js") != true { |
| + t.Fatal("Declared is For's companion, not its gate: the build's declaration still reads") |
| + } |
| +} |
| + |
| +// TestForMakesNoRegistryReadPerCall: once a decision exists, For and |
| +// Declared read nothing; the first call on a box whose writer has not run |
| +// yet computes one, and one that overlaps a pass is not kept. |
| +// |
| +// Not acceptance: the clocks are fed reports directly; the writer and the |
| +// reader are the production ones. |
| +func TestForMakesNoRegistryReadPerCall(t *testing.T) { |
| + t.Parallel() |
| + w := newAssetWorld(t) |
| + w.ship("v1", "/assets/") |
| + w.instance("shop.example.com", assetGen1, "v1") |
| + w.confirm() |
| + reads := 0 |
| + w.policy.betweenReads = func() { reads++ } |
| + |
| + // Lazy first use, overlapping a pass: nothing kept, nothing answered. |
| + w.auth.passBegin() |
| + if _, ok := w.policy.For("shop.example.com"); ok { |
| + t.Fatal("For answered from a decision that overlapped a pass") |
| + } |
| + if reads != 1 || w.policy.snap.Load() != nil { |
| + t.Fatalf("reads = %d, snapshot kept = %v; want one read and none kept", reads, w.policy.snap.Load() != nil) |
| + } |
| + w.auth.passEnd() |
| + // Lazy first use, settled: one read, then none. The manifest was never |
| + // read (only the writer reads the bucket), so nothing catches yet. |
| + if _, ok := w.policy.For("shop.example.com"); ok { |
| + t.Fatal("the lazy decision caught a build whose manifest was never read") |
| + } |
| + if reads != 2 { |
| + t.Fatalf("reads = %d after the lazy decision, want 2", reads) |
| + } |
| + w.pass() // the writer's settled pass replaces it (one more hooked read) |
| + reads = 0 |
| + for range 100 { |
| + if _, ok := w.policy.For("shop.example.com"); !ok { |
| + t.Fatal("For refused after the writer's pass") |
| + } |
| + if !w.policy.Declared("shop.example.com", "v1", "/assets/x.js") { |
| + t.Fatal("Declared refused after the writer's pass") |
| + } |
| + } |
| + if reads != 0 { |
| + t.Fatalf("200 calls made %d registry reads, want 0", reads) |
| + } |
| +} |
| + |
| +// TestForIsSafeWhileTheWriterReplacesTheSnapshot runs For and Declared from |
| +// several goroutines while the writer decides, generations change and passes |
| +// overlap. Run with -race. |
| +// |
| +// Not acceptance: the clocks are fed reports directly; the writer and the |
| +// reader are the production ones. |
| +func TestForIsSafeWhileTheWriterReplacesTheSnapshot(t *testing.T) { |
| + t.Parallel() |
| + w := newAssetWorld(t) |
| + w.ship("v1", "/assets/") |
| + w.instance("shop.example.com", assetGen1, "v1") |
| + w.confirm() |
| + w.pass() |
| + stop := make(chan struct{}) |
| + var wg sync.WaitGroup |
| + for range 4 { |
| + wg.Add(1) |
| + go func() { |
| + defer wg.Done() |
| + for { |
| + select { |
| + case <-stop: |
| + return |
| + default: |
| + } |
| + if a, ok := w.policy.For("shop.example.com"); ok && a.Gen != assetGen1 && a.Gen != assetGen2 { |
| + t.Errorf("For answered an unknown generation %q", a.Gen) |
| + } |
| + w.policy.Declared("shop.example.com", "v1", "/assets/a.js") |
| + } |
| + }() |
| + } |
| + for i := range 40 { |
| + gen := assetGen1 |
| + if i%2 == 1 { |
| + gen = assetGen2 |
| + } |
| + if err := w.reg.SetAssets("shop.example.com", false, gen); err != nil { |
| + t.Fatal(err) |
| + } |
| + w.clock.advance(time.Minute) |
| + w.confirm() |
| + if i%3 == 0 { |
| + w.auth.passBegin() |
| + w.pass() |
| + w.auth.passEnd() |
| + } else { |
| + w.pass() |
| + } |
| + } |
| + close(stop) |
| + wg.Wait() |
| + // The last pass (i = 39, gen2) overlapped a pass, so the decision kept is |
| + // i = 38's (gen1); the next settled pass moves it to gen2. |
| + if got, ok := w.policy.For("shop.example.com"); !ok || got.Gen != assetGen1 { |
| + t.Fatalf("after an overlapped last pass For = %+v %v, want the last settled generation %s", got, ok, assetGen1) |
| + } |
| + w.pass() |
| + if got, ok := w.policy.For("shop.example.com"); !ok || got.Gen != assetGen2 { |
| + t.Fatalf("after a settled pass For = %+v %v, want %s", got, ok, assetGen2) |
| + } |
| +} |
| + |
| // Compile-time: the policy is what plan 3's edge consumes. |
| var _ edge.LocalAssets = (*assetPolicy)(nil) |