Files
sm-sitemap-plugin/classes/lastmod.go
Jakub Zych f58d55555c fix: IndexNow hears of deleted and edited records
RecordDeleted marks the listed rows it excludes and RecordSaved the rows
whose lastmod it moves (indexnow_pending_at); the next rebuild sends
them to IndexNow as removed or changed and clears the marks. RecordSaved
checks for a nil event before reading it. TestIndexNow now deletes and
edits through the record events, as the admin does.
2026-11-04 23:02:09 +01:00

220 lines
7.1 KiB
Go

package classes
import (
"context"
"fmt"
"strings"
"time"
"git.golem15.com/golem15/sm-sitemap-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/conga"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/signpost"
"gorm.io/gorm"
)
// The config keys of the event-driven lastmod and the debounced rebuild.
const (
// LastmodIgnoreConfigKey lists the attributes whose change does not
// move lastmod.
LastmodIgnoreConfigKey = "golem15.sitemap.lastmod_ignore"
// RebuildDelayConfigKey is the delay of the rebuild a record event
// queues, in seconds.
RebuildDelayConfigKey = "golem15.sitemap.rebuild_delay_seconds"
)
// DefaultLastmodIgnore is the ignore list when the config has none.
var DefaultLastmodIgnore = []string{"updated_at", "created_at", "views", "sort_order"}
// defaultRebuildDelay is the rebuild delay when the config has none.
const defaultRebuildDelay = 60 * time.Second
// LastmodIgnore reads golem15.sitemap.lastmod_ignore: a list of attribute
// names (or one comma-separated string); DefaultLastmodIgnore when unset.
func LastmodIgnore(cfg *compass.Config) map[string]bool {
names := DefaultLastmodIgnore
if cfg != nil {
if v, ok := cfg.Lookup(LastmodIgnoreConfigKey); ok && v != nil {
names = nil
switch list := v.(type) {
case []any:
for _, item := range list {
names = append(names, fmt.Sprint(item))
}
case []string:
names = list
case string:
names = strings.Split(list, ",")
}
}
}
out := make(map[string]bool, len(names))
for _, n := range names {
if n = strings.TrimSpace(n); n != "" {
out[n] = true
}
}
return out
}
// RebuildDelay reads golem15.sitemap.rebuild_delay_seconds; 60 seconds when
// unset, 0 or more when set.
func RebuildDelay(cfg *compass.Config) time.Duration {
if cfg == nil || !cfg.Has(RebuildDelayConfigKey) {
return defaultRebuildDelay
}
if n := cfg.Int(RebuildDelayConfigKey); n > 0 {
return time.Duration(n) * time.Second
}
return 0
}
// Changed is what a save changed for lastmod: Any when a change outside the
// ignore list exists, All when one of them is on the record's own row
// (every locale's address moves), else Locales names the translated
// locales that changed.
type Changed struct {
Any bool
All bool
Locales map[string]bool
}
// RealChanges filters a save's changes through the ignore list. A no-op
// save (no changes) and one that changes only ignored attributes change
// nothing (RESEARCH Pitfall 4).
func RealChanges(changes []pact.RecordChange, ignore map[string]bool) Changed {
out := Changed{Locales: map[string]bool{}}
for _, c := range changes {
if ignore[c.Attribute] {
continue
}
out.Any = true
if c.Locale == "" {
out.All = true
continue
}
out.Locales[c.Locale] = true
}
if out.All {
out.Locales = map[string]bool{}
}
return out
}
// RecordSaved handles a saved record of a content type (D-30): when the
// save changed an attribute outside golem15.sitemap.lastmod_ignore, the
// lastmod of the record's rows moves to now (every locale's for a change
// on the record's own row, else only the changed locales'), the same rows
// are marked for IndexNow (indexnow_pending_at, D-31: the next rebuild
// sends the listed ones as changed), and a debounced rebuild is queued. A
// model no content type declares, or a nil event, is ignored.
func RecordSaved(ctx context.Context, app *backpack.App, ev *pact.RecordSaved) error {
if ev == nil {
return nil
}
typ, db, ok := contentTypeOf(app, ev.Model)
if !ok {
return nil
}
changed := RealChanges(ev.Changes, LastmodIgnore(app.Config))
if !changed.Any {
return nil
}
db = db.WithContext(ctx)
now := time.Now().UTC().Truncate(time.Microsecond)
q := db.Model(&models.URL{}).Where("type_key = ? AND record_id = ?", typ, ev.ID)
if !changed.All {
locales := make([]string, 0, len(changed.Locales))
for l := range changed.Locales {
locales = append(locales, l)
}
q = q.Where("locale IN ?", locales)
}
if err := q.Updates(map[string]any{"lastmod": now, "updated_at": now, "indexnow_pending_at": now}).Error; err != nil {
return err
}
return RequestDebouncedRebuild(ctx, app, db)
}
// RecordDeleted handles a deleted record of a content type: its rows leave
// the files at once (reason deleted, their file numbers kept as holes),
// those that were listed are marked for IndexNow in the same update
// (indexnow_pending_at, D-31: the next rebuild sends them as removed), and
// a debounced rebuild is queued.
func RecordDeleted(ctx context.Context, app *backpack.App, ev *pact.RecordDeleted) error {
if ev == nil {
return nil
}
typ, db, ok := contentTypeOf(app, ev.Model)
if !ok {
return nil
}
db = db.WithContext(ctx)
now := time.Now().UTC()
if err := db.Model(&models.URL{}).
Where("type_key = ? AND record_id = ? AND excluded_reason <> ?", typ, ev.ID, models.ReasonDeleted).
Updates(map[string]any{
"excluded_reason": models.ReasonDeleted,
"updated_at": now,
"indexnow_pending_at": gorm.Expr("CASE WHEN excluded_reason = '' THEN ?::timestamptz ELSE indexnow_pending_at END", now),
}).Error; err != nil {
return err
}
return RequestDebouncedRebuild(ctx, app, db)
}
// contentTypeOf finds the content type key of a record event's model and
// the application database.
func contentTypeOf(app *backpack.App, model string) (string, *gorm.DB, bool) {
if app == nil {
return "", nil, false
}
reg, ok := app.Lookup[*signpost.Registry]()
if !ok || reg == nil {
return "", nil, false
}
typ, ok := reg.ContentTypeForModel(model)
if !ok || typ.Content == nil {
return "", nil, false
}
db, ok := app.Lookup[*gorm.DB]()
if !ok || db == nil {
return "", nil, false
}
return typ.Key, db, true
}
// RequestDebouncedRebuild queues one rebuild golem15.sitemap.rebuild_delay_seconds
// from now unless one is already pending (P33-12): a conditional UPDATE of
// rebuild_pending_until decides, and the job is enqueued in the same
// transaction, so events within the delay share one rebuild. The job
// clears the pending time when it starts.
func RequestDebouncedRebuild(ctx context.Context, app *backpack.App, db *gorm.DB) error {
delay := RebuildDelay(app.Config)
m, err := conga.From(app)
if err != nil {
return err
}
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
res := tx.Exec(`UPDATE golem15_sitemap_settings
SET rebuild_pending_until = NOW() + make_interval(secs => ?)
WHERE id = 1 AND (rebuild_pending_until IS NULL OR rebuild_pending_until <= NOW())`, delay.Seconds())
if res.Error != nil || res.RowsAffected == 0 {
return res.Error
}
return m.Enqueue(ctx, tx, RebuildArgs{}, conga.EnqueueOpts{Delay: delay})
})
}
// ClearPending ends the debounce window; the rebuild job calls it when it
// starts, so events during the rebuild queue the next one.
func ClearPending(ctx context.Context, app *backpack.App) error {
db, ok := app.Lookup[*gorm.DB]()
if !ok || db == nil {
return ErrNoDatabase
}
return db.WithContext(ctx).Exec(`UPDATE golem15_sitemap_settings SET rebuild_pending_until = NULL WHERE id = 1`).Error
}