fix(ip-limit): CAS-retry inbound_client_ips merges under Postgres (#6612)

* fix(ip-limit): CAS-retry inbound_client_ips merges under Postgres

Two writers RMW the same ips JSON blob; on PostgreSQL a lost update drops
remote IPs that partitionLiveIps only sees through that blob (#6587).
Compare-and-set on the previous blob with re-merge on miss, matching the
repo's conditional Where+RowsAffected pattern.

Fixes #6587

* ci: retrigger frontend after npm registry maintenance

The frontend job failed solely on `npm audit` while registry.npmjs.org
returned 503 (Service Under Maintenance). Lint, typecheck, vitest, vite
build, and storybook all passed. Local `npm audit --omit=dev
--audit-level=high` now reports 0 vulnerabilities.

* test(ip-limit): cover the scan's CAS against a mid-scan node sync

The job-side compare-and-set had no test of its own. A write injected
between the scan's read and its update now has to keep the node's remote
IP; main's blind Save drops it. Also keeps the new comments to two lines,
as CLAUDE.md requires.

---------

Co-authored-by: mrchatam <mrchatam@users.noreply.github.com>
Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
mrchatam
2026-09-26 23:30:40 +03:30
committed by GitHub
parent d42e2133c7
commit 8f1201553e
4 changed files with 266 additions and 44 deletions
+51 -17
View File
@@ -2,6 +2,7 @@ package service
import (
"encoding/json"
"fmt"
"sort"
"time"
@@ -156,28 +157,61 @@ func (s *InboundService) MergeInboundClientIps(incomingIps []model.InboundClient
continue
}
var oldEntries []clientIpEntry
if current.Ips != "" {
_ = json.Unmarshal([]byte(current.Ips), &oldEntries)
}
merged := mergeClientIpEntries(oldEntries, incomingEntries, cutoff)
b, _ := json.Marshal(merged)
mergedStr := string(b)
// A concurrent check_client_ip_job db.Save on the same row can interleave
// with this update (benign last-writer-wins; any dropped IP reappears on the
// next scan/sync), so only write when the blob actually changed.
if current.Ips != mergedStr {
if err := tx.Model(&model.InboundClientIps{}).Where("id = ?", current.Id).Update("ips", mergedStr).Error; err != nil {
tx.Rollback()
return err
}
// check_client_ip_job rewrites this blob too; an unconditional Update loses
// whichever writer commits second and its remote IPs with it (#6587).
if err := mergeExistingClientIps(tx, current.Id, incomingEntries, cutoff); err != nil {
tx.Rollback()
return err
}
}
return tx.Commit().Error
}
// ClientIpCasRetries bounds the re-reads after losing a compare-and-set. Running
// out is an error, so the caller retries on its next schedule instead of dropping IPs.
const ClientIpCasRetries = 8
// CasUpdateInboundClientIps writes newIps only while the row still holds expectedIps;
// updated=false with a nil error means another writer won and the caller must re-read.
func CasUpdateInboundClientIps(tx *gorm.DB, id int, expectedIps, newIps string) (updated bool, err error) {
res := tx.Model(&model.InboundClientIps{}).
Where("id = ? AND ips = ?", id, expectedIps).
Update("ips", newIps)
if res.Error != nil {
return false, res.Error
}
return res.RowsAffected == 1, nil
}
// mergeExistingClientIps folds incoming into the row at id under a CAS loop so
// a concurrent check_client_ip_job write cannot erase the merge (#6587).
func mergeExistingClientIps(tx *gorm.DB, id int, incoming []clientIpEntry, cutoff int64) error {
for attempt := 0; attempt < ClientIpCasRetries; attempt++ {
var row model.InboundClientIps
if err := tx.Where("id = ?", id).First(&row).Error; err != nil {
return err
}
var oldEntries []clientIpEntry
if row.Ips != "" {
_ = json.Unmarshal([]byte(row.Ips), &oldEntries)
}
merged := mergeClientIpEntries(oldEntries, incoming, cutoff)
b, _ := json.Marshal(merged)
mergedStr := string(b)
if row.Ips == mergedStr {
return nil
}
ok, err := CasUpdateInboundClientIps(tx, id, row.Ips, mergedStr)
if err != nil {
return err
}
if ok {
return nil
}
}
return fmt.Errorf("inbound_client_ips id=%d: exhausted CAS retries merging client IPs", id)
}
func (s *InboundService) UpdateClientIPs(tx *gorm.DB, oldEmail string, newEmail string) error {
// The caller only renames onto a free identity, so a row already sitting on
// newEmail is stale tracking data — drop it instead of failing the edit.