perf(media): хелпер путей медиа-кеша + in-memory MIME-кеш на горячем пути /media
Docker Image CI / build (pull_request) Waiting to run

Задание 15: MEDIA_CACHE_DIR (резолвится один раз при импорте) + media_cache_path(
channel, post_id, fid=None) -> <root>/<channel>/<post_id>[/<fid>] как единый
источник истины формата пути. Заменены все ручные сборки os.path.abspath('./data/
cache')+join (7 мест); строки путей дословно идентичны (drop-in). remove_old_
cached_files_sync/download_new_files сохраняют параметр cache_dir (тестируемость).

Задание 16: in-memory _mime_types (bound _MIME_CACHE_MAX=50000, clear() при
переполнении) перед SQLite в prepare_file_response. Порядок: dict -> SQLite ->
python-magic; SQLite-хит и magic-детект наполняют dict. MIME по file_unique_id
иммутабелен -> повторный /media-хит не открывает SQLite-соединение (ни на чтение
MIME, ни на запись access-time — она уже в аккумуляторе). Кэшируются только
реальные типы.

closes #26

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-10 00:57:32 +03:00
parent 3c9ce72b51
commit 7e17b6e5b8
6 changed files with 229 additions and 15 deletions
+17
View File
@@ -22,3 +22,20 @@ sys.modules['config'] = _mock_config
# without this pin RSS <pubDate> values drift between machines (this sandbox is MSK).
os.environ['TZ'] = 'UTC'
time.tzset()
import pytest
@pytest.fixture(autouse=True)
def _reset_media_mime_cache():
"""Clear the process-lifetime MIME dict before each test (issue #26).
``api_server._mime_types`` persists for the whole process, so an entry populated by one
test would otherwise leak into another and mask a get/magic call the next test asserts on.
"""
try:
import api_server
api_server._mime_types.clear()
except Exception:
pass
yield
+1
View File
@@ -111,6 +111,7 @@ async def test_download_atomic_race_loser_keeps_existing_final(monkeypatch, tmp_
# --------------------------------------------------------------------------- #
async def test_concurrent_large_video_no_partial_served(monkeypatch, tmp_path):
monkeypatch.chdir(tmp_path) # download_media_file writes under ./data/cache
monkeypatch.setattr(api_server, "MEDIA_CACHE_DIR", str(tmp_path / "data" / "cache"))
msg = SimpleNamespace(
media=MessageMediaType.VIDEO,
+3
View File
@@ -46,6 +46,7 @@ def _clean_accumulator():
@pytest.mark.asyncio
async def test_cache_hit_records_accumulator_no_sqlite(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(api_server, "MEDIA_CACHE_DIR", str(tmp_path / "data" / "cache"))
channel, post_id, fid = "testchan", 5, "fidHIT"
cache_dir = tmp_path / "data" / "cache" / channel / str(post_id)
cache_dir.mkdir(parents=True)
@@ -77,6 +78,7 @@ async def test_cache_hit_records_accumulator_no_sqlite(tmp_path, monkeypatch):
@pytest.mark.asyncio
async def test_get_media_pre_semaphore_cache_hit_no_sqlite(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(api_server, "MEDIA_CACHE_DIR", str(tmp_path / "data" / "cache"))
channel, post_id, fid = "gmchan", 11, "fidGM"
cache_dir = tmp_path / "data" / "cache" / channel / str(post_id)
cache_dir.mkdir(parents=True)
@@ -108,6 +110,7 @@ async def test_get_media_pre_semaphore_cache_hit_no_sqlite(tmp_path, monkeypatch
@pytest.mark.asyncio
async def test_cache_hit_keys_channel_as_str(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(api_server, "MEDIA_CACHE_DIR", str(tmp_path / "data" / "cache"))
channel_int, post_id, fid = 12345, 7, "fidINT"
cache_dir = tmp_path / "data" / "cache" / str(channel_int) / str(post_id)
cache_dir.mkdir(parents=True)
+4
View File
@@ -73,6 +73,7 @@ def _seed_cache(tmp_path, channel, post_id, fid, body=BODY):
# --------------------------------------------------------------------------- #
def test_media_route_range_prefix_0_99(monkeypatch, tmp_path):
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(api_server, "MEDIA_CACHE_DIR", str(tmp_path / "data" / "cache"))
_seed_cache(tmp_path, "chan", 3, "fidR")
monkeypatch.setattr(api_server, "verify_media_digest", lambda url, digest: True)
# Keep the MIME path DB-free; the FileResponse/Range machinery is what we exercise.
@@ -90,6 +91,7 @@ def test_media_route_range_prefix_0_99(monkeypatch, tmp_path):
def test_media_route_range_suffix_last_100(monkeypatch, tmp_path):
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(api_server, "MEDIA_CACHE_DIR", str(tmp_path / "data" / "cache"))
_seed_cache(tmp_path, "chan", 3, "fidS")
monkeypatch.setattr(api_server, "verify_media_digest", lambda url, digest: True)
monkeypatch.setattr(api_server, "get_mime_type_sync", lambda *a, **k: None)
@@ -105,6 +107,7 @@ def test_media_route_range_suffix_last_100(monkeypatch, tmp_path):
def test_media_route_range_unsatisfiable_416(monkeypatch, tmp_path):
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(api_server, "MEDIA_CACHE_DIR", str(tmp_path / "data" / "cache"))
_seed_cache(tmp_path, "chan", 3, "fidU")
monkeypatch.setattr(api_server, "verify_media_digest", lambda url, digest: True)
monkeypatch.setattr(api_server, "get_mime_type_sync", lambda *a, **k: None)
@@ -298,6 +301,7 @@ async def test_get_media_request_cancel_does_not_stick_registry_or_hang_sibling(
# --------------------------------------------------------------------------- #
async def test_media_cache_hit_flush_updates_str_keyed_db_row(monkeypatch, tmp_path):
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(api_server, "MEDIA_CACHE_DIR", str(tmp_path / "data" / "cache"))
api_server._access_updates = {}
channel, post_id, fid = "12345", 7, "fidINT" # int-ish channel, passed as the route str
+161
View File
@@ -0,0 +1,161 @@
# flake8: noqa
# pylint: disable=protected-access, missing-function-docstring, missing-class-docstring
# pylint: disable=redefined-outer-name, line-too-long
"""
Issue #26 package E — media-cache path helper + in-memory MIME cache on the hot /media path.
Covers:
- media_cache_path builds EXACTLY the pre-existing on-disk layout (path-format regression).
- The in-memory _mime_types dict short-circuits the SQLite MIME read on a repeat hit
(get_mime_type_sync not called a second time; no new SQLite connection opened).
- _mime_types overflow (>= _MIME_CACHE_MAX) clears the dict without raising and repopulates.
"""
import os
import pytest
from fastapi import FastAPI, Request
from fastapi.testclient import TestClient
import file_io
import api_server
BODY = bytes(range(256)) * 8 # 2048 deterministic bytes
def _make_client(file_path, media_key=None):
"""Drive prepare_file_response through real ASGI (as the stage-3 tests do)."""
app = FastAPI()
@app.get("/f")
async def _serve(request: Request):
return await api_server.prepare_file_response(file_path, request=request, media_key=media_key)
return TestClient(app)
@pytest.fixture
def sample_file(tmp_path):
fp = tmp_path / "myfile.bin"
fp.write_bytes(BODY)
return str(fp)
# --------------------------------------------------------------------------- #
# Task 15 — media_cache_path path-format regression.
# --------------------------------------------------------------------------- #
def test_media_cache_path_layout():
root = api_server.MEDIA_CACHE_DIR
# The constant must resolve to the exact absolute string every legacy site built.
assert root == os.path.abspath("./data/cache")
# Without file_unique_id -> the per-post directory <root>/<channel>/<post_id>.
assert api_server.media_cache_path("mychan", 42) == os.path.join(root, "mychan", "42")
# With file_unique_id -> the full per-file path <root>/<channel>/<post_id>/<fid>.
assert api_server.media_cache_path("mychan", 42, "fidABC") == os.path.join(root, "mychan", "42", "fidABC")
# Faithful drop-in: identical to the old manual assembly (incl. int channel stringified).
assert api_server.media_cache_path(-100123, 7, "fidABC") == os.path.join(
os.path.abspath("./data/cache"), "-100123", "7", "fidABC"
)
# --------------------------------------------------------------------------- #
# Task 16 — in-memory MIME cache short-circuits the SQLite read on a repeat hit.
# --------------------------------------------------------------------------- #
def test_second_request_served_from_dict_not_sqlite(sample_file, monkeypatch):
calls = {"get": 0, "magic": 0}
def fake_get(db, ch, pid, fid):
calls["get"] += 1
return "video/mp4" # SQLite HIT on the first (dict-miss) request
def fake_magic(_path):
calls["magic"] += 1
return "application/octet-stream"
monkeypatch.setattr(api_server, "get_mime_type_sync", fake_get)
monkeypatch.setattr(api_server.magic_mime, "from_file", fake_magic)
key = ("chanE", 100, "fidE")
c = _make_client(sample_file, media_key=key)
r1 = c.get("/f")
r2 = c.get("/f")
assert r1.status_code == 200 and r2.status_code == 200
assert r1.headers["content-type"].startswith("video/mp4")
assert r2.headers["content-type"].startswith("video/mp4")
# First request populated the dict from SQLite; the second is served from the dict.
assert calls["get"] == 1
assert calls["magic"] == 0
assert api_server._mime_types[key] == "video/mp4"
def test_repeat_hit_opens_no_new_db_connection(tmp_path, sample_file, monkeypatch):
"""Acceptance recipe: monkeypatch file_io._open_db with a counter; two consecutive
requests for the same file — the counter must NOT increase on the second."""
db_path = str(tmp_path / "media.db")
file_io.init_db_sync(db_path)
# Seed a row whose MIME is already stored, so the first request reads it from SQLite
# (no python-magic, no set-write) and caches it in the dict.
file_io.upsert_media_file_id_sync(db_path, "chanDB", 55, "fidDB", 0.0)
file_io.set_mime_type_sync(db_path, "chanDB", 55, "fidDB", "image/png")
monkeypatch.setattr(api_server, "DB_PATH", db_path)
count = {"n": 0}
orig_open = file_io._open_db
def counting_open(p):
count["n"] += 1
return orig_open(p)
monkeypatch.setattr(file_io, "_open_db", counting_open)
c = _make_client(sample_file, media_key=("chanDB", 55, "fidDB"))
r1 = c.get("/f")
after_first = count["n"]
r2 = c.get("/f")
after_second = count["n"]
assert r1.status_code == 200 and r2.status_code == 200
assert r1.headers["content-type"].startswith("image/png")
assert r2.headers["content-type"].startswith("image/png")
assert after_first >= 1 # the first (dict-miss) hit did open a connection for the read
# The repeat hit opened NO new SQLite connection.
assert after_second == after_first
# --------------------------------------------------------------------------- #
# Task 16 — overflow clears the dict without raising, then repopulates.
# --------------------------------------------------------------------------- #
def test_mime_cache_overflow_clears_and_repopulates(sample_file, monkeypatch):
def fake_get(db, ch, pid, fid):
return "text/plain"
monkeypatch.setattr(api_server, "get_mime_type_sync", fake_get)
monkeypatch.setattr(api_server.magic_mime, "from_file", lambda _p: "text/plain")
# Fill the dict up to the bound with dummy entries.
api_server._mime_types.clear()
for i in range(api_server._MIME_CACHE_MAX):
api_server._mime_types[("dummy", i, "f")] = "x/y"
assert len(api_server._mime_types) == api_server._MIME_CACHE_MAX
key = ("chanOverflow", 1, "fidOverflow")
c = _make_client(sample_file, media_key=key)
r = c.get("/f") # must clear-all on overflow, then insert the new key — no exception
assert r.status_code == 200
assert r.headers["content-type"].startswith("text/plain")
# Cleared then repopulated with just the one fresh key.
assert len(api_server._mime_types) == 1
assert api_server._mime_types[key] == "text/plain"
# The next request is now a clean dict hit.
r2 = c.get("/f")
assert r2.status_code == 200