Files
AgentLab/backend/app/data/catalog_profile_jobs.py
T
ThearchyHelios f774e42463 数据剖析可以同时剖析几张表:剖析设置加「同时剖析的表数」(table_concurrency,1–3,缺省 1,即以前的一张做完再做下一张;上限 3 是给这个源上的分析留出连接池的临时连接),每张表的查询仍一条一条发,所以同一时刻对业务库最多这么多条查询。剖析任务的执行改成按设置开几个执行者,按任务里的顺序一张结束就开始下一张;进度、发过的查询各落在自己那张表上(不再记「正在做的那一张」)。每张表开始前照旧重读数据源:停用、关掉剖析或止损后不再开始新的表,正在做的几张照常做完(止损时剩下的项记止损的原因),都结束后再把没开始的记停下;止损的连续失败、超时比例按几张表的查询合起来算。停止、关停时正在做的几张各自收尾,做完的项照常写进目录,都收完才往上交(等的时候又被停止也等完);一张表让任务整体出错(快照被改动、数据源没了、记结果写库出错)时,另几张按停止收尾、原因写这一句,本进程续不上租约的不写。同时要估同一张表的行数时只估一次(按表加锁,先来的没估成后来的自己估)。同源已有剖析时的 409 说法从「第 x / y 张表」改成「已完成 x / y 张表」。界面:设置表单多一格(单位「张」,提示写明业务库同时要应付几条查询、和分析共用连接),风险说明、卡片悬停、开始前的醒目一句在大于 1 时写明「同时剖析 N 张表、每张表的查询一条一条地发」,预算多一行,这次最多用时按几轮算。测试:同一时刻在途查询不超过设置、每张表仍一条一条发(1/2/3 三种)、几张一起显示进行中并按顺序开始、同时停止各自写进目录、止损后不再开始新表、停用时正在做的做完、一张出错另几张按停止收尾、共享行数只估一次;check-catalog-profile 加一节(预算、估算、说法、两行进行中、表清单)。验收:后端全量 14979 通过;止损那条并发测试单独跑 30 遍、整个剖析任务测试文件并行跑 15 遍都没有偶发;tsc;check-catalog-profile 217 项、check-copy 28 项全过
2026-10-21 09:08:10 +08:00

