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.
135 lines
4.2 KiB
Go
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)
|
|
}
|
|
}
|