feature: воркер удаляет профили, не использованные PROFILE_TTL_DAYS дней (#20)
Отметка последнего использования — mtime файла в профиле, ставится при открытии и закрытии окна. Очистка при старте воркера и раз в час; профили с открытым окном (на этом воркере или по ключу сессии в Redis) не трогаются. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1075267d27
commit
4c8394eb4c
6 files changed
+97
-8
No files matched your search
@@ -13,7 +13,6 @@ Not implemented yet, though planned for v1:
|
||||
|
||||
- API-key auth (`X-API-Key` is accepted but **not checked**) — [#4](https://redmine.sokolab.xyz/issues/4)
|
||||
- persisting request outcomes (success rate) — [#17](https://redmine.sokolab.xyz/issues/17)
|
||||
- cleanup of old browser profiles — [#20](https://redmine.sokolab.xyz/issues/20)
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -41,7 +40,7 @@ Client → Caddy (TLS) → api (FastAPI) ──task──▶ RabbitMQ ──▶
|
||||
reCAPTCHA, hCaptcha) are solved via the client's 2captcha key.
|
||||
- **Proxies**: not managed by the service — the client may supply their own proxy per request. Browser locale and
|
||||
timezone are derived from the proxy's exit IP.
|
||||
- **Sessions**: `session_id` cookie → persistent Chrome profile on disk. A fingerprint (screen, DPR) is generated on
|
||||
- **Sessions**: `session_id` cookie → persistent Chrome profile on disk, removed after `PROFILE_TTL_DAYS` (7) unused days. A fingerprint (screen, DPR) is generated on
|
||||
the first request of a session; cookies passed in a request replace the profile's cookies.
|
||||
- **Browser identity is not configurable**: user agent and window size are the real browser's own — a spoofed UA
|
||||
mismatches `userAgentData` and Cloudflare blocks it. Clients take `user_agent` from the response.
|
||||
|
||||
+4
-1
@@ -52,7 +52,9 @@ API доступно на `http://localhost:8000`.
|
||||
- `extra/user_data_dir/<session_id>` — профили браузера (cookies, local storage, fingerprint) по `session_id`;
|
||||
- `extra/geoip_cache.json` — кэш геолокации прокси.
|
||||
|
||||
Профили со временем накапливаются — чистить вручную:
|
||||
Воркер удаляет профили, которыми не пользовались `PROFILE_TTL_DAYS` дней (по умолчанию 7): при старте и затем раз
|
||||
в час. Профиль с открытым окном не трогается. Клиент, вернувшийся позже, получает новый профиль и заново проходит
|
||||
антибот. Удалить все профили вручную:
|
||||
|
||||
```bash
|
||||
docker compose exec worker sh -c 'rm -rf /app/extra/user_data_dir/*'
|
||||
@@ -78,6 +80,7 @@ docker compose exec worker sh -c 'rm -rf /app/extra/user_data_dir/*'
|
||||
| `WORKER_THREADS` | `2 × BROWSER_SLOTS` | worker: потоков dramatiq |
|
||||
| `WORKER_ID` | hostname | worker: имя воркера и его персональной очереди |
|
||||
| `RESULT_TTL_HOURS` | `24` | сколько хранится результат задачи |
|
||||
| `PROFILE_TTL_DAYS` | `7` | worker: через сколько дней без использования удаляется профиль браузера |
|
||||
| `V1_QUEUE_WAIT` | `30` | api: сколько `/v1/solve` ждёт, пока воркер возьмёт задачу; дальше `503` |
|
||||
| `NO_SLOTS_RETRY_AFTER` | `5` | api: значение `Retry-After` (с) в ответе `503` |
|
||||
| `RABBITMQ_URL` | `amqp://guest:guest@localhost:5672/` | api, worker; в compose задаётся сам |
|
||||
|
||||
@@ -10,6 +10,8 @@ BROWSER_SLOTS=4
|
||||
RABBITMQ_PASSWORD=robotex
|
||||
# сколько часов хранится результат задачи в Redis
|
||||
RESULT_TTL_HOURS=24
|
||||
# через сколько дней без использования удаляется профиль браузера
|
||||
PROFILE_TTL_DAYS=7
|
||||
# сколько /v1/solve ждёт, пока воркер возьмёт задачу, прежде чем ответить 503
|
||||
V1_QUEUE_WAIT=30
|
||||
NO_SLOTS_RETRY_AFTER=5
|
||||
|
||||
+17
-5
@@ -31,10 +31,11 @@ BROWSER_SLOTS = int(os.getenv("BROWSER_SLOTS", "4"))
|
||||
# через сколько задача без свободного слота снова попадёт в очередь
|
||||
NO_SLOT_DELAY_MS = 2000
|
||||
HEARTBEAT_INTERVAL = 10
|
||||
PROFILE_CLEANUP_INTERVAL = 3600
|
||||
TASK_TIME_LIMIT_MS = task_store.RUNNING_TTL * 1000
|
||||
|
||||
_pool: WindowPool | None = None
|
||||
_background: asyncio.Task | None = None
|
||||
_background: list[asyncio.Task] = []
|
||||
|
||||
|
||||
def _run(coro):
|
||||
@@ -52,17 +53,28 @@ async def _heartbeat_loop(pool: WindowPool) -> None:
|
||||
await asyncio.sleep(HEARTBEAT_INTERVAL)
|
||||
|
||||
|
||||
async def _cleanup_loop(pool: WindowPool) -> None:
|
||||
"""Удаление старых профилей: сразу при старте и затем раз в час."""
|
||||
while True:
|
||||
try:
|
||||
await pool.cleanup_profiles()
|
||||
except Exception:
|
||||
log.warning("profile cleanup failed", exc_info=True)
|
||||
await asyncio.sleep(PROFILE_CLEANUP_INTERVAL)
|
||||
|
||||
|
||||
async def _start() -> None:
|
||||
global _pool, _background
|
||||
global _pool
|
||||
_pool = WindowPool(task_store.TaskStore(task_store.connect()), WORKER_ID, BROWSER_SLOTS)
|
||||
await _pool.report()
|
||||
_background = asyncio.create_task(_heartbeat_loop(_pool))
|
||||
_background.extend([asyncio.create_task(_heartbeat_loop(_pool)), asyncio.create_task(_cleanup_loop(_pool))])
|
||||
log.info("worker %s: %d browser slots, queues %s, %s", WORKER_ID, BROWSER_SLOTS, COMMON_QUEUE, personal_queue(WORKER_ID))
|
||||
|
||||
|
||||
async def _stop() -> None:
|
||||
if _background:
|
||||
_background.cancel()
|
||||
for task in _background:
|
||||
task.cancel()
|
||||
_background.clear()
|
||||
await _pool.close_all()
|
||||
await _pool.store.remove_worker(WORKER_ID)
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ Redis: TTL ключа `session:<id>`, каждая задача в окно ст
|
||||
import asyncio
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
from contextlib import asynccontextmanager, suppress
|
||||
from dataclasses import dataclass, field
|
||||
@@ -30,11 +32,16 @@ from api.geoip import resolve_geo
|
||||
from api.logs import safe_request_dump, safe_response_dump
|
||||
from api.schemas import TaskRequest
|
||||
from engine import AsyncStealthySession
|
||||
from tasks.sessions import parse_session_id
|
||||
from tasks.store import RUNNING_TTL, Status, TaskStore
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
USER_DATA_ROOT = (Path(__file__).parent / "../../extra/user_data_dir").resolve()
|
||||
# профиль, которым не пользовались столько дней, удаляется (#20)
|
||||
PROFILE_TTL_DAYS = float(os.getenv("PROFILE_TTL_DAYS", "7"))
|
||||
# время последнего использования профиля — mtime этого файла
|
||||
LAST_USED_FILE = ".robotex_last_used"
|
||||
|
||||
|
||||
class Outcome:
|
||||
@@ -82,6 +89,18 @@ def session_dir(session_id: str) -> Path:
|
||||
return user_data_dir
|
||||
|
||||
|
||||
def touch_profile(user_data_dir: Path) -> None:
|
||||
(user_data_dir / LAST_USED_FILE).touch()
|
||||
|
||||
|
||||
def profile_last_used(user_data_dir: Path) -> float:
|
||||
try:
|
||||
return (user_data_dir / LAST_USED_FILE).stat().st_mtime
|
||||
except OSError:
|
||||
# профиль из версии без отметки — по времени изменения каталога
|
||||
return user_data_dir.stat().st_mtime
|
||||
|
||||
|
||||
def make_page_action(payload: TaskRequest):
|
||||
"""page_action для AsyncStealthySession.fetch()/run(): выполняет очередь действий (вместе с
|
||||
прохождением антибота, api.actions.execute) и достаёт со страницы всё, что нужно для ответа.
|
||||
@@ -275,6 +294,7 @@ class WindowPool:
|
||||
async def _open(self, session_id: str, payload: TaskRequest, nav_kwargs: dict):
|
||||
user_data_dir = session_dir(session_id)
|
||||
user_data_dir.mkdir(parents=True, exist_ok=True)
|
||||
touch_profile(user_data_dir)
|
||||
# отпечаток задаётся профилю один раз (см. api/profile.py)
|
||||
profile_kwargs = profile.browser_kwargs(profile.load_or_create(user_data_dir))
|
||||
|
||||
@@ -312,6 +332,9 @@ class WindowPool:
|
||||
|
||||
async def _close(self, window: Window) -> None:
|
||||
"""Закрыть окно и освободить его слот."""
|
||||
if window.engine is not None:
|
||||
with suppress(OSError):
|
||||
touch_profile(session_dir(window.session_id))
|
||||
await self._close_engine(window)
|
||||
if self.windows.get(window.session_id) is window:
|
||||
del self.windows[window.session_id]
|
||||
@@ -346,3 +369,22 @@ class WindowPool:
|
||||
await self._close(window)
|
||||
with suppress(Exception):
|
||||
await self.store.delete_session(session_id)
|
||||
|
||||
async def cleanup_profiles(self, ttl_days: float = PROFILE_TTL_DAYS) -> list[str]:
|
||||
"""Удалить профили, которыми не пользовались ttl_days дней (#20). Профиль с открытым окном —
|
||||
на этом воркере или на другом (ключ session:<id> в Redis) — не трогается."""
|
||||
if not USER_DATA_ROOT.is_dir():
|
||||
return []
|
||||
deadline = time.time() - ttl_days * 86400
|
||||
removed = []
|
||||
for user_data_dir in USER_DATA_ROOT.iterdir():
|
||||
session_id = parse_session_id(user_data_dir.name)
|
||||
if not session_id or not user_data_dir.is_dir() or profile_last_used(user_data_dir) > deadline:
|
||||
continue
|
||||
if session_id in self.windows or self._busy(session_id) or await self.store.get_session(session_id):
|
||||
continue
|
||||
await asyncio.to_thread(shutil.rmtree, user_data_dir, True)
|
||||
removed.append(session_id)
|
||||
if removed:
|
||||
log.info("removed %d browser profiles unused for %s days", len(removed), ttl_days)
|
||||
return removed
|
||||
@@ -178,3 +178,34 @@ def test_dead_worker_window_goes_to_common_queue(env):
|
||||
env.auto = False
|
||||
create(env, url="https://example.com")
|
||||
assert env.messages[-1].queue_name == COMMON_QUEUE
|
||||
|
||||
|
||||
def test_old_profiles_cleaned_up(env, tmp_path):
|
||||
"""#20: профили старше PROFILE_TTL_DAYS удаляются, кроме тех, где открыто окно."""
|
||||
import os
|
||||
import time
|
||||
|
||||
from worker.windows import LAST_USED_FILE
|
||||
|
||||
create(env, url="https://example.com", browser_idle_timeout=5)
|
||||
held = env.client.cookies["session_id"]
|
||||
elsewhere, old, fresh = (str(uuid.uuid4()) for _ in range(3))
|
||||
env.redis.hset(session_key(elsewhere), mapping={"worker_id": "w2", "idle_timeout": 5})
|
||||
|
||||
long_ago = time.time() - 8 * 86400
|
||||
for sid in (held, elsewhere, old, fresh, "not-a-session"):
|
||||
(tmp_path / sid).mkdir(exist_ok=True)
|
||||
marker = tmp_path / sid / LAST_USED_FILE
|
||||
marker.touch()
|
||||
if sid != fresh:
|
||||
os.utime(marker, (long_ago, long_ago))
|
||||
|
||||
assert env.call(env.pool.cleanup_profiles) == [old]
|
||||
assert sorted(p.name for p in tmp_path.iterdir()) == sorted([held, elsewhere, fresh, "not-a-session"])
|
||||
|
||||
|
||||
def test_profile_marked_as_used(env, tmp_path):
|
||||
from worker.windows import LAST_USED_FILE
|
||||
|
||||
create(env, url="https://example.com")
|
||||
assert (tmp_path / env.client.cookies["session_id"] / LAST_USED_FILE).exists()
|
||||
Reference in new issue
Block a user