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 {