Времена скачиваний. Раньше хранился только агрегат, поэтому в ленте было видно лишь «сколько раз» и «когда в последний». Добавлена таблица событий, раскрытие по клику на счётчик и точное время рядом с относительным. Метки округляются до минуты — и это не косметика. При нынешнем трафике (десятки загрузок в сутки, единицы роликов) микросекундная метка однозначно выделяет сеанс: тот, кто знает, когда человек заходил, узнал бы, что он скачал. Уже накопленные метки огрубляются одноразовой миграцией при старте, включая first_seen/last_seen ленты, где запись с единственным скачиванием публиковала точную секунду. Эндпоинт событий закрыт тем же лимитом частоты, что и остальные: он был единственным открытым. Просадка SQLite в 1700 раз. Прошлая починка утечки дескрипторов открывала и закрывала соединение на каждый вызов, а в режиме WAL SQLite выполняет контрольную точку при закрытии последнего соединения — то есть на каждой операции. Замер: 12 операций в секунду. Доступ и так сериализован глобальной блокировкой, поэтому теперь одно долгоживущее соединение: 20 203 операции в секунду, дескрипторов 3, прогон тестов ускорился с 90 секунд до двух. Молчаливая подмена формата. В каскаде не хватало ступени «снять фильтр кодека, но оставить склейку»: на сайтах с раздельными дорожками выбор проваливался мимо склейки к муксованному 360p, и пользователь получал не то, что заказывал, при верной подписи. Замыкающий вариант вдобавок игнорировал ограничение высоты и отдавал 1080p на запрос 360p. Продвинутый режим не работал на DASH-сайтах: регулярка отвергала настоящие идентификаторы формата, в которых остаются '=', ':', '@', '~'. Мусор от неудачных загрузок. Уборка частичных файлов стояла только в ветке отмены, а самый частый исход в проде — сетевой сбой, 403 или падение ffmpeg — оставлял до двух потолков размера на два часа. Он считается в квоте, и сервис начинал отвечать «нет места» здоровым пользователям. Сторож размера считал байты по одному файлу, поэтому связка video+audio давала вдвое больше потолка, а всё, что скачивалось быстрее интервала опроса, не проверялось вовсе. Снятие зависшей задачи не останавливало воркер: он вечно занимал слот из трёх, уборщик сносил файлы у него из-под рук, а по завершении ссылка всё равно уходила в ленту. Кнопка «Скачать» залипала навсегда. Ранний выход при «Выберите формат» стоял до try/finally, поэтому признак занятости не снимался и кнопка становилась некликабельной до перезагрузки страницы. Регрессия предыдущего коммита. Карточка замирала навсегда при 404 на поток прогресса: EventSource уходит в CLOSED и больше не переподключается, а порог попыток не достигался никогда, поэтому сообщение о потере связи не показывалось. Смена сортировки ленты молча терялась во время подгрузки — блокировка «уже гружу» отбрасывала запрос, кнопка выглядела нажатой, а данные ей не соответствовали. Заменено счётчиком поколений. Ошибки nginx приходят HTML-страницей, и разбор JSON падал сырым английским исключением прямо в интерфейс. Также: init и миграция больше не роняют сервис при недоступном хранилище (они вызываются на импорте, и исключение не давало воркеру подняться); миграция переносит и события, иначе история оставалась осиротевшей; чистка событий выполняется сразу при старте, а не через сутки сна, иначе обещанные 90 дней были неправдой при частых перезапусках; UnavailableVideoError больше не выдаётся за внутреннюю ошибку; размер страницы ленты берётся с сервера; добавлен фавикон. Тестов 199. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AwY2Cg54RK7WZdvMaLV5Di
178 lines
6.8 KiB
Python
178 lines
6.8 KiB
Python
"""Регрессии на гонки и живучесть, найденные аудитом.
|
||
|
||
Каждый тест здесь закрывает конкретный воспроизведённый баг, а не
|
||
гипотетический сценарий.
|
||
"""
|
||
import threading
|
||
import time
|
||
|
||
import pytest
|
||
|
||
import config
|
||
import downloader as dl
|
||
|
||
|
||
@pytest.fixture
|
||
def manager():
|
||
m = dl.DownloadManager()
|
||
yield m
|
||
|
||
|
||
def _task(m, status="finished", tid=None):
|
||
t = dl.Task(id=tid or ("a" * 32), url="https://example.com/v", title="Ролик",
|
||
fmt="best", extra={}, label="тест")
|
||
t.status = status
|
||
with m.lock:
|
||
m.tasks[t.id] = t
|
||
return t
|
||
|
||
|
||
# --- выдача файла ----------------------------------------------------------
|
||
|
||
def test_mark_served_is_atomic(manager):
|
||
"""Параллельные Range-запросы кратно завышали публичную статистику:
|
||
проверка served_at и присваивание шли по отдельности."""
|
||
t = _task(manager)
|
||
results = []
|
||
|
||
def grab():
|
||
results.append(manager.mark_served(t))
|
||
|
||
threads = [threading.Thread(target=grab) for _ in range(16)]
|
||
for th in threads:
|
||
th.start()
|
||
for th in threads:
|
||
th.join()
|
||
|
||
assert results.count(True) == 1, "первой выдачей может быть только одна"
|
||
assert results.count(False) == 15
|
||
|
||
|
||
# --- уборщик ---------------------------------------------------------------
|
||
|
||
def test_janitor_keeps_files_of_live_tasks(manager, monkeypatch):
|
||
"""Загрузка длиннее FILE_TTL теряла из-под yt-dlp уже скачанную
|
||
дорожку: страховочное удаление по mtime не смотрело на живые задачи."""
|
||
monkeypatch.setattr(config, "FILE_TTL_MINUTES", 0)
|
||
config.DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||
live = _task(manager, status="downloading", tid="b" * 32)
|
||
part = config.DOWNLOAD_DIR / f"{live.id}.f137.mp4.part"
|
||
part.write_bytes(b"data")
|
||
old = config.DOWNLOAD_DIR / "cccccccc.mp4"
|
||
old.write_bytes(b"junk")
|
||
# состарим оба файла
|
||
past = time.time() - 3600
|
||
import os
|
||
os.utime(part, (past, past))
|
||
os.utime(old, (past, past))
|
||
|
||
try:
|
||
manager._janitor_pass()
|
||
assert part.exists(), "файл живой задачи удалён"
|
||
assert not old.exists(), "чужой протухший файл должен был уйти"
|
||
finally:
|
||
part.unlink(missing_ok=True)
|
||
old.unlink(missing_ok=True)
|
||
|
||
|
||
def test_janitor_evicts_terminal_tasks(manager, monkeypatch):
|
||
monkeypatch.setattr(config, "TASK_TTL_MINUTES", 0)
|
||
t = _task(manager, status="finished", tid="d" * 32)
|
||
t.created_at = time.time() - 10
|
||
manager._janitor_pass()
|
||
assert manager.get(t.id) is None
|
||
|
||
|
||
def test_janitor_removes_stuck_task(manager, monkeypatch):
|
||
"""Задача в нетерминальном статусе не выселялась НИКОГДА: карточки
|
||
копились до TASKS_MAX, и сервис отвечал вечным «перегружен»."""
|
||
monkeypatch.setattr(config, "TASK_TTL_MINUTES", 1)
|
||
monkeypatch.setattr(config, "STUCK_TASK_SEC", 60)
|
||
t = _task(manager, status="downloading", tid="e" * 32)
|
||
t.created_at = time.time() - 10_000
|
||
manager._janitor_pass()
|
||
stuck = manager.get(t.id)
|
||
assert stuck.status == "error"
|
||
# Без флага отмены воркер продолжал работать и вечно занимал слот,
|
||
# а уборщик сносил файлы у него из-под рук.
|
||
assert stuck.cancel.is_set()
|
||
|
||
|
||
def test_janitor_keeps_gitkeep(manager, monkeypatch):
|
||
monkeypatch.setattr(config, "FILE_TTL_MINUTES", 0)
|
||
config.DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||
keep = config.DOWNLOAD_DIR / ".gitkeep"
|
||
keep.write_bytes(b"")
|
||
import os
|
||
past = time.time() - 3600
|
||
os.utime(keep, (past, past))
|
||
manager._janitor_pass()
|
||
assert keep.exists(), "маркер каталога удалён уборщиком"
|
||
|
||
|
||
# --- очередь и пул ---------------------------------------------------------
|
||
|
||
def test_queue_overflow_is_rejected(manager, monkeypatch):
|
||
"""Переполнение должно давать честный отказ сразу, а не молчаливое
|
||
ожидание в очереди.
|
||
|
||
Очередь набиваем не вручную: постоянные воркеры её тут же разбирают
|
||
(что само по себе подтверждает их живучесть). Поэтому имитируем
|
||
переполнение на самой постановке.
|
||
"""
|
||
import queue as _q
|
||
|
||
def full(_item):
|
||
raise _q.Full
|
||
|
||
monkeypatch.setattr(manager.queue, "put_nowait", full)
|
||
with pytest.raises(dl.Overloaded):
|
||
manager.create(url="https://example.com/v", fmt="best", extra={},
|
||
label="l", title="t", thumbnail=None)
|
||
# карточка не должна остаться висеть после отказа
|
||
assert manager.health()["tasks_total"] == 0
|
||
|
||
|
||
def test_tasks_cap_is_enforced(manager, monkeypatch):
|
||
monkeypatch.setattr(config, "TASKS_MAX", 1)
|
||
_task(manager, tid="f" * 32)
|
||
with pytest.raises(dl.Overloaded):
|
||
manager.create(url="https://example.com/v", fmt="best", extra={},
|
||
label="l", title="t", thumbnail=None)
|
||
|
||
|
||
def test_worker_survives_failing_task(manager):
|
||
"""Воркер умирал молча, и пул усыхал до нуля."""
|
||
m = dl.DownloadManager()
|
||
done = threading.Event()
|
||
seen = []
|
||
m.on_complete = lambda t: (seen.append(t.status), done.set())
|
||
|
||
def boom(task):
|
||
raise BaseException("внезапно") # noqa: TRY002
|
||
|
||
m._run = boom
|
||
t = dl.Task(id="g" * 32, url="https://example.com/v", title="t",
|
||
fmt="best", extra={}, label="l")
|
||
with m.lock:
|
||
m.tasks[t.id] = t
|
||
m.queue.put_nowait(t)
|
||
|
||
assert done.wait(timeout=5), "on_complete не вызван — воркер умер"
|
||
assert seen == ["error"]
|
||
# пул всё ещё разбирает очередь
|
||
done2 = threading.Event()
|
||
m.on_complete = lambda t: done2.set()
|
||
t2 = dl.Task(id="h" * 32, url="https://example.com/v", title="t",
|
||
fmt="best", extra={}, label="l")
|
||
m.queue.put_nowait(t2)
|
||
assert done2.wait(timeout=5), "после сбоя пул перестал работать"
|
||
|
||
|
||
# --- здоровье --------------------------------------------------------------
|
||
|
||
def test_health_reports_numbers(manager):
|
||
h = manager.health()
|
||
for key in ("free_disk_mb", "downloads_mb", "tasks_total",
|
||
"tasks_active", "queue_pending", "janitor_age_sec"):
|
||
assert key in h
|