fix(19.3-06): queue site-cache purges after commit and coalesce them (WR-01)

- PurgeSiteCacheAfterCommit queues the purge on a per-purger worker after
  commit: one request in flight, saves committed meanwhile collapse into
  one follow-up, so the admin request never waits for the site
- new cooler.SiteCachePurger.Wait drains the queue; Handle.Close waits for
  it at shutdown (timeout plus one second)
- Purge stays the synchronous send; exported names and signatures unchanged
This commit is contained in:
Jakub Zych
2026-10-15 22:36:24 +02:00
parent 50a584c757
commit db6a7f5c3f
6 changed files with 236 additions and 48 deletions

View File

@@ -244,6 +244,16 @@ func newTestPurger(t *testing.T, srv *httptest.Server) *SiteCachePurger {
return p
}
// waitPurges drains the purges queued on p, failing the test after 5 s.
func waitPurges(t *testing.T, p *SiteCachePurger) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := p.Wait(ctx); err != nil {
t.Fatalf("Wait: %v", err)
}
}
func assertNoToken(t *testing.T, logs *syncBuffer) {
t.Helper()
for _, line := range logs.lines() {
@@ -371,10 +381,12 @@ func TestSiteCachePurgeRequest(t *testing.T) {
}
})
t.Run("outside a transaction the after-commit purge runs at once", func(t *testing.T) {
t.Run("outside a transaction the after-commit purge is queued at once", func(t *testing.T) {
captureLogs(t)
srv, hits := purgeReceiver(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"purged":2}`)) })
PurgeSiteCacheAfterCommit(context.Background(), nil, newTestPurger(t, srv), "category", "now")
p := newTestPurger(t, srv)
PurgeSiteCacheAfterCommit(context.Background(), nil, p, "category", "now")
waitPurges(t, p)
if got := hits(); len(got) != 1 || got[0].body["kind"] != "category" || got[0].body["slug"] != "now" {
t.Fatalf("requests = %#v, want one", got)
}
@@ -464,6 +476,7 @@ func TestSiteCacheAfterCommitOnly(t *testing.T) {
if n := countPosts(t, gdb, "rolled-back"); n != 0 {
t.Fatalf("%d rows after rollback", n)
}
waitPurges(t, p)
if got := hits(); len(got) != 0 {
t.Fatalf("a rolled-back save sent %d purges", len(got))
}
@@ -485,6 +498,7 @@ func TestSiteCacheAfterCommitOnly(t *testing.T) {
if during != 0 {
t.Fatalf("%d purges were sent before the commit", during)
}
waitPurges(t, p)
got := hits()
if len(got) != 1 || got[0].body["kind"] != "post" || got[0].body["slug"] != "committed" {
t.Fatalf("purges = %#v, want one for post committed", got)
@@ -510,6 +524,7 @@ func TestSiteCacheAfterCommitOnly(t *testing.T) {
if afterInner != before {
t.Fatal("the purge ran when the inner transaction finished")
}
waitPurges(t, p)
if got := hits(); len(got) != before+1 || got[len(got)-1].body["slug"] != "nested" {
t.Fatalf("purges = %#v, want one more for nested", got)
}
@@ -531,6 +546,7 @@ func TestSiteCacheFailureNeverFails(t *testing.T) {
if err != nil {
t.Fatalf("a failing purge failed the save: %v", err)
}
waitPurges(t, p)
if n := countPosts(t, gdb, "server-error"); n != 1 || len(hits()) != 1 {
t.Fatalf("rows %d, purges %d; want 1 and 1", n, len(hits()))
}
@@ -559,12 +575,13 @@ func TestSiteCacheFailureNeverFails(t *testing.T) {
if err != nil {
t.Fatalf("a slow purge failed the save: %v", err)
}
if elapsed := time.Since(start); elapsed > SiteCachePurgeTimeout+2*time.Second {
t.Fatalf("the save waited %s, want the purge bounded by %s", elapsed, SiteCachePurgeTimeout)
if elapsed := time.Since(start); elapsed > 500*time.Millisecond {
t.Fatalf("the save waited %s for a queued purge", elapsed)
}
if n := countPosts(t, gdb, "slow"); n != 1 {
t.Fatalf("%d rows, want the save committed", n)
}
waitPurges(t, p)
if lines := logs.lines(); len(lines) != 1 || !strings.Contains(lines[0], "error=timeout") {
t.Fatalf("log = %q", lines)
}
@@ -582,6 +599,7 @@ func TestSiteCacheFailureNeverFails(t *testing.T) {
if err != nil || countPosts(t, gdb, "unreachable") != 1 {
t.Fatalf("Transaction = %v, rows %d", err, countPosts(t, gdb, "unreachable"))
}
waitPurges(t, p)
})
}
@@ -660,3 +678,42 @@ func TestSiteCachePurgeNoRedirect(t *testing.T) {
}
assertNoToken(t, logs)
}
func TestSiteCachePurgeAsyncCoalesced(t *testing.T) {
logs := captureLogs(t)
release := make(chan struct{})
srv, hits := purgeReceiver(t, func(w http.ResponseWriter, _ *http.Request) {
<-release
_, _ = w.Write([]byte(`{"purged":3}`))
})
p := newTestPurger(t, srv)
released := false
defer func() {
if !released {
close(release)
}
waitPurges(t, p)
}()
for i := range 20 {
start := time.Now()
PurgeSiteCacheAfterCommit(context.Background(), nil, p, "post", "slug-"+strconv.Itoa(i))
if elapsed := time.Since(start); elapsed > 50*time.Millisecond {
t.Fatalf("call %d blocked for %s", i, elapsed)
}
}
close(release)
released = true
waitPurges(t, p)
got := hits()
if len(got) != 2 {
t.Fatalf("%d requests, want 2 (one in flight, one coalesced follow-up)", len(got))
}
if got[0].body["slug"] != "slug-0" || got[1].body["slug"] != "slug-19" {
t.Fatalf("slugs %q, %q; want slug-0 then the last, slug-19", got[0].body["slug"], got[1].body["slug"])
}
lines := logs.lines()
if len(lines) != 2 || strings.Contains(lines[0], "coalesced=") || !strings.Contains(lines[1], "slug=slug-19") || !strings.Contains(lines[1], "coalesced=18") {
t.Fatalf("log = %q, want the second purged line with coalesced=18", lines)
}
assertNoToken(t, logs)
}