Files
sm-sitemap-plugin/rebuild_lock_test.go
Jakub Zych 2200e46d98 fix: rebuilds collect under one lock and keep moved lastmods
A rebuild takes the transaction-scoped advisory lock
golem15.sitemap.rebuild before it reads the settings and collects, and
applies in the same transaction, so a second rebuild collects only after
the first has committed. apply no longer writes the lastmod of a content
row it did not create, so a lastmod RecordSaved moved meanwhile stays.
2026-11-04 23:04:01 +01:00

135 lines
4.2 KiB
Go

package sitemap_test
import (
"context"
"slices"
"sync"
"testing"
"time"
"git.golem15.com/golem15/sm-sitemap-plugin/classes"
"git.golem15.com/golem15/summercms/modules/pact"
)
// rebuildResult is the outcome of one classes.Rebuild run in a goroutine.
type rebuildResult struct {
res classes.RebuildResult
err error
}
// waitFor polls cond until it holds or the deadline passes.
func waitFor(t *testing.T, what string, cond func() bool) {
t.Helper()
deadline := time.Now().Add(15 * time.Second)
for !cond() {
if time.Now().After(deadline) {
t.Fatalf("timed out waiting for %s", what)
}
time.Sleep(20 * time.Millisecond)
}
}
// TestConcurrentRebuilds pins L2 WR-02 (D-30, P33-63): a second rebuild
// waits on the rebuild's advisory lock while the first one collects, so it
// collects only after the first has committed; a record published while
// the first rebuild ran is listed afterwards and never sent to IndexNow as
// removed.
func TestConcurrentRebuilds(t *testing.T) {
e := bootIndex(t, filterPluginID)
mod := time.Date(2026, 10, 1, 8, 0, 0, 0, time.UTC)
contentStore.put(pageType, page1(1, "a", mod))
if _, err := classes.Rebuild(t.Context(), e.app); err != nil {
t.Fatal(err)
}
entered, release := make(chan struct{}), make(chan struct{})
var once sync.Once
theFilter.setHook(t, func() {
first := false
once.Do(func() { first = true; close(entered) })
if first {
<-release
}
})
run := func() <-chan rebuildResult {
out := make(chan rebuildResult, 1)
go func() {
res, err := classes.Rebuild(context.Background(), e.app)
out <- rebuildResult{res, err}
}()
return out
}
a := run()
select {
case <-entered:
case <-time.After(15 * time.Second):
t.Fatal("rebuild A never asked the filter")
}
contentStore.put(pageType, page1(2, "x", mod))
b := run()
waitFor(t, "rebuild B to wait on the advisory lock", func() bool {
var waiting int64
e.gdb.Raw(`SELECT COUNT(*) FROM pg_locks WHERE locktype = 'advisory' AND NOT granted`).Scan(&waiting)
return waiting > 0
})
close(release)
ra, rb := <-a, <-b
if ra.err != nil || rb.err != nil {
t.Fatalf("rebuilds: %v, %v", ra.err, rb.err)
}
var reason []string
e.gdb.Raw(`SELECT excluded_reason FROM golem15_sitemap_urls WHERE type_key = ? AND record_id = '2'`, pageType).Scan(&reason)
if len(reason) != 1 || reason[0] != "" {
t.Fatalf("record x after both rebuilds = %v, want listed", reason)
}
x := "https://api.example.test/pages/x"
if slices.Contains(ra.res.Removed, x) || slices.Contains(rb.res.Removed, x) {
t.Fatalf("x sent as removed: A %v, B %v", ra.res.Removed, rb.res.Removed)
}
if !slices.Contains(rb.res.Added, x) {
t.Fatalf("rebuild B added %v, want x", rb.res.Added)
}
}
// TestRebuildKeepsMovedLastmod pins L2 WR-03 (D-30): a lastmod that
// RecordSaved moves while a rebuild runs stays, also when the same rebuild
// rewrites the row for another change (its title).
func TestRebuildKeepsMovedLastmod(t *testing.T) {
e := bootIndex(t)
old := time.Date(2026, 10, 1, 8, 0, 0, 0, time.UTC)
contentStore.put(pageType, page1(1, "a", old))
if _, err := classes.Rebuild(t.Context(), e.app); err != nil {
t.Fatal(err)
}
renamed := page1(1, "a", old)
renamed.Locales[0].Title = "Page a, renamed"
contentStore.put(pageType, renamed)
lastmod := func() time.Time {
var at time.Time
e.gdb.Raw(`SELECT lastmod FROM golem15_sitemap_urls WHERE type_key = ? AND record_id = '1'`, pageType).Scan(&at)
return at
}
saved := make(chan error, 1)
ctx := classes.WithAfterSlotRead(t.Context(), func() {
go func() {
saved <- e.app.Events.Fire(context.Background(), &pact.RecordSaved{Model: pageModel, ID: "1", Changes: []pact.RecordChange{{Attribute: "title"}}})
}()
waitFor(t, "RecordSaved to move the lastmod", func() bool { return !lastmod().Equal(old) })
})
if _, err := classes.Rebuild(ctx, e.app); err != nil {
t.Fatal(err)
}
if err := <-saved; err != nil {
t.Fatal(err)
}
if got := lastmod(); got.Equal(old) {
t.Fatal("the rebuild wrote the lastmod it read back over the moved one")
}
var title string
e.gdb.Raw(`SELECT title FROM golem15_sitemap_urls WHERE type_key = ? AND record_id = '1'`, pageType).Scan(&title)
if title != "Page a, renamed" {
t.Fatalf("title = %q, want the rebuild's change", title)
}
}