|
3 | 3 | This module contains common utilities for building and versioning project documents. |
4 | 4 | """ |
5 | 5 |
|
| 6 | +import logging |
| 7 | +import re |
6 | 8 | from typing import cast |
7 | 9 |
|
8 | 10 | from aiohttp import web |
9 | 11 | from models_library.api_schemas_webserver.projects import ( |
10 | 12 | ProjectDocument, |
11 | 13 | ProjectDocumentVersion, |
12 | 14 | ) |
| 15 | +from models_library.api_schemas_webserver.socketio import SocketIORoomStr |
13 | 16 | from models_library.projects import ProjectID, ProjectTemplateType |
14 | 17 | from models_library.projects import ProjectType as ProjectTypeAPI |
| 18 | +from servicelib.logging_errors import create_troubleshootting_log_kwargs |
| 19 | +from servicelib.logging_utils import log_context |
15 | 20 | from servicelib.redis import ( |
16 | 21 | PROJECT_DB_UPDATE_REDIS_LOCK_KEY, |
17 | 22 | exclusive, |
|
22 | 27 | get_redis_document_manager_client_sdk, |
23 | 28 | get_redis_lock_manager_client_sdk, |
24 | 29 | ) |
| 30 | +from ..resource_manager.registry import get_registry |
| 31 | +from ..resource_manager.service import list_opened_project_ids |
| 32 | +from ..socketio._utils import get_socket_server |
25 | 33 | from . import _projects_repository |
26 | 34 |
|
| 35 | +_logger = logging.getLogger(__name__) |
| 36 | + |
27 | 37 |
|
28 | 38 | async def create_project_document_and_increment_version( |
29 | 39 | app: web.Application, project_uuid: ProjectID |
@@ -84,3 +94,110 @@ async def _create_project_document_and_increment_version() -> ( |
84 | 94 | return project_document, document_version |
85 | 95 |
|
86 | 96 | return await _create_project_document_and_increment_version() |
| 97 | + |
| 98 | + |
| 99 | +async def remove_project_documents_as_admin(app: web.Application) -> None: |
| 100 | + """Admin function to clean up project documents for projects with no connected users. |
| 101 | +
|
| 102 | + This function scans through all project documents in the Redis DOCUMENTS database, |
| 103 | + checks if there are any users currently connected to the project room via socketio, |
| 104 | + and removes documents that have no connected users. |
| 105 | + """ |
| 106 | + with log_context( |
| 107 | + _logger, |
| 108 | + logging.INFO, |
| 109 | + msg="Project document cleanup started", |
| 110 | + ): |
| 111 | + # Get Redis document manager client to access the DOCUMENTS database |
| 112 | + redis_client = get_redis_document_manager_client_sdk(app) |
| 113 | + |
| 114 | + # Pattern to match project document keys - looking for keys that contain project UUIDs |
| 115 | + project_document_pattern = "projects:*:version" |
| 116 | + |
| 117 | + # Get socketio server instance |
| 118 | + sio = get_socket_server(app) |
| 119 | + |
| 120 | + # Get known opened projects ids based on Redis resources table |
| 121 | + registry = get_registry(app) |
| 122 | + known_opened_project_ids = await list_opened_project_ids(registry) |
| 123 | + known_opened_project_ids_set = set(known_opened_project_ids) |
| 124 | + |
| 125 | + projects_removed = 0 |
| 126 | + |
| 127 | + # Scan through all project document keys |
| 128 | + async for key in redis_client.redis.scan_iter( |
| 129 | + match=project_document_pattern, count=1000 |
| 130 | + ): |
| 131 | + # Extract project UUID from the key pattern "projects:{project_uuid}:version" |
| 132 | + key_str = key.decode("utf-8") if isinstance(key, bytes) else key |
| 133 | + match = re.match(r"projects:(?P<project_uuid>[0-9a-f-]+):version", key_str) |
| 134 | + |
| 135 | + if not match: |
| 136 | + continue |
| 137 | + |
| 138 | + project_uuid_str = match.group("project_uuid") |
| 139 | + project_uuid = ProjectID(project_uuid_str) |
| 140 | + project_room = SocketIORoomStr.from_project_id(project_uuid) |
| 141 | + |
| 142 | + # 1. CHECK - Check if the project UUID is in the known opened projects |
| 143 | + if project_uuid in known_opened_project_ids_set: |
| 144 | + _logger.debug( |
| 145 | + "Project %s is in Redis Resources table (which means Project is opened), keeping document", |
| 146 | + project_uuid, |
| 147 | + ) |
| 148 | + continue |
| 149 | + |
| 150 | + # 2. CHECK - Check if there are any users connected to this project room |
| 151 | + try: |
| 152 | + # Get all session IDs (socket IDs) in the project room |
| 153 | + room_sessions = list( |
| 154 | + sio.manager.get_participants(namespace="/", room=project_room) |
| 155 | + ) |
| 156 | + |
| 157 | + # If no users are connected to this project room, remove the document |
| 158 | + if not room_sessions: |
| 159 | + await redis_client.redis.delete(key_str) |
| 160 | + projects_removed += 1 |
| 161 | + _logger.info( |
| 162 | + "Removed project document for project %s (no connected users)", |
| 163 | + project_uuid, |
| 164 | + ) |
| 165 | + else: |
| 166 | + # Create a synthetic exception for this unexpected state |
| 167 | + unexpected_state_error = RuntimeError( |
| 168 | + f"Project {project_uuid} has {len(room_sessions)} connected users but is not in Redis Resources table" |
| 169 | + ) |
| 170 | + _logger.error( |
| 171 | + **create_troubleshootting_log_kwargs( |
| 172 | + user_error_msg=f"Project {project_uuid} has {len(room_sessions)} connected users in the socket io room (This is not expected, as project resource is not in the Redis Resources table), keeping document just in case", |
| 173 | + error=unexpected_state_error, |
| 174 | + error_context={ |
| 175 | + "project_uuid": str(project_uuid), |
| 176 | + "project_room": project_room, |
| 177 | + "key_str": key_str, |
| 178 | + "connected_users_count": len(room_sessions), |
| 179 | + "room_sessions": room_sessions[ |
| 180 | + :5 |
| 181 | + ], # Limit to first 5 sessions for debugging |
| 182 | + }, |
| 183 | + tip="This indicates a potential race condition or inconsistency between the Redis Resources table and socketio room state. Check if the project was recently closed but users are still connected, or if there's a synchronization issue between services.", |
| 184 | + ) |
| 185 | + ) |
| 186 | + continue |
| 187 | + |
| 188 | + except (KeyError, AttributeError, ValueError) as exc: |
| 189 | + _logger.exception( |
| 190 | + **create_troubleshootting_log_kwargs( |
| 191 | + user_error_msg=f"Failed to check room participants for project {project_uuid}", |
| 192 | + error=exc, |
| 193 | + error_context={ |
| 194 | + "project_uuid": str(project_uuid), |
| 195 | + "project_room": project_room, |
| 196 | + "key_str": key_str, |
| 197 | + }, |
| 198 | + tip="Check if socketio server is properly initialized and the room exists. This could indicate a socketio manager issue or invalid room format.", |
| 199 | + ) |
| 200 | + ) |
| 201 | + continue |
| 202 | + |
| 203 | + _logger.info("Completed: removed %d project documents", projects_removed) |
0 commit comments