From 2dfc606cdc7d90c9d77775a3581653f9c4f7305c Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 4 Nov 2026 13:44:12 +0100 Subject: [PATCH] feat: purge the site cache after SEO writes - every write that records history (admin, MCP, batch, import, undo, automatic) queues the framework's site-cache purge (kind seo, slug the address or * for settings and robots.txt) after its commit, once per transaction; a rolled-back write never purges - the record-deleted cleanup and the address tracking of saves outside the admin run in lagoon transactions, so their history purges as well --- classes/generate.go | 5 ++- classes/history.go | 75 ++++++++++++++++++++++++++++++++++++++ listeners.go | 5 ++- registry_test.go | 88 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 171 insertions(+), 2 deletions(-) diff --git a/classes/generate.go b/classes/generate.go index 4472a27..6da612e 100644 --- a/classes/generate.go +++ b/classes/generate.go @@ -7,6 +7,7 @@ import ( "git.golem15.com/golem15/sm-seo-plugin/models" "git.golem15.com/golem15/summercms/modules/conga" + "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/pact" "gorm.io/gorm" ) @@ -193,7 +194,9 @@ func (s *Service) RecordDeleted(ctx context.Context, ev *pact.RecordDeleted) err return err } ctx = WithVia(ctx, models.ViaAuto) - return db.Transaction(func(tx *gorm.DB) error { + // A lagoon transaction, so the history it records purges the site + // cache after the commit. + return lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error { entries, err := LoadEntries(tx, typ.Content.Model, ev.ID) if err != nil || len(entries) == 0 { return err diff --git a/classes/history.go b/classes/history.go index 932ccce..87f7dbc 100644 --- a/classes/history.go +++ b/classes/history.go @@ -7,10 +7,13 @@ import ( "fmt" "reflect" "strings" + "sync" "time" "git.golem15.com/golem15/sm-seo-plugin/models" "git.golem15.com/golem15/summercms/modules/bouncer" + "git.golem15.com/golem15/summercms/modules/cooler" + "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/phrasebook" "gorm.io/gorm" @@ -232,10 +235,82 @@ func (s *Service) Record(ctx context.Context, db *gorm.DB, changes ...Change) er if err := db.Create(&row).Error; err != nil { return err } + s.purgeAfterCommit(ctx, db, purgeSlug(c)) } return nil } +// SiteCacheKind is the purge kind of SEO writes (P33-19). +const SiteCacheKind = "seo" + +// purgeAfterCommit queues the rendered site's cache purge once the write +// db belongs to commits (P33-19, D-97): every SEO write that records +// history (admin, MCP, batch, import, undo, automatic) refreshes the +// layer's cached resolve answers and pages. A transaction purges once, +// however many rows it records; a rolled-back write never purges. Without +// a configured purger (site_cache.purge_url) it does nothing. +func (s *Service) purgeAfterCommit(ctx context.Context, db *gorm.DB, slug string) { + if s == nil || s.app == nil { + return + } + p := cooler.SiteCacheFromApp(s.app) + if p == nil { + return + } + var pool gorm.ConnPool + if db != nil && db.Statement != nil { + if _, inTx := db.Statement.ConnPool.(gorm.TxCommitter); inTx { + pool = db.Statement.ConnPool + } + } + if pool != nil { + purgeMu.Lock() + if purging == nil || len(purging) > maxPendingPurges { + // Rolled-back transactions leave their key behind; the map is + // reset rather than grow. + purging = map[gorm.ConnPool]bool{} + } + if purging[pool] { + purgeMu.Unlock() + return + } + purging[pool] = true + purgeMu.Unlock() + lagoon.AfterCommit(ctx, db, func(context.Context, *gorm.DB) { + purgeMu.Lock() + delete(purging, pool) + purgeMu.Unlock() + }) + } + cooler.PurgeSiteCacheAfterCommit(ctx, db, p, SiteCacheKind, slug) +} + +// maxPendingPurges caps the transactions purgeAfterCommit remembers. +const maxPendingPurges = 1024 + +// purging holds the open transactions whose site-cache purge is queued, so +// each purges once whichever Service instance records in it. +var ( + purgeMu sync.Mutex + purging map[gorm.ConnPool]bool +) + +// purgeSlug is the purge's slug: "*" for the settings and robots.txt, +// else the changed object's address. +func purgeSlug(c Change) string { + if c.Kind == models.SubjectSettings || c.Kind == models.SubjectRobots { + return "*" + } + for _, values := range []map[string]any{c.After, c.Before} { + for _, key := range []string{"url", "source"} { + if v, ok := values[key].(string); ok && strings.TrimSpace(v) != "" { + return v + } + } + } + return "*" +} + // translator returns the application translator, nil when none. func (s *Service) translator() *phrasebook.Translator { if s == nil || s.app == nil { diff --git a/listeners.go b/listeners.go index 9e81c82..e28ef8f 100644 --- a/listeners.go +++ b/listeners.go @@ -5,6 +5,7 @@ import ( "git.golem15.com/golem15/sm-seo-plugin/classes" "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/pact" "gorm.io/gorm" ) @@ -56,7 +57,9 @@ func (p *Plugin) addressesAfterSave(ctx context.Context, svc *classes.Service, m if err != nil { return err } - return db.Transaction(func(tx *gorm.DB) error { + // A lagoon transaction, so the history the 301 records purges the site + // cache after the commit. + return lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error { changes, err := svc.AddressChanges(ctx, tx, typ, id) if err != nil { return err diff --git a/registry_test.go b/registry_test.go index e7d88f1..f31ad50 100644 --- a/registry_test.go +++ b/registry_test.go @@ -3,10 +3,13 @@ package seo_test import ( "encoding/json" "net/http" + "net/http/httptest" "strings" + "sync" "testing" "git.golem15.com/golem15/sm-sitemap-plugin/contract" + "git.golem15.com/golem15/summercms/modules/cooler" "git.golem15.com/golem15/summercms/modules/signpost" ) @@ -506,3 +509,88 @@ func TestRobotsSitemapLine(t *testing.T) { t.Fatalf("robots.txt with robots_sitemaps:\n%s", body) } } + +// purgeLog is a fake site-cache purge route: it records each request's +// kind and slug. +type purgeLog struct { + mu sync.Mutex + bodies []map[string]string +} + +func (p *purgeLog) ServeHTTP(w http.ResponseWriter, r *http.Request) { + var body map[string]string + _ = json.NewDecoder(r.Body).Decode(&body) + if r.Header.Get("Authorization") != "Bearer "+purgeToken { + w.WriteHeader(http.StatusUnauthorized) + return + } + p.mu.Lock() + p.bodies = append(p.bodies, body) + p.mu.Unlock() + _, _ = w.Write([]byte(`{"purged":1}`)) +} + +// take waits for the queued purges and returns the requests since the +// last take. +func (p *purgeLog) take(t *testing.T, env *testEnv) []map[string]string { + t.Helper() + if err := cooler.SiteCacheFromApp(env.app).Wait(t.Context()); err != nil { + t.Fatal(err) + } + p.mu.Lock() + defer p.mu.Unlock() + out := p.bodies + p.bodies = nil + return out +} + +const purgeToken = "seo-test-purge-token-0123456789abcdef" + +// TestPurgeAfterWrite covers the site-cache purge after SEO writes (P33-19, +// D-97, T-33-23): an SEO tab save, a redirect created through MCP, a +// settings save and an applied batch each post one purge (kind seo) after +// the commit; a refused batch and a dry run post none. +func TestPurgeAfterWrite(t *testing.T) { + log := &purgeLog{} + srv := httptest.NewServer(log) + defer srv.Close() + env := bootEnv(t, "SUMMER_SITE_CACHE__PURGE_URL="+srv.URL+"/api/__site__/cache/purge", "SUMMER_SITE_CACHE__PURGE_TOKEN="+purgeToken) + token := env.admin(t, true) + id := env.createPage(t, token, map[string]any{"title": "About", "slug": "about", "published": true}) + log.take(t, env) + + env.expect(t, http.StatusOK, http.MethodPut, adminAPI("/acme/pages/pages/"+id), token, map[string]any{ + "title": "About", "slug": "about", "published": true, + "seo_title": map[string]any{"en": "About us", "pl": "O nas"}, "seo_description": map[string]any{"en": "Who we are"}, + }) + if got := log.take(t, env); len(got) != 1 || got[0]["kind"] != "seo" { + t.Fatalf("purges after the SEO tab save = %v, want one of kind seo", got) + } + + agent := env.principal(t, env.admin(t, false, "golem15.seo.manage")) + env.mcpExpect(t, http.StatusCreated, http.MethodPost, "/_seo/api/v1/redirects", agent, map[string]any{"source": "/old", "target": "/pages/about"}) + if got := log.take(t, env); len(got) != 1 || got[0]["slug"] != "/old" { + t.Fatalf("purges after the MCP redirect = %v, want one naming /old", got) + } + + env.expect(t, http.StatusOK, http.MethodPut, adminAPI("/golem15/seo/settings/1"), token, map[string]any{"twitter_site": "@acme"}) + if got := log.take(t, env); len(got) != 1 || got[0]["slug"] != "*" { + t.Fatalf("purges after the settings save = %v, want one with slug *", got) + } + + changes := []any{redirectChange("/b1", "/pages/about"), redirectChange("/b2", "/pages/about"), redirectChange("/b3", "/pages/about")} + env.mcpExpect(t, http.StatusOK, http.MethodPost, "/_seo/api/v1/batch", agent, map[string]any{"dry_run": true, "changes": changes}) + if got := log.take(t, env); len(got) != 0 { + t.Fatalf("purges after a dry run = %v, want none", got) + } + env.mcpExpect(t, http.StatusUnprocessableEntity, http.MethodPost, "/_seo/api/v1/batch", agent, map[string]any{"changes": []any{ + redirectChange("/l1", "/l2"), redirectChange("/l2", "/l1"), + }}) + if got := log.take(t, env); len(got) != 0 { + t.Fatalf("purges after a refused batch = %v, want none", got) + } + env.mcpExpect(t, http.StatusOK, http.MethodPost, "/_seo/api/v1/batch", agent, map[string]any{"changes": changes}) + if got := log.take(t, env); len(got) != 1 { + t.Fatalf("purges after an applied batch of three = %v, want one", got) + } +}