diff --git a/CHANGELOG.md b/CHANGELOG.md index 9826e17a..e2f58d50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## [0.9.19] - 2025-09-01 +### 🚀 ML Models Runtime Preloading +- **🔧 models loading**: Перенесена предзагрузка ML моделей из Docker build в runtime startup + - Убрана предзагрузка из `Dockerfile` - модели теперь загружаются после монтирования `/dump` папки + - Добавлена async функция `preload_models()` в `services/search.py` для фоновой загрузки + - Интеграция предзагрузки в `lifespan` функцию `main.py` + - Использование `asyncio.run_in_executor()` для неблокирующей загрузки моделей + - Исправлена проблема с недоступностью `/dump` папки во время сборки Docker образа + ### 🔧 Reactions Type Compatibility Fix - **🐛 rating functions**: Исправлена ошибка `AttributeError: 'str' object has no attribute 'value'` в создании реакций - Функции `is_positive()` и `is_negative()` в `orm/rating.py` теперь поддерживают как `ReactionKind` enum, так и строки diff --git a/Dockerfile b/Dockerfile index 1a671731..b5332f16 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,9 +25,6 @@ RUN uv sync --no-install-project COPY . . RUN uv sync --no-editable -# 🚀 Предзагрузка HuggingFace моделей для ускорения первого запуска -RUN uv run python scripts/preload_models.py - # Установка Node.js LTS и npm RUN curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - && \ apt-get install -y nsolid \ diff --git a/main.py b/main.py index f49cb0d4..cafbd5aa 100644 --- a/main.py +++ b/main.py @@ -22,7 +22,7 @@ from auth.oauth import oauth_callback, oauth_login from cache.precache import precache_data from cache.revalidator import revalidation_manager from rbac import initialize_rbac -from services.search import check_search_service, initialize_search_index, search_service +from services.search import check_search_service, initialize_search_index, preload_models, search_service from services.viewed import ViewedStorage from settings import DEV_SERVER_PID_FILE_NAME from storage.redis import redis @@ -263,6 +263,11 @@ async def lifespan(app: Starlette): await initialize_search_index_with_data() print("[lifespan] Search service initialized with Muvera") + # 🚀 Предзагружаем ML модели после монтирования /dump + print("[lifespan] Starting ML models preloading...") + await preload_models() + print("[lifespan] ML models preloading completed") + yield finally: print("[lifespan] Shutting down application services") diff --git a/scripts/preload_models.py b/scripts/preload_models.py deleted file mode 100755 index e9b46d88..00000000 --- a/scripts/preload_models.py +++ /dev/null @@ -1,107 +0,0 @@ -#!/usr/bin/env python3 -""" -🚀 Предзагрузка HuggingFace моделей для кеширования в Docker - -Этот скрипт загружает модели заранее при сборке Docker образа, -чтобы избежать загрузки во время первого запуска приложения. -""" - -import os -import sys -from pathlib import Path - - -def get_models_cache_dir() -> str: - """Определяет лучшую папку для кеша моделей""" - # Пробуем /dump если доступен для записи - dump_path = Path("/dump") - print( - f"🔍 Checking /dump - exists: {dump_path.exists()}, writable: {os.access('/dump', os.W_OK) if dump_path.exists() else 'N/A'}" - ) - - if dump_path.exists() and os.access("/dump", os.W_OK): - cache_dir = "/dump/huggingface" - try: - Path(cache_dir).mkdir(parents=True, exist_ok=True) - print(f"✅ Using mounted storage: {cache_dir}") - return cache_dir - except Exception as e: - print(f"❌ Failed to create {cache_dir}: {e}") - - # Fallback - локальная папка ./dump - cache_dir = "./dump/huggingface" - Path(cache_dir).mkdir(parents=True, exist_ok=True) - print(f"📁 Using local fallback: {cache_dir}") - return cache_dir - - -# Настройка переменных окружения для кеша -MODELS_CACHE_DIR = get_models_cache_dir() -os.environ["TRANSFORMERS_CACHE"] = MODELS_CACHE_DIR -os.environ["HF_HOME"] = MODELS_CACHE_DIR - - -def is_model_cached(model_name: str) -> bool: - """🔍 Проверяет наличие модели в кеше""" - try: - cache_path = Path(MODELS_CACHE_DIR) - model_cache_name = f"models--sentence-transformers--{model_name}" - model_path = cache_path / model_cache_name - - if not model_path.exists(): - return False - - # Проверяем наличие snapshots папки (новый формат HuggingFace) - snapshots_path = model_path / "snapshots" - if snapshots_path.exists(): - # Ищем любой snapshot с config.json - for snapshot_dir in snapshots_path.iterdir(): - if snapshot_dir.is_dir(): - config_file = snapshot_dir / "config.json" - if config_file.exists(): - return True - - # Fallback: проверяем старый формат - config_file = model_path / "config.json" - return config_file.exists() - except Exception: - return False - - -try: - from sentence_transformers import SentenceTransformer - - # Создаем папку для кеша - Path(MODELS_CACHE_DIR).mkdir(parents=True, exist_ok=True) - print(f"📁 Created cache directory: {MODELS_CACHE_DIR}") - - # Список моделей для предзагрузки - models = [ - "paraphrase-multilingual-MiniLM-L12-v2", # Основная многоязычная модель - "all-MiniLM-L6-v2", # Fallback модель - ] - - for model_name in models: - try: - if is_model_cached(model_name): - print(f"🔍 Found cached model: {model_name}") - continue - - print(f"🔽 Downloading model: {model_name}") - model = SentenceTransformer(model_name, cache_folder=MODELS_CACHE_DIR) - print(f"✅ Successfully cached: {model_name}") - - # Освобождаем память - del model - - except Exception as e: - print(f"❌ Failed to download {model_name}: {e}") - - print("🚀 Model preloading completed!") - -except ImportError as e: - print(f"❌ Failed to import dependencies: {e}") - sys.exit(1) -except Exception as e: - print(f"❌ Unexpected error: {e}") - sys.exit(1) diff --git a/services/search.py b/services/search.py index b41449ca..81b94a84 100644 --- a/services/search.py +++ b/services/search.py @@ -51,6 +51,77 @@ os.environ.setdefault("HF_HOME", MODELS_CACHE_DIR) background_tasks: List[asyncio.Task] = [] +async def preload_models() -> None: + """🚀 Асинхронная предзагрузка моделей для кеширования""" + logger.info("🔄 Начинаем предзагрузку моделей...") + + # Ждем импорта SentenceTransformer + _lazy_import_sentence_transformers() + + if SentenceTransformer is None: + logger.error("❌ SentenceTransformer недоступен для предзагрузки") + return + + # Создаем папку для кеша + Path(MODELS_CACHE_DIR).mkdir(parents=True, exist_ok=True) + logger.info(f"📁 Используем кеш директорию: {MODELS_CACHE_DIR}") + + # Список моделей для предзагрузки + models = [ + "paraphrase-multilingual-MiniLM-L12-v2", # Основная многоязычная модель + "all-MiniLM-L6-v2", # Fallback модель + ] + + for model_name in models: + try: + # Проверяем, есть ли модель в кеше + if _is_model_cached(model_name): + logger.info(f"🔍 Модель уже в кеше: {model_name}") + continue + + logger.info(f"🔽 Загружаем модель: {model_name}") + + # Запускаем загрузку в executor чтобы не блокировать event loop + loop = asyncio.get_event_loop() + await loop.run_in_executor( + None, lambda name=model_name: SentenceTransformer(name, cache_folder=MODELS_CACHE_DIR) + ) + + logger.info(f"✅ Модель загружена: {model_name}") + + except Exception as e: + logger.warning(f"❌ Не удалось загрузить {model_name}: {e}") + + logger.info("🚀 Предзагрузка моделей завершена!") + + +def _is_model_cached(model_name: str) -> bool: + """🔍 Проверяет наличие модели в кеше""" + try: + cache_path = Path(MODELS_CACHE_DIR) + model_cache_name = f"models--sentence-transformers--{model_name}" + model_path = cache_path / model_cache_name + + if not model_path.exists(): + return False + + # Проверяем наличие snapshots папки (новый формат HuggingFace) + snapshots_path = model_path / "snapshots" + if snapshots_path.exists(): + # Ищем любой snapshot с config.json + for snapshot_dir in snapshots_path.iterdir(): + if snapshot_dir.is_dir(): + config_file = snapshot_dir / "config.json" + if config_file.exists(): + return True + + # Fallback: проверяем старый формат + config_file = model_path / "config.json" + return config_file.exists() + except Exception: + return False + + def _lazy_import_sentence_transformers(): """🔄 Lazy import SentenceTransformer для избежания блокировки старта приложения""" global SentenceTransformer # noqa: PLW0603 @@ -83,37 +154,6 @@ class MuveraWrapper: self.encoder = None self._model_loaded = False - def _is_model_cached(self, model_name: str) -> bool: - """🔍 Проверяет наличие модели в кеше""" - try: - # Проверяем наличие папки модели в кеше - cache_path = Path(MODELS_CACHE_DIR) - - # SentenceTransformer сохраняет модели в формате models--org--model-name - model_cache_name = f"models--sentence-transformers--{model_name}" - model_path = cache_path / model_cache_name - - # Проверяем существование папки модели - if not model_path.exists(): - return False - - # Проверяем наличие snapshots папки (новый формат HuggingFace) - snapshots_path = model_path / "snapshots" - if snapshots_path.exists(): - # Ищем любой snapshot с config.json - for snapshot_dir in snapshots_path.iterdir(): - if snapshot_dir.is_dir(): - config_file = snapshot_dir / "config.json" - if config_file.exists(): - return True - - # Fallback: проверяем старый формат - config_file = model_path / "config.json" - return config_file.exists() - except Exception as e: - logger.debug(f"Error checking model cache for {model_name}: {e}") - return False - def _ensure_model_loaded(self) -> bool: """🔄 Убеждаемся что модель загружена (lazy loading)""" if self._model_loaded: @@ -129,7 +169,7 @@ class MuveraWrapper: logger.info(f"💾 Using models cache directory: {MODELS_CACHE_DIR}") # Проверяем наличие основной модели - is_cached = self._is_model_cached(primary_model) + is_cached = _is_model_cached(primary_model) if is_cached: logger.info(f"🔍 Found cached model: {primary_model}") else: @@ -150,7 +190,7 @@ class MuveraWrapper: # Fallback - простая модель try: fallback_model = "all-MiniLM-L6-v2" - is_fallback_cached = self._is_model_cached(fallback_model) + is_fallback_cached = _is_model_cached(fallback_model) if is_fallback_cached: logger.info(f"🔍 Found cached fallback model: {fallback_model}") else: