From 7ece5f3c44a6dc8575078273a5c875d935bb502e Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Fri, 20 Mar 2026 11:00:36 +0900 Subject: [PATCH] =?UTF-8?q?Fix:=20=EB=A1=9C=EB=94=A9=20=EC=A4=91=20?= =?UTF-8?q?=EA=B0=95=EC=A0=9C=20=EC=A2=85=EB=A3=8C=20=EC=8B=9C=20stale=20w?= =?UTF-8?q?aiting=20room=20=EC=9E=90=EB=8F=99=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 로딩 화면에서 강제 종료하면 BossRoom이 waiting 상태로 남아 재입장이 영구 차단되는 문제 수정. - waiting 상태 2분 초과 BossRoom 자동 정리 (15초 주기) - RequestEntry 시 해당 유저의 stale waiting room 선제 정리 - 연결된 RoomSlot도 idle로 리셋 Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/bossraid/repository.go | 46 +++++++++++++++++++++++++++++++++ internal/bossraid/service.go | 41 +++++++++++++++++++++++++++-- 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/internal/bossraid/repository.go b/internal/bossraid/repository.go index 52cfc7b..ef5f774 100644 --- a/internal/bossraid/repository.go +++ b/internal/bossraid/repository.go @@ -64,6 +64,18 @@ func (r *Repository) CountActiveByUsername(username string) (int64, error) { return count, err } +// FindStaleWaitingRoomsByUsername returns waiting rooms containing the given username +// that were created before the threshold. +func (r *Repository) FindStaleWaitingRoomsByUsername(username string, threshold time.Time) ([]BossRoom, error) { + escaped := strings.NewReplacer("%", "\\%", "_", "\\_").Replace(username) + search := `"` + escaped + `"` + var rooms []BossRoom + err := r.db.Where("status = ? AND players LIKE ? AND created_at < ?", + StatusWaiting, "%"+search+"%", threshold). + Find(&rooms).Error + return rooms, err +} + // --- DedicatedServer & RoomSlot --- // UpsertDedicatedServer creates or updates a server group by name. @@ -213,6 +225,40 @@ func (r *Repository) DeleteRoomBySessionName(sessionName string) error { return r.db.Unscoped().Where("session_name = ?", sessionName).Delete(&BossRoom{}).Error } +// CleanupStaleWaitingRooms deletes BossRoom records stuck in "waiting" status +// past the given threshold and resets their associated RoomSlots to idle. +// This handles cases where players disconnect during loading before the Fusion session starts. +func (r *Repository) CleanupStaleWaitingRooms(threshold time.Time) (int64, error) { + // 1. waiting 상태에서 threshold보다 오래된 방 조회 + var staleRooms []BossRoom + if err := r.db.Where("status = ? AND created_at < ?", StatusWaiting, threshold). + Find(&staleRooms).Error; err != nil { + return 0, err + } + if len(staleRooms) == 0 { + return 0, nil + } + + // 2. 연결된 슬롯을 idle로 리셋 + staleSessionNames := make([]string, len(staleRooms)) + for i, room := range staleRooms { + staleSessionNames[i] = room.SessionName + } + r.db.Model(&RoomSlot{}). + Where("session_name IN ? AND status = ?", staleSessionNames, SlotWaiting). + Updates(map[string]interface{}{ + "status": SlotIdle, + "boss_room_id": nil, + }) + + // 3. BossRoom 레코드 하드 삭제 + result := r.db.Unscoped(). + Where("status = ? AND created_at < ?", StatusWaiting, threshold). + Delete(&BossRoom{}) + + return result.RowsAffected, result.Error +} + // ResetStaleSlots clears instanceID for slots with stale heartbeats // and resets any active raids on those slots. func (r *Repository) ResetStaleSlots(threshold time.Time) (int64, error) { diff --git a/internal/bossraid/service.go b/internal/bossraid/service.go index ffbe582..f393503 100644 --- a/internal/bossraid/service.go +++ b/internal/bossraid/service.go @@ -22,6 +22,9 @@ const ( entryTokenPrefix = "bossraid:entry:" // pendingEntryPrefix is the Redis key prefix for username → {sessionName, entryToken}. pendingEntryPrefix = "bossraid:pending:" + // waitingRoomTimeout is the maximum time a room can stay in "waiting" status + // before being considered stale and cleaned up. Covers loading + Fusion connection + retries. + waitingRoomTimeout = 2 * time.Minute ) // entryTokenData is stored in Redis for each entry token. @@ -56,8 +59,10 @@ func (s *Service) SetExpGranter(fn func(username string, exp int) error) { // Allocates an idle room slot from a registered dedicated server. // Returns the room with assigned session name. func (s *Service) RequestEntry(usernames []string, bossID int) (*BossRoom, error) { - // 좀비 슬롯 정리 — idle 슬롯 검색 전에 stale 인스턴스를 리셋 + // 좀비 슬롯 정리 — idle 슬롯 검색 전에 stale 인스턴스와 대기방을 리셋 s.CheckStaleSlots() + // 입장 요청 플레이어들의 stale waiting room 선제 정리 + s.cleanupStaleWaitingForUsers(usernames) if len(usernames) == 0 { return nil, fmt.Errorf("플레이어 목록이 비어있습니다") @@ -408,6 +413,26 @@ func (s *Service) RequestEntryWithTokens(usernames []string, bossID int) (*BossR return room, tokens, nil } +// cleanupStaleWaitingForUsers checks if any of the given users are stuck in +// a stale waiting room (older than waitingRoomTimeout) and cleans them up. +// This provides instant resolution for players who force-quit during loading. +func (s *Service) cleanupStaleWaitingForUsers(usernames []string) { + threshold := time.Now().Add(-waitingRoomTimeout) + for _, username := range usernames { + rooms, err := s.repo.FindStaleWaitingRoomsByUsername(username, threshold) + if err != nil || len(rooms) == 0 { + continue + } + for _, room := range rooms { + log.Printf("stale 대기방 정리: session=%s, player=%s", room.SessionName, username) + if err := s.repo.DeleteRoomBySessionName(room.SessionName); err != nil { + log.Printf("stale 대기방 삭제 실패: %v", err) + } + _ = s.repo.ResetRoomSlot(room.SessionName) + } + } +} + // --- Dedicated Server Management --- const staleTimeout = 30 * time.Second @@ -458,7 +483,8 @@ func (s *Service) Heartbeat(instanceID string) error { return s.repo.UpdateHeartbeat(instanceID) } -// CheckStaleSlots resets slots whose instances have gone silent. +// CheckStaleSlots resets slots whose instances have gone silent +// and cleans up waiting rooms that have exceeded the timeout. func (s *Service) CheckStaleSlots() { threshold := time.Now().Add(-staleTimeout) count, err := s.repo.ResetStaleSlots(threshold) @@ -469,6 +495,17 @@ func (s *Service) CheckStaleSlots() { if count > 0 { log.Printf("스태일 슬롯 %d개 리셋", count) } + + // waiting 상태로 너무 오래 머문 방 정리 (로딩 중 강제 종료 등) + waitingThreshold := time.Now().Add(-waitingRoomTimeout) + cleaned, err := s.repo.CleanupStaleWaitingRooms(waitingThreshold) + if err != nil { + log.Printf("스태일 대기방 정리 실패: %v", err) + return + } + if cleaned > 0 { + log.Printf("스태일 대기방 %d개 정리", cleaned) + } } // ResetRoom resets a room slot back to idle and cleans up any lingering BossRoom records.