Files
AgentLab/backend/app/data/source_maintenance.py
T
ThearchyHelios f5c94cf12d W3-a 变化记录的接口与处理、定时维护(next-round 03 B3、B4)
变化没有统一的记录:进不了审阅,发不了通知,口径账本的召回也没有可以订阅的接口;目录只能手动探查、手动剖析。现在:

- api/source_changes.py(骨架已在 datasources 通配之前登记,搭建者可用,访问密钥不放):GET /api/datasources/{id}/changes
  (缺省开着的和知道了的,按「使用次数权重 × 严重程度」排,带各级别计数)、GET /changes/{change_id}、POST
  /changes/{change_id}/resolve。处理动作落到目录已有的写入接口:清理目录里说到被删的列的项(整份写、乐观锁)、加入码值
  (apply_patch 配 _merge_codes,含义待填写,依据写变化编号)、重新起草这张表(模型用不了时只按表结构起草)、重新剖析、
  知道了、误报(必须写理由);每个动作写 resolution,动了目录的记目录版本,建了任务的记任务编号。
- data/source_changes.py 给召回:list_changes(按 seq 游标拉)、affecting(按源和表反查)、resolve、triggers_recall(只认
  结构类、处理时改了目录的、触及有效记录条件的新码值)。
- 通知 source.changed 的发送函数 notify/service.notify_source_changed:只推还要人看的「要处理」级变化,按源汇总,消息里
  只有源名、各级别条数和链接(D9);探查、剖析任务结束时有新的要处理变化就调。这一行的订阅范围第 4 波有了界面才打开,
  现在推不出去(照实)。
- 定时维护(data/source_maintenance.py,X4:就是一条 target_kind=source_maintenance 的定时,不另建表):fire 按顺序建
  探查任务 → 剖析任务(范围 needed / observe / maintenance 按选的步骤),任务上记 trigger=schedule、fire_at、schedule_id;
  剖析等探查进了同源队才建,靠 SourceLanes 串行;剖析没开、数据源停用 / 不存在 / 是上传表格时照实记没执行,同源还有
  探查或剖析在做时记跳过;触发记录的 inputs 写明建了哪几个任务、哪几步没执行。check_target 核对目标。维护步骤存在
  options.catalog_maintenance,经 GET / PUT /api/datasources/{id}/maintenance 改,数据源表单不显示它(api/datasources.py
  的 _MANAGED_OPTIONS 加了这一个键)。

测试:test_source_changes(列表与计数、误报要理由、处理过的 409、清理目录与加入码值记目录版本、剖析没开照实 422、
去重、游标拉取与反查、召回触发、通知只有源名条数链接)、test_source_maintenance(按顺序串行、trigger 与 fire_at、
剖析没开记未执行、停用 / 不存在 / 忙、调度循环只补最近一次且积压只记一条、步骤接口与核对目标)。
2026-10-16 16:48:40 +08:00

243 lines
14 KiB
Python
Raw 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.
"""数据源的定时维护:探查 → 剖析需要的表 → 取值观察(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 张表结束、提醒里写明为什么)。预算、止损照剖析设置。
照实记没执行的:数据源不存在、已停用、是上传表格或记录表时整次不执行(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 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
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))).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"]