fix(scraper): диагностируем «кнопка не работает» — Redis lock + task_received log

Симптом: POST /admin/scrape/kn возвращает task_id, но run_id не появляется
в kn_scrape_runs. Причина: зомби-worker оставил Redis-lock с TTL=2h, новые
задачи скипаются с reason='lock_held' молча.

Backend:
- _LOCK_TTL_SECONDS 7200 → 1800 (full sweep ~25-30 мин — потолок).
- force_release_lock() helper + новый POST /api/v1/admin/scrape/release-lock.
- TriggerKnRequest +force: bool — при true перед apply_async удалить lock.
- _log_task_received() / _log_task_skipped() — kn_scrape_log пишет «task
  подхвачен/skip» с run_id=NULL до основного flow. Теперь UI показывает
  whether worker реально получил задачу.

Frontend:
- Чекбокс  Force в форме запуска.
- Кнопка 🔓 Снять Redis-lock (отдельный endpoint).
- Уведомление о результате release.
This commit is contained in:
lekss361 2026-04-28 23:17:42 +03:00
parent 7a23aa9edc
commit c3bc96535d
8 changed files with 298 additions and 19 deletions

3
.gitignore vendored
View file

@ -13,6 +13,9 @@ node_modules/
.next/
frontend/.next/
out/
# TypeScript incremental build info — must NOT be committed; locally-generated
# and changes on every build, which busts Docker build context cache.
*.tsbuildinfo
# Env / secrets
.env

View file

@ -1,3 +1,7 @@
# backend Dockerfile делает целевой `COPY app ./app` (не COPY .), поэтому
# контекст и так чистый. Но build context передаётся ЦЕЛИКОМ от docker buildx
# в daemon/builder — лишние мегабайты замедляют upload и хешинг.
.venv
__pycache__
*.pyc
@ -8,3 +12,28 @@ tests
.env
.env.*
!.env.example
# VCS
.git
.gitignore
.gitattributes
# Local data (raw downloads — никогда не нужны в образе)
data/raw
data/raw_endpoints
data/photos
# Logs / coverage
*.log
.coverage
htmlcov
# Docs / IDE
*.md
docs
.vscode
.idea
# OS
.DS_Store
Thumbs.db

View file

