Files
sm-sitemap-plugin/classes/lastmod.go
Jakub Zych 653627b8c4 feat: real lastmod from record events and debounced rebuilds
- RecordSaved with a change outside golem15.sitemap.lastmod_ignore moves
  the lastmod of the record's rows (only the changed locale's for a
  translated change); a no-op save, an ignored change or a rebuild does not
- RecordDeleted takes the record's rows out of the files at once
- record events queue one rebuild per golem15.sitemap.rebuild_delay_seconds
  (rebuild_pending_until, P33-12); the job clears it when it starts
- sitemap:rebuild [--queue], scheduled nightly at 03:00 (pact.HasSchedule)
2026-11-04 11:17:28 +01:00

210 lines
6.6 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'), and a
// debounced rebuild is queued. A model no content type declares is
// ignored.
func RecordSaved(ctx context.Context, app *backpack.App, ev *pact.RecordSaved) error {
typ, db, ok := contentTypeOf(app, ev.Model)
if !ok || ev == nil {
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}).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) 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}).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
}