fix(server-transfer): break loader sort ties by transferID

NewServerTransferClass merged Verified and acked-rollback rows and sorted
them by AckedAt only. On Windows time.Now() granularity is ~15.6ms, so
MarkVerified and an immediately-following MarkRevertDelivered routinely
share a timestamp. The stable sort then left the Verified candidate
ahead of the rollback that is actually on disk and the agent was locked
out on the next restart. Add transferID as a deterministic tiebreaker so
the later rotation always wins.
This commit is contained in:
naiba
2026-05-25 10:36:43 +00:00
parent 6b88cdb012
commit b83d93fdc9
+28 -14
View File
@@ -306,11 +306,12 @@ func NewServerTransferClass() *ServerTransferClass {
} }
type credCandidate struct { type credCandidate struct {
serverID uint64 serverID uint64
secret string transferID uint64
ackedAt time.Time secret string
isRevert bool ackedAt time.Time
toUserID uint64 isRevert bool
toUserID uint64
} }
candidates := make([]credCandidate, 0, len(verified)+len(rollbackAcked)) candidates := make([]credCandidate, 0, len(verified)+len(rollbackAcked))
for i := range verified { for i := range verified {
@@ -319,10 +320,11 @@ func NewServerTransferClass() *ServerTransferClass {
continue continue
} }
candidates = append(candidates, credCandidate{ candidates = append(candidates, credCandidate{
serverID: t.ServerID, serverID: t.ServerID,
secret: t.HandshakeSecret, transferID: t.ID,
ackedAt: *t.AckedAt, secret: t.HandshakeSecret,
toUserID: t.ToUserID, ackedAt: *t.AckedAt,
toUserID: t.ToUserID,
}) })
} }
for i := range rollbackAcked { for i := range rollbackAcked {
@@ -331,14 +333,26 @@ func NewServerTransferClass() *ServerTransferClass {
continue continue
} }
candidates = append(candidates, credCandidate{ candidates = append(candidates, credCandidate{
serverID: t.ServerID, serverID: t.ServerID,
secret: t.RevertHandshakeSecret, transferID: t.ID,
ackedAt: *t.AckedAt, secret: t.RevertHandshakeSecret,
isRevert: true, ackedAt: *t.AckedAt,
toUserID: t.FromUserID, isRevert: true,
toUserID: t.FromUserID,
}) })
} }
// Sort newest-first by AckedAt, breaking ties with transferID. AckedAt
// alone is not enough on platforms whose time.Now() granularity is
// coarse (Windows: ~15.6ms): MarkVerified and the immediately-following
// MarkRevertDelivered routinely produce identical timestamps, and a
// stable sort then leaves the Verified candidate (appended first) ahead
// of the rollback that is actually on disk, locking the agent out on
// restart. transferID is monotonically increasing within a server's
// transfer lifecycle, so the later rotation always wins the tiebreak.
sort.SliceStable(candidates, func(i, j int) bool { sort.SliceStable(candidates, func(i, j int) bool {
if candidates[i].ackedAt.Equal(candidates[j].ackedAt) {
return candidates[i].transferID > candidates[j].transferID
}
return candidates[i].ackedAt.After(candidates[j].ackedAt) return candidates[i].ackedAt.After(candidates[j].ackedAt)
}) })