fix: eliminate greenlet crash + memory leak protection

- docker-compose: switch worker to --pool=solo --concurrency=1 --max-tasks-per-child=1
- tasks.py: remove ThreadPoolExecutor wrapper from _run_browser_job (greenlet crash)
- scraper.py: browser fallback runs sequentially instead of ThreadPoolExecutor
- scraper.py: add gc.collect() after scraper close to prevent memory leaks
This commit is contained in:
qananasikq
2026-04-20 17:33:51 +03:00
parent 3c71c098cd
commit 133c125e9c
3 changed files with 16 additions and 45 deletions

View File

@@ -144,9 +144,9 @@ services:
stop_grace_period: 60s stop_grace_period: 60s
command: > command: >
celery -A iaai_scraper.worker.celery_app worker celery -A iaai_scraper.worker.celery_app worker
--loglevel=info --concurrency=${CELERY_WORKER_CONCURRENCY:-2} --pool=prefork --loglevel=info --concurrency=1 --pool=solo
--pidfile=/tmp/celery-worker.pid --pidfile=/tmp/celery-worker.pid
-Q scraping --max-tasks-per-child=${CELERY_WORKER_MAX_TASKS_PER_CHILD:-3} -Q scraping --max-tasks-per-child=${CELERY_WORKER_MAX_TASKS_PER_CHILD:-1}
healthcheck: healthcheck:
test: ["CMD-SHELL", "test -f /tmp/celery-worker.pid && kill -0 $(cat /tmp/celery-worker.pid)"] test: ["CMD-SHELL", "test -f /tmp/celery-worker.pid && kill -0 $(cat /tmp/celery-worker.pid)"]
interval: 60s interval: 60s

View File

@@ -1,3 +1,4 @@
import gc
import json import json
import logging import logging
import os import os
@@ -201,6 +202,7 @@ class IAAIScraper:
pass pass
finally: finally:
self._http_pool = None self._http_pool = None
gc.collect()
def _new_context(self) -> BrowserContext: def _new_context(self) -> BrowserContext:
if self.browser is None: if self.browser is None:
@@ -1623,19 +1625,13 @@ class IAAIScraper:
"protection_events": local_protection, "protection_events": local_protection,
} }
# Одна страница — в главном потоке. # Playwright sync API использует greenlets, привязанные к потоку.
if n_pages == 1: # Запуск в ThreadPoolExecutor вызывает greenlet crash.
res = _process_slice(slices[0] if slices else []) # Обрабатываем все слайсы последовательно в текущем потоке.
records.extend(res["records"]) for s in slices:
failures.extend(res["failures"]) if not s:
cars_failed += res["cars_failed"] continue
protection_events += res["protection_events"] res = _process_slice(s)
else:
# Несколько страниц — параллельно, каждый поток со своей Page.
with ThreadPoolExecutor(max_workers=n_pages) as executor:
futures = [executor.submit(_process_slice, s) for s in slices if s]
for fut in as_completed(futures):
res = fut.result()
records.extend(res["records"]) records.extend(res["records"])
failures.extend(res["failures"]) failures.extend(res["failures"])
cars_failed += res["cars_failed"] cars_failed += res["cars_failed"]

View File

@@ -1,6 +1,5 @@
# Задачи Celery для синхронизации автомобилей и листинга IAAI. # Задачи Celery для синхронизации автомобилей и листинга IAAI.
from concurrent.futures import ThreadPoolExecutor
import json import json
import logging import logging
import os import os
@@ -61,35 +60,11 @@ def _retry_with_backoff(func, *, attempts: int = 5, base_delay_s: float = 1.0):
def _run_browser_job(func, *args, **kwargs): def _run_browser_job(func, *args, **kwargs):
# Браузерный код запускаем в отдельном потоке без активного loop. # С pool=solo Celery worker работает в одном процессе/потоке.
# НЕ используем `with` — иначе shutdown(wait=True) заблокирует main thread # Playwright sync API использует greenlets, которые привязаны к потоку.
# если SoftTimeLimitExceeded прервёт future.result(), а browser thread ещё работает. # Запуск в отдельном потоке вызывает greenlet.error: cannot switch to a different thread.
settings = Settings() # Поэтому запускаем напрямую в текущем потоке.
soft = settings.celery.task_soft_time_limit return func(*args, **kwargs)
hard = settings.celery.task_time_limit
# Таймаут для future.result(): берём hard limit (или soft + 120), чтобы не висеть вечно.
wait_timeout = min(hard, soft + 120) if soft and hard else None
executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="iaai-browser")
future = executor.submit(func, *args, **kwargs)
try:
result = future.result(timeout=wait_timeout)
except SoftTimeLimitExceeded:
# Отпускаем executor без ожидания — thread умрёт когда Celery убьёт процесс (hard limit).
future.cancel()
executor.shutdown(wait=False, cancel_futures=True)
raise
except TimeoutError:
# future.result(timeout=...) вышел по таймауту — browser thread завис.
future.cancel()
executor.shutdown(wait=False, cancel_futures=True)
raise SoftTimeLimitExceeded("Browser thread did not finish within time limit")
except Exception:
executor.shutdown(wait=False, cancel_futures=True)
raise
else:
executor.shutdown(wait=True)
return result
def _sync_listing_lock_ttl_seconds() -> int: def _sync_listing_lock_ttl_seconds() -> int: