- 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)
210 lines
6.6 KiB
Go
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
|
|
}
|