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:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user