From 87040c55bb5f23af824914823f96c5bc01b44e51 Mon Sep 17 00:00:00 2001 From: Houmgaor Date: Sat, 21 Feb 2026 00:53:10 +0100 Subject: [PATCH] fix(channelserver): prevent guild RP rollover race and redundant stepup query RolloverDailyRP now locks the guild row with SELECT FOR UPDATE and re-checks rp_reset_at inside the transaction, so concurrent callers cannot double-rollover and zero out freshly donated RP. Gacha stepup entry-type check is now skipped when the row was already deleted as stale, avoiding a redundant DELETE on step 0. --- server/channelserver/handlers_gacha.go | 15 ++++++++------- server/channelserver/repo_guild.go | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/server/channelserver/handlers_gacha.go b/server/channelserver/handlers_gacha.go index 4245be3f2..d9e6cbbf2 100644 --- a/server/channelserver/handlers_gacha.go +++ b/server/channelserver/handlers_gacha.go @@ -327,14 +327,15 @@ func handleMsgMhfGetStepupStatus(s *Session, p mhfpacket.MHFPacket) { s.logger.Error("Failed to reset stale gacha stepup", zap.Error(err)) } step = 0 - } - - hasEntry, _ := s.server.gachaRepo.HasEntryType(pkt.GachaID, step) - if !hasEntry { - if err := s.server.gachaRepo.DeleteStepup(pkt.GachaID, s.charID); err != nil { - s.logger.Error("Failed to reset gacha stepup state", zap.Error(err)) + } else if err == nil { + // Only check for valid entry type if the stepup is fresh + hasEntry, _ := s.server.gachaRepo.HasEntryType(pkt.GachaID, step) + if !hasEntry { + if err := s.server.gachaRepo.DeleteStepup(pkt.GachaID, s.charID); err != nil { + s.logger.Error("Failed to reset gacha stepup state", zap.Error(err)) + } + step = 0 } - step = 0 } bf := byteframe.NewByteFrame() bf.WriteUint8(step) diff --git a/server/channelserver/repo_guild.go b/server/channelserver/repo_guild.go index 734de2969..68087a3d0 100644 --- a/server/channelserver/repo_guild.go +++ b/server/channelserver/repo_guild.go @@ -942,11 +942,26 @@ func (r *GuildRepository) ListInvitedCharacters(guildID uint32) ([]*ScoutedChara // RolloverDailyRP moves rp_today into rp_yesterday for all members of a guild, // then updates the guild's rp_reset_at timestamp. +// Uses SELECT FOR UPDATE to prevent concurrent rollovers from racing. func (r *GuildRepository) RolloverDailyRP(guildID uint32, noon time.Time) error { tx, err := r.db.Begin() if err != nil { return err } + // Lock the guild row and re-check whether rollover is still needed. + var rpResetAt time.Time + if err := tx.QueryRow( + `SELECT COALESCE(rp_reset_at, '2000-01-01'::timestamptz) FROM guilds WHERE id = $1 FOR UPDATE`, + guildID, + ).Scan(&rpResetAt); err != nil { + _ = tx.Rollback() + return err + } + if !rpResetAt.Before(noon) { + // Another goroutine already rolled over; nothing to do. + _ = tx.Rollback() + return nil + } if _, err := tx.Exec( `UPDATE guild_characters SET rp_yesterday = rp_today, rp_today = 0 WHERE guild_id = $1`, guildID,