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
This commit is contained in:
Jakub Zych
2026-11-04 13:44:12 +01:00
parent 59f0457c69
commit 2dfc606cdc
4 changed files with 171 additions and 2 deletions

View File

@@ -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

View File

@@ -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 {

View File

@@ -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

View File

@@ -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)
}
}