1254 lines
73 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.
"""数据目录的后台剖析任务(/api/datasources/{id}/catalog/profile-jobs)。
以前剖析是一次同步长请求(POST /catalog/profile,已去掉):预算按整次剖析算,选 50 张表只做得完前两三张;进行中只有一条
按时长走的横条、不能停;关掉页面报告就没了,对业务库查了什么无从查。现在和起草一样是后台任务(生命周期在
data/catalog_job_runtime.py),逐表剖析(引擎在 data/catalog_profile.py):
- **建任务**(create_job):范围三种——指定的表(selected:按给的顺序去重;表结构里没有、视图、没有可剖析的项照样建进
任务、状态 skipped)、需要剖析的表(needed:从没剖析过、上次剖析没做完、剖析之后冒出了新的待核实关系,见 due_reason)、
全部表(all)。后两种只挑表结构里、不是视图、有可剖析的项的表,有待核实关系的在前,再按使用次数、表结构顺序;挑出 0 张
抛 ProfileNothing。剖析设置随任务记下快照,每张表的检查项按当时的目录先列一遍。同一个源同时只有一个排队或进行中的
剖析任务(ProfileBusy,说法里写明做完了几张;部分唯一索引兜底);和同源的起草任务按建立的先后排队(每个源一条队),
起草在前时 waiting_for 是 "draft"。建任务时顺手清掉超出保留数的旧任务(prune)。
- **在后台剖析**(manager.start):轮到了才开始。按任务里的顺序逐表调 catalog_profile.profile_table(它读的是那时目录里
的最新内容:起草刚提的关系也核对),同时最多剖析设置里 table_concurrency 张(缺省 1,一张做完再做下一张;每张表的查询
仍一条一条发),一张结束就开始下一张。每张表开始前重读数据源——停用了、关掉了剖析就不再开始新的表,正在做的做完,
剩下的表 cancelled、error 写明;遮罩按最新的设置。检查项、正在做哪一项、查询数、发过的查询随时落在各自那张表上;整张表
的结果和写目录在同一个事务里,停止时也不会被打断。
- **任务级止损**(替代以前的整次总量上限):连续 3 条查询失败、最近 10 条里 7 条超时(同时剖析的几张表的查询合起来算),
整个任务停下(stop_reason、error),正在做的几张表写入已做完的项,后面的表 cancelled、error 带同一句原因;最近 6 条里
4 条超时时 notice 有一句提醒,好转后清掉。任务最后的状态按逐表算:有 failed、partial、cancelled 的表是 partial,否则
succeeded;任务整体出错(数据源没了、快照被改动、意外异常)是 failed,同时在做的另几张表照停止收尾、原因写这一句。
- **停止**(cancel):排队的直接 cancelled;进行中的停掉正在等的查询(库那边按单条时限截止),正在做的几张表做完的项照常
写进目录、没做的保留旧结论,后面的表 cancelled,任务 cancelled。
- **重试**(retry_tables + create_job):新任务,只含原任务里 failed、partial、cancelled 的表(按原来的顺序),范围 selected,
retry_of 记原任务;设置取数据源当前的。
- **服务重启**(startup):排队、进行中的标 interrupted,没结束的表 cancelled、error「服务重启,剖析中断」(进程没了时正在
剖析的那张表,查完的项也没写进目录,照实写明,见文末)。不自动续跑:
复制出来的数据目录启动时不能自己去查业务库,界面让人点「重试」。关停时(manager.shutdown)停掉进行中的,同样标中断。
启动时还一次性迁移剖析设置的旧键(catalog_profile.migrate_settings)。
- **保留**(prune):每个源保留最近 KEEP_JOBS 个结束了的剖析任务,更早的删掉;某张表「上次剖析」所在的任务不删,表详情的
「上次剖析」一直在。
- **取值观察**(next-round 03 B2,data/catalog_profile_observe):范围另有两种——observe(有数据目录、观察到期的表,只做
观察检查)和 maintenance(定时维护用:需要剖析的表做剖析,观察到期的表做观察,两样都要的两样都做)。每张表做哪几样按建
任务时列出的检查项定;观察到的随这张表的结果落库(同一个事务),和同口径的历史比出的取值变化写进 source_changes。人点的
「需要剖析 / 全部 / 指定的表」照旧不观察。
- **触发**:任务的设置快照里另记 trigger(manual 人点的 / schedule 定时维护)、fire_at、schedule_id(剖析任务表没有这几列、
这一轮不加列,见 trigger_of),接口按 trigger、fire_at 给。
- **读**:job_dict(任务和逐表一行)、task_detail(一张表的检查项、发现、跳过的项、发过的查询)、last_profiles / last_detail
(表清单每行、表详情的「上次剖析」)、due_reason(这张表要不要剖析、为什么)。「上次剖析」是真正开始过的最近一次(见
_ranked):没开始就停了的、还排着的那一项不挡掉上一次真正剖析的行数、发过的查询。
没结束的表收尾时(停止、中断、整体出错)检查项里还排着的、正在做的记为跳过(原因 cancelled 或 error),不留「排队中」的
检查项在一张已经结束的表上。目录只在一张表结束时和它的结果一起写(同一个事务):收尾时还没结束的表,查完的项结论也没
写进目录(进程被杀、快照被改动),同样记为跳过、写明「查完了但结论没有写进数据目录」,算没做完,重试时带上这张表。
停止、关停这类能收尾的停下不走这条路:正在剖析的那张表做完的项照常写进目录(catalog_profile.profile_table)。
"""
from __future__ import annotations
import asyncio
import collections
import logging
import time
from typing import Any
from sqlalchemy import case, delete, func, not_, or_, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.data import catalog, catalog_profile
from app.data import catalog_job_runtime as runtime
from app.data.catalog_job_runtime import ACTIVE, FINISHED, OPEN, RETRYABLE, STOP_GRACE_S, TASK_DONE, TASK_STATUSES
from app.data.catalog_profile import (
NOTHING_ERROR, UNFINISHED_REASONS, VIEW_ERROR, ProfileSettings, ProfileSkip, TableHooks, TableResult,
)
from app.data.engine import SnapshotTampered
from app.db.base import SessionLocal, new_id, utcnow
from app.db.portable import json_flag
from app.db.models import CatalogDraftJob, CatalogProfileJob, CatalogProfileTask, DataSource
logger = logging.getLogger(__name__)
#: 剖析任务占同源队(叫法登记在运行时里,见 core/job_runtime.register_lane_kind)
runtime.register_lane_kind("profile", "剖析")
#: 一个任务最多几张表
MAX_TABLES = 500
#: 每个源保留几个结束了的剖析任务(某张表最近一次剖析所在的任务另外保留)
KEEP_JOBS = 50
INTERRUPTED_ERROR = "服务重启,剖析中断"
STOPPED_ERROR = catalog_profile.STOPPED
UNFINISHED_ERROR = "剖析结束时这张表还没有收尾,请重试这张表"
JOB_ERROR = "剖析任务出错:{line}"
SOURCE_GONE = "数据源不存在,可能已被删除"
SOURCE_DISABLED = "数据源「{name}」已停用,剖析已停止"
PROFILE_TURNED_OFF = "数据源「{name}」已关闭数据剖析,剖析已停止"
#: 没结束的检查项在收尾时的说明({why} 是停止、中断、出错的原因)
_UNCHECKED = "{why},未检查"
#: 查完了、结论却没写进目录的检查项:这张表没走到整张表结束、写目录那一步(进程没了、任务整体出错)
_UNSAVED = "{why},查完了但结论没有写进数据目录"
_CHECK_OPEN = ("pending", "running")
def unsaved_error(why: str, done: int, rest: int) -> str:
"""没走到写目录那一步的表的整句原因:查完的几项没写进目录,剩下几项没查。"""
text = f"{why}:已查完的 {done} 项没有写进数据目录"
return text + f",剩下 {rest} 项没查" if rest else text
class ProfileBusy(Exception):
"""这个数据源已经有排队或进行中的剖析任务。message 给人看(写明做完了几张)。"""
class ProfileFinished(Exception):
"""要停止的剖析任务已经结束了。"""
class ProfileNothing(Exception):
"""按范围挑出 0 张表。message 说明原因、建议怎么办。"""
class NothingToRetry(Exception):
"""原任务里没有需要重试的表。"""
# ==========================================================================
# 读任务
# ==========================================================================
def _iso(value: Any) -> str | None:
return value.isoformat() if value is not None else None
def _empty_summary() -> dict[str, int]:
return catalog_profile.summarize([])
async def active_job(session: AsyncSession, source_id: str) -> CatalogProfileJob | None:
"""排队或进行中的那个(同一个源最多一个)。"""
return (await session.execute(
select(CatalogProfileJob).where(CatalogProfileJob.source_id == source_id,
CatalogProfileJob.status.in_(ACTIVE))
.order_by(CatalogProfileJob.created_at.desc(), CatalogProfileJob.id.desc()).limit(1)
)).scalar_one_or_none()
async def latest_job(session: AsyncSession, source_id: str) -> CatalogProfileJob | None:
"""最近建的一个(任何状态)。"""
return (await session.execute(
select(CatalogProfileJob).where(CatalogProfileJob.source_id == source_id)
.order_by(CatalogProfileJob.created_at.desc(), CatalogProfileJob.id.desc()).limit(1)
)).scalar_one_or_none()
async def load(session: AsyncSession, source_id: str, job_id: str) -> CatalogProfileJob | None:
"""这个源的这个任务;不存在、不属于这个源都是 None。"""
job = await session.get(CatalogProfileJob, job_id)
return job if job is not None and job.source_id == source_id else None
#: 逐表一行要的列:不读发现、跳过的项、发过的查询(任务进行中界面每秒都要取一次)
_ROW_COLUMNS = (CatalogProfileTask.seq, CatalogProfileTask.table_name, CatalogProfileTask.status,
CatalogProfileTask.checks, CatalogProfileTask.current, CatalogProfileTask.queries,
CatalogProfileTask.elapsed_ms, CatalogProfileTask.row_estimate, CatalogProfileTask.summary,
CatalogProfileTask.skipped_count, CatalogProfileTask.unfinished, CatalogProfileTask.added,
CatalogProfileTask.updated, CatalogProfileTask.removed, CatalogProfileTask.version,
CatalogProfileTask.error, CatalogProfileTask.started_at, CatalogProfileTask.finished_at)
async def task_rows(session: AsyncSession, job_id: str) -> list[Any]:
return list((await session.execute(
select(*_ROW_COLUMNS).where(CatalogProfileTask.job_id == job_id).order_by(CatalogProfileTask.seq)
)).all())
#: 取值观察的四类检查在逐表进度里合成一类(observe)
_OBSERVE_GROUP = frozenset({"rows", "codes_observe", "freshness", "daily_volume"})
def _by_kind(checks: list[Any]) -> dict[str, list[int]]:
"""检查项按种类的进度:{relation: [结束了的, 总数], codes: […], date: […], observe: […]}。结束了的含跳过的;取值观察的
四类合成 observe 一类。"""
out = {"relation": [0, 0], "codes": [0, 0], "date": [0, 0], "observe": [0, 0]}
for check in checks:
if not isinstance(check, dict):
continue
kind = "observe" if check.get("kind") in _OBSERVE_GROUP else check.get("kind")
if kind not in out:
continue
pair = out[kind]
pair[1] += 1
if check.get("state") in ("done", "skipped"):
pair[0] += 1
return out
def task_row(task: Any) -> dict[str, Any]:
"""逐表一行(CatalogProfileTaskRow)。task 是 CatalogProfileTask,或者 task_rows 取的那几列。"""
checks = [c for c in (task.checks or []) if isinstance(c, dict)]
return {
"seq": task.seq,
"table_name": task.table_name,
"status": task.status,
"checks_total": len(checks),
"checks_done": sum(1 for c in checks if c.get("state") in ("done", "skipped")),
"checks_by_kind": _by_kind(checks),
"current": task.current if task.status == "running" else None,
"queries": task.queries or 0,
"elapsed_ms": task.elapsed_ms or 0,
"row_estimate": task.row_estimate,
"summary": {**_empty_summary(), **(task.summary or {})},
"skipped_count": task.skipped_count or 0,
"unfinished": task.unfinished or 0,
"added": task.added or 0,
"updated": task.updated or 0,
"removed": task.removed or 0,
"version": task.version or 0,
"error": task.error,
"started_at": _iso(task.started_at),
"finished_at": _iso(task.finished_at),
}
def _counts(statuses: list[str]) -> dict[str, int]:
counts = dict.fromkeys(TASK_STATUSES, 0)
for status in statuses:
counts[status] = counts.get(status, 0) + 1
return counts
#: 剖析任务的范围:selected 指定的表、needed 需要剖析的表、all 全部表;observe 观察到期的表(只做取值观察)、maintenance
#: 定时维护(需要剖析的表 + 观察到期的表)
SCOPES = ("selected", "needed", "all", "observe", "maintenance")
#: 带取值观察的范围
OBSERVING_SCOPES = ("observe", "maintenance")
#: 设置快照里记触发的几个键(不是剖析设置:ProfileSettings.from_dict 不认它们)
_TRIGGER_KEYS = ("trigger", "fire_at", "schedule_id")
def trigger_of(job: Any) -> dict[str, Any]:
"""这个任务是怎么建的:{trigger: manual|schedule, fire_at, schedule_id}。剖析任务表没有这几列(只加不改的迁移这一轮
不加),记在设置快照里。"""
raw = getattr(job, "settings", None) or {}
return {"trigger": raw.get("trigger") or "manual", "fire_at": raw.get("fire_at"),
"schedule_id": raw.get("schedule_id")}
def job_dict(job: CatalogProfileJob, tasks: list[Any], *, waiting_for: str | None = None) -> dict[str, Any]:
"""接口返回的任务(CatalogProfileJob):逐表按剖析顺序;计数、合计都按逐表的算。"""
rows = [task_row(t) for t in tasks]
counts = _counts([r["status"] for r in rows])
summary = _empty_summary()
for r in rows:
for key in summary:
summary[key] += int(r["summary"].get(key) or 0)
return {
"id": job.id,
"source_id": job.source_id,
"status": job.status,
"scope": job.scope,
"settings": ProfileSettings.from_dict(job.settings).to_dict(),
"created_at": _iso(job.created_at),
"started_at": _iso(job.started_at),
"finished_at": _iso(job.finished_at),
"created_by": job.created_by,
"retry_of": job.retry_of,
"trigger": trigger_of(job)["trigger"],
"fire_at": trigger_of(job)["fire_at"],
"waiting_for": waiting_for,
"total": len(rows),
"done": sum(counts.get(s, 0) for s in TASK_DONE),
"counts": counts,
"queries": job.queries or 0,
"timeouts": job.timeouts or 0,
"errors": job.errors or 0,
"stop_reason": job.stop_reason,
"notice": job.notice,
"error": job.error,
"added": sum(r["added"] for r in rows),
"updated": sum(r["updated"] for r in rows),
"removed": sum(r["removed"] for r in rows),
"summary": summary,
"tasks": rows,
}
async def waiting_for(session: AsyncSession, job: CatalogProfileJob) -> str | None:
"""排队中的剖析任务在等什么:排在同源的起草任务(或同步起草)后面时是 "draft",排在探查任务后面时是 "introspect",
否则 None。
先看这个源的队(进程内,排在它前面的是谁);任务刚建、后台还没排进队时看库:同源有比它先建的、排队或进行中的起草
任务,或者正在同步起草。"""
if job.status != "queued":
return None
ahead = await runtime.lanes.ahead(job.source_id, job.id)
if ahead is not None:
if any(kind in ("draft", "sync_draft") for kind in ahead):
return "draft"
# 排在同源的探查任务后面(next-round 03 A1:探查也进同一条队)
return "introspect" if "introspect" in ahead else None
from app.data import catalog_jobs
if await catalog_jobs.sync_drafting(job.source_id):
return "draft"
earlier = (await session.execute(
select(CatalogDraftJob.id).where(CatalogDraftJob.source_id == job.source_id,
CatalogDraftJob.status.in_(ACTIVE),
CatalogDraftJob.created_at <= job.created_at).limit(1)
)).first()
return "draft" if earlier is not None else None
async def job_out(session: AsyncSession, job: CatalogProfileJob | None) -> dict[str, Any] | None:
if job is None:
return None
return job_dict(job, await task_rows(session, job.id), waiting_for=await waiting_for(session, job))
async def job_brief(session: AsyncSession, job: CatalogProfileJob | None) -> dict[str, Any] | None:
"""目录清单顶上的进度(CatalogActiveProfileJob):{id, status, total, done, waiting_for}。"""
if job is None:
return None
rows = (await session.execute(
select(CatalogProfileTask.status, func.count()).where(CatalogProfileTask.job_id == job.id)
.group_by(CatalogProfileTask.status)
)).all()
counts = {status: n for status, n in rows}
return {"id": job.id, "status": job.status, "total": sum(counts.values()),
"done": sum(counts.get(s, 0) for s in TASK_DONE), "waiting_for": await waiting_for(session, job)}
def task_detail(job: CatalogProfileJob, task: CatalogProfileTask) -> dict[str, Any]:
"""一张表的明细(CatalogProfileTaskDetail):逐表一行,加上检查项、发现、跳过的项、日期范围、发过的查询。"""
return {
**task_row(task),
"job_id": job.id,
"job_status": job.status,
"created_by": job.created_by,
"checks": [c for c in task.checks or [] if isinstance(c, dict)],
"findings": list(task.findings or []),
"skipped": list(task.skipped or []),
"date_ranges": list(task.date_ranges or []),
"query_log": list(task.query_log or []),
}
async def load_task(session: AsyncSession, job: CatalogProfileJob, seq: int) -> CatalogProfileTask | None:
return (await session.execute(
select(CatalogProfileTask).where(CatalogProfileTask.job_id == job.id, CatalogProfileTask.seq == seq)
)).scalar_one_or_none()
#: 同一张表的各项谁算「最近」:任务建得晚的在前;同一个任务里同名的取 seq 小的
_NEWEST = (CatalogProfileJob.created_at.desc(), CatalogProfileJob.id.desc(), CatalogProfileTask.seq)
#: 表清单、表详情显示的「上次剖析」:真正开始过(started_at 有值)的排在没开始过的前面,各自再按 _NEWEST
_SHOWN = (case((CatalogProfileTask.started_at.is_(None), 1), else_=0), *_NEWEST)
#: 只做了取值观察的那一项(observe 范围、maintenance 范围里只到期观察的表)在逐表一行的 summary 里带这个标记:它不是一次
#: 剖析,不算「上次剖析」——不然表清单的「上次剖析」、给助手的剖析事实(行数、日期范围)会被一次只记了行数的观察顶掉
OBSERVE_ONLY = "observe_only"
_PROFILED = not_(json_flag(CatalogProfileTask.summary, OBSERVE_ONLY))
def _observe_only(checks: list[Any] | None) -> bool:
from app.data import catalog_profile_observe as observe
kinds = {c.get("kind") if isinstance(c, dict) else getattr(c, "kind", None) for c in checks or []}
return bool(kinds) and kinds <= set(observe.OBSERVE_KINDS)
def _summary_of(result: TableResult) -> dict[str, Any]:
out: dict[str, Any] = dict(result.summary())
if _observe_only(result.checks):
out[OBSERVE_ONLY] = 1
return out
def _ranked(source_id: str, *columns: Any) -> Any:
"""每张表剖析过的各项:子查询,带两个排名。
- shown:这张表的「上次剖析」(表清单每行、表详情、要不要剖析都按它;保留也保护它所在的任务)——真正开始过的最近一项,
一次都没开始过才退回最近一项。没开始就结束了的那一项(停在前面的表上、任务止损或整体出错后没轮到、建任务时就判为
跳过)、排队中还没轮到的那一项都不算:它没有行数估算、发过的查询,要是挡掉上一次真正剖析的那一项,表详情就只剩
一句「已停止」,那次剖析所在的任务还会被当成不是谁的最近一次给清掉——行数、日期范围、发过的查询只存在剖析任务里。
- latest:最近一项,不管开始没有。它还排着、在做(pending / running)说明这张表已经在进行中的任务里了。
"""
shown = func.row_number().over(partition_by=CatalogProfileTask.table_name, order_by=_SHOWN).label("shown")
latest = func.row_number().over(partition_by=CatalogProfileTask.table_name, order_by=_NEWEST).label("latest")
return (select(*columns, shown, latest).join(CatalogProfileJob, CatalogProfileJob.id == CatalogProfileTask.job_id)
.where(CatalogProfileJob.source_id == source_id, _PROFILED)).subquery()
async def last_profiles(session: AsyncSession, source_id: str) -> dict[str, dict[str, Any]]:
"""每张表的「上次剖析」(见 _ranked 的 shown):{表名: CatalogLastProfile 加上内部用的 paths(那一项检查项的路径,判断
有没有新的待核实关系用)、keys(那一项检查项的 (kind, target),判断有没有新的码值、日期检查用)、queued(最近一项还排着
或在做:这张表已经在进行中的任务里了)}。在做的就是进行中的状态。
每张表取哪一项在库里挑好。"""
inner = _ranked(source_id, CatalogProfileTask.table_name, CatalogProfileTask.job_id,
CatalogProfileTask.seq, CatalogProfileTask.status, CatalogProfileTask.started_at,
CatalogProfileTask.finished_at, CatalogProfileTask.summary,
CatalogProfileTask.skipped_count, CatalogProfileTask.unfinished,
CatalogProfileTask.error, CatalogProfileTask.row_estimate, CatalogProfileTask.checks)
rows = list((await session.execute(
select(inner).where(or_(inner.c.shown == 1, inner.c.latest == 1)))).mappings())
queued = {row["table_name"] for row in rows if row["latest"] == 1 and row["status"] in OPEN}
out: dict[str, dict[str, Any]] = {}
for row in rows:
if row["shown"] != 1:
continue
out[row["table_name"]] = {
"job_id": row["job_id"], "seq": row["seq"], "status": row["status"],
"started_at": _iso(row["started_at"]), "finished_at": _iso(row["finished_at"]),
"summary": {**_empty_summary(), **(row["summary"] or {})}, "skipped_count": row["skipped_count"] or 0,
"unfinished": row["unfinished"] or 0, "error": row["error"], "row_estimate": row["row_estimate"],
"paths": {c.get("path") for c in row["checks"] or [] if isinstance(c, dict) and c.get("path")},
"keys": {(c.get("kind"), c.get("target")) for c in row["checks"] or [] if isinstance(c, dict)},
"queued": row["table_name"] in queued,
}
return out
#: last_profiles 里只给内部用的键
_INTERNAL = ("paths", "keys", "queued")
def public_last(last: dict[str, Any] | None) -> dict[str, Any] | None:
"""接口里的 CatalogLastProfile:去掉内部用的 paths、keys、queued。"""
return None if last is None else {k: v for k, v in last.items() if k not in _INTERNAL}
async def last_facts(session: AsyncSession, source_id: str,
tables: list[str] | None = None) -> dict[str, dict[str, Any]]:
"""每张表「上次剖析」(见 _ranked 的 shown,和表清单、表详情同一项)里给助手看的剖析事实:{表名: {at, job_id,
row_estimate, date_ranges, snapshot_id, observed?}},一条查询批量取。没有行数估算也没有日期范围的不给;tables 给了只取
这几张。observed 是取值观察最近一次的结论(catalog_profile_observe.observed_facts:最新业务日期、行数),有就给。
look、运行时 db_schema、/schema、表助手四处都经 schema_view.profile_lines 渲染这一份(next-round 遗留 e):剖析数字
是会过期的观测,不写进数据目录;遮罩、快照在渲染那一刻再核。"""
inner = _ranked(source_id, CatalogProfileTask.table_name, CatalogProfileTask.job_id,
CatalogProfileTask.started_at, CatalogProfileTask.finished_at, CatalogProfileTask.row_estimate,
CatalogProfileTask.date_ranges, CatalogProfileTask.snapshot_id)
query = select(inner).where(inner.c.shown == 1)
if tables is not None:
if not tables:
return {}
query = query.where(inner.c.table_name.in_(list(tables)))
out: dict[str, dict[str, Any]] = {}
for row in (await session.execute(query)).mappings():
estimate = row["row_estimate"] if isinstance(row["row_estimate"], dict) else None
ranges = [r for r in row["date_ranges"] or [] if isinstance(r, dict)]
if not estimate and not ranges:
continue
at = row["finished_at"] or row["started_at"]
out[row["table_name"]] = {"at": _iso(at), "job_id": row["job_id"], "row_estimate": estimate,
"date_ranges": ranges, "snapshot_id": row["snapshot_id"]}
# 取值观察的结论(最新业务日期、行数,next-round 03 B2)和剖析事实同一节:只有观察、没剖析过的表也给
from app.data import catalog_profile_observe
for table, observed in (await catalog_profile_observe.observed_facts(session, source_id, tables)).items():
out.setdefault(table, {"at": None, "job_id": None, "row_estimate": None, "date_ranges": [],
"snapshot_id": None})["observed"] = observed
return out
async def last_detail(session: AsyncSession, source_id: str, table: str) -> dict[str, Any] | None:
"""这张表「上次剖析」(见 _ranked 的 shown)的明细(表详情的「数据剖析」一节);没剖析过是 None。"""
found = (await session.execute(
select(CatalogProfileTask, CatalogProfileJob)
.join(CatalogProfileJob, CatalogProfileJob.id == CatalogProfileTask.job_id)
.where(CatalogProfileJob.source_id == source_id, CatalogProfileTask.table_name == table, _PROFILED)
.order_by(*_SHOWN).limit(1)
)).first()
return None if found is None else task_detail(found[1], found[0])
def due_reason(meta: dict[str, Any] | None, notes: dict[str, Any] | None, last: dict[str, Any] | None,
planned: list[catalog_profile.PlannedCheck]) -> str | None:
"""这张表要不要剖析、为什么:never 从没剖析过;unfinished 上次剖析是 partial / failed / cancelled;new_relations
目录里有待核实的关系不在上次剖析的检查项里;new_checks 上次剖析之后多出了要做的码值、日期检查(上次剖析时没有可剖析的
项、后来起草或人工把某一列记成了状态类,或者日期列变了)。表结构里没有、视图、没有可剖析的项、已经在进行中的任务里
(排着或在做)的不要。planned 是按当前目录和表结构列出的检查(catalog_profile.plan_checks)。
「上次剖析」和表清单显示的是同一项(last_profiles,真正开始过的最近一项):上次真正剖析完整了,后来某个任务没轮到它
就停了,不算没做完——不然表清单一边显示「上次剖析:完成」,一边说「上次剖析没做完」。一次都没开始过的,按最近一项算。
上次是空表而跳过的不算要剖析:表里有没有数据,不发查询就不知道;空表等有了数据,选「全部表」或单独剖析它。"""
runnable = [c for c in planned if c.skip is None]
if not isinstance(meta, dict) or meta.get("is_view") or not runnable:
return None
if last is None:
return "never"
if last.get("queued") or last["status"] in OPEN:
return None
if last["status"] in RETRYABLE:
return "unfinished"
if catalog_profile.pending_relation_paths(notes) - set(last.get("paths") or ()):
return "new_relations"
seen = last.get("keys") or set()
if any(c.kind != "relation" and (c.kind, c.target) not in seen for c in runnable):
return "new_checks"
return None
# ==========================================================================
# 建任务、重试、停止
# ==========================================================================
#: 撞上剖析任务时说法的最后一句(「请等这次剖析结束(或先停止它){then}」)缺省是建剖析任务的;导入目录、同步起草撞上
#: 时各说各的(catalog_jobs):那时要做的是导入、起草,不是开始新的剖析
THEN_PROFILE = "再开始新的剖析"
def busy_message(row: Any, brief: dict[str, Any] | None, *, table: str | None = None,
position: int | None = None, then: str = THEN_PROFILE) -> str:
"""同源已有剖析任务时的说法:写明做完了几张(同时剖析几张表时「第几张」说不清,只说做完几张);从表详情点「剖析这张
表」时再说这张表在不在那个任务里、排第几;最后一句按调用方要做的事说(then)。"""
name = getattr(row, "name", "") or ""
if brief is None:
text = f"数据源「{name}」正在剖析"
elif brief["status"] == "queued":
text = f"数据源「{name}」已有一个排队中的剖析任务(共 {brief['total']} 张表)"
else:
text = f"数据源「{name}」正在剖析:已完成 {brief['done']} / {brief['total']} 张表"
if table is not None:
text += f",{table} 在这次剖析里,排第 {position} 张" if position else f",{table} 不在这次剖析里"
return text + f"。请等这次剖析结束(或先停止它){then}"
async def ensure_free(session: AsyncSession, row: Any, tables: list[str] | None = None, *,
then: str = THEN_PROFILE) -> None:
"""这个源没有排队或进行中的剖析任务;有就抛 ProfileBusy。只指定了一张表时,说法里带上这张表在不在那个任务里;then 是
说法的最后一句要做的事(见 busy_message)。"""
job = await active_job(session, row.id)
if job is None:
return
brief = await job_brief(session, job)
table = position = None
if tables and len(tables) == 1:
key = catalog.resolve_table_name(getattr(row, "schema_cache", None), str(tables[0])) or str(tables[0])
table = key
names = [r.table_name for r in await task_rows(session, job.id)]
position = names.index(key) + 1 if key in names else None
raise ProfileBusy(busy_message(row, brief, table=table, position=position, then=then))
#: 挑出 0 张表时的说法
NOTHING_NEEDED = ("没有需要剖析的表:能剖析的表都剖析过了,上次剖析也都做完了,之后没有新的待核实关系,也没有新的码值、"
"日期列(上次是空表的不算)。要重新剖析,请选「全部表」或指定要剖析的表")
NOTHING_AT_ALL = ("没有可以剖析的表:表结构里的表都是视图,或者没有可剖析的项(要核对的关联关系、像码值的列、日期列都没有,"
"或者都被遮罩了)。请先起草数据目录,或指定要剖析的表")
NOTHING_TO_OBSERVE = ("没有要观察的表:取值观察只看有数据目录的表,这些表最近 {h} 小时内都观察过了(或者还没有数据目录)")
NOTHING_TO_MAINTAIN = ("没有需要剖析或观察的表:能剖析的表都剖析过了,有数据目录的表最近 {h} 小时内也都观察过了")
async def outside_of(session: AsyncSession, row: Any) -> frozenset[str]:
"""对象索引里探查范围外的对象(name_key):剖析从范围内挑表,关系指向范围外的表时说「在探查范围外,可以加入」
(catalog_profile._relation_ends)。上传表格、记录表、升级前探查的源没有对象索引,是空集。"""
from app.data import source_objects
if (getattr(row, "origin", None) or "manual") != "manual":
return frozenset()
if not isinstance((getattr(row, "schema_cache", None) or {}).get("scope"), dict):
return frozenset()
return await source_objects.outside_names(session, row)
def _skipped_task(job_id: str, seq: int, name: str, reason: str, why: str, checks: list[dict[str, Any]],
skipped: list[ProfileSkip], now: Any) -> CatalogProfileTask:
entries = [s.to_dict() for s in skipped] + [ProfileSkip("table", name, reason, why).to_dict()]
return CatalogProfileTask(job_id=job_id, seq=seq, table_name=name, status="skipped", checks=checks or None,
skipped=entries, skipped_count=len(entries),
unfinished=sum(1 for e in entries if e["reason"] in UNFINISHED_REASONS),
summary=_empty_summary(), error=why, finished_at=now)
async def _pick(session: AsyncSession, row: Any, source: Any, *, tables: list[str] | None,
scope: str) -> list[tuple[str, str | None, str | None, list[Any]]]:
"""要剖析的表:[(表名, 跳过的原因 / None, 整句, 检查项)],按剖析顺序。"""
cache = getattr(source, "schema_cache", None) or {}
all_tables = cache.get("tables") or {}
masked = catalog_profile.masked_of(getattr(row, "options", None))
entries = await catalog.read_catalog(session, source.id)
outside = await outside_of(session, row)
def notes(name: str) -> dict[str, Any]:
entry = entries.get(name)
return entry.notes if entry else {}
def plan(name: str) -> list[Any]:
return catalog_profile.plan_checks(name, all_tables.get(name), notes(name), tables=all_tables, masked=masked,
outside=outside)
if scope == "selected":
picked: list[tuple[str, str | None, str | None, list[Any]]] = []
seen: set[str] = set()
for name in dict.fromkeys(str(t) for t in tables or []):
key = catalog.resolve_table_name(cache, name)
if key is None:
picked.append((name, "missing", catalog.missing_table_error(name), []))
continue
if key in seen:
continue
seen.add(key)
if (all_tables.get(key) or {}).get("is_view"):
picked.append((key, "view", VIEW_ERROR, []))
continue
checks = plan(key)
if not any(c.skip is None for c in checks):
picked.append((key, "nothing", NOTHING_ERROR, checks))
else:
picked.append((key, None, None, checks))
return picked
if scope in OBSERVING_SCOPES:
return await _pick_observing(session, source, scope, all_tables, entries, notes, plan, masked)
usage = await catalog.table_usage(session, source)
last = await last_profiles(session, source.id) if scope == "needed" else {}
order = {name: i for i, name in enumerate(all_tables)}
candidates: list[tuple[str, list[Any]]] = []
for name, meta in all_tables.items():
if not isinstance(meta, dict) or meta.get("is_view"):
continue
checks = plan(name)
if not any(c.skip is None for c in checks):
continue
if scope == "needed" and due_reason(meta, notes(name), last.get(name), checks) is None:
continue
candidates.append((name, checks))
if not candidates:
raise ProfileNothing(NOTHING_NEEDED if scope == "needed" else NOTHING_AT_ALL)
# 还有待核实(推断)关系的表排在前面:剖析首先是来核实它们的;再按使用次数、表结构顺序
candidates.sort(key=lambda c: (not catalog_profile.has_pending_relations(notes(c[0])), -usage.get(c[0], 0),
order[c[0]]))
return [(name, None, None, checks) for name, checks in candidates]
async def _pick_observing(session: AsyncSession, source: Any, scope: str, all_tables: dict[str, Any],
entries: dict[str, Any], notes: Any, plan: Any,
masked: set[str]) -> list[tuple[str, str | None, str | None, list[Any]]]:
"""observe / maintenance 的表:观察到期的、有数据目录的表做观察检查(catalog_profile_observe.plan_observations);
maintenance 另加需要剖析的表做剖析(同 needed)。两样都要的两样都做。剖析的在前(有待核实关系的再往前),再按使用次数、
表结构顺序。挑出 0 张抛 ProfileNothing。"""
from app.data import catalog_profile_observe as observe
observed = await observe.last_observed(session, source.id)
last = await last_profiles(session, source.id) if scope == "maintenance" else {}
usage = await catalog.table_usage(session, source)
order = {name: i for i, name in enumerate(all_tables)}
candidates: list[tuple[str, list[Any], bool]] = []
for name, meta in all_tables.items():
if not isinstance(meta, dict) or meta.get("is_view"):
continue
regular: list[Any] = []
if scope == "maintenance":
planned = plan(name)
if any(c.skip is None for c in planned) and due_reason(meta, notes(name), last.get(name), planned):
regular = planned
watched: list[Any] = []
if name in entries and observe.observe_reason(observed.get(name)) is not None:
watched = observe.plan_observations(name, meta, notes(name), masked=masked)
if regular or watched:
candidates.append((name, regular + watched, bool(regular)))
if not candidates:
text = NOTHING_TO_MAINTAIN if scope == "maintenance" else NOTHING_TO_OBSERVE
raise ProfileNothing(text.format(h=observe.OBSERVE_EVERY_H))
candidates.sort(key=lambda c: (not c[2], not catalog_profile.has_pending_relations(notes(c[0])),
-usage.get(c[0], 0), order[c[0]]))
return [(name, None, None, checks) for name, checks, _ in candidates]
def task_modes(scope: str, checks: list[Any] | None) -> tuple[bool, bool]:
"""一张表这次做哪几样:(剖析, 观察)。按建任务(或开始时重挑)列出的检查项:observe 范围只观察;maintenance 看检查项里
有没有剖析的、观察的;别的范围只剖析。"""
from app.data import catalog_profile_observe as observe
kinds = {c.get("kind") for c in checks or [] if isinstance(c, dict)}
watched = bool(kinds & set(observe.OBSERVE_KINDS))
if scope == "observe":
return False, True
if scope == "maintenance":
return bool(kinds - set(observe.OBSERVE_KINDS)), watched
return True, False
def _tasks_for(job_id: str, picked: list[tuple[str, str | None, str | None, list[Any]]],
now: Any) -> list[CatalogProfileTask]:
"""_pick 挑出的表 → 逐表的行:按规则不剖析的(表结构里没有、视图、没有可剖析的项)直接记跳过,其余排队。"""
tasks: list[CatalogProfileTask] = []
for seq, (name, reason, why, checks) in enumerate(picked):
shown = [c.as_check() for c in checks]
if reason is not None:
rule = [ProfileSkip(c.kind, c.target, c.skip[0], c.skip[1], c.path) for c in checks if c.skip]
tasks.append(_skipped_task(job_id, seq, name, reason, str(why), shown, rule, now))
continue
summary = {**_empty_summary(), **({OBSERVE_ONLY: 1} if _observe_only(checks) else {})}
tasks.append(CatalogProfileTask(job_id=job_id, seq=seq, table_name=name, status="pending", checks=shown,
summary=summary))
return tasks
async def _behind_draft(session: AsyncSession, source_id: str) -> bool:
"""这个源有排队、进行中的起草任务,或者正在同步起草:新建的剖析任务要排在它后面。"""
from app.data import catalog_jobs
return await catalog_jobs.sync_drafting(source_id) or await catalog_jobs.active_job(session, source_id) is not None
async def nothing_reason(session: AsyncSession, row: Any, source: Any, scope: str) -> str | None:
"""按此刻的表结构和目录,这个范围一张表都挑不出来时为什么(ProfileNothing 的原话);挑得出、或者排在起草后面(开始时按
起草后的目录再挑,现在说不准)返回 None。定时维护建出 0 张表的任务时用它把原因记进触发记录。"""
if await _behind_draft(session, row.id):
return None
try:
await _pick(session, row, source, tables=None, scope=scope)
except ProfileNothing as e:
return str(e)
return None
async def create_job(session: AsyncSession, row: Any, source: Any, *, tables: list[str] | None, scope: str,
settings: ProfileSettings, actor: str | None, retry_of: str | None = None,
trigger: str = "manual", fire_at: str | None = None, schedule_id: str | None = None,
allow_empty: bool = False) -> tuple[CatalogProfileJob, list[CatalogProfileTask]]:
"""建一个排队的剖析任务(还没开始,调用方接着 manager.start)。row 是数据源记录(遮罩按它的设置),source 是读表结构
用的源(上传源是绑定当前快照的视图)。同源已有排队或进行中的剖析任务抛 ProfileBusy;挑出 0 张表抛 ProfileNothing。
同源有起草任务时照建,排在它后面——这时挑出 0 张表也照建。不管排没排队,这里挑出的表单都是暂定的:开始剖析时按那时的
目录再定一遍(_ProfileRun._rescope)。建完顺手清掉超出保留数的旧任务。
trigger / fire_at / schedule_id 记进设置快照(见 trigger_of)。allow_empty:挑出 0 张也照建(定时维护排在同一次触发的
探查后面:探查完了表结构、目录可能就不一样了,开始时再挑)。"""
if scope not in SCOPES:
raise ValueError(f"不认识的剖析范围:{scope}")
await ensure_free(session, row, tables)
try:
picked = await _pick(session, row, source, tables=tables, scope=scope)
except ProfileNothing:
# 排在起草后面:起草多半会提出新的关系,开始时(_ProfileRun._rescope)按那时的目录再挑;现在挑不出也照建
if not allow_empty and not await _behind_draft(session, row.id):
raise
picked = []
snapshot = settings.to_dict()
if trigger != "manual":
snapshot.update(trigger=trigger, fire_at=fire_at, schedule_id=schedule_id)
job = CatalogProfileJob(id=new_id(), source_id=row.id, status="queued", scope=scope,
settings=snapshot, created_by=actor, retry_of=retry_of)
tasks = _tasks_for(job.id, picked, utcnow())
session.add(job)
try:
await session.flush()
session.add_all(tasks)
await session.commit()
except IntegrityError as e:
# 部分唯一索引:另一个请求同时给这个源建了剖析任务
await session.rollback()
raise ProfileBusy(busy_message(row, None)) from e
try:
await prune(session, row.id)
except Exception: # noqa: BLE001 - 清旧任务失败不影响这次剖析
logger.exception("清理数据源 %s 的旧剖析任务时出错", row.id)
await session.rollback()
return job, tasks
async def retry_tables(session: AsyncSession, job: CatalogProfileJob) -> list[str]:
"""重试要带上的表:原任务里 failed、partial、cancelled 的,按原来的顺序。没有抛 NothingToRetry。"""
names = [t.table_name for t in await task_rows(session, job.id) if t.status in RETRYABLE]
if not names:
raise NothingToRetry("这次剖析里没有失败、部分完成或停止了的表,不需要重试")
return names
async def prune(session: AsyncSession, source_id: str, *, keep: int | None = None) -> int:
"""保留:这个源最近 keep(缺省 KEEP_JOBS)个结束了的剖析任务留着,更早的删掉;某张表「上次剖析」(真正开始过的最近
一次,见 _ranked)所在的任务不删——排队中还没轮到、没开始就结束了的那一项不算,不然新建一个任务就能把上一次真正剖析
清掉。返回删了几个。"""
keep = KEEP_JOBS if keep is None else keep
finished = list((await session.execute(
select(CatalogProfileJob.id).where(CatalogProfileJob.source_id == source_id,
CatalogProfileJob.status.in_(FINISHED))
.order_by(CatalogProfileJob.created_at.desc(), CatalogProfileJob.id.desc())
)).scalars())
old = finished[keep:]
if not old:
return 0
inner = _ranked(source_id, CatalogProfileTask.job_id)
protected = set((await session.execute(select(inner.c.job_id).where(inner.c.shown == 1))).scalars())
victims = [job_id for job_id in old if job_id not in protected]
if not victims:
return 0
await session.execute(delete(CatalogProfileTask).where(CatalogProfileTask.job_id.in_(victims)))
await session.execute(delete(CatalogProfileJob).where(CatalogProfileJob.id.in_(victims)))
await session.commit()
return len(victims)
async def _close_tasks(session: AsyncSession, job_id: str, task_status: str, task_error: str | None,
*, why: str) -> None:
"""还没结束的表记 task_status(原因 task_error);它们的检查项里还排着的、正在做的记为跳过(停止、中断是 cancelled,
出错是 error,说明是「{why},未检查」),计进跳过的项和没做完的项。
还没结束的表没走到整张表结束那一步:目录只在那一步和这张表的结果一起写(catalog_profile._record,同一个事务),
所以这里已经查完(done)的项结论也没写进目录——进程没了、任务整体出错(快照被改动……)都是这样。它们同样记为跳过、
原因同上,说明是「{why},查完了但结论没有写进数据目录」,算没做完;这张表的原因写明查完的几项没写进目录。不能留着
「完成」:检查清单上标着完成、目录里却没有,看的人会以为结论已经在目录里了。"""
now = utcnow()
reason = "cancelled" if task_status == "cancelled" else "error"
detail, unsaved = _UNCHECKED.format(why=why), _UNSAVED.format(why=why)
tasks = (await session.execute(
select(CatalogProfileTask).where(CatalogProfileTask.job_id == job_id, CatalogProfileTask.status.in_(OPEN))
)).scalars()
for task in tasks:
checks = [dict(c) for c in task.checks or [] if isinstance(c, dict)]
skipped = list(task.skipped or [])
listed = {(s.get("kind"), s.get("target"), s.get("path")) for s in skipped if isinstance(s, dict)}
done = rest = 0
for check in checks:
state = check.get("state")
if state == "done":
done += 1
check.update(state="skipped", reason=reason, detail=unsaved)
elif state in _CHECK_OPEN:
rest += 1
check.update(state="skipped", reason=reason, detail=detail)
elif state != "skipped":
continue
# 跳过的项按检查项列全:进行中的表只随时落了检查项,按规则不查的(遮罩……)也要进跳过的项
key = (check.get("kind"), check.get("target"), check.get("path"))
if key not in listed:
listed.add(key)
skipped.append(ProfileSkip(check.get("kind") or "", check.get("target") or "",
check.get("reason") or "", check.get("detail") or "",
check.get("path")).to_dict())
error = unsaved_error(task_error or why, done, rest) if done else task_error
task.status, task.error, task.finished_at, task.current = task_status, error, now, None
task.checks, task.skipped = checks or None, skipped or None
task.skipped_count = len(skipped)
task.unfinished = sum(1 for s in skipped if s.get("reason") in UNFINISHED_REASONS)
async def _close(session: AsyncSession, job_id: str, job_status: str, job_error: str | None, task_status: str,
task_error: str | None, *, why: str | None = None) -> None:
"""收尾:还没结束的表记 task_status(见 _close_tasks),任务(还没结束的话)记 job_status。提交。"""
await _close_tasks(session, job_id, task_status, task_error, why=why or task_error or STOPPED_ERROR)
await session.execute(
update(CatalogProfileJob).where(CatalogProfileJob.id == job_id, CatalogProfileJob.status.in_(ACTIVE))
.values(status=job_status, error=job_error, finished_at=utcnow()).execution_options(synchronize_session=False))
await session.commit()
async def cancel(session: AsyncSession, job: CatalogProfileJob) -> None:
"""停止一个剖析任务。排队的直接 cancelled(在队里等着的后台执行跟着停掉、出队);进行中的交给 manager 停:正在等的那条
查询不再等(库那边按单条时限截止),那张表做完的项照常写进目录,后面的表 cancelled。已经结束的抛 ProfileFinished。"""
# 先取出来:排队的没停成(刚被后台认领)时要回滚,回滚让 job 的属性全部过期,再读 job.id 就要在异步会话里同步地
# 重新加载(MissingGreenlet)
job_id, status = job.id, job.status
if status in FINISHED:
raise ProfileFinished("这次剖析已经结束了,不需要停止")
if status == "queued":
claimed = await session.execute(
update(CatalogProfileJob).where(CatalogProfileJob.id == job_id, CatalogProfileJob.status == "queued")
.values(status="cancelled", finished_at=utcnow()).execution_options(synchronize_session=False))
if claimed.rowcount == 1:
await _close_tasks(session, job_id, "cancelled", None, why=STOPPED_ERROR)
await session.commit()
await manager.stop(job_id, "user", grace=STOP_GRACE_S)
return
await session.rollback() # 刚被后台认领、开始了:按进行中的停
if await manager.stop(job_id, "user", grace=STOP_GRACE_S):
return
# 库里说在进行,这个进程里却没有在跑它(记录对不上):直接记停止
await _close(session, job_id, "cancelled", None, "cancelled", None)
async def startup(*, live: Any = None) -> int:
"""服务启动时:排队、进行中的剖析任务标成 interrupted,没结束的表记 cancelled、写明服务重启,不自动续跑;再把剖析设置的
旧键迁移掉。返回标了几个任务。"""
async def interrupt(session: AsyncSession, job_id: str) -> None:
await _close(session, job_id, "interrupted", INTERRUPTED_ERROR, "cancelled", INTERRUPTED_ERROR)
n = await runtime.interrupt_on_startup(CatalogProfileJob, interrupt, running=manager.running, live=live)
if n:
logger.warning("服务重启:%d 个数据剖析任务没有做完,已标为中断,可在数据目录页重试", n)
async with SessionLocal() as session:
migrated = await catalog_profile.migrate_settings(session)
if migrated:
logger.info("数据剖析设置:%d 个数据源去掉了按整次剖析算的旧预算", migrated)
return n
# ==========================================================================
# 在后台剖析
# ==========================================================================
def _job_error(e: BaseException) -> str:
"""任务整体意外出错时的整句(任务、没结束的表的 error)。"""
from app.core.errors import describe_exception, first_line
return JOB_ERROR.format(line=first_line(e) or describe_exception(e))
def _elapsed(started: float) -> int:
return int((time.monotonic() - started) * 1000)
async def _drain(tasks: Any) -> None:
"""停掉这几个协程,等它们都结束(正在剖析的表各自收尾、把做完的项写进目录)。等的时候自己又被停止也等完,再把停止
往上交:不然 JobRun 收尾时这几张表还没写完,会被当成没写进目录。"""
tasks = list(tasks)
for task in tasks:
task.cancel()
stopped = False
while pending := [t for t in tasks if not t.done()]:
try:
await asyncio.wait(pending)
except asyncio.CancelledError:
stopped = True
for task in tasks:
if not task.cancelled():
task.exception() # 取走,免得「异常没人取」的告警;要往上交的那个调用方已经拿着
if stopped:
raise asyncio.CancelledError
class _ProfileRun(runtime.JobRun):
"""一个剖析任务在后台的一次执行(进队、停止、收尾的框架在 runtime.JobRun)。"""
kind = "profile"
model = CatalogProfileJob
def __init__(self, job_id: str) -> None:
super().__init__(job_id)
self.actor: str | None = None
self.cx: catalog_profile.ProfileContext | None = None
#: 不再开始新的表(_tables):(原因, stop_reason)。止损时 stop_reason 是 failed / slow,数据源停用、关掉了剖析时为 None
self._halted: tuple[str | None, str | None] | None = None
#: 同时剖析的另一张表让整个任务出错时(_tables),正在做的几张表停下时的说法
self._abort: str | None = None
#: 开始时按此刻的目录一张表都挑不出来(_rescope):为什么,记在任务的提醒里
self._scope_note: str | None = None
#: 这个任务是怎么建的(trigger_of):取值变化的 detected_by 按它记
self._trigger: dict[str, Any] = {"trigger": "manual", "fire_at": None, "schedule_id": None}
# ---------------------------------------------------------------- 停止、出错时收尾
async def _stopped(self) -> None:
if self.stop_reason == "user":
await self._end("cancelled", None, "cancelled", None)
else:
await self._end("interrupted", INTERRUPTED_ERROR, "cancelled", INTERRUPTED_ERROR)
async def _failed(self, reason: str) -> None:
await self._end("failed", reason, "failed", reason)
async def _crashed(self, e: Exception) -> None:
logger.exception("数据剖析任务 %s 出错", self.job_id)
reason = _job_error(e)
await self._end("failed", reason, "failed", reason)
async def _end(self, job_status: str, job_error: str | None, task_status: str, task_error: str | None) -> None:
"""停止、出错时收尾:等手上的库写入写完,记下查询计数,再把没结束的表和任务记下来。收尾本身写不进去只记日志(下次
启动时 startup 会把它标成中断)。"""
try:
await self._settle()
await self._write(self._save_counters)
await self._write(_close, self.job_id, job_status, job_error, task_status, task_error)
except Exception: # noqa: BLE001
logger.exception("数据剖析任务 %s 收尾时写库出错", self.job_id)
await self._notify()
async def _notify(self) -> None:
"""这次取值观察新记了「要处理」的变化(撤销了已列全):按源推一条 source.changed。"""
from app.data import source_changes
async with SessionLocal() as session:
job = await session.get(CatalogProfileJob, self.job_id)
if job is not None and job.scope in OBSERVING_SCOPES:
await source_changes.notify_after_job(job.source_id, self.job_id)
def stop_text(self) -> str:
if self._abort is not None:
return self._abort
return STOPPED_ERROR if self.stop_reason == "user" else INTERRUPTED_ERROR
# ---------------------------------------------------------------- 主流程
async def _run(self) -> None:
if not await self._write(self._claim):
return # 开始之前就被停止了
async with SessionLocal() as session:
job = await session.get(CatalogProfileJob, self.job_id)
row = await session.get(DataSource, job.source_id) if job is not None else None
if job is None or row is None:
raise runtime.JobFailed(SOURCE_GONE)
from app.data import table_versions
try:
source = await table_versions.resolve_source(session, row)
except table_versions.SnapshotMissing as e:
raise runtime.JobFailed(str(e)) from e
settings = ProfileSettings.from_dict(job.settings)
options = dict(row.options or {})
outside = await outside_of(session, row)
# 要剖析哪些表按开始这一刻的目录定:排在起草后面时,建任务那会儿起草还没提出关系
await self._write(self._rescope, row, source)
async with SessionLocal() as session:
job = await session.get(CatalogProfileJob, self.job_id)
scope = job.scope
pending = [(t.seq, t.table_name, *task_modes(scope, t.checks)) for t in await task_rows(session, job.id)
if t.status == "pending"]
self.actor = job.created_by
self._trigger = trigger_of(job)
try:
self.cx = catalog_profile.make_context(source, settings, options=options, outside=outside)
except ValueError as e: # 不支持的数据库类型
raise runtime.JobFailed(str(e)) from e
width = min(max(1, int(settings.table_concurrency)), catalog_profile.MAX_TABLE_CONCURRENCY)
await self._tables(pending, width)
if (halt := self._stopping()) is not None:
await self._write(self._halt, *halt)
await self._settle()
await self._write(self._finish)
await self._notify()
async def _tables(self, pending: list[tuple[int, str, bool, bool]], width: int) -> None:
"""按顺序剖析这些表,同时最多 width 张(设置 table_concurrency):一张结束就开始下一张,开始的先后照任务里的顺序。
- 每张表开始前重读数据源:停用了、关掉了剖析就不再开始新的表;止损(cx.runner)停下时同样。正在做的几张照常做完
(止损时它们剩下的项记止损的原因),都结束之后由 _run 把还没开始的表记停下(_halt)。
- 停止、关停:正在做的几张各自收尾(做完的项写进目录),都收完再把停止往上交。
- 一张表让整个任务出错(快照被改动、数据源没了、记结果时写库出错):正在做的另几张按停止收尾、说法写这个原因,
再把错往上交,由 JobRun 记任务失败。"""
queue = collections.deque(pending)
async def worker() -> None:
while queue and self._stopping() is None:
seq, name, regular, watched = queue.popleft()
stop = await self._write(self._recheck)
if stop is not None and self._halted is None:
self._halted = (stop, None)
if self._stopping() is not None:
return # 这张没开始:还排着,由 _halt 记停下
await self._table(seq, name, regular, watched)
workers = [asyncio.create_task(worker(), name=f"catalog-profile-{self.job_id}-{i}")
for i in range(min(width, len(pending)))]
if not workers:
return
try:
done, rest = await asyncio.wait(workers, return_when=asyncio.FIRST_EXCEPTION)
except asyncio.CancelledError:
await _drain(workers)
raise
errors = [e for w in done if not w.cancelled() and (e := w.exception()) is not None]
if not errors:
return
from app.cluster import fence
# 几张同时出错时按头一个说;本进程续不上租约的(Fenced)先交:那时谁都不该再写
failed = next((e for e in errors if isinstance(e, fence.Fenced)), errors[0])
if not isinstance(failed, fence.Fenced):
self._abort = str(failed) if isinstance(failed, runtime.JobFailed) else _job_error(failed)
await _drain(rest)
raise failed
def _stopping(self) -> tuple[str | None, str | None] | None:
"""不再开始新的表的原因(见 _halted),先到的为准:数据源停用、关掉了剖析(_tables 里重读时记下),或者止损。"""
if self._halted is None and self.cx is not None and self.cx.runner.stopped:
self._halted = (self.cx.runner.error, self.cx.runner.stopped)
return self._halted
async def _table(self, seq: int, name: str, regular: bool, watched: bool) -> None:
"""剖析一张表:检查项进度、发过的查询随时落在这张表上,整张表的结果和目录在同一个事务里写。"""
assert self.cx is not None
started = time.monotonic()
await self._write(self._start_table, seq)
async def progress(result: TableResult) -> None:
await self._write(self._save_progress, seq, result, started)
async def queried(result: TableResult) -> None:
await self._write(self._save_queries, seq, result, started)
async def recorded(session: AsyncSession, result: TableResult) -> None:
await self._save_table(session, seq, result)
hooks = TableHooks(progress=progress, query=queried, recorded=recorded, stop_text=self.stop_text)
try:
await catalog_profile.profile_table(self.cx, name, db=self._write, hooks=hooks, actor=self.actor,
regular=regular, observe=watched)
except SnapshotTampered as e:
raise runtime.JobFailed(str(e)) from e
# ---------------------------------------------------------------- 库操作
def _task(self, *where: Any) -> Any:
return update(CatalogProfileTask).where(CatalogProfileTask.job_id == self.job_id, *where) \
.execution_options(synchronize_session=False)
def _counters(self) -> dict[str, Any]:
runner = self.cx.runner if self.cx is not None else None
if runner is None:
return {}
return {"queries": runner.queries, "timeouts": runner.timeouts, "errors": runner.errors,
"notice": runner.notice or self._scope_note}
async def _save_counters(self, session: AsyncSession) -> None:
if values := self._counters():
await session.execute(update(CatalogProfileJob).where(CatalogProfileJob.id == self.job_id).values(**values)
.execution_options(synchronize_session=False))
await session.commit()
async def _claim(self, session: AsyncSession) -> bool:
return await runtime.claim(session, CatalogProfileJob, self.job_id)
async def _rescope(self, session: AsyncSession, row: Any, source: Any) -> None:
"""开始剖析时按此刻的目录重定范围(建任务时挑的表单是暂定的:排在起草后面时,起草还没提出关系)。
- 需要剖析的表、全部表:整份重挑(这个任务的表都还没开始)。挑不出来就照实记在提醒里,任务以 0 张表结束;
- 指定的表:建任务时判为「没有可剖析的项」的再看一次,现在有了就排上;表结构里没有、视图照旧跳过。
要做的检查项在每张表开始时还会按那时的目录再列一遍(profile_table)。"""
job = await session.get(CatalogProfileJob, self.job_id)
if job is None:
return
if job.scope == "selected":
stale = [t for t in await task_rows(session, job.id) if t.status == "skipped" and t.error == NOTHING_ERROR]
if stale:
fresh = {name: (reason, checks) for name, reason, _why, checks in await _pick(
session, row, source, tables=[t.table_name for t in stale], scope="selected")}
for task in stale:
reason, checks = fresh.get(task.table_name, ("nothing", []))
if reason is None:
await session.execute(self._task(CatalogProfileTask.seq == task.seq).values(
status="pending", checks=[c.as_check() for c in checks], skipped=None, skipped_count=0,
unfinished=0, error=None, finished_at=None))
await session.commit()
return
await session.execute(delete(CatalogProfileTask).where(CatalogProfileTask.job_id == self.job_id)
.execution_options(synchronize_session=False))
try:
picked = await _pick(session, row, source, tables=None, scope=job.scope)
except ProfileNothing as e:
picked = []
self._scope_note = str(e)
await session.execute(update(CatalogProfileJob).where(CatalogProfileJob.id == self.job_id)
.values(notice=self._scope_note).execution_options(synchronize_session=False))
session.add_all(_tasks_for(self.job_id, picked, utcnow()))
await session.commit()
async def _recheck(self, session: AsyncSession) -> str | None:
"""重读数据源:停用了、关掉了剖析返回停下的整句;遮罩换成最新的设置。数据源没了抛 JobFailed。"""
job = await session.get(CatalogProfileJob, self.job_id)
row = await session.get(DataSource, job.source_id) if job is not None else None
if row is None:
raise runtime.JobFailed(SOURCE_GONE)
if row.enabled is False:
return SOURCE_DISABLED.format(name=row.name)
if not catalog_profile.profile_settings(row.options).enabled:
return PROFILE_TURNED_OFF.format(name=row.name)
assert self.cx is not None
self.cx.masked = catalog_profile.masked_of(row.options)
return None
async def _halt(self, session: AsyncSession, error: str | None, stop_reason: str | None) -> None:
"""整个任务停下(止损、数据源停用或关掉了剖析):后面的表 cancelled、error 是同一句原因;任务记下原因,状态到收尾时
按逐表算。"""
await _close_tasks(session, self.job_id, "cancelled", error, why=error or STOPPED_ERROR)
await session.execute(update(CatalogProfileJob).where(CatalogProfileJob.id == self.job_id)
.values(error=error, stop_reason=stop_reason, **self._counters())
.execution_options(synchronize_session=False))
await session.commit()
async def _start_table(self, session: AsyncSession, seq: int) -> None:
# 上传表格源记下这一刻剖析的是哪个快照:换了版本之后,旧版本的剖析事实不再交给助手(schema_view.profile_lines)
snapshot = getattr(self.cx.source, "snapshot_id", None) if self.cx is not None else None
await session.execute(self._task(CatalogProfileTask.seq == seq, CatalogProfileTask.status == "pending")
.values(status="running", started_at=utcnow(), snapshot_id=snapshot))
await session.commit()
async def _save_progress(self, session: AsyncSession, seq: int, result: TableResult, started: float) -> None:
"""一项检查开始或结束:这张表的检查项、正在做哪一项、查询数、发过的查询、行数估算,任务的查询计数。started 是这张
表开始的时刻(time.monotonic)。"""
await session.execute(self._task(CatalogProfileTask.seq == seq, CatalogProfileTask.status == "running")
.values(checks=[dict(c) for c in result.checks] or None, current=result.current,
queries=result.queries, elapsed_ms=_elapsed(started),
query_log=list(result.query_log) or None,
row_estimate=result.row_estimate.to_dict() if result.row_estimate else None))
if values := self._counters():
await session.execute(update(CatalogProfileJob).where(CatalogProfileJob.id == self.job_id).values(**values)
.execution_options(synchronize_session=False))
await session.commit()
async def _save_queries(self, session: AsyncSession, seq: int, result: TableResult, started: float) -> None:
"""发出一条查询之后:这张表和任务的查询计数、提醒(发过的查询本身在检查项结束时一起写)。"""
await session.execute(self._task(CatalogProfileTask.seq == seq, CatalogProfileTask.status == "running")
.values(queries=result.queries, elapsed_ms=_elapsed(started)))
if values := self._counters():
await session.execute(update(CatalogProfileJob).where(CatalogProfileJob.id == self.job_id).values(**values)
.execution_options(synchronize_session=False))
await session.commit()
async def _save_table(self, session: AsyncSession, seq: int, result: TableResult) -> None:
"""一张表结束:整张表的结果。和写目录在同一个事务里,由 catalog_profile._record 一起提交,这里不提交。"""
await session.execute(
self._task(CatalogProfileTask.seq == seq, CatalogProfileTask.status.in_(OPEN))
.values(status=result.status, checks=[dict(c) for c in result.checks] or None, current=None,
queries=result.queries, elapsed_ms=result.elapsed_ms,
row_estimate=result.row_estimate.to_dict() if result.row_estimate else None,
findings=list(result.findings) or None, skipped=[s.to_dict() for s in result.skipped] or None,
date_ranges=list(result.date_ranges) or None, query_log=list(result.query_log) or None,
summary=_summary_of(result), skipped_count=result.skipped_count, unfinished=result.unfinished,
added=result.added, updated=result.updated, removed=result.removed, version=result.version,
error=result.error, finished_at=utcnow()))
if values := self._counters():
await session.execute(update(CatalogProfileJob).where(CatalogProfileJob.id == self.job_id).values(**values)
.execution_options(synchronize_session=False))
if self.cx is not None and (result.observations or result.findings):
# 取值观察:观察记录、比出的取值变化、撤销「已列全」,以及关联覆盖率的下降,都和这张表的结果、目录在同一个
# 事务里
from app.data import catalog_profile_observe
from app.data.source_changes import Detected
detected = Detected(job_kind="profile", job_id=self.job_id, task_seq=seq,
trigger=self._trigger["trigger"], actor=self.actor, fire_at=self._trigger["fire_at"])
if result.observations:
await catalog_profile_observe.save(session, self.cx.source, result.table, result, job_id=self.job_id,
seq=seq, detected=detected)
await catalog_profile_observe.record_coverage_drops(session, self.cx.source, result.table, result,
detected=detected)
async def _finish(self, session: AsyncSession) -> None:
"""剖析完:万一还有没收尾的表(进度回调写库失败之类)记失败;有 failed、partial、cancelled 的表是 partial,否则
succeeded。"""
await _close_tasks(session, self.job_id, "failed", UNFINISHED_ERROR, why=UNFINISHED_ERROR)
statuses = list((await session.execute(
select(CatalogProfileTask.status).where(CatalogProfileTask.job_id == self.job_id))).scalars())
status = "partial" if any(s in RETRYABLE for s in statuses) else "succeeded"
await session.execute(
update(CatalogProfileJob).where(CatalogProfileJob.id == self.job_id, CatalogProfileJob.status == "running")
.values(status=status, finished_at=utcnow(), **self._counters())
.execution_options(synchronize_session=False))
await session.commit()
class ProfileJobs(runtime.JobManager):
"""进程内的剖析任务(start / stop / wait / running / shutdown 见 runtime.JobManager)。"""
def __init__(self) -> None:
super().__init__(_ProfileRun, label="数据剖析任务", name="catalog-profile")
manager = ProfileJobs()
__all__ = ["INTERRUPTED_ERROR", "KEEP_JOBS", "MAX_TABLES", "NOTHING_AT_ALL", "NOTHING_NEEDED", "NOTHING_TO_MAINTAIN",
"NOTHING_TO_OBSERVE", "NothingToRetry", "OBSERVING_SCOPES", "SCOPES", "task_modes", "trigger_of",
"ProfileBusy", "ProfileFinished", "ProfileJobs", "ProfileNothing", "THEN_PROFILE", "active_job",
"busy_message", "cancel", "create_job", "due_reason", "ensure_free", "job_brief", "job_dict", "job_out",
"last_detail", "last_facts", "last_profiles", "outside_of", "latest_job", "load", "load_task", "manager",
"nothing_reason", "prune", "public_last", "retry_tables", "startup", "task_detail", "task_row", "task_rows",
"unsaved_error", "waiting_for"]