fix: serialise redirect writes so concurrent saves cannot store a loop (WR-10)

The save-time simulation read the rules at READ COMMITTED without a lock,
so A -> B and B -> A written concurrently both passed. CheckRedirect,
AddressChanged and every batch now take a transaction-level advisory lock
before they read the rules.
This commit is contained in:
2026-10-27 15:29:08 +01:00
parent cb614cb1fb
commit c2f136ad7d
5 changed files with 69 additions and 1 deletions

View File

@@ -428,6 +428,47 @@ func TestAddressChangeGuards(t *testing.T) {
}
}
// TestConcurrentRedirectWrites checks that redirect writes are serialised
// (WR-10): while one transaction holds A -> B uncommitted, a concurrent
// B -> A waits, then sees A -> B and is refused as a loop.
func TestConcurrentRedirectWrites(t *testing.T) {
env := bootEnv(t)
svc := env.seoPlugin(t).Service()
newRule := func(source, target string) *models.Redirect {
r := models.NewRedirect()
r.Source, r.Target = source, target
return &r
}
first := env.db.Begin()
if _, err := svc.SaveRedirect(t.Context(), first, newRule("/cw-a", "/cw-b")); err != nil {
first.Rollback()
t.Fatal(err)
}
done := make(chan error, 1)
go func() {
done <- env.db.Transaction(func(tx *gorm.DB) error {
_, err := svc.SaveRedirect(context.Background(), tx, newRule("/cw-b", "/cw-a"))
return err
})
}()
select {
case err := <-done:
first.Rollback()
t.Fatalf("B -> A finished while A -> B was uncommitted: %v", err)
case <-time.After(300 * time.Millisecond):
}
if err := first.Commit().Error; err != nil {
t.Fatal(err)
}
var loop *classes.LoopError
if err := <-done; !errors.As(err, &loop) {
t.Fatalf("B -> A after A -> B committed: err = %v, want a loop", err)
}
if got := redirectTo(env.redirects(t), "/cw-b"); got != "" {
t.Fatalf("/cw-b -> %q was stored", got)
}
}
// redirectCount is the number of stored redirects.
func (e *testEnv) redirectCount(t *testing.T) int64 {
t.Helper()