@ -15,20 +15,23 @@ ENV PYTHONUNBUFFERED=1 \
UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy
RUN apt-get update && apt-get install -y --no-install-recommends \
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libpq-dev \
libgdal-dev \
libproj-dev \
libgeos-dev \
&& rm -rf /var/lib/apt/lists/*
libgeos-dev
RUN pip install --no-cache-dir uv
WORKDIR /app
COPY pyproject.toml uv.lock* ./
RUN if [ -f uv.lock ]; then uv sync --frozen --no-dev; else uv sync --no-dev; fi
# uv-кеш переживает между билдами: при неизменном lock'е sync ~5 сек вместо 1-2 мин.
RUN --mount=type=cache,target=/root/.cache/uv \
if [ -f uv.lock ]; then uv sync --frozen --no-dev; else uv sync --no-dev; fi
COPY app ./app
COPY alembic.ini ./
@ -42,7 +45,9 @@ ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PATH="/app/.venv/bin:$PATH"
RUN apt-get update && apt-get install -y --no-install-recommends \
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update && apt-get install -y --no-install-recommends \
libpq5 \
libgdal36 \
libproj25 \
@ -52,7 +57,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libpangoft2-1.0-0 \
fonts-dejavu-core \
curl \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --create-home --uid 1000 app
WORKDIR /app
@ -75,7 +79,9 @@ FROM runner AS runner-with-chromium
USER root
RUN apt-get update && apt-get install -y --no-install-recommends \
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update && apt-get install -y --no-install-recommends \
# Chromium runtime libs (раньше заходили через `playwright install --with-deps`)
libnss3 \
libnspr4 \
@ -97,7 +103,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
ENV PLAYWRIGHT_BROWSERS_PATH=/opt/playwright
RUN mkdir -p $PLAYWRIGHT_BROWSERS_PATH \
&& /app/.venv/bin/playwright install chromium \
&& chmod -R a+rX $PLAYWRIGHT_BROWSERS_PATH \
&& rm -rf /var/lib/apt/lists/*
&& chmod -R a+rX $PLAYWRIGHT_BROWSERS_PATH
USER app

View file

@ -29,6 +29,9 @@ class TriggerKnRequest(BaseModel):
fetch_flats: bool = True
extras: bool = True
download_photos: bool = False
# Если True — ПЕРЕД apply_async удалить existing Redis lock. Эмердженси-режим
# для случая «зомби-worker оставил lock и блокирует новые задачи».
force: bool = False
def _check_token(x_admin_token: str | None) -> None:
@ -48,7 +51,14 @@ def trigger_kn_sweep(
) -> dict[str, Any]:
_check_token(x_admin_token)
# Defer Celery import so the API works without a broker in dev.
from app.workers.tasks.scrape_kn import scrape_kn_region
from app.workers.tasks.scrape_kn import (
force_release_lock,
scrape_kn_region,
)
lock_released = False
if payload.force:
lock_released = force_release_lock(payload.region_code, payload.developers)
result = scrape_kn_region.apply_async(
args=[payload.region_code, payload.developers],
@ -64,6 +74,8 @@ def trigger_kn_sweep(
"region_code": payload.region_code,
"developers": payload.developers,
"queued_at": result.date_done.isoformat() if result.date_done else None,
"force": payload.force,
"lock_was_released": lock_released,
}
@ -208,6 +220,29 @@ def queue_status(
}
class ReleaseLockRequest(BaseModel):
region_code: int = Field(..., ge=1, le=99)
developers: list[str] | None = Field(default=None, max_length=20)
@router.post("/release-lock")
def release_lock(
payload: ReleaseLockRequest,
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
) -> dict[str, Any]:
"""Принудительно удалить Redis-lock для (region, devs). Использовать когда
зомби-worker оставил lock и новые задачи скипаются с reason='lock_held'."""
_check_token(x_admin_token)
from app.workers.tasks.scrape_kn import force_release_lock
released = force_release_lock(payload.region_code, payload.developers)
return {
"region_code": payload.region_code,
"developers": payload.developers,
"released": released,
}
class RevokeRequest(BaseModel):
task_id: str = Field(..., min_length=1, max_length=255)
terminate: bool = False # SIGTERM running task vs just unmark from queue

View file

@ -18,18 +18,94 @@ from app.workers.celery_app import celery_app
logger = logging.getLogger(__name__)
# Lock TTL longer than the longest expected sweep (442-obj sweep with extras
# can run ~30-60 min). 2 hours is a safe upper bound — after that we assume
# the worker died and let a new run proceed.
_LOCK_TTL_SECONDS = 2 * 60 * 60
# Lock TTL — full Свердл sweep с extras занимает ~25-30 мин. 30 мин — потолок.
# Если worker умер с lock'ом — он истечёт за полчаса, а не за 2 часа (раньше).
# Heartbeat-based autoclean в /queue endpoint всё равно подхватит зомби-runs.
_LOCK_TTL_SECONDS = 30 * 60
def _lock_key(region_code: int, developers: list[str] | None) -> str:
devs_key = ",".join(developers) if developers else "*"
return f"scrape:kn:lock:{region_code}:{devs_key}"
def _log_task_received(region_code: int, devs: list[str] | None, task_id: str | None) -> None:
"""Записать в kn_scrape_log что worker получил задачу. Без run_id —
он появится позже в run_region_sweep. Best-effort: SQL ошибки гасим."""
from sqlalchemy import text
from app.core.db import SessionLocal
db = SessionLocal()
try:
db.execute(
text(
"""
INSERT INTO kn_scrape_log (run_id, level, stage, message)
VALUES (NULL, 'info', 'task_received', :msg)
"""
),
{
"msg": (
f"Worker подхватил task_id={task_id or '?'}, region={region_code},"
f" devs={devs or '*'}"
)[:500],
},
)
db.commit()
except Exception as e:
logger.warning("log_task_received failed: %s", e)
finally:
db.close()
def _log_task_skipped(
region_code: int, devs: list[str] | None, task_id: str | None, reason: str
) -> None:
from sqlalchemy import text
from app.core.db import SessionLocal
db = SessionLocal()
try:
db.execute(
text(
"""
INSERT INTO kn_scrape_log (run_id, level, stage, message)
VALUES (NULL, 'warn', 'task_skipped', :msg)
"""
),
{
"msg": (
f"task_id={task_id or '?'} skipped: {reason}, region={region_code},"
f" devs={devs or '*'}"
)[:500],
},
)
db.commit()
except Exception as e:
logger.warning("log_task_skipped failed: %s", e)
finally:
db.close()
def force_release_lock(region_code: int, developers: list[str] | None) -> bool:
"""Принудительно удалить Redis-lock (для emergency manual trigger,
когда зомби-worker оставил key). Возвращает True если ключ был."""
key = _lock_key(region_code, developers)
r = redis.Redis.from_url(settings.redis_url)
try:
return bool(r.delete(key))
except Exception as e:
logger.warning("force_release_lock %s failed: %s", key, e)
return False
@contextmanager
def _region_lock(region_code: int, developers: list[str] | None):
"""Redis singleton lock so beat double-fire or two workers can't run the
same (region, devs) sweep concurrently. SET NX EX is atomic."""
devs_key = ",".join(developers) if developers else "*"
key = f"scrape:kn:lock:{region_code}:{devs_key}"
key = _lock_key(region_code, developers)
r = redis.Redis.from_url(settings.redis_url)
acquired = r.set(key, "1", nx=True, ex=_LOCK_TTL_SECONDS)
try:
@ -59,6 +135,12 @@ def scrape_kn_region(
DOM.РФ at exactly the same minute. Manual triggers pass skip_jitter=True for
immediate execution.
"""
# Сразу пишем «task received» в kn_scrape_log БЕЗ run_id (run_id ещё не
# создан — он появится в run_region_sweep). Это позволяет UI видеть
# «worker подхватил задачу» даже если потом сработает lock_held или
# упадём ещё до Phase A.
_log_task_received(region_code, developers, self.request.id)
if not skip_jitter and settings.scrape_kn_jitter_seconds > 0:
delay = random.uniform(0, settings.scrape_kn_jitter_seconds)
logger.info("jitter delay %.0fs before scrape", delay)
@ -77,11 +159,13 @@ def scrape_kn_region(
region_code,
developers,
)
_log_task_skipped(region_code, developers, self.request.id, "lock_held")
return {
"skipped": True,
"reason": "lock_held",
"region_code": region_code,
"developers": developers,
"lock_key": _lock_key(region_code, developers),
}
logger.info(
@ -145,6 +229,8 @@ def resume_kn_run(self: Any, run_id: int) -> dict[str, Any]:
return {"skipped": True, "reason": "no_region", "run_id": run_id}
region_code = region_codes[0]
_log_task_received(region_code, developers, self.request.id)
with _region_lock(region_code, developers) as got_lock:
if not got_lock:
logger.warning(
@ -152,6 +238,7 @@ def resume_kn_run(self: Any, run_id: int) -> dict[str, Any]:
run_id,
region_code,
)
_log_task_skipped(region_code, developers, self.request.id, "lock_held")
return {"skipped": True, "reason": "lock_held", "run_id": run_id}
logger.info("resume_kn_run start run=%s region=%s devs=%s", run_id, region_code, developers)

View file

@ -1,6 +1,35 @@
# Каждое лишнее изменение в build context инвалидирует layer cache в Docker.
# Здесь — только то, что НЕ нужно для `npm ci` + `next build`.
node_modules
.next
.next/cache
.env
.env.*
!.env.example
.eslintcache
*.tsbuildinfo
# VCS
.git
.gitignore
.gitattributes
# Logs / coverage / IDE
*.log
coverage
.vscode
.idea
# Tests + docs (не нужны для production build)
__tests__
**/*.test.ts
**/*.test.tsx
**/*.spec.ts
**/*.spec.tsx
*.md
docs
# OS
.DS_Store
Thumbs.db

View file

@ -76,6 +76,7 @@ export default function ScrapeAdminPage() {
const [fetchFlats, setFetchFlats] = useState(true);
const [extras, setExtras] = useState(true);
const [downloadPhotos, setDownloadPhotos] = useState(false);
const [forceRun, setForceRun] = useState(false);
const [showFailures, setShowFailures] = useState(false);
const [failuresRunId, setFailuresRunId] = useState<number | "">("");
const [logsRunId, setLogsRunId] = useState<number | "">("");
@ -107,6 +108,7 @@ export default function ScrapeAdminPage() {
fetch_flats: fetchFlats,
extras,
download_photos: downloadPhotos,
force: forceRun,
}),
}),
onSuccess: (data) => {
@ -118,6 +120,35 @@ export default function ScrapeAdminPage() {
},
});
const releaseLock = useMutation({
mutationFn: () =>
apiFetch<{
region_code: number;
developers: string[] | null;
released: boolean;
}>("/api/v1/admin/scrape/release-lock", {
method: "POST",
headers: { "X-Admin-Token": token },
body: JSON.stringify({
region_code: region,
developers: devs
? devs
.split(",")
.map((s) => s.trim())
.filter(Boolean)
: null,
}),
}),
onSuccess: (data) => {
console.log("[scrape] lock released:", data);
queryClient.invalidateQueries({ queryKey: ["scrape-queue"] });
},
onError: (err) => {
console.error("[scrape] release-lock failed:", err);
alert(`Не удалось снять lock: ${String(err)}`);
},
});
const queue = useQuery({
queryKey: ["scrape-queue", token],
queryFn: () =>
@ -297,8 +328,28 @@ export default function ScrapeAdminPage() {
/>
<span>Скачать фото (binary)</span>
</label>
<label
style={{
display: "flex",
alignItems: "center",
gap: 6,
marginTop: 22,
}}
title="Перед запуском удалить existing Redis-lock (на случай зомби-worker'а)"
>
<input
type="checkbox"
checked={forceRun}
onChange={(e) => setForceRun(e.target.checked)}
/>
<span style={{ color: forceRun ? "#b3261e" : "inherit" }}>
Force (снять lock)
</span>
</label>
</div>
<div style={{ marginTop: 16 }}>
<div
style={{ marginTop: 16, display: "flex", gap: 12, flexWrap: "wrap" }}
>
<button
onClick={() => trigger.mutate()}
disabled={!token || trigger.isPending}
@ -307,12 +358,53 @@ export default function ScrapeAdminPage() {
>
{trigger.isPending ? "Запускаю…" : "Запустить сейчас"}
</button>
<button
onClick={() => {
if (
confirm(
"Снять Redis-lock для этого региона/devs? Используй когда подозреваешь зомби-worker.",
)
) {
releaseLock.mutate();
}
}}
disabled={!token || releaseLock.isPending}
style={secondaryBtn}
title="Удалить scrape:kn:lock:<region>:<devs> в Redis"
>
{releaseLock.isPending ? "Снимаю…" : "🔓 Снять Redis-lock"}
</button>
{!token ? (
<span style={{ marginLeft: 12, color: "#9a6700", fontSize: 13 }}>
<span
style={{
marginLeft: 12,
color: "#9a6700",
fontSize: 13,
alignSelf: "center",
}}
>
введите Admin Token выше
</span>
) : null}
</div>
{releaseLock.isSuccess ? (
<div
style={{
marginTop: 8,
padding: 8,
background: releaseLock.data.released ? "#f0fdf4" : "#f6f7f9",
border: "1px solid",
borderColor: releaseLock.data.released ? "#bbf7d0" : "#e6e8ec",
borderRadius: 6,
fontSize: 12,
color: releaseLock.data.released ? "#065f46" : "#5b6066",
}}
>
{releaseLock.data.released
? `✓ Lock снят для region=${releaseLock.data.region_code} devs=${releaseLock.data.developers ?? "*"}. Можно запускать.`
: `Lock не был активен (region=${releaseLock.data.region_code}) — путь чист.`}
</div>
) : null}
{trigger.isError ? (
<div

File diff suppressed because one or more lines are too long