gendesign/backend/app/services/job_settings.py
Light1YT a5ff281bad
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m37s
Deploy / build-worker (push) Successful in 2m46s
Deploy / deploy (push) Successful in 1m19s
fix(admin-jobs): UPSERT в PUT /admin/jobs/settings — не терять изменения (#1223)
UPDATE без проверки rowcount затрагивал 0 строк для несуществующего
job_type; get_one возвращал hardcoded _fallback. PUT отвечал 200,
админ считал настройку сохранённой — но она терялась (новый job_type
до seed-миграции, опечатка в path).

Patch: update() проверяет result.rowcount: при 0 делает INSERT с
переданными колонками (непереданные → дефолты таблицы из м.81).
INSERT использует ON CONFLICT (job_type) DO NOTHING + повторный
UPDATE как защита от гонки. Поведение для существующих строк не
изменилось. 8 новых тестов (6 service + 2 API).

Closes #1223
2026-06-13 08:36:23 +00:00

359 lines
13 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.

"""Сервис для чтения и обновления централизованных настроек Celery-job'ов.
Таблица job_settings содержит по одной строке на job_type:
'scrape_kn' | 'nspd_geo' | 'objective_etl' | 'objective_sync'
Используется:
- workers.celery_app: строит beat_schedule при старте (один раз)
- api.v1.admin_jobs: GET/PUT /admin/jobs/settings
- workers.tasks.*: читают rate_ms / extra_config при каждом запуске
Безопасный fallback: если строка отсутствует или БД недоступна — возвращает
hardcoded defaults чтобы не ломать старый код.
"""
from __future__ import annotations
import logging
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import text
logger = logging.getLogger(__name__)
# ── Fallback defaults (до инициализации БД / отсутствующие строки) ────────────
_DEFAULTS: dict[str, dict[str, Any]] = {
"scrape_kn": {
"enabled": True,
"queue_name": "scrape_kn",
"cron_schedule": "15 4 * * mon",
"rate_ms": None,
"max_retries": 2,
"max_concurrency": 1,
"extra_config": {"default_regions": [66]},
"description": "Скрейпинг КН (fallback default)",
},
"nspd_geo": {
"enabled": True,
"queue_name": "geo",
"cron_schedule": None,
"rate_ms": 600,
"max_retries": 2,
"max_concurrency": 1,
"extra_config": {},
"description": "NSPD geo backfill (fallback default)",
},
"objective_etl": {
"enabled": True,
"queue_name": "celery",
"cron_schedule": None,
"rate_ms": None,
"max_retries": 2,
"max_concurrency": 1,
"extra_config": {"sqlite_path": "/data/anton-sqlite/analysis.db"},
"description": "ETL SQLite → PG (fallback default)",
},
"objective_sync": {
"enabled": True,
"queue_name": "celery",
"cron_schedule": "0 6 * * tue",
"rate_ms": None,
"max_retries": 2,
"max_concurrency": 1,
"extra_config": {
"groups_csv": "Свердловская область,Челябинск,Тюмень,Пермь",
"use_ddu": True,
"use_dkp": True,
"period_months_back": 1,
"inter_group_delay_s": 30,
"rate_ms": 3000,
},
"description": "Objective sync (fallback default)",
},
}
_FALLBACK_UPDATED_AT = datetime(2026, 1, 1, tzinfo=UTC)
def _row_to_dict(r: Any) -> dict[str, Any]:
"""Маппинг ORM / sqlalchemy row → dict."""
return {
"job_type": r["job_type"],
"enabled": bool(r["enabled"]),
"queue_name": r["queue_name"],
"cron_schedule": r["cron_schedule"],
"rate_ms": r["rate_ms"],
"max_retries": int(r["max_retries"]),
"max_concurrency": int(r["max_concurrency"]),
"extra_config": r["extra_config"] or {},
"updated_at": r["updated_at"],
"updated_by": r["updated_by"],
"description": r["description"],
}
def _fallback(job_type: str) -> dict[str, Any]:
"""Возвращает fallback dict для job_type; логирует warning."""
logger.warning("job_settings: строка '%s' не найдена — используем hardcoded defaults", job_type)
base = _DEFAULTS.get(
job_type,
{
"enabled": True,
"queue_name": "celery",
"cron_schedule": None,
"rate_ms": None,
"max_retries": 2,
"max_concurrency": 1,
"extra_config": {},
"description": f"Unknown job_type '{job_type}' — fallback",
},
)
return {
"job_type": job_type,
**base,
"updated_at": _FALLBACK_UPDATED_AT,
"updated_by": None,
}
# ── Public API ────────────────────────────────────────────────────────────────
def get_all(db) -> list[dict[str, Any]]:
"""Вернуть все строки job_settings. При ошибке БД — fallback на _DEFAULTS."""
try:
rows = (
db.execute(
text(
"""
SELECT job_type, enabled, queue_name, cron_schedule, rate_ms,
max_retries, max_concurrency, extra_config,
updated_at, updated_by, description
FROM job_settings
ORDER BY job_type
"""
)
)
.mappings()
.all()
)
except Exception as e:
logger.warning("get_all job_settings: БД недоступна — fallback. %s", e)
return [_fallback(jt) for jt in _DEFAULTS]
if not rows:
logger.warning("get_all job_settings: таблица пустая — fallback")
return [_fallback(jt) for jt in _DEFAULTS]
return [_row_to_dict(r) for r in rows]
def get_one(job_type: str, db) -> dict[str, Any]:
"""Вернуть одну строку по job_type. При отсутствии — fallback с warning."""
try:
row = (
db.execute(
text(
"""
SELECT job_type, enabled, queue_name, cron_schedule, rate_ms,
max_retries, max_concurrency, extra_config,
updated_at, updated_by, description
FROM job_settings
WHERE job_type = :jt
"""
),
{"jt": job_type},
)
.mappings()
.first()
)
except Exception as e:
logger.warning("get_one job_settings '%s': БД недоступна — fallback. %s", job_type, e)
return _fallback(job_type)
if row is None:
return _fallback(job_type)
return _row_to_dict(row)
def update(
job_type: str,
db,
*,
enabled: bool | None = None,
queue_name: str | None = None,
cron_schedule: str | None = None,
rate_ms: int | None = None,
max_retries: int | None = None,
max_concurrency: int | None = None,
extra_config: dict | None = None,
updated_by: str | None = None,
) -> dict[str, Any]:
"""PATCH-style upsert — обновляет только переданные поля.
Если строки с таким job_type ещё нет, она создаётся (с дефолтами таблицы из
data/sql/81_job_settings.sql для непереданных колонок). Раньше UPDATE без
проверки rowcount молча терял изменения для несуществующих job_type, а PUT
отвечал 200 c fallback-дефолтами (issue #1223).
cron_schedule='' → NULL (manual-only). updated_at выставляется в NOW().
"""
import json
# Нормализация значений по той же логике, что и в исходном UPDATE.
# cron_schedule: None = не трогать; '' = явный NULL (manual-only)
cron_provided = cron_schedule is not None
norm_cron: str | None = cron_schedule if cron_schedule else None
# rate_ms: None = не трогать; 0 = явно обнулить (no throttle)
rate_provided = rate_ms is not None
norm_rate: int | None = rate_ms if rate_provided and rate_ms and rate_ms > 0 else None
extra_provided = extra_config is not None
norm_extra_json = json.dumps(extra_config, ensure_ascii=False) if extra_provided else None
# ── UPDATE для существующей строки (PATCH-семантика без изменений) ───────
sets: list[str] = ["updated_at = NOW()"]
params: dict[str, Any] = {"jt": job_type}
for field, value in [
("enabled", enabled),
("queue_name", queue_name),
("max_retries", max_retries),
("max_concurrency", max_concurrency),
("updated_by", updated_by),
]:
if value is not None:
sets.append(f"{field} = :{field}")
params[field] = value
if cron_provided:
sets.append("cron_schedule = :cron_schedule")
params["cron_schedule"] = norm_cron
if rate_provided:
sets.append("rate_ms = :rate_ms")
params["rate_ms"] = norm_rate
if extra_provided:
sets.append("extra_config = CAST(:extra_config AS jsonb)")
params["extra_config"] = norm_extra_json
result = db.execute(
text(f"UPDATE job_settings SET {', '.join(sets)} WHERE job_type = :jt"),
params,
)
# Если строки не было — INSERT с явными значениями только для переданных колонок.
# Остальные колонки получат defaults таблицы (enabled=true, queue_name='celery',
# max_retries=2, max_concurrency=1, extra_config='{}'::jsonb, updated_at=now()).
if (result.rowcount or 0) == 0:
insert_cols: list[str] = ["job_type"]
insert_vals: list[str] = [":jt"]
insert_params: dict[str, Any] = {"jt": job_type}
for field, value in [
("enabled", enabled),
("queue_name", queue_name),
("max_retries", max_retries),
("max_concurrency", max_concurrency),
("updated_by", updated_by),
]:
if value is not None:
insert_cols.append(field)
insert_vals.append(f":{field}")
insert_params[field] = value
if cron_provided:
insert_cols.append("cron_schedule")
insert_vals.append(":cron_schedule")
insert_params["cron_schedule"] = norm_cron
if rate_provided:
insert_cols.append("rate_ms")
insert_vals.append(":rate_ms")
insert_params["rate_ms"] = norm_rate
if extra_provided:
insert_cols.append("extra_config")
insert_vals.append("CAST(:extra_config AS jsonb)")
insert_params["extra_config"] = norm_extra_json
# ON CONFLICT DO NOTHING — защита от гонки: между нашими UPDATE и INSERT
# параллельный PUT мог вставить строку. В этом случае повторим UPDATE,
# чтобы наши изменения не потерялись.
insert_result = db.execute(
text(
f"INSERT INTO job_settings ({', '.join(insert_cols)}) "
f"VALUES ({', '.join(insert_vals)}) "
"ON CONFLICT (job_type) DO NOTHING"
),
insert_params,
)
if (insert_result.rowcount or 0) == 0:
logger.info(
"job_settings.update: гонка по '%s' — повторный UPDATE после INSERT race",
job_type,
)
db.execute(
text(f"UPDATE job_settings SET {', '.join(sets)} WHERE job_type = :jt"),
params,
)
else:
logger.info("job_settings.update: вставлена новая строка '%s'", job_type)
db.commit()
return get_one(job_type, db)
def get_setting_value(job_type: str, key: str, default: Any = None, db=None) -> Any:
"""Читает значение из top-level полей строки или из extra_config[key].
Порядок поиска:
1. Прямое поле строки (enabled, rate_ms, queue_name, ...)
2. extra_config[key]
3. default
Если db=None — возвращает default из _DEFAULTS[job_type].extra_config[key].
"""
top_level_fields = {
"enabled",
"queue_name",
"cron_schedule",
"rate_ms",
"max_retries",
"max_concurrency",
"updated_at",
"updated_by",
"description",
}
if db is None:
defaults = _DEFAULTS.get(job_type, {})
if key in top_level_fields:
return defaults.get(key, default)
return (defaults.get("extra_config") or {}).get(key, default)
row = get_one(job_type, db)
if key in top_level_fields:
return row.get(key, default)
return (row.get("extra_config") or {}).get(key, default)
def get_all_safe(db_factory) -> list[dict[str, Any]]:
"""Безопасное чтение всех строк при старте Celery beat — открывает свою сессию.
db_factory: callable () -> Session (обычно SessionLocal)
При любой ошибке — fallback на hardcoded defaults.
"""
try:
db = db_factory()
try:
return get_all(db)
finally:
db.close()
except Exception as e:
logger.warning("get_all_safe job_settings: fallback на defaults: %s", e)
return [_fallback(jt) for jt in _DEFAULTS]