fixes global
This commit is contained in:
1 parent
13dc4f39c8
commit
cad0f6aebe
64 files changed
+10379
-254
No files matched your search
@@ -0,0 +1,271 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import asdict
|
||||
from typing import Dict, Any, Optional, List, Tuple
|
||||
|
||||
from app.core.converter.converter_client import ConverterClient
|
||||
from app.core.crypto.content_cipher import ContentCipher
|
||||
from app.core.content.chunk_manager import ChunkManager
|
||||
from app.core.models.converter.conversion_models import (
|
||||
ConversionTask,
|
||||
ConversionResult,
|
||||
ConversionStatus,
|
||||
ConversionPriority,
|
||||
ContentMetadata,
|
||||
)
|
||||
from app.core.stats.metrics_collector import MetricsCollector
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _PriorityQueue:
|
||||
"""
|
||||
Простая приоритетная очередь на базе asyncio.PriorityQueue.
|
||||
Чем больше приоритет, тем раньше задача (инвертируем знак).
|
||||
"""
|
||||
def __init__(self) -> None:
|
||||
self._q: asyncio.PriorityQueue[Tuple[int, str, ConversionTask]] = asyncio.PriorityQueue()
|
||||
self._counter = 0 # стабилизация порядка
|
||||
|
||||
async def put(self, task: ConversionTask) -> None:
|
||||
self._counter += 1
|
||||
# Инвертируем, чтобы HIGH(90) шел раньше LOW(10)
|
||||
await self._q.put((-int(task.priority), self._counter, task))
|
||||
|
||||
async def get(self) -> ConversionTask:
|
||||
p, _, t = await self._q.get()
|
||||
return t
|
||||
|
||||
def empty(self) -> bool:
|
||||
return self._q.empty()
|
||||
|
||||
|
||||
class ConversionManager:
|
||||
"""
|
||||
Управляет жизненным циклом конвертации:
|
||||
- постановка в очередь (приоритет)
|
||||
- запуск через ConverterClient
|
||||
- post-processing: шифрование ContentCipher, чанкинг ChunkManager
|
||||
- retry при ошибках
|
||||
- метрики через MetricsCollector
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
converter_client: Optional[ConverterClient] = None,
|
||||
metrics: Optional[MetricsCollector] = None,
|
||||
concurrent_limit: int = 2,
|
||||
) -> None:
|
||||
self._client = converter_client or ConverterClient()
|
||||
self._cipher = ContentCipher()
|
||||
self._chunker = ChunkManager(self._cipher)
|
||||
self._metrics = metrics or MetricsCollector()
|
||||
|
||||
self._queue = _PriorityQueue()
|
||||
self._inflight: Dict[str, ConversionTask] = {}
|
||||
self._results: Dict[str, ConversionResult] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
self._sem = asyncio.Semaphore(concurrent_limit)
|
||||
|
||||
# -------------------- Public API --------------------
|
||||
|
||||
async def process_upload(
|
||||
self,
|
||||
local_input_path: str,
|
||||
input_ext: str,
|
||||
quality: str,
|
||||
metadata: ContentMetadata,
|
||||
priority: ConversionPriority = ConversionPriority.NORMAL,
|
||||
custom: Optional[List[str]] = None,
|
||||
trim: Optional[str] = None,
|
||||
max_retries: int = 3,
|
||||
) -> str:
|
||||
"""
|
||||
Точка входа из API: ставит задачу в очередь и возвращает task_id.
|
||||
"""
|
||||
task_id = str(uuid.uuid4())
|
||||
task = ConversionTask(
|
||||
task_id=task_id,
|
||||
input_path=local_input_path,
|
||||
input_ext=input_ext,
|
||||
quality="high" if quality == "high" else "low",
|
||||
trim=trim,
|
||||
custom=custom or [],
|
||||
priority=priority,
|
||||
max_retries=max_retries,
|
||||
metadata=metadata,
|
||||
)
|
||||
await self.queue_conversion(task)
|
||||
return task_id
|
||||
|
||||
async def queue_conversion(self, task: ConversionTask) -> None:
|
||||
logger.info("Queue conversion task_id=%s priority=%s", task.task_id, task.priority)
|
||||
await self._queue.put(task)
|
||||
await self._metrics.inc_requests()
|
||||
|
||||
async def get_conversion_status(self, task_id: str) -> ConversionStatus:
|
||||
async with self._lock:
|
||||
res = self._results.get(task_id)
|
||||
if res:
|
||||
return res.status
|
||||
if task_id in self._inflight:
|
||||
return ConversionStatus.RUNNING
|
||||
# иначе он в очереди
|
||||
return ConversionStatus.QUEUED
|
||||
|
||||
async def handle_conversion_result(self, task_id: str) -> Optional[ConversionResult]:
|
||||
"""
|
||||
Возвращает итоговый ConversionResult если уже готов.
|
||||
"""
|
||||
async with self._lock:
|
||||
return self._results.get(task_id)
|
||||
|
||||
# -------------------- Worker logic --------------------
|
||||
|
||||
async def _run_single(self, task: ConversionTask) -> None:
|
||||
"""
|
||||
Полный цикл одной задачи: запуск конвертера, шифрование, чанкинг, сохранение результата.
|
||||
"""
|
||||
start_ts = time.time()
|
||||
async with self._sem:
|
||||
async with self._lock:
|
||||
self._inflight[task.task_id] = task
|
||||
|
||||
try:
|
||||
# 1) Запуск конвертера
|
||||
await self._metrics.observe_latency_ms(1) # лёгкий трейс
|
||||
await self._client.submit_conversion(task, task.input_path)
|
||||
|
||||
# 2) Ожидание завершения: опрашиваем статус, затем забираем результат
|
||||
status = await self._poll_until_done(task.task_id)
|
||||
conv_res = await self._client.download_result(task.task_id)
|
||||
if status != ConversionStatus.SUCCESS or conv_res.status != ConversionStatus.SUCCESS:
|
||||
raise RuntimeError(conv_res.error or "conversion failed")
|
||||
|
||||
# 3) Прочитать выходной файл и выполнить шифрование + чанкинг
|
||||
output_path = conv_res.converter_output_path
|
||||
if not output_path or not os.path.exists(output_path):
|
||||
raise FileNotFoundError("converted output not found")
|
||||
|
||||
with open(output_path, "rb") as f:
|
||||
converted_bytes = f.read()
|
||||
|
||||
# Шифрование полной сущности перед чанкингом
|
||||
content_key = self._cipher.generate_content_key()
|
||||
encrypted_obj = self._cipher.encrypt_content(
|
||||
plaintext=converted_bytes,
|
||||
key=content_key,
|
||||
metadata={
|
||||
"title": task.metadata.title,
|
||||
"author": task.metadata.author,
|
||||
"description": task.metadata.description,
|
||||
"attributes": task.metadata.attributes,
|
||||
"quality": task.quality,
|
||||
"source_ext": task.input_ext,
|
||||
},
|
||||
)
|
||||
content_id = encrypted_obj["content_id"]
|
||||
|
||||
# Для дедупликации и совместимости чанкуем уже шифротекст по архитектуре:
|
||||
# Используем nonce/tag каждого чанка отдельно (ChunkManager делает encrypt_content для каждого чанка).
|
||||
# Но нам нужен plaintext для разбиения на куски до шифрования? В архитектуре зашифрованные чанки требуются.
|
||||
# Следуем текущей реализации ChunkManager: он сам шифрует куски.
|
||||
chunks = self._chunker.split_content(
|
||||
content_id=content_id,
|
||||
plaintext=converted_bytes,
|
||||
content_key=content_key,
|
||||
metadata={
|
||||
"nft_title": task.metadata.title,
|
||||
"nft_author": task.metadata.author,
|
||||
"quality": task.quality,
|
||||
},
|
||||
)
|
||||
|
||||
# Сериализуем чанки для отдачи через API
|
||||
chunks_serialized = [asdict(c) for c in chunks]
|
||||
|
||||
nft_metadata = {
|
||||
"name": task.metadata.title,
|
||||
"description": task.metadata.description,
|
||||
"author": task.metadata.author,
|
||||
"attributes": task.metadata.attributes,
|
||||
"tags": task.metadata.tags,
|
||||
"collection": task.metadata.collection,
|
||||
"external_url": None,
|
||||
}
|
||||
|
||||
result = ConversionResult(
|
||||
task_id=task.task_id,
|
||||
status=ConversionStatus.SUCCESS,
|
||||
converter_output_path=output_path,
|
||||
logs_path=None,
|
||||
content_id=content_id,
|
||||
chunks=chunks_serialized,
|
||||
nft_metadata=nft_metadata,
|
||||
finished_at=int(time.time()),
|
||||
)
|
||||
|
||||
async with self._lock:
|
||||
self._results[task.task_id] = result
|
||||
self._inflight.pop(task.task_id, None)
|
||||
|
||||
await self._metrics.inc_conversions()
|
||||
await self._metrics.observe_latency_ms((time.time() - start_ts) * 1000.0)
|
||||
logger.info("Conversion completed: task_id=%s content_id=%s chunks=%d",
|
||||
task.task_id, content_id, len(chunks))
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Conversion task %s failed: %s", task.task_id, e)
|
||||
task.attempts += 1
|
||||
if task.attempts <= task.max_retries:
|
||||
# Retry: возвращаем задачу в очередь с тем же приоритетом (экспоненциальная пауза)
|
||||
backoff = min(2 ** (task.attempts - 1), 30)
|
||||
await asyncio.sleep(backoff)
|
||||
await self._queue.put(task)
|
||||
await self._metrics.inc_errors()
|
||||
else:
|
||||
fail_res = ConversionResult(
|
||||
task_id=task.task_id,
|
||||
status=ConversionStatus.FAILED,
|
||||
error=str(e),
|
||||
finished_at=int(time.time()),
|
||||
)
|
||||
async with self._lock:
|
||||
self._results[task.task_id] = fail_res
|
||||
self._inflight.pop(task.task_id, None)
|
||||
await self._metrics.inc_errors()
|
||||
|
||||
async def _poll_until_done(self, task_id: str, interval_sec: float = 1.0, timeout_sec: float = 3600.0) -> ConversionStatus:
|
||||
"""
|
||||
Простой polling статуса процесса конвертера.
|
||||
"""
|
||||
start = time.time()
|
||||
while True:
|
||||
status = await self._client.get_conversion_status(task_id)
|
||||
if status in (ConversionStatus.SUCCESS, ConversionStatus.FAILED, ConversionStatus.CANCELED):
|
||||
return status
|
||||
if time.time() - start > timeout_sec:
|
||||
return ConversionStatus.FAILED
|
||||
await asyncio.sleep(interval_sec)
|
||||
|
||||
# -------------------- Scheduler loop --------------------
|
||||
|
||||
async def run_scheduler(self, shutdown_event: Optional[asyncio.Event] = None) -> None:
|
||||
"""
|
||||
Основной цикл: достаёт из очереди и обрабатывает задачи.
|
||||
"""
|
||||
while True:
|
||||
if shutdown_event and shutdown_event.is_set():
|
||||
break
|
||||
try:
|
||||
task = await self._queue.get()
|
||||
asyncio.create_task(self._run_single(task))
|
||||
except Exception as e:
|
||||
logger.error("Scheduler loop error: %s", e)
|
||||
await asyncio.sleep(1.0)
|
||||
@@ -0,0 +1,214 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shlex
|
||||
import uuid
|
||||
from dataclasses import asdict
|
||||
from typing import Dict, Any, Optional, Tuple, List
|
||||
|
||||
from app.core.models.converter.conversion_models import ConversionTask, ConversionResult, ConversionStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConverterClient:
|
||||
"""
|
||||
Клиент-адаптер для взаимодействия с converter-module без модификации его кода.
|
||||
|
||||
Предполагаемая интеграция:
|
||||
- converter-module/converter/converter.py запускается как отдельный процесс (например, Docker/Podman или локальный python)
|
||||
- входной файл должен быть доступен по фиксированному пути /app/input
|
||||
- выход сохраняется в /app/output/output.<ext> и метаданные в /app/output/output.json
|
||||
- параметры: --ext, --quality, --custom (список), --trim "start-end"
|
||||
|
||||
Данный клиент предоставляет унифицированный async API:
|
||||
submit_conversion() -> str (task_id)
|
||||
get_conversion_status(task_id) -> ConversionStatus
|
||||
download_result(task_id) -> ConversionResult (локальные пути к артефактам)
|
||||
|
||||
Реализация по умолчанию использует локальный запуск python-процесса конвертера.
|
||||
Для контейнеров можно переопределить _build_command/_prepare_io.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
converter_entry: str = "converter-module/converter/converter.py",
|
||||
workdir: str = "converter-module",
|
||||
io_input_path: str = "/app/input",
|
||||
io_output_dir: str = "/app/output",
|
||||
python_bin: str = "python3",
|
||||
concurrent_limit: int = 2,
|
||||
) -> None:
|
||||
self.converter_entry = converter_entry
|
||||
self.workdir = workdir
|
||||
self.io_input_path = io_input_path
|
||||
self.io_output_dir = io_output_dir
|
||||
self.python_bin = python_bin
|
||||
self._sem = asyncio.Semaphore(concurrent_limit)
|
||||
|
||||
# Локальное состояние задач (простая in-memory мапа процессов)
|
||||
self._tasks_proc: Dict[str, asyncio.subprocess.Process] = {}
|
||||
self._tasks_info: Dict[str, Dict[str, Any]] = {} # {task_id: {local_input, local_output_dir, logs_path}}
|
||||
self._tasks_status: Dict[str, ConversionStatus] = {}
|
||||
self._tasks_error: Dict[str, str] = {}
|
||||
|
||||
os.makedirs(self.workdir, exist_ok=True)
|
||||
|
||||
async def submit_conversion(self, task: ConversionTask, local_input_path: str) -> str:
|
||||
"""
|
||||
Подготовка окружения и запуск конвертации.
|
||||
local_input_path — путь к исходному файлу на диске ноды uploader-bot.
|
||||
"""
|
||||
task_id = task.task_id or str(uuid.uuid4())
|
||||
logger.info("Submitting conversion task_id=%s", task_id)
|
||||
|
||||
# Готовим IO: копируем/линкуем файл в ожидаемое место converter-module
|
||||
local_output_dir, logs_path = await self._prepare_io(task_id, local_input_path)
|
||||
|
||||
# Формируем команду запуска
|
||||
cmd = self._build_command(task)
|
||||
logger.debug("Converter command: %s", " ".join(map(shlex.quote, cmd)))
|
||||
|
||||
# Старт процесса
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
cwd=self.workdir,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
)
|
||||
self._tasks_proc[task_id] = proc
|
||||
self._tasks_status[task_id] = ConversionStatus.RUNNING
|
||||
self._tasks_info[task_id] = {
|
||||
"local_input": local_input_path,
|
||||
"local_output_dir": local_output_dir,
|
||||
"logs_path": logs_path,
|
||||
}
|
||||
|
||||
# Запускаем корутину логгирования и ожидания завершения
|
||||
asyncio.create_task(self._stream_and_wait(task_id, proc, logs_path))
|
||||
return task_id
|
||||
|
||||
async def get_conversion_status(self, task_id: str) -> ConversionStatus:
|
||||
return self._tasks_status.get(task_id, ConversionStatus.QUEUED)
|
||||
|
||||
async def download_result(self, task_id: str) -> ConversionResult:
|
||||
"""
|
||||
Возвращает результат: путь к сгенерированному файлу и output.json.
|
||||
Ничего не копирует, возвращает локальные пути внутри converter-module рабочего каталога.
|
||||
"""
|
||||
status = self._tasks_status.get(task_id)
|
||||
if not status:
|
||||
return ConversionResult(task_id=task_id, status=ConversionStatus.FAILED, error="unknown task")
|
||||
info = self._tasks_info.get(task_id, {})
|
||||
output_dir = info.get("local_output_dir")
|
||||
logs_path = info.get("logs_path")
|
||||
|
||||
if status != ConversionStatus.SUCCESS:
|
||||
return ConversionResult(task_id=task_id, status=status, logs_path=logs_path, error=self._tasks_error.get(task_id))
|
||||
|
||||
# Определяем финальный файл: ищем output.* в каталоге вывода
|
||||
output_file = await self._detect_output_file(output_dir)
|
||||
if not output_file:
|
||||
return ConversionResult(task_id=task_id, status=ConversionStatus.FAILED, logs_path=logs_path, error="output file not found")
|
||||
|
||||
return ConversionResult(
|
||||
task_id=task_id,
|
||||
status=ConversionStatus.SUCCESS,
|
||||
converter_output_path=output_file,
|
||||
logs_path=logs_path,
|
||||
)
|
||||
|
||||
# -------------------- helpers --------------------
|
||||
|
||||
async def _prepare_io(self, task_id: str, local_input_path: str) -> Tuple[str, str]:
|
||||
"""
|
||||
Подготавливает папки converter-module для запуска и логи.
|
||||
Мы не можем писать в абсолютные /app/* на хосте, но converter ждёт такие пути.
|
||||
Поэтому используем симлинки внутри workdir: workdir/app/input -> реальный файл.
|
||||
"""
|
||||
# Готовим подкаталоги
|
||||
app_dir = os.path.join(self.workdir, "app")
|
||||
os.makedirs(app_dir, exist_ok=True)
|
||||
|
||||
linked_input = os.path.join(app_dir, "input")
|
||||
# Чистим старый симлинк/файл
|
||||
try:
|
||||
if os.path.islink(linked_input) or os.path.exists(linked_input):
|
||||
os.remove(linked_input)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to cleanup old input link: %s", e)
|
||||
# Создаем симлинк на входной файл
|
||||
os.symlink(os.path.abspath(local_input_path), linked_input)
|
||||
|
||||
output_dir = os.path.join(app_dir, "output")
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
# Очистим выходы
|
||||
for name in os.listdir(output_dir):
|
||||
try:
|
||||
os.remove(os.path.join(output_dir, name))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logs_dir = os.path.join(self.workdir, "logs")
|
||||
os.makedirs(logs_dir, exist_ok=True)
|
||||
logs_path = os.path.join(logs_dir, f"{task_id}.log")
|
||||
|
||||
# Сопоставляем ожидаемые фиксированные пути converter'а с нашими
|
||||
# Хотя converter использует /app/input и /app/output, cwd=self.workdir и наличие app/input, app/output достаточно.
|
||||
return output_dir, logs_path
|
||||
|
||||
def _build_command(self, task: ConversionTask) -> List[str]:
|
||||
cmd: List[str] = [
|
||||
self.python_bin,
|
||||
self.converter_entry,
|
||||
"--ext", task.input_ext,
|
||||
"--quality", task.quality,
|
||||
]
|
||||
if task.custom:
|
||||
cmd += ["--custom", *task.custom]
|
||||
if task.trim:
|
||||
cmd += ["--trim", task.trim]
|
||||
return cmd
|
||||
|
||||
async def _stream_and_wait(self, task_id: str, proc: asyncio.subprocess.Process, logs_path: str) -> None:
|
||||
"""
|
||||
Стримит логи процесса в файл и обновляет статус по завершению.
|
||||
"""
|
||||
try:
|
||||
with open(logs_path, "a", encoding="utf-8") as lf:
|
||||
if proc.stdout:
|
||||
async for line in proc.stdout:
|
||||
try:
|
||||
text = line.decode("utf-8", errors="ignore")
|
||||
except AttributeError:
|
||||
text = line
|
||||
lf.write(text)
|
||||
lf.flush()
|
||||
logger.info("[converter %s] %s", task_id, text.strip())
|
||||
rc = await proc.wait()
|
||||
if rc == 0:
|
||||
self._tasks_status[task_id] = ConversionStatus.SUCCESS
|
||||
else:
|
||||
self._tasks_status[task_id] = ConversionStatus.FAILED
|
||||
self._tasks_error[task_id] = f"exit_code={rc}"
|
||||
except Exception as e:
|
||||
logger.exception("Converter task %s failed: %s", task_id, e)
|
||||
self._tasks_status[task_id] = ConversionStatus.FAILED
|
||||
self._tasks_error[task_id] = str(e)
|
||||
|
||||
async def _detect_output_file(self, output_dir: str) -> Optional[str]:
|
||||
"""
|
||||
Ищет файл output.* в каталоге результата.
|
||||
"""
|
||||
try:
|
||||
for name in os.listdir(output_dir):
|
||||
if name.startswith("output."):
|
||||
return os.path.join(output_dir, name)
|
||||
if name.startswith("output") and "." in name:
|
||||
return os.path.join(output_dir, name)
|
||||
except Exception as e:
|
||||
logger.error("detect_output_file error: %s", e)
|
||||
return None
|
||||
Reference in new issue
Block a user