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

@@ -165,7 +165,14 @@ func classHas(ranges []rune, c rune) bool {
// matches comes back round or does not end within MaxRedirectHops, a
// chain when it takes more than one hop or when another rule's target
// leads into r. A 410 rule ends every chain and is never a loop.
//
// db must be the writer's transaction: CheckRedirect takes the redirect
// write lock (lockRedirectWrites) before it reads, so two concurrent writes
// cannot each pass the simulation without the other's rule.
func (s *Service) CheckRedirect(ctx context.Context, db *gorm.DB, r models.Redirect) (RedirectCheck, error) {
if err := lockRedirectWrites(db.WithContext(ctx)); err != nil {
return RedirectCheck{}, err
}
if r.Status == models.Status410 {
return RedirectCheck{}, nil
}
@@ -176,6 +183,18 @@ func (s *Service) CheckRedirect(ctx context.Context, db *gorm.DB, r models.Redir
return checkRedirectRules(s.SiteURL(), rules, r)
}
// redirectLockKey is the transaction-level advisory lock that serialises
// redirect writes ("g15seord").
const redirectLockKey int64 = 0x6731357365_6f7264
// lockRedirectWrites takes the redirect write lock for the rest of tx (it
// is released on commit or rollback). Every path that simulates a redirect
// write takes it before reading the rules (WR-10): at READ COMMITTED the
// next statement after the wait sees what the other writer committed.
func lockRedirectWrites(tx *gorm.DB) error {
return tx.Exec("SELECT pg_advisory_xact_lock(?)", redirectLockKey).Error
}
// checkRedirectRules is CheckRedirect over the stored rules given, for the
// site URL site.
func checkRedirectRules(site string, rules []models.Redirect, r models.Redirect) (RedirectCheck, error) {