yt-dlp-gui/tests/test_lifecycle.py
loop-uh 16ee3f594b Квота папки загрузок: старые готовые файлы вытесняются, а не дают отказ
10.10.2026 девять роликов за 12 минут набрали 5,46 ГБ при квоте 5 ГБ, и
сервис отвечал 503 «На сервере временно нет места» при 947 ГБ свободного
диска: место освобождали только таймеры (30 минут после выдачи, 120 без неё).

DownloadManager._evict_for_quota() удаляет готовые файлы при превышении
квоты: ничьи, затем отлежавшие YTG_EVICT_MIN_AGE_SEC (300 с), затем свежие;
забранный раньше незабранного, старый раньше нового. Файлы идущих загрузок
не трогаются. Зовётся из quota_exceeded() перед отказом и из уборщика.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:54:35 +03:00

277 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Регрессии на гонки и живучесть, найденные аудитом.
Каждый тест здесь закрывает конкретный воспроизведённый баг, а не
гипотетический сценарий.
"""
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(), "маркер каталога удалён уборщиком"
# --- вытеснение по квоте ---------------------------------------------------
@pytest.fixture
def quota(monkeypatch):
"""Квота в 1 МБ и чистый каталог: три файла по 400 КБ её превышают."""
monkeypatch.setattr(config, "DISK_QUOTA_MB", 1)
monkeypatch.setattr(config, "MIN_FREE_DISK_MB", 0)
monkeypatch.setattr(config, "EVICT_MIN_AGE_SEC", 300)
config.DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True)
def wipe():
for p in config.DOWNLOAD_DIR.iterdir():
if p.is_file() and p.name != ".gitkeep":
p.unlink()
wipe()
yield
wipe()
def _ready_file(m, tid, age, status="finished", served_ago=None):
"""Задача с файлом в 400 КБ, готовым `age` секунд назад."""
import os
t = _task(m, status=status, tid=tid * 32)
p = config.DOWNLOAD_DIR / f"{t.id}.mp4"
p.write_bytes(b"\0" * 400_000)
when = time.time() - age
os.utime(p, (when, when))
if status == "finished":
t.filename = p.name
if served_ago is not None:
t.served_at = time.time() - served_ago
return t, p
def test_quota_evicts_oldest_instead_of_refusing(manager, quota):
"""Квота только отказывала: готовые файлы ждали своего срока, и сервис
отвечал «нет места» всем при пустом диске (10.10.2026)."""
_, oldest = _ready_file(manager, "1", age=3000)
_, middle = _ready_file(manager, "2", age=2000)
_, newest = _ready_file(manager, "3", age=1000)
assert manager.quota_exceeded() is False
assert not oldest.exists(), "самый старый файл должен был уйти"
assert middle.exists() and newest.exists(), "удалено больше, чем нужно"
def test_quota_evicts_served_before_unclaimed(manager, quota):
_, unclaimed = _ready_file(manager, "1", age=3000)
_, other = _ready_file(manager, "2", age=2000)
taken, served = _ready_file(manager, "3", age=1000, served_ago=600)
assert manager.quota_exceeded() is False
assert not served.exists(), "забранный файл уходит раньше незабранных"
assert unclaimed.exists() and other.exists()
assert taken.status == "served" and taken.filename is None
def test_quota_spares_fresh_files_while_older_suffice(manager, quota):
_, aged = _ready_file(manager, "1", age=400)
_, fresh = _ready_file(manager, "2", age=10)
_, taken = _ready_file(manager, "3", age=200, served_ago=5)
assert manager.quota_exceeded() is False
assert not aged.exists()
assert fresh.exists() and taken.exists(), "свежие удалены раньше отлежавшего"
def test_quota_evicts_fresh_files_rather_than_refusing(manager, quota):
"""Срок — предпочтение, а не запрет: всплеск свежих файлов не должен
возвращать отказ."""
_, first = _ready_file(manager, "1", age=30)
_, second = _ready_file(manager, "2", age=20)
_, third = _ready_file(manager, "3", age=10)
assert manager.quota_exceeded() is False
assert not first.exists()
assert second.exists() and third.exists()
def test_quota_never_evicts_live_downloads(manager, quota):
"""Отказ остаётся только тогда, когда квоту занимают идущие загрузки."""
files = [_ready_file(manager, c, age=3000, status="downloading")[1]
for c in "123"]
assert manager.quota_exceeded() is True
assert all(p.exists() for p in files), "удалён файл идущей загрузки"
def test_janitor_brings_folder_under_quota(manager, quota):
_, oldest = _ready_file(manager, "1", age=3000)
_ready_file(manager, "2", age=2000)
_ready_file(manager, "3", age=1000)
manager._janitor_pass()
assert not oldest.exists()
assert manager.health()["downloads_mb"] < config.DISK_QUOTA_MB
# --- очередь и пул ---------------------------------------------------------
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