fix(hwid): serialize the device-limit write with its trim (#6591)

setClientLimitHwidByEmail wrote clients.limit_hwid and then trimmed client_hwids as two independent statements. A traffic-cycle Save that read the record before the limit changed could write the stale value back after it, and a failed trim committed the new limit anyway.

Both halves now run inside runSerializedTx, the transaction the traffic writer already owns. setClientLimitHwidByEmailTx and clearClientHwidsBySubIDTx refuse a handle that is not that transaction (errClientHwidWriteNotSerialized) instead of falling back to the shared handle. Client delete moves onto the same writer, and BulkCreate withdraws a re-created client's tombstone before applying its optional HWID limit.

TestSetClientLimitHwidIsSerializedWithSyncInbound holds a stale traffic-cycle Save open across the limit change and fails without the serialization (limit_hwid = 5, want 1).
This commit is contained in:
n0ctal
2026-09-26 23:30:22 +05:00
committed by GitHub
parent d86a3def85
commit c0c0136037
6 changed files with 255 additions and 16 deletions
+5 -5
View File
@@ -553,7 +553,7 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
}
}
if adjustHwid {
if err := s.setClientLimitHwidByEmail(db, email, *limitHwid); err != nil {
if err := s.setClientLimitHwidByEmail(email, *limitHwid); err != nil {
if _, already := skippedReasons[email]; !already {
skippedReasons[email] = err.Error()
}
@@ -1487,22 +1487,22 @@ func (s *ClientService) BulkCreate(inboundSvc *InboundService, payloads []Client
}
}
createdEmails := make([]string, 0, len(prep))
for idx := range prep {
if failed[idx] {
skip(prep[idx].client.Email, reason[idx])
continue
}
if err := s.setClientLimitHwidByEmail(nil, prep[idx].client.Email, prep[idx].limitHwid); err != nil {
// The client is already live after fanout; never leave a stale delete
// tombstone merely because applying its optional HWID limit failed.
withdrawClientTombstones(prep[idx].client.Email)
if err := s.setClientLimitHwidByEmail(prep[idx].client.Email, prep[idx].limitHwid); err != nil {
skip(prep[idx].client.Email, err.Error())
continue
}
createdEmails = append(createdEmails, prep[idx].client.Email)
result.Created++
}
// A re-created email is a live identity again: a delete tombstone left
// standing makes the next node merge prune the new client's inbound links.
withdrawClientTombstones(createdEmails...)
return result, needRestart, nil
}