fix: condición de carrera al unirse múltiples jugadores
- Agrega sistema de locks en Redis para operaciones de sala - add_player, remove_player y change_team ahora son atómicos - Previene sobrescritura de estado cuando jugadores se unen simultáneamente - Nuevo método change_player_team con lock integrado Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,17 @@
|
|||||||
import json
|
import json
|
||||||
import random
|
import random
|
||||||
import string
|
import string
|
||||||
|
import asyncio
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
import redis.asyncio as redis
|
import redis.asyncio as redis
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
|
# Lock timeout in seconds
|
||||||
|
LOCK_TIMEOUT = 5
|
||||||
|
LOCK_RETRY_DELAY = 0.05 # 50ms
|
||||||
|
|
||||||
|
|
||||||
class RoomManager:
|
class RoomManager:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@@ -20,6 +25,31 @@ class RoomManager:
|
|||||||
if self.redis:
|
if self.redis:
|
||||||
await self.redis.close()
|
await self.redis.close()
|
||||||
|
|
||||||
|
async def _acquire_lock(self, room_code: str, timeout: float = LOCK_TIMEOUT) -> bool:
|
||||||
|
"""Acquire a lock for room operations."""
|
||||||
|
await self.connect()
|
||||||
|
lock_key = f"lock:room:{room_code}"
|
||||||
|
# Try to acquire lock with NX (only if not exists) and EX (expire)
|
||||||
|
acquired = await self.redis.set(lock_key, "1", nx=True, ex=int(timeout))
|
||||||
|
return acquired is not None
|
||||||
|
|
||||||
|
async def _release_lock(self, room_code: str):
|
||||||
|
"""Release a room lock."""
|
||||||
|
await self.connect()
|
||||||
|
lock_key = f"lock:room:{room_code}"
|
||||||
|
await self.redis.delete(lock_key)
|
||||||
|
|
||||||
|
async def _with_lock(self, room_code: str, operation, max_retries: int = 20):
|
||||||
|
"""Execute an operation with a room lock."""
|
||||||
|
for attempt in range(max_retries):
|
||||||
|
if await self._acquire_lock(room_code):
|
||||||
|
try:
|
||||||
|
return await operation()
|
||||||
|
finally:
|
||||||
|
await self._release_lock(room_code)
|
||||||
|
await asyncio.sleep(LOCK_RETRY_DELAY)
|
||||||
|
raise Exception(f"Could not acquire lock for room {room_code}")
|
||||||
|
|
||||||
def _generate_room_code(self) -> str:
|
def _generate_room_code(self) -> str:
|
||||||
"""Generate a 6-character room code."""
|
"""Generate a 6-character room code."""
|
||||||
return ''.join(random.choices(string.ascii_uppercase + string.digits, k=6))
|
return ''.join(random.choices(string.ascii_uppercase + string.digits, k=6))
|
||||||
@@ -88,78 +118,160 @@ class RoomManager:
|
|||||||
team: str,
|
team: str,
|
||||||
socket_id: str
|
socket_id: str
|
||||||
) -> Optional[dict]:
|
) -> Optional[dict]:
|
||||||
"""Add a player to a room."""
|
"""Add a player to a room (with lock to prevent race conditions)."""
|
||||||
room = await self.get_room(room_code)
|
|
||||||
if not room:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Check if team is full
|
|
||||||
if len(room["teams"][team]) >= 4:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Check if name is taken
|
|
||||||
for t in ["A", "B"]:
|
|
||||||
for p in room["teams"][t]:
|
|
||||||
if p["name"].lower() == player_name.lower():
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Add player
|
|
||||||
player = {
|
|
||||||
"name": player_name,
|
|
||||||
"team": team,
|
|
||||||
"position": len(room["teams"][team]),
|
|
||||||
"socket_id": socket_id
|
|
||||||
}
|
|
||||||
room["teams"][team].append(player)
|
|
||||||
|
|
||||||
# Save player mapping
|
|
||||||
await self.redis.setex(
|
|
||||||
f"player:{socket_id}",
|
|
||||||
3600 * 3,
|
|
||||||
json.dumps({"name": player_name, "room": room_code, "team": team})
|
|
||||||
)
|
|
||||||
|
|
||||||
await self.update_room(room_code, room)
|
|
||||||
return room
|
|
||||||
|
|
||||||
async def remove_player(self, socket_id: str) -> Optional[dict]:
|
|
||||||
"""Remove a player from their room."""
|
|
||||||
await self.connect()
|
await self.connect()
|
||||||
|
|
||||||
# Get player info
|
async def _do_add_player():
|
||||||
|
room = await self.get_room(room_code)
|
||||||
|
if not room:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check if team is full
|
||||||
|
if len(room["teams"][team]) >= 4:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check if name is taken
|
||||||
|
for t in ["A", "B"]:
|
||||||
|
for p in room["teams"][t]:
|
||||||
|
if p["name"].lower() == player_name.lower():
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Add player
|
||||||
|
player = {
|
||||||
|
"name": player_name,
|
||||||
|
"team": team,
|
||||||
|
"position": len(room["teams"][team]),
|
||||||
|
"socket_id": socket_id
|
||||||
|
}
|
||||||
|
room["teams"][team].append(player)
|
||||||
|
|
||||||
|
# Save player mapping
|
||||||
|
await self.redis.setex(
|
||||||
|
f"player:{socket_id}",
|
||||||
|
3600 * 3,
|
||||||
|
json.dumps({"name": player_name, "room": room_code, "team": team})
|
||||||
|
)
|
||||||
|
|
||||||
|
await self.update_room(room_code, room)
|
||||||
|
return room
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await self._with_lock(room_code, _do_add_player)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error adding player: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def remove_player(self, socket_id: str) -> Optional[dict]:
|
||||||
|
"""Remove a player from their room (with lock)."""
|
||||||
|
await self.connect()
|
||||||
|
|
||||||
|
# Get player info first (outside lock)
|
||||||
player_data = await self.redis.get(f"player:{socket_id}")
|
player_data = await self.redis.get(f"player:{socket_id}")
|
||||||
if not player_data:
|
if not player_data:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
player_info = json.loads(player_data)
|
player_info = json.loads(player_data)
|
||||||
room_code = player_info["room"]
|
room_code = player_info["room"]
|
||||||
team = player_info["team"]
|
|
||||||
|
|
||||||
# Get room
|
async def _do_remove_player():
|
||||||
room = await self.get_room(room_code)
|
# Get room
|
||||||
if not room:
|
room = await self.get_room(room_code)
|
||||||
|
if not room:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Remove player from both teams (in case of inconsistency)
|
||||||
|
for t in ["A", "B"]:
|
||||||
|
room["teams"][t] = [
|
||||||
|
p for p in room["teams"][t] if p["socket_id"] != socket_id
|
||||||
|
]
|
||||||
|
# Update positions
|
||||||
|
for i, p in enumerate(room["teams"][t]):
|
||||||
|
p["position"] = i
|
||||||
|
|
||||||
|
# Delete player mapping
|
||||||
|
await self.redis.delete(f"player:{socket_id}")
|
||||||
|
|
||||||
|
# If room is empty, delete it
|
||||||
|
if not room["teams"]["A"] and not room["teams"]["B"]:
|
||||||
|
await self.redis.delete(f"room:{room_code}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
await self.update_room(room_code, room)
|
||||||
|
return room
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await self._with_lock(room_code, _do_remove_player)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error removing player: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Remove player from team
|
async def change_player_team(
|
||||||
room["teams"][team] = [
|
self,
|
||||||
p for p in room["teams"][team] if p["socket_id"] != socket_id
|
room_code: str,
|
||||||
]
|
player_name: str,
|
||||||
|
socket_id: str,
|
||||||
|
new_team: str
|
||||||
|
) -> Optional[dict]:
|
||||||
|
"""Change a player's team (with lock)."""
|
||||||
|
await self.connect()
|
||||||
|
|
||||||
# Update positions
|
async def _do_change_team():
|
||||||
for i, p in enumerate(room["teams"][team]):
|
room = await self.get_room(room_code)
|
||||||
p["position"] = i
|
if not room:
|
||||||
|
return None
|
||||||
|
|
||||||
# Delete player mapping
|
# Find current team
|
||||||
await self.redis.delete(f"player:{socket_id}")
|
current_team = None
|
||||||
|
for t in ["A", "B"]:
|
||||||
|
for p in room["teams"][t]:
|
||||||
|
if p["name"] == player_name:
|
||||||
|
current_team = t
|
||||||
|
break
|
||||||
|
if current_team:
|
||||||
|
break
|
||||||
|
|
||||||
# If room is empty, delete it
|
# If already on target team, just return room
|
||||||
if not room["teams"]["A"] and not room["teams"]["B"]:
|
if current_team == new_team:
|
||||||
await self.redis.delete(f"room:{room_code}")
|
return room
|
||||||
|
|
||||||
|
# Check if target team is full
|
||||||
|
if len(room["teams"][new_team]) >= 4:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Remove from both teams (safety)
|
||||||
|
for t in ["A", "B"]:
|
||||||
|
room["teams"][t] = [p for p in room["teams"][t] if p["name"] != player_name]
|
||||||
|
|
||||||
|
# Add to new team
|
||||||
|
player = {
|
||||||
|
"name": player_name,
|
||||||
|
"team": new_team,
|
||||||
|
"position": len(room["teams"][new_team]),
|
||||||
|
"socket_id": socket_id
|
||||||
|
}
|
||||||
|
room["teams"][new_team].append(player)
|
||||||
|
|
||||||
|
# Update positions in both teams
|
||||||
|
for t in ["A", "B"]:
|
||||||
|
for i, p in enumerate(room["teams"][t]):
|
||||||
|
p["position"] = i
|
||||||
|
|
||||||
|
# Update player record
|
||||||
|
await self.redis.setex(
|
||||||
|
f"player:{socket_id}",
|
||||||
|
3600 * 3,
|
||||||
|
json.dumps({"name": player_name, "room": room_code, "team": new_team})
|
||||||
|
)
|
||||||
|
|
||||||
|
await self.update_room(room_code, room)
|
||||||
|
return room
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await self._with_lock(room_code, _do_change_team)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error changing team: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
await self.update_room(room_code, room)
|
|
||||||
return room
|
|
||||||
|
|
||||||
async def get_player(self, socket_id: str) -> Optional[dict]:
|
async def get_player(self, socket_id: str) -> Optional[dict]:
|
||||||
"""Get player info by socket ID."""
|
"""Get player info by socket ID."""
|
||||||
await self.connect()
|
await self.connect()
|
||||||
|
|||||||
@@ -189,14 +189,13 @@ def register_socket_events(sio: socketio.AsyncServer):
|
|||||||
|
|
||||||
room_code = player["room"]
|
room_code = player["room"]
|
||||||
new_team = data.get("team")
|
new_team = data.get("team")
|
||||||
current_team = player["team"]
|
|
||||||
|
|
||||||
# Don't do anything if already on that team
|
# Use room_manager method with lock to prevent race conditions
|
||||||
if current_team == new_team:
|
room = await room_manager.change_player_team(
|
||||||
return
|
room_code, player["name"], sid, new_team
|
||||||
|
)
|
||||||
|
|
||||||
room = await room_manager.get_room(room_code)
|
if not room:
|
||||||
if not room or len(room["teams"][new_team]) >= 4:
|
|
||||||
await sio.emit(
|
await sio.emit(
|
||||||
"error",
|
"error",
|
||||||
{"message": "Cannot change team. It may be full."},
|
{"message": "Cannot change team. It may be full."},
|
||||||
@@ -204,30 +203,6 @@ def register_socket_events(sio: socketio.AsyncServer):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Remove from current team (by socket_id to be safe)
|
|
||||||
room["teams"][current_team] = [
|
|
||||||
p for p in room["teams"][current_team] if p["socket_id"] != sid
|
|
||||||
]
|
|
||||||
|
|
||||||
# Also remove from new team if somehow already there (prevent duplicates)
|
|
||||||
room["teams"][new_team] = [
|
|
||||||
p for p in room["teams"][new_team] if p["socket_id"] != sid
|
|
||||||
]
|
|
||||||
|
|
||||||
# Add to new team
|
|
||||||
room["teams"][new_team].append({
|
|
||||||
"name": player["name"],
|
|
||||||
"team": new_team,
|
|
||||||
"position": len(room["teams"][new_team]),
|
|
||||||
"socket_id": sid
|
|
||||||
})
|
|
||||||
|
|
||||||
# Update room state
|
|
||||||
await room_manager.update_room(room_code, room)
|
|
||||||
|
|
||||||
# Update player record with new team
|
|
||||||
await room_manager.update_player(sid, {"team": new_team})
|
|
||||||
|
|
||||||
await sio.emit("team_changed", {"room": room}, room=room_code)
|
await sio.emit("team_changed", {"room": room}, room=room_code)
|
||||||
|
|
||||||
@sio.event
|
@sio.event
|
||||||
|
|||||||
Reference in New Issue
Block a user