mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 03:01:58 +00:00
4e3623885b
# Conflicts: # backend/app/api/schedules.py # backend/app/main.py
251 lines
14 KiB
Python
251 lines
14 KiB
Python
"""数据源的定时维护:探查 → 剖析需要的表 → 取值观察(docs/design/next-round/03-数据接入与目录维护.md B4,目录-D5)。
|
||
|
||
**为什么要有它。** 以前只能手动探查、手动剖析,目录只在点按钮那一刻是对的;结构变化、取值漂移要靠定期观察才看得到。
|
||
|
||
**定时只有一套**(总方案 X4):维护就是一条 Schedule,target_kind=source_maintenance、target_id=数据源 id。认领、错过的只补
|
||
最近一次、积压只记一条、重叠跳过、触发记录、通知都是 scheduler 那一套(scheduler/service.py 按目标种类导入本模块:fire 到点
|
||
发起一次、check_target 建任务时核对目标),不另建表、不另写 cron。按数据源开、缺省关(没有这条定时就不维护),只在设定的
|
||
时段跑(cron 自己定)。
|
||
|
||
**做哪几步**存在数据源的 options.catalog_maintenance({steps: [...], updated_by, updated_at},由 PUT
|
||
/api/datasources/{id}/maintenance 维护,数据源表单不显示、不提交它):introspect 探查结构、profile_needed 剖析需要的表、
|
||
observe 取值观察,可多选,没设过是三步都做。
|
||
|
||
**到点**(fire,持着 scheduler 的 launch_lock):按顺序建探查任务 → 剖析任务(范围 needed / observe / maintenance,按选的
|
||
步骤),任务上记 trigger=schedule 和 fire_at,靠 SourceLanes 串行:剖析排在同一次触发的探查后面(等探查进了同源队才建剖析
|
||
任务),开始时按那时的表结构和目录挑表(挑出 0 张也照建,任务以 0 张表结束、提醒里写明为什么)。建任务时就判断得出会是 0 张
|
||
的(这次没有探查、也没排在起草后面,开始时表结构和目录都不会变——比如有数据目录的表都在 12 小时内观察过),原因另记进触发
|
||
记录的 inputs.skipped 和 reason;同一次触发里有探查、或者排在起草后面时说不准(探查完、起草完可能就有了),保持现状,只在
|
||
任务的提醒里写。预算、止损照剖析设置。
|
||
照实记没执行的:数据源不存在、已停用、是上传表格或记录表时整次不执行(failed_to_start);剖析没开时剖析、观察两步不执行、
|
||
探查照做;上一次的维护(或别的探查、剖析)还没做完时这次跳过(skipped_overlap)。每次触发记下建了哪几个任务、哪几步没执行、
|
||
为什么(触发记录的 inputs:{steps, jobs, skipped})。
|
||
|
||
AGENTLAB_SCHEDULER 关着时调度循环不起,到点不会触发(拷来的数据目录不替原库查业务库);「立即运行」是人点的,照常执行。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import logging
|
||
from collections.abc import Mapping
|
||
from datetime import datetime
|
||
from typing import Any
|
||
|
||
from sqlalchemy import select, update
|
||
|
||
from app.db.base import SessionLocal, utcnow
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
#: options 里维护设置的键
|
||
MAINTENANCE_OPTION = "catalog_maintenance"
|
||
STEPS = ("introspect", "profile_needed", "observe")
|
||
STEP_LABEL = {"introspect": "探查结构", "profile_needed": "剖析需要的表", "observe": "取值观察"}
|
||
DEFAULT_STEPS = STEPS
|
||
#: 等同一次触发的探查任务进同源队最多等几秒(进了队,剖析任务才排得到它后面)
|
||
LANE_WAIT_S = 5.0
|
||
|
||
#: 先记下这次触发、还没建完任务时的说法:进程在这中间没了,留下的记录照实说没有执行
|
||
INTERRUPTED = "服务在建维护任务前停止,这次维护没有执行"
|
||
SOURCE_GONE = "数据源不存在,可能已被删除"
|
||
SOURCE_DISABLED = "数据源「{name}」已停用,这次维护没有执行"
|
||
NOT_MAINTAINED = "上传的表格、记录表的结构随版本或表单走,不需要定时维护"
|
||
NO_STEPS = "没有选要做的维护步骤(探查结构、剖析需要的表、取值观察至少选一步)"
|
||
BUSY = "数据源「{name}」上一次的维护(或别的探查、剖析)还没做完:{what},这次触发已跳过"
|
||
NOT_RUN = "{step}没有执行:{why}"
|
||
|
||
|
||
def steps_of(options: Mapping[str, Any] | None) -> list[str]:
|
||
"""这个源定时维护要做哪几步(按 STEPS 的顺序);没设过是三步都做。"""
|
||
raw = (options or {}).get(MAINTENANCE_OPTION)
|
||
steps = raw.get("steps") if isinstance(raw, Mapping) else None
|
||
if not isinstance(steps, list):
|
||
return list(DEFAULT_STEPS)
|
||
picked = {s for s in steps if s in STEPS}
|
||
return [s for s in STEPS if s in picked]
|
||
|
||
|
||
def steps_problem(raw: Any) -> str | None:
|
||
"""PUT /maintenance 交来的步骤合不合规:不合规返回一句中文。"""
|
||
if not isinstance(raw, list) or not all(isinstance(s, str) for s in raw):
|
||
return "维护步骤要是一组文字:introspect(探查结构)、profile_needed(剖析需要的表)、observe(取值观察)"
|
||
unknown = [s for s in raw if s not in STEPS]
|
||
if unknown:
|
||
return f"不认识的维护步骤:{'、'.join(unknown)}。可选:{'、'.join(f'{k}({v})' for k, v in STEP_LABEL.items())}"
|
||
if not raw:
|
||
return NO_STEPS
|
||
return None
|
||
|
||
|
||
def with_steps(options: Mapping[str, Any] | None, steps: list[str], *, actor: str | None) -> dict[str, Any]:
|
||
"""options 换上新的维护步骤(不改别的项)。"""
|
||
ordered = [s for s in STEPS if s in set(steps)]
|
||
return {**dict(options or {}),
|
||
MAINTENANCE_OPTION: {"steps": ordered, "updated_by": actor, "updated_at": utcnow().isoformat()}}
|
||
|
||
|
||
def _origin_problem(row: Any) -> str | None:
|
||
return NOT_MAINTAINED if (getattr(row, "origin", None) or "manual") in ("upload", "records") else None
|
||
|
||
|
||
async def check_target(session: Any, target_id: str) -> str | None:
|
||
"""建定时任务、改目标时核对:数据源要在、不是上传表格或记录表、选了至少一步。停用了、没开剖析不拦(到点照实记没执行)。"""
|
||
from app.db.models import DataSource
|
||
|
||
row = await session.get(DataSource, target_id)
|
||
if row is None:
|
||
return SOURCE_GONE
|
||
if problem := _origin_problem(row):
|
||
return problem
|
||
if not steps_of(row.options):
|
||
return NO_STEPS
|
||
return None
|
||
|
||
|
||
async def _busy(session: Any, source_id: str) -> str | None:
|
||
"""同源有排队或进行中的探查、剖析任务:说明是哪一种。"""
|
||
from app.data import catalog_profile_jobs, introspect_jobs
|
||
|
||
if await introspect_jobs.active_job(session, source_id) is not None:
|
||
return "正在探查结构"
|
||
if await catalog_profile_jobs.active_job(session, source_id) is not None:
|
||
return "正在剖析"
|
||
return None
|
||
|
||
|
||
async def _entered(source_id: str, job_id: str) -> None:
|
||
"""等这个探查任务进了同源队(或者已经不在跑了):剖析任务要排在它后面。最多等 LANE_WAIT_S 秒。"""
|
||
from app.core import job_runtime as runtime
|
||
from app.data import introspect_jobs
|
||
|
||
loop = asyncio.get_running_loop()
|
||
deadline = loop.time() + LANE_WAIT_S
|
||
while loop.time() < deadline:
|
||
if await runtime.lanes.ahead(source_id, job_id) is not None or not introspect_jobs.manager.running(job_id):
|
||
return
|
||
await asyncio.sleep(0.01)
|
||
logger.warning("定时维护:探查任务 %s 过了 %.0f 秒还没进同源队,剖析任务照建", job_id, LANE_WAIT_S)
|
||
|
||
|
||
async def _settle_fire(fire_id: str, status: str, reason: str | None, inputs: dict[str, Any]) -> Any:
|
||
from app.db.models import ScheduleFire
|
||
|
||
async with SessionLocal() as session:
|
||
await session.execute(update(ScheduleFire).where(ScheduleFire.id == fire_id)
|
||
.values(status=status, reason=reason, inputs=inputs))
|
||
await session.commit()
|
||
return await session.get(ScheduleFire, fire_id)
|
||
|
||
|
||
async def fire(*, schedule_id: str, fire_at: datetime, kind: str, actor: str | None, target_id: str, name: str,
|
||
tz: str, cron_expr: str) -> Any:
|
||
"""到点维护一次(持着 launch_lock 调用):建探查任务、剖析任务并在后台开始,记下触发。同一次触发已经记过就交回那一条。"""
|
||
from app.db.models import DataSource, ScheduleFire
|
||
from app.scheduler import service
|
||
|
||
async with SessionLocal() as session:
|
||
existing = (await session.execute(select(ScheduleFire).where(
|
||
ScheduleFire.schedule_id == schedule_id, ScheduleFire.fire_at == fire_at,
|
||
ScheduleFire.kind == kind))).scalar_one_or_none()
|
||
if existing is not None:
|
||
return existing
|
||
row = await session.get(DataSource, target_id)
|
||
if row is None:
|
||
return await service.record_fire(schedule_id, fire_at, kind, "failed_to_start", SOURCE_GONE)
|
||
if problem := _origin_problem(row):
|
||
return await service.record_fire(schedule_id, fire_at, kind, "failed_to_start", problem)
|
||
if row.enabled is False:
|
||
return await service.record_fire(schedule_id, fire_at, kind, "failed_to_start",
|
||
SOURCE_DISABLED.format(name=row.name))
|
||
steps = steps_of(row.options)
|
||
if not steps:
|
||
return await service.record_fire(schedule_id, fire_at, kind, "failed_to_start", NO_STEPS)
|
||
if what := await _busy(session, row.id):
|
||
return await service.record_fire(schedule_id, fire_at, kind, "skipped_overlap",
|
||
BUSY.format(name=row.name, what=what), {"steps": steps})
|
||
placeholder = await service.record_fire(schedule_id, fire_at, kind, "failed_to_start", INTERRUPTED,
|
||
{"steps": steps})
|
||
if placeholder is None:
|
||
return None
|
||
try:
|
||
jobs, skipped = await _start(target_id, steps, actor=actor or service.actor_label(name),
|
||
fire_at=fire_at.isoformat(), schedule_id=schedule_id)
|
||
except Exception: # noqa: BLE001 - 建任务出意外不能让调度循环停下:照实记下,详细信息进日志
|
||
logger.exception("定时维护(%s)建任务时出错", schedule_id)
|
||
return await _settle_fire(placeholder.id, "failed_to_start", "建维护任务时出错,详细信息见服务日志",
|
||
{"steps": steps})
|
||
reason = ";".join(NOT_RUN.format(step=STEP_LABEL[s["step"]], why=s["reason"]) for s in skipped) or None
|
||
return await _settle_fire(placeholder.id, "started" if jobs else "failed_to_start", reason,
|
||
{"steps": steps, "jobs": jobs, "skipped": skipped})
|
||
|
||
|
||
async def _start(source_id: str, steps: list[str], *, actor: str, fire_at: str,
|
||
schedule_id: str) -> tuple[list[dict[str, Any]], list[dict[str, str]]]:
|
||
"""按顺序建任务并开始:探查(introspect)→ 剖析 / 观察(一个剖析任务:范围按选的步骤)。返回 (建了的任务, 没执行的步骤
|
||
和原因)。"""
|
||
from app.data import catalog_profile, catalog_profile_jobs, introspect_jobs, table_versions
|
||
from app.db.models import DataSource
|
||
|
||
jobs: list[dict[str, Any]] = []
|
||
skipped: list[dict[str, str]] = []
|
||
introspect_id: str | None = None
|
||
if "introspect" in steps:
|
||
async with SessionLocal() as session:
|
||
row = await session.get(DataSource, source_id)
|
||
try:
|
||
job, n = await introspect_jobs.prepare(session, row, actor=actor, trigger="schedule", fire_at=fire_at,
|
||
schedule_id=schedule_id)
|
||
except (introspect_jobs.IntrospectRefused, introspect_jobs.IntrospectBusy, introspect_jobs.IntrospectFailed,
|
||
introspect_jobs.ScopeTooLarge) as e:
|
||
skipped.append({"step": "introspect", "reason": str(e)})
|
||
else:
|
||
introspect_jobs.manager.start(job.id)
|
||
introspect_id = job.id
|
||
jobs.append({"step": "introspect", "kind": "introspect", "id": job.id, "tables": n})
|
||
wanted = [s for s in steps if s in ("profile_needed", "observe")]
|
||
if not wanted:
|
||
return jobs, skipped
|
||
if introspect_id is not None:
|
||
await _entered(source_id, introspect_id)
|
||
scope = "maintenance" if len(wanted) == 2 else ("needed" if wanted == ["profile_needed"] else "observe")
|
||
async with SessionLocal() as session:
|
||
row = await session.get(DataSource, source_id)
|
||
try:
|
||
settings = catalog_profile.ensure_enabled(row)
|
||
except catalog_profile.ProfileDisabled as e:
|
||
skipped += [{"step": s, "reason": str(e)} for s in wanted]
|
||
return jobs, skipped
|
||
try:
|
||
view = await table_versions.resolve_source(session, row)
|
||
job, tasks = await catalog_profile_jobs.create_job(
|
||
session, row, view, tables=None, scope=scope, settings=settings, actor=actor, trigger="schedule",
|
||
fire_at=fire_at, schedule_id=schedule_id, allow_empty=True)
|
||
except (catalog_profile_jobs.ProfileBusy, catalog_profile_jobs.ProfileNothing,
|
||
table_versions.SnapshotMissing) as e:
|
||
skipped += [{"step": s, "reason": str(e)} for s in wanted]
|
||
return jobs, skipped
|
||
if not tasks and introspect_id is None and (why := await catalog_profile_jobs.nothing_reason(
|
||
session, row, view, scope)):
|
||
# 建任务时就挑不出表、这次又没有探查(开始时表结构不会变)、也没排在起草后面(目录不会变):任务会以 0 张表
|
||
# 结束,原因照实记进触发记录,不只在剖析任务的提醒里。任务照建(触发照旧是 started)
|
||
skipped += [{"step": s, "reason": why} for s in wanted]
|
||
catalog_profile_jobs.manager.start(job.id)
|
||
jobs.append({"step": "+".join(wanted), "kind": "profile", "id": job.id, "scope": scope, "tables": len(tasks)})
|
||
return jobs, skipped
|
||
|
||
|
||
async def schedules_for(session: Any, source_id: str) -> list[dict[str, Any]]:
|
||
"""指向这个数据源的维护定时(数据源设置里「定时维护」的快捷入口读它,第 4 波):[{id, name, cron, timezone, enabled,
|
||
next_run_at}]。"""
|
||
from app.db.models import Schedule
|
||
|
||
rows = (await session.execute(select(Schedule).where(
|
||
Schedule.target_kind == "source_maintenance", Schedule.target_id == source_id)
|
||
.order_by(Schedule.created_at, Schedule.id))).scalars()
|
||
return [{"id": r.id, "name": r.name, "cron": r.cron, "timezone": r.timezone, "enabled": r.enabled,
|
||
"next_run_at": r.next_run_at.isoformat() if r.next_run_at else None} for r in rows]
|
||
|
||
|
||
__all__ = ["DEFAULT_STEPS", "MAINTENANCE_OPTION", "STEPS", "STEP_LABEL", "check_target", "fire", "schedules_for",
|
||
"steps_of", "steps_problem", "with_steps"]
|