Fix: 로딩 중 강제 종료 시 stale waiting room 자동 정리
All checks were successful
Server CI/CD / lint-and-build (push) Successful in 38s
Server CI/CD / deploy (push) Successful in 55s

로딩 화면에서 강제 종료하면 BossRoom이 waiting 상태로 남아
재입장이 영구 차단되는 문제 수정.

- waiting 상태 2분 초과 BossRoom 자동 정리 (15초 주기)
- RequestEntry 시 해당 유저의 stale waiting room 선제 정리
- 연결된 RoomSlot도 idle로 리셋

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-20 11:00:36 +09:00
parent 4bbab002ea
commit 7ece5f3c44
2 changed files with 85 additions and 2 deletions

View File

@@ -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) {

View File

@@ -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.