W3-a 取值观察:剖析引擎的四类观察检查、比较器、撤销「已列全」(next-round 03 B2)

码值出现新取值、比例剧变、业务日期断档、某天没数据,以前都看不到:确认过的码值列不再取分布,大表只看物理顺序最老的
前 10 万行,「已列全」过期以后 SQL 检查还照它提示。现在:

- 剖析引擎加四类观察检查(data/catalog_profile_observe.py):rows(行数,用剖析开头那条估行数的结果)、codes_observe
  (所有码值列,包括人工确认过、标了已列全的)、freshness(已确认或已验证的业务日期的最新日期)、daily_volume(最近 35
  天按天计数)。计入每张表的预算、止损和留痕,每条观察记口径 basis 和它出自哪几条查询;遮罩列不进任何观察查询。
- 大表口径照目录-D7:有确认的业务日期且该列打头有索引看 35 天窗口;PG / Oracle 没有就块采样(比例按表的大小取一档,
  不跨档比);MySQL / SQLite 没有就按存储顺序的前 N 行,照实写口径。最新业务日期、按日行数在大表上只在有索引时才查。
  SqlDialect 加 max_date_sql(有效区间里的最大日期),方言金样跟着加了两条。
- data/value_drift.py 纯函数比较器:新码值(已列全的任何新值、没列全的要占 0.5% 以上且有历史)、PSI 剧变(≥0.25
  warning、0.1–0.25 info,两次都要 1,000 行)、断档(超过 max(2×中位间隔, 3 天))、缺天(以前的窗口没有过)、量跌
  (不到前 28 天中位数的 30%)、行数跌 30% / 涨 3 倍(估算值要连续两次)。只和同口径的历史比。
- 观察记进 table_observations(每张表每种观察留最近 26 条),比出的取值变化进 source_changes,和这张表的剖析结果、目录在
  同一个事务里。机器唯一写进目录的事:数据证明码值表不再列全时撤销「已列全」(目录版本 +1,署名「取值观察」,值、含义、
  状态都不动),SQL 检查随之不再按旧码值表提示 unknown_code。
- 剖析任务的范围加 observe(观察到期的有目录的表,只观察)和 maintenance(定时维护:需要剖析的 + 观察到期的);人点的
  「需要剖析 / 全部 / 指定的表」照旧不观察。只观察的那一项不算「上次剖析」(不顶掉表清单的上次剖析和给助手的剖析事实)。
  任务的设置快照里记 trigger / fire_at / schedule_id(剖析任务表没有这几列)。
- 观察结论(最新业务日期、更新的行数)经 last_facts → schema_view.profile_lines 和剖析事实同一节进 look、db_schema、
  /schema、表助手(X7),遮罩在渲染那一刻再核。

测试:test_value_drift(阈值两侧、口径不同不比)、test_catalog_observe(四类观察与口径、撤销已列全与 SQL 检查、去重、
遮罩不进查询、大表窗口与前 N 行、PG / Oracle 采样 SQL、观察不算上次剖析、进表详情)。
This commit is contained in:
ThearchyHelios
2026-10-16 16:40:41 +08:00
parent 8d59ca1581
commit c95e5b4e84
13 changed files with 1348 additions and 23 deletions
+51 -6
View File
@@ -28,6 +28,10 @@ hooks 回调(后台任务据此把进度落库),整张表结束把结论
- 数据源设置里遮罩的列不取样,也不出现在任何一条剖析查询里;
- 只取低基数列的取值分布(最多 21 个取值),不取明细行。
**取值观察**(next-round 03 B2,data/catalog_profile_observe)是这里的另外四类检查(行数、码值观察、最新业务日期、按日
行数),同样计入每张表的预算、止损和留痕,遮罩列同样不出现在任何一条查询里;只在剖析任务的范围是 observe / maintenance
时带上(profile_table 的 observe)。
表助手的只读查询、起草时核对有效记录条件走单条查询的入口(run_single_query):同一个只读守卫、同一个单条时限,一次一条,
不设预算、不留痕。
"""
@@ -480,7 +484,14 @@ class SqlDialect:
f"SUM(CASE WHEN {c} < {lo} OR {c} >= {hi} THEN 1 ELSE 0 END)")
return f"SELECT {', '.join(parts)} FROM {table}"
# ---- 取值观察(下一轮第 3 波用):窗口、按日行数、块采样。这里先把各方言的写法定下来,金样和容器集成测试都覆盖
# ---- 取值观察(data/catalog_profile_observe):最新业务日期、窗口、按日行数、块采样。金样和容器集成测试都覆盖
def max_date_sql(self, table: str, column: str, *, text: bool = False) -> str:
"""业务日期列的最新日期:有效区间(VALID_DATE_FROM ≤ x < VALID_DATE_BEFORE)里的最大值,占位日期、无穷大不算。大表
只在这一列打头有索引时发(取最大值走索引)。"""
lo = self.date_literal(VALID_DATE_FROM, text=text)
hi = self.date_literal(VALID_DATE_BEFORE, text=text)
return f"SELECT MAX({column}) FROM {table} WHERE {column} >= {lo} AND {column} < {hi}"
def _window(self, column: str, since: Any, text: bool) -> str:
"""时间窗口的条件:since 起(含),并且早于占位日期 9999-12-31——PostgreSQL 的 infinity、Oracle 的 9999-12-31
@@ -829,7 +840,8 @@ class ProfileDisabled(Exception):
class ProfileSkip:
"""没做的一项和原因。
kind:relation 关系、codes 码值、date 日期列、row_estimate 行数估算、table 整张表。
kind:relation 关系、codes 码值、date 日期列、row_estimate 行数估算、table 整张表;取值观察的 rows 行数、codes_observe
码值观察、freshness 最新业务日期、daily_volume 按日行数(data/catalog_profile_observe)。
reason:budget 这张表的查询次数用完、deadline 这张表的时长用完、failed 连续失败止损、slow 超时比例止损、
cancelled 停止或服务重启、timeout 超时、error 查询失败、rejected 未通过守卫(以上算「运行原因没查成」,见
UNFINISHED_REASONS);masked 字段被遮罩、too_large 表太大、view 视图、unsupported 暂不支持、no_data 没有数据、
@@ -875,6 +887,8 @@ class TableResult:
version: int = 0
#: 整张表的原因:表结构里没有、视图、空表、没有可剖析的项、写入一直冲突、停止 / 中断 / 止损
error: str | None = None
#: 取值观察(data/catalog_profile_observe)观察到的:[{kind, column, basis, basis_key, data}],由剖析任务和结果一起落库
observations: list[dict[str, Any]] = field(default_factory=list)
@property
def skipped_count(self) -> int:
@@ -1018,6 +1032,8 @@ class _TableCx:
self.budget = TableBudget(job.settings)
#: 正在做的检查项(result.checks 里的那一个);None 是这张表开头的估行数
self.check: dict[str, Any] | None = None
#: 这张表此刻的目录(开始剖析这张表时读的):取值观察按它找已确认的业务日期
self.table_notes: dict[str, Any] = {}
def is_masked(self, column: str) -> bool:
return column.lower() in self.job.masked
@@ -1767,8 +1783,16 @@ def pending_relation_paths(notes: dict[str, Any] | None) -> set[str]:
ESTIMATING = "正在估算 {table} 的行数"
#: 取值观察各项进行中的说法
OBSERVE_CURRENT = {"rows": "正在记 {table} 的行数", "codes_observe": "正在观察 {table}.{column} 的取值分布",
"freshness": "正在取 {table}.{column} 的最新业务日期",
"daily_volume": "正在数 {table} 最近 35 天每天的行数"}
def _current_text(table: str, check: PlannedCheck) -> str:
"""进行中时正在做哪一项的整句(逐表一行的最后一栏)。"""
if check.kind in OBSERVE_CURRENT:
return OBSERVE_CURRENT[check.kind].format(table=table, column=check.column or "")
if check.kind == "relation":
return f"正在核对 {table}.{check.target}"
if check.kind == "codes":
@@ -1805,7 +1829,11 @@ def _stop_detail(cx: _TableCx, reason: str) -> str:
async def _execute(cx: _TableCx, table: str, check: PlannedCheck, out: _TableOutcome) -> None:
if check.kind == "relation":
from app.data import catalog_profile_observe as observe
if check.kind in observe.OBSERVE_KINDS:
await observe.execute(cx, table, check, cx.result)
elif check.kind == "relation":
await _check_relation(cx, table, check.relation or {}, out, cx.result)
elif check.kind == "codes":
await _check_codes(cx, table, str(check.column), out, cx.result)
@@ -1834,6 +1862,14 @@ async def _run_checks(cx: _TableCx, table: str, plan: list[PlannedCheck], runnab
finally:
result.current = None
if size.rows == 0:
# 空表:行数观察照记 0 行(一张表变空是最大的「行数下跌」),别的项都不查
for check, planned in zip(result.checks, plan):
if planned.kind == "rows" and check["state"] == "pending":
result.observations.append({"kind": "rows", "column": "", "basis": {"method": size.method,
"query_refs": []},
"basis_key": str(size.method), "data": {"rows": 0, "at_least": None,
"method": size.method}})
check["state"] = "done"
_close_open(result, plan, out, "no_data", f"{table} 是空表,无法剖析")
result.status, result.error = "skipped", EMPTY_ERROR
return
@@ -1875,9 +1911,13 @@ async def _read_notes(session: AsyncSession, source_id: str, table: str) -> dict
async def profile_table(cx: ProfileContext, table: str, *,
db: Callable[..., Awaitable[Any]] | None = None, hooks: TableHooks | None = None,
actor: str | None = None) -> TableResult:
actor: str | None = None, regular: bool = True, observe: bool = False) -> TableResult:
"""剖析一张表并把结论写进目录,返回这张表的结果(TableResult)。
regular:做关系、码值、日期这几项剖析(缺省);observe:接着做取值观察的四类检查(data/catalog_profile_observe,观察到的
记在 result.observations,由剖析任务和结果一起落库)。只观察(regular=False)时不往目录里并剖析结论——不然这次没做的剖析
项会被当成「这次完整检查过、却没再得出」删掉;目录里唯一可能变的是观察撤销「已列全」。
- 表结构里没有、视图:skipped,不发查询。
- 先列全检查项(plan_checks,读的是这时目录里的最新内容),按规则不查的直接记跳过;一项可查的都没有:skipped
「没有可剖析的项」,不发查询。
@@ -1905,9 +1945,14 @@ async def profile_table(cx: ProfileContext, table: str, *,
await db(_record, cx.source.id, table, None, result, actor, hooks.recorded)
return result
notes = await db(_read_notes, cx.source.id, table)
plan = plan_checks(table, meta, notes, tables=cx.tables, masked=cx.masked, outside=cx.outside)
plan = plan_checks(table, meta, notes, tables=cx.tables, masked=cx.masked, outside=cx.outside) if regular else []
if observe:
from app.data import catalog_profile_observe
plan += catalog_profile_observe.plan_observations(table, meta, notes, masked=cx.masked)
out = _TableOutcome()
tcx = _TableCx(cx, table, result, hooks)
tcx.table_notes = notes
result.checks = [p.as_check() for p in plan]
for planned in plan:
if planned.skip is not None:
@@ -1939,7 +1984,7 @@ async def profile_table(cx: ProfileContext, table: str, *,
result.current = None
result.row_estimate = cx.sizes.get(table)
result.elapsed_ms = int((time.monotonic() - started) * 1000)
await db(_record, cx.source.id, table, out, result, actor, hooks.recorded)
await db(_record, cx.source.id, table, out if regular else None, result, actor, hooks.recorded)
if stopped is not None:
raise stopped
return result
+144 -14
View File
@@ -27,6 +27,12 @@ data/catalog_job_runtime.py),逐表剖析(引擎在 data/catalog_profile.p
启动时还一次性迁移剖析设置的旧键(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):没开始就停了的、还排着的那一项不挡掉上一次真正剖析的行数、发过的查询。
@@ -200,6 +206,23 @@ def _counts(statuses: list[str]) -> dict[str, int]:
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]
@@ -219,6 +242,8 @@ def job_dict(job: CatalogProfileJob, tasks: list[Any], *, waiting_for: str | Non
"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),
@@ -309,6 +334,26 @@ _NEWEST = (CatalogProfileJob.created_at.desc(), CatalogProfileJob.id.desc(), Cat
_SHOWN = (case((CatalogProfileTask.started_at.is_(None), 1), else_=0), *_NEWEST)
#: 只做了取值观察的那一项(observe 范围、maintenance 范围里只到期观察的表)在逐表一行的 summary 里带这个标记:它不是一次
#: 剖析,不算「上次剖析」——不然表清单的「上次剖析」、给助手的剖析事实(行数、日期范围)会被一次只记了行数的观察顶掉
OBSERVE_ONLY = "observe_only"
_PROFILED = func.coalesce(func.json_extract(CatalogProfileTask.summary, f"$.{OBSERVE_ONLY}"), 0) == 0
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:
"""每张表剖析过的各项:子查询,带两个排名。
@@ -321,7 +366,7 @@ def _ranked(source_id: str, *columns: Any) -> Any:
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)).subquery()
.where(CatalogProfileJob.source_id == source_id, _PROFILED)).subquery()
async def last_profiles(session: AsyncSession, source_id: str) -> dict[str, dict[str, Any]]:
@@ -365,7 +410,8 @@ def public_last(last: dict[str, Any] | None) -> dict[str, Any] | None:
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}},一条查询批量取。没有行数估算也没有日期范围的不给;tables 给了只取这几张。
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):剖析数字
是会过期的观测,不写进数据目录;遮罩、快照在渲染那一刻再核。"""
@@ -386,6 +432,12 @@ async def last_facts(session: AsyncSession, source_id: str,
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
@@ -394,7 +446,7 @@ async def last_detail(session: AsyncSession, source_id: str, table: str) -> dict
found = (await session.execute(
select(CatalogProfileTask, CatalogProfileJob)
.join(CatalogProfileJob, CatalogProfileJob.id == CatalogProfileTask.job_id)
.where(CatalogProfileJob.source_id == source_id, CatalogProfileTask.table_name == table)
.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])
@@ -476,6 +528,8 @@ NOTHING_NEEDED = ("没有需要剖析的表:能剖析的表都剖析过了,
"日期列(上次是空表的不算)。要重新剖析,请选「全部表」或指定要剖析的表")
NOTHING_AT_ALL = ("没有可以剖析的表:表结构里的表都是视图,或者没有可剖析的项(要核对的关联关系、像码值的列、日期列都没有,"
"或者都被遮罩了)。请先起草数据目录,或指定要剖析的表")
NOTHING_TO_OBSERVE = ("没有要观察的表:取值观察只看有数据目录的表,这些表最近 {h} 小时内都观察过了(或者还没有数据目录)")
NOTHING_TO_MAINTAIN = ("没有需要剖析或观察的表:能剖析的表都剖析过了,有数据目录的表最近 {h} 小时内也都观察过了")
async def outside_of(session: AsyncSession, row: Any) -> frozenset[str]:
@@ -536,6 +590,8 @@ async def _pick(session: AsyncSession, row: Any, source: Any, *, tables: list[st
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)}
@@ -557,6 +613,54 @@ async def _pick(session: AsyncSession, row: Any, source: Any, *, tables: list[st
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 挑出的表 → 逐表的行:按规则不剖析的(表结构里没有、视图、没有可剖析的项)直接记跳过,其余排队。"""
@@ -567,8 +671,9 @@ def _tasks_for(job_id: str, picked: list[tuple[str, str | None, str | None, list
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=_empty_summary()))
summary=summary))
return tasks
@@ -580,22 +685,31 @@ async def _behind_draft(session: AsyncSession, source_id: str) -> bool:
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) -> tuple[CatalogProfileJob, list[CatalogProfileTask]]:
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)。建完顺手清掉超出保留数的旧任务。"""
目录再定一遍(_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 await _behind_draft(session, row.id):
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=settings.to_dict(), created_by=actor, retry_of=retry_of)
settings=snapshot, created_by=actor, retry_of=retry_of)
tasks = _tasks_for(job.id, picked, utcnow())
session.add(job)
try:
@@ -760,6 +874,8 @@ class _ProfileRun(runtime.JobRun):
self._table_started = 0.0
#: 开始时按此刻的目录一张表都挑不出来(_rescope):为什么,记在任务的提醒里
self._scope_note: str | None = None
#: 这个任务是怎么建的(trigger_of):取值变化的 detected_by 按它记
self._trigger: dict[str, Any] = {"trigger": "manual", "fire_at": None, "schedule_id": None}
# ---------------------------------------------------------------- 停止、出错时收尾
@@ -815,14 +931,17 @@ class _ProfileRun(runtime.JobRun):
await self._write(self._rescope, row, source)
async with SessionLocal() as session:
job = await session.get(CatalogProfileJob, self.job_id)
pending = [(t.seq, t.table_name) for t in await task_rows(session, job.id) if t.status == "pending"]
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
hooks = TableHooks(progress=self._progress, query=self._queried, stop_text=self.stop_text)
for seq, name in pending:
for seq, name, regular, watched in pending:
# 每张表开始前重读数据源:停用了、关掉了剖析就停下;遮罩按最新的设置
stop = await self._write(self._recheck)
if stop is not None:
@@ -837,7 +956,8 @@ class _ProfileRun(runtime.JobRun):
hooks.recorded = recorded
self._seq = seq
try:
await catalog_profile.profile_table(self.cx, name, db=self._write, hooks=hooks, actor=self.actor)
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
if self.cx.runner.stopped:
@@ -974,12 +1094,21 @@ class _ProfileRun(runtime.JobRun):
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=result.summary(), skipped_count=result.skipped_count, unfinished=result.unfinished,
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 result.observations and self.cx is not None:
# 取值观察:观察记录、比出的取值变化、撤销「已列全」都和这张表的结果、目录在同一个事务里
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"])
await catalog_profile_observe.save(session, self.cx.source, result.table, result, job_id=self.job_id,
seq=seq, detected=detected)
async def _finish(self, session: AsyncSession) -> None:
"""剖析完:万一还有没收尾的表(进度回调写库失败之类)记失败;有 failed、partial、cancelled 的表是 partial,否则
@@ -1005,7 +1134,8 @@ class ProfileJobs(runtime.JobManager):
manager = ProfileJobs()
__all__ = ["INTERRUPTED_ERROR", "KEEP_JOBS", "MAX_TABLES", "NOTHING_AT_ALL", "NOTHING_NEEDED", "NothingToRetry",
__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", "prune", "public_last",
+442
View File
@@ -0,0 +1,442 @@
"""取值观察:剖析引擎的四类观察检查(docs/design/next-round/03-数据接入与目录维护.md B2)。
**为什么要有它。** 码值出现新取值、比例剧变、业务日期断档、某天没数据,以前都看不到:确认过的码值列不再取分布,大表只看
物理顺序最老的前 10 万行,没有最新业务日期、按日行数、总行数的观察和历史;「已列全」过期以后 SQL 检查还照它提示。
**四类检查**(和关系、码值、日期检查一样是剖析的检查项:计入每张表的预算、止损,每条查询都留痕):
- rows:这张表的行数(剖析开头估行数那一条的结果,不另发查询);口径 stats(数据库统计的估算值)/ count(数到上限);
- codes_observe:码值列的取值分布——**所有**码值列都观察,包括人工确认过、标了已列全的(目录-D6);只读,不改含义;
- freshness:业务日期列(已确认或已验证)的最新日期;
- daily_volume:业务日期最近 35 天每天的行数。
**大表的口径**(目录-D7):小表(行数不超过整表统计的上限)看整表(full)。大表:业务日期已确认或已验证、而且这一列打头
有索引(探查记下的 index_first_columns)时看最近 35 天的窗口(window,走索引的范围扫描);PostgreSQL / Oracle 没有就块采样
(sample,按数据块随机取约 p%,p 按表的大小取一档,不跨档比);MySQL / SQLite 没有块采样,只看按存储顺序的前 N 行
(first_rows),照实写口径。最新业务日期、按日行数在大表上只在业务日期列打头有索引时才查,否则记「表太大」跳过。每条观察
都记口径(basis),只和口径相同的历史比(data/value_drift)。
**遮罩**:遮罩列不出现在任何一条观察查询里(被遮罩的码值列、业务日期列记「被遮罩」跳过;窗口要用的业务日期列被遮罩时
不用窗口)。
**落库与比较**(save,和这张表的剖析结果、目录在同一个事务里):每条观察写进 table_observations,每张表每种观察(码值按
列)留最近 26 条;拿同口径的历史比出取值变化写进 source_changes。机器唯一能写进目录的事:数据证明码值表不再列全时撤销
「已列全」(目录版本 +1,现有的目录漂移、冻结快照、SQL 检查自然跟上);新取值本身、含义、状态都不改,交给人在变化里处理。
**什么时候观察**:剖析任务的范围 observe(只观察)和 maintenance(需要剖析的表 + 观察到期的表,定时维护用)才带观察检查;
人点的「需要剖析 / 全部 / 指定的表」照旧不观察。观察到期(observe_due):有数据目录、不是视图的表,从没观察过,或者上一次
观察在 OBSERVE_EVERY_H 小时以前。
"""
from __future__ import annotations
import logging
from collections.abc import Iterable, Mapping
from datetime import date, datetime, timedelta, timezone
from typing import Any
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.data import catalog
from app.db.base import utcnow
from app.db.models_sources import TableObservation
logger = logging.getLogger(__name__)
OBSERVE_KINDS = ("rows", "codes_observe", "freshness", "daily_volume")
#: 按日行数、窗口观察看最近几天
WINDOW_DAYS = 35
#: 码值观察最多取几个取值(多出来的记 truncated,不判新码值、不算比例)
OBSERVE_CODES_MAX = 50
#: 块采样的比例按表的大小取一档:目标约 max_scan_rows 行,向下取到这几档里的一档(同一张表大小不变时口径不变)
SAMPLE_LADDER = (10.0, 5.0, 2.0, 1.0, 0.5, 0.2, 0.1, 0.05, 0.02, 0.01)
#: 上一次观察多久以前算「观察到期」(每天跑一次的定时维护每次都会观察;每小时跑一次的一天观察两次)
OBSERVE_EVERY_H = 12
#: 撤销「已列全」时目录的署名
OBSERVER = "取值观察"
KIND_LABEL = {"rows": "行数", "codes_observe": "码值观察", "freshness": "最新业务日期", "daily_volume": "按日行数"}
TOO_LARGE_DATE = ("{table} 的行数超过整表统计的行数上限,业务日期列 {column} 打头没有索引,不看{what}"
"(大表只在业务日期列打头有索引时才查)")
NO_DATE = "{table} 没有已确认或已验证的业务日期,不看{what}"
# ==========================================================================
# 列出检查(纯函数)
# ==========================================================================
def _business_date(meta: Mapping[str, Any], notes: Mapping[str, Any]) -> str | None:
"""已确认或已验证的业务日期列(表结构里的写法);没有为 None。"""
from app.data.catalog_profile import _column
item = notes.get("business_date") if isinstance(notes.get("business_date"), Mapping) else None
if not item or item.get("status") not in ("confirmed", "verified"):
return None
column = str((item.get("value") or {}).get("column") or "")
return _column(dict(meta), column) if column else None
def code_columns(meta: Mapping[str, Any], notes: Mapping[str, Any]) -> list[str]:
"""要观察的码值列:目录里有码值项(没被驳回)的列,加上 ENUM / SET 列;按表结构的顺序。人工确认过、标了已列全的照样
观察(目录-D6)。"""
from app.data.catalog_profile import _is_enum
described = notes.get("columns") if isinstance(notes.get("columns"), Mapping) else {}
out: list[str] = []
for col in meta.get("columns") or []:
if not isinstance(col, Mapping):
continue
name = str(col.get("name") or "")
items = described.get(name) if isinstance(described.get(name), Mapping) else {}
codes = items.get("codes") if isinstance(items, Mapping) else None
listed = isinstance(codes, Mapping) and codes.get("status") != "rejected"
if listed or (_is_enum(col.get("type"), col.get("enum_values")) and not isinstance(codes, Mapping)):
out.append(name)
return out
def plan_observations(table: str, meta: Mapping[str, Any] | None, notes: Mapping[str, Any] | None, *,
masked: set[str]) -> list[Any]:
"""这张表要做的观察检查(catalog_profile.PlannedCheck),接在关系、码值、日期检查后面:行数、各码值列、最新业务日期、
按日行数。被遮罩的列照样列出来、带 skip(不查,让人看得见为什么没有)。视图、表结构里没有的返回空列表。"""
from app.data.catalog_profile import PlannedCheck
if not isinstance(meta, Mapping) or meta.get("is_view"):
return []
notes = notes or {}
out = [PlannedCheck("rows", table, None)]
for column in code_columns(meta, notes):
skip = (("masked", f"{table}.{column} 在数据源设置中被遮罩,不观察取值", False)
if column.lower() in masked else None)
out.append(PlannedCheck("codes_observe", column, f"columns.{column}.codes", skip, column=column))
day = _business_date(meta, notes)
for kind, what in (("freshness", "最新业务日期"), ("daily_volume", "按日行数")):
if day is None:
continue
skip = (("masked", f"{table}.{day} 在数据源设置中被遮罩,不看{what}", False) if day.lower() in masked else None)
out.append(PlannedCheck(kind, day, "business_date", skip, column=day))
return out
# ==========================================================================
# 执行(在 catalog_profile._TableCx 上发查询:预算、止损、留痕都在那里)
# ==========================================================================
def sample_percent(rows: int | None, target: int) -> float | None:
"""块采样取几档:约 target 行、向下取到 SAMPLE_LADDER 的一档;表不大于 target 时不采样(None)。"""
if not rows or rows <= target:
return None
want = 100.0 * target / rows
return next((p for p in SAMPLE_LADDER if p <= want), SAMPLE_LADDER[-1])
def _today() -> date:
return datetime.now(timezone.utc).date()
def _since() -> date:
return _today() - timedelta(days=WINDOW_DAYS)
def _window_ok(cx: Any, meta: Mapping[str, Any], day: str | None) -> bool:
"""大表能不能看窗口:业务日期列已确认或已验证、打头有索引、没被遮罩。"""
if not day or cx.is_masked(day):
return False
return day.lower() in {str(c).lower() for c in meta.get("index_first_columns") or []}
def _observation(kind: str, column: str, basis: dict[str, Any], data: dict[str, Any], refs: list[int]) -> dict[str, Any]:
key = basis_key(basis)
return {"kind": kind, "column": column, "basis": {**basis, "query_refs": refs}, "basis_key": key, "data": data}
def basis_key(basis: Mapping[str, Any]) -> str:
"""口径的规范写法:只和它相同的历史比。"""
method = basis.get("method")
if method == "window":
return f"window:{basis.get('column')}:{basis.get('days')}"
if method == "sample":
return f"sample:{basis.get('percent'):g}"
if method == "first_rows":
return f"first_rows:{basis.get('rows')}"
return str(method or "")
def basis_text(basis: Mapping[str, Any]) -> str:
"""口径的说法(跳过、留痕的说明里用)。"""
method = basis.get("method")
if method == "window":
return f"业务日期 {basis.get('column')} 最近 {basis.get('days')} 天"
if method == "sample":
return f"块采样约 {basis.get('percent'):g}%"
if method == "first_rows":
return f"按存储顺序取前 {basis.get('rows')} 行"
if method == "stats":
return "数据库统计的估算值"
if method == "count":
return "逐行数出"
return "整表"
async def execute(cx: Any, table: str, check: Any, result: Any) -> None:
"""执行一项观察检查,观察到的追加进 result.observations。按规则不查的抛 catalog_profile._Skip。"""
from app.data.catalog_profile import _Skip
meta = cx.tables[table]
start = len(result.query_log)
size = await cx.size_with_data(table, result) if check.kind != "rows" else await cx.size(table, result)
if check.kind == "rows":
basis: dict[str, Any] = {"method": size.method}
data = {"rows": size.rows, "at_least": size.at_least, "method": size.method}
if size.method == "unknown":
raise _Skip("no_data", f"{table} 的行数没能得到(统计信息和计数都没查成),不记行数观察")
result.observations.append(_observation("rows", "", basis, data, []))
return
before = len(result.observations)
if check.kind == "codes_observe":
await _codes(cx, table, str(check.column), meta, size, result)
else:
await _dates(cx, table, check.kind, str(check.column), meta, size, result)
if len(result.observations) > before:
# 留痕:这条观察是哪几条查询得出的(这张表 query_log 里的下标)
result.observations[-1]["basis"]["query_refs"] = list(range(start, len(result.query_log)))
async def _codes(cx: Any, table: str, column: str, meta: Mapping[str, Any], size: Any, result: Any) -> None:
from app.data import catalog_profile as cp
if cx.is_masked(column):
raise cp._Skip("masked", f"{table}.{column} 在数据源设置中被遮罩,不观察取值", carry=False)
d = cx.dialect
col_meta = cp._column_meta(dict(meta), column) or {}
t, c = d.table(dict(meta), table), d.column(dict(meta), column)
n = OBSERVE_CODES_MAX + 1
day = _business_date(meta, getattr(cx, "table_notes", None) or {})
if size.within(cx.settings.max_scan_rows):
basis: dict[str, Any] = {"method": "full"}
sql = d.value_counts_sql(t, c, n, scan_cap=None)
elif _window_ok(cx, meta, day):
since = _since()
basis = {"method": "window", "column": day, "days": WINDOW_DAYS, "since": since.isoformat()}
text = catalog.column_family(cp._column_meta(dict(meta), str(day))) != "time"
sql = d.window_value_counts_sql(t, c, d.column(dict(meta), str(day)), since, n, text=text)
else:
percent = sample_percent(size.rows, max(cx.settings.max_scan_rows, cx.settings.sample_size))
sampled = d.sample_value_counts_sql(t, c, percent, n) if percent else None
if sampled is not None:
basis, sql = {"method": "sample", "percent": percent}, sampled
else:
cap = cx.scan_cap(size) or max(cx.settings.sample_size, cx.settings.max_scan_rows)
basis, sql = {"method": "first_rows", "rows": cap}, d.value_counts_sql(t, c, n, scan_cap=cap)
res = await cx.run(sql, max_rows=n)
fixed = cp.is_fixed_char(col_meta.get("type"))
counts: dict[str, int] = {}
for row in res.rows[:OBSERVE_CODES_MAX]:
key = cp._code_key(row[0], fixed=fixed)
if key is None:
raise cp._Skip("unsupported", f"{table}.{column} 的取值不是整数或短文字,不观察", carry=False)
counts[key] = counts.get(key, 0) + (cp._as_int(row[1]) or 0)
data = {"values": [[k, v] for k, v in counts.items()], "rows": sum(counts.values()),
"truncated": len(res.rows) > OBSERVE_CODES_MAX}
result.observations.append(_observation("codes_observe", column, basis, data, []))
async def _dates(cx: Any, table: str, kind: str, column: str, meta: Mapping[str, Any], size: Any, result: Any) -> None:
from app.data import catalog_profile as cp
what = KIND_LABEL[kind]
if cx.is_masked(column):
raise cp._Skip("masked", f"{table}.{column} 在数据源设置中被遮罩,不看{what}", carry=False)
if not size.within(cx.settings.max_scan_rows) and not _window_ok(cx, meta, column):
raise cp._Skip("too_large", TOO_LARGE_DATE.format(table=table, column=column, what=what))
d = cx.dialect
t, c = d.table(dict(meta), table), d.column(dict(meta), column)
text = catalog.column_family(cp._column_meta(dict(meta), column)) != "time"
if kind == "freshness":
res = await cx.run(d.max_date_sql(t, c, text=text))
value = res.rows[0][0] if res.rows and res.rows[0] else None
if value is None or not cp._DATE_TEXT.match(str(value)):
raise cp._Skip("no_data", f"{table}.{column} 没有可识别的日期值")
result.observations.append(_observation("freshness", column, {"method": "full"},
{"max": str(value)[:19]}, []))
return
since = _since()
res = await cx.run(d.day_volume_sql(t, c, since, text=text), max_rows=WINDOW_DAYS + 2)
days: dict[str, int] = {}
for row in res.rows:
day = str(row[0])[:10] if row and row[0] is not None else ""
if cp._ISO_DAY.fullmatch(day):
days[day] = days.get(day, 0) + (cp._as_int(row[1]) or 0)
basis = {"method": "window", "column": column, "days": WINDOW_DAYS, "since": since.isoformat()}
result.observations.append(_observation("daily_volume", column, basis,
{"since": since.isoformat(), "until": _today().isoformat(),
"days": days}, []))
# ==========================================================================
# 落库、比较、撤销「已列全」
# ==========================================================================
async def _history(session: AsyncSession, source_id: str, table: str, kind: str, column: str) -> list[TableObservation]:
return list((await session.execute(select(TableObservation).where(
TableObservation.source_id == source_id, TableObservation.table_name == table, TableObservation.kind == kind,
TableObservation.column_name == column).order_by(TableObservation.at.desc(), TableObservation.id.desc())
.limit(TableObservation.KEEP))).scalars())
async def _prune(session: AsyncSession, source_id: str, table: str, kind: str, column: str) -> None:
keep = [r.id for r in await _history(session, source_id, table, kind, column)]
if keep:
await session.execute(delete(TableObservation).where(
TableObservation.source_id == source_id, TableObservation.table_name == table,
TableObservation.kind == kind, TableObservation.column_name == column,
TableObservation.id.not_in(keep)).execution_options(synchronize_session=False))
def _rules_columns(notes: Mapping[str, Any], meta: Mapping[str, Any] | None, table: str, kind: str | None) -> set[str]:
"""有效记录条件用到的列(小写)。"""
from app.data import sqlcheck
item = notes.get("valid_filter") if isinstance(notes.get("valid_filter"), Mapping) else None
if not item or item.get("status") == "rejected":
return set()
parsed = sqlcheck.parse_valid_filter(item.get("value"), meta, kind=kind, table=table)
return {c.lower() for c in parsed.columns}
async def _revoke_complete(session: AsyncSession, source_id: str, table: str, column: str) -> int | None:
"""撤销码值项上的「已列全」(目录版本 +1,不提交):值、含义、状态、来源都不动。撞上别人刚改过重读重来,最多三次。
已经不是已列全了(别人刚改过)返回 None。"""
for _attempt in range(3):
entry = await catalog.read_entry(session, source_id, table)
if entry is None:
return None
notes = entry.notes
item = ((notes.get("columns") or {}).get(column) or {}).get("codes")
if not catalog.codes_complete(item):
return None
item.pop("complete", None)
item["updated_at"] = catalog.now_iso()
try:
written = await catalog.write_entry(session, source_id, table, notes, if_version=entry.version,
actor=OBSERVER, commit=False)
except catalog.CatalogConflict:
continue
return written.version
return None
async def save(session: AsyncSession, source: Any, table: str, result: Any, *, job_id: str | None,
seq: int | None, detected: Any) -> int:
"""这张表这一次的观察写进 table_observations、和同口径的历史比,比出来的取值变化写进 source_changes(不提交:和剖析
结果、目录在同一个事务里)。返回记了几条新变化。出错只记日志、不挡剖析(观察是辅助步骤)。"""
from app.data import source_changes as sc
from app.data import value_drift as vd
observations = list(getattr(result, "observations", None) or [])
if not observations:
return 0
fresh = 0
try:
entry = await catalog.read_entry(session, source.id, table)
notes = entry.notes if entry else {}
meta = ((getattr(source, "schema_cache", None) or {}).get("tables") or {}).get(table)
rules = _rules_columns(notes, meta, table, getattr(source, "kind", None))
now = utcnow()
today = now.date()
for obs in observations:
kind, column = obs["kind"], obs["column"]
history = await _history(session, source.id, table, kind, column)
session.add(TableObservation(source_id=source.id, table_name=table, column_name=column, kind=kind,
basis=obs["basis"], basis_key=obs["basis_key"], data=obs["data"],
job_id=job_id, task_seq=seq, at=now))
await session.flush()
await _prune(session, source.id, table, kind, column)
item = ((notes.get("columns") or {}).get(column) or {}).get("codes") if column else None
known = list((item or {}).get("value") or {}) if isinstance(item, Mapping) and \
item.get("status") != "rejected" else []
current = vd.Obs(kind, obs["basis_key"], obs["data"], now)
past = [vd.Obs(h.kind, h.basis_key, h.data or {}, h.at) for h in history]
findings = vd.compare(current, past, today=today, known=known,
complete=catalog.codes_complete(item))
for f in findings:
after = dict(f.after) if isinstance(f.after, Mapping) else {"value": f.after}
if f.revoke_complete:
version = await _revoke_complete(session, source.id, table, column)
after.update(complete_revoked=version is not None, catalog_version=version)
touched = bool(column) and column.lower() in rules
change = sc.NewChange(kind=f.kind, table=table, column=column or None, severity=f.severity,
before=f.before, after=after,
basis={"method": obs["basis"].get("method"), "key": obs["basis_key"],
"query_refs": obs["basis"].get("query_refs") or []},
affects={"catalog": [], "templates": [], "rules_touched": touched},
key=(obs["basis_key"], f.key))
_row, new = await sc.record(session, source, change, detected)
fresh += int(new)
except Exception: # noqa: BLE001 - 观察是辅助步骤:落库、比较出错不挡剖析结果
logger.exception("保存 %s 的取值观察时出错", table)
return fresh
# ==========================================================================
# 观察到期、给助手的观察结论
# ==========================================================================
async def last_observed(session: AsyncSession, source_id: str,
tables: Iterable[str] | None = None) -> dict[str, datetime]:
"""每张表最近一次观察的时刻。"""
query = select(TableObservation.table_name, func.max(TableObservation.at)).where(
TableObservation.source_id == source_id).group_by(TableObservation.table_name)
if tables is not None:
wanted = list(tables)
if not wanted:
return {}
query = query.where(TableObservation.table_name.in_(wanted))
return {name: at for name, at in (await session.execute(query)).all() if at is not None}
def observe_reason(last: datetime | None, *, now: datetime | None = None) -> str | None:
"""观察到期没有:never 从没观察过;observe_due 上一次在 OBSERVE_EVERY_H 小时以前;没到期为 None。"""
if last is None:
return "never"
now = now or utcnow()
if last.tzinfo is None:
last = last.replace(tzinfo=timezone.utc)
return "observe_due" if now - last >= timedelta(hours=OBSERVE_EVERY_H) else None
async def observed_facts(session: AsyncSession, source_id: str,
tables: Iterable[str] | None = None) -> dict[str, dict[str, Any]]:
"""给助手看的观察结论(经 schema_view.profile_lines 和剖析事实同一节,next-round X7):{表名: {freshness: [{column, max,
at}], rows: {rows, method, at_least, at}}},每种取最近一次。遮罩在渲染那一刻再核(不在这里滤)。"""
query = select(TableObservation).where(TableObservation.source_id == source_id,
TableObservation.kind.in_(("freshness", "rows")))
if tables is not None:
wanted = list(tables)
if not wanted:
return {}
query = query.where(TableObservation.table_name.in_(wanted))
out: dict[str, dict[str, Any]] = {}
seen: set[tuple[str, str, str]] = set()
for r in (await session.execute(query.order_by(TableObservation.at.desc()))).scalars():
key = (r.table_name, r.kind, r.column_name)
if key in seen:
continue
seen.add(key)
facts = out.setdefault(r.table_name, {})
at = r.at.isoformat() if r.at else None
if r.kind == "freshness" and (r.data or {}).get("max"):
facts.setdefault("freshness", []).append({"column": r.column_name, "max": r.data["max"], "at": at})
elif r.kind == "rows":
facts["rows"] = {**{k: (r.data or {}).get(k) for k in ("rows", "method", "at_least")}, "at": at}
return out
__all__ = ["KIND_LABEL", "OBSERVE_CODES_MAX", "OBSERVE_EVERY_H", "OBSERVE_KINDS", "OBSERVER", "SAMPLE_LADDER",
"WINDOW_DAYS", "basis_key", "basis_text", "code_columns", "execute", "last_observed", "observe_reason",
"observed_facts", "plan_observations", "sample_percent", "save"]
+19 -2
View File
@@ -65,6 +65,9 @@ PROFILE_ROWS_PROMPT = {"stats": "约 {n} 行(数据库统计估算)", "count
PROFILE_RANGE_PROMPT = "{column} 从 {low} 到 {high}"
PROFILE_HINT_PROMPT = "只供写查询参考(定时间范围、估表大小),不是查询结果,不能作为回答里的数"
PROFILE_STALE_PROMPT = "数据剖析:剖析早于当前版本,不给"
#: 取值观察的结论(next-round 03 B2):最新业务日期;行数比剖析的新时用观察的
PROFILE_LATEST_PROMPT = "最新业务日期 {column} 到 {value}(取值观察,{day})"
OBSERVE_HEAD_PROMPT = "取值观察({day}):{facts}"
#: 三种「不在」(absent_detail)。{source} 数据源名,{table} 点名的表
ABSENT_REMOVED_PROMPT = "数据源「{source}」里已经没有 {table}(表已删除或改名)。别拿名字相近的表顶替"
@@ -229,10 +232,15 @@ def profile_lines(facts: Mapping[str, Any] | None, masked: set[str], snapshot_id
之前一律不给。没有剖析事实(或者滤完什么都不剩)返回空列表:表详情和以前一字不差。"""
if not facts:
return []
observed = facts.get("observed") if isinstance(facts.get("observed"), Mapping) and not upload else {}
if upload and (not snapshot_id or facts.get("snapshot_id") != snapshot_id):
return [indent + PROFILE_STALE_PROMPT]
parts: list[str] = []
estimate = facts.get("row_estimate") if isinstance(facts.get("row_estimate"), Mapping) else None
seen_rows = observed.get("rows") if isinstance(observed.get("rows"), Mapping) else None
if seen_rows and str(seen_rows.get("at") or "") > str(facts.get("at") or ""):
# 取值观察记的行数比上次剖析的新:用新的(同一种说法,口径照写)
estimate = seen_rows
if estimate:
method, rows, least = estimate.get("method"), estimate.get("rows"), estimate.get("at_least")
if isinstance(rows, int) and method in ("stats", "count"):
@@ -246,10 +254,19 @@ def profile_lines(facts: Mapping[str, Any] | None, masked: set[str], snapshot_id
if not column or column.lower() in masked or found.get("min") is None or found.get("max") is None:
continue
parts.append(PROFILE_RANGE_PROMPT.format(column=column, low=_when(found["min"]), high=_when(found["max"])))
for found in observed.get("freshness") or []:
column = str((found or {}).get("column") or "")
if column and column.lower() not in masked and found.get("max"):
parts.append(PROFILE_LATEST_PROMPT.format(column=column, value=_when(found["max"]), day=_day(found.get("at"))))
if not parts:
return []
return [indent + PROFILE_HEAD_PROMPT.format(day=_day(facts.get("at")), facts=";".join(parts)),
indent + " " + PROFILE_HINT_PROMPT]
if facts.get("at"):
head = PROFILE_HEAD_PROMPT.format(day=_day(facts.get("at")), facts=";".join(parts))
else:
# 只有取值观察、没剖析过:照实说是观察
latest = max((str(f.get("at") or "") for f in [seen_rows or {}, *(observed.get("freshness") or [])]), default="")
head = OBSERVE_HEAD_PROMPT.format(day=_day(latest), facts=";".join(parts))
return [indent + head, indent + " " + PROFILE_HINT_PROMPT]
# ==========================================================================
+263
View File
@@ -0,0 +1,263 @@
"""取值观察的比较器(docs/design/next-round/03-数据接入与目录维护.md B2 ④):纯函数,不连库。
**为什么要有它。** 确认过的码值列以前不再取分布,新状态永远发现不了;大表只看最老的前 10 万行;没有最新业务日期、按日
行数、总行数的历史。剖析引擎的四类观察检查(data/catalog_profile_observe)把每次看到的记进 table_observations,这里拿这一次
和**同一口径**(basis_key 相同)的历史比,得出取值变化(写进 source_changes)。口径不同不比、照实说(comparable)。
规则(阈值第一版,要用真实库跑一段时间再调):
- **新码值**(codes_new):码值表标了「已列全」的,出现任何一个目录里没有的取值都算——数据证明它不再列全,这是机器唯一能
写进目录的事(撤销「已列全」,revoke_complete),严重程度是「要处理」。没列全的,新值要占非空行的至少 0.5%,而且要有同
口径的历史(第一次只建基线),已见过的(目录里的、历史里出现过的)不算新。
- **比例剧变**(codes_shift):和上一次同口径的观察算 PSI,两次都至少 1,000 行、都没被截断;PSI ≥ 0.25 记 warning,0.1–0.25
记 info,列出变化最大的几个取值。
- **断档**(date_stalled):最新业务日期没前进,且这个日期离今天超过 max(2 × 以前前进的中位间隔, 3 天);要见过至少一次
前进才知道它多久前进一次。
- **缺天**(date_gap):窗口里(有数据的第一天到最后一天之间)有日子没数据,而以前同口径的窗口都没有过。
- **量跌**(volume_drop):最近一个整天的行数不到它前面 28 天中位数的 30%(前 28 天至少 14 天有数据、中位数至少 20 行)。
最后一天和观察当天同一天(还在写入)时往前退一天。只看这一次的窗口,不需要历史。
- **行数**(rows_drop / rows_jump):比上一次同口径的少了 30% 以上 / 涨到 3 倍以上;依据是数据库统计的估算值时,要连续
两次都这样(和跌、涨之前那一次比)才报。数到上限、只知道「至少多少行」的不比。
"""
from __future__ import annotations
import math
import statistics
from collections.abc import Iterable, Mapping
from dataclasses import dataclass, field
from datetime import date, datetime, timedelta
from typing import Any
NEW_CODE_SHARE = 0.005
PSI_MIN_ROWS = 1000
PSI_WARN = 0.25
PSI_INFO = 0.1
PSI_EPS = 1e-4
PSI_TOP = 5
STALL_MIN_DAYS = 3
VOLUME_RATIO = 0.3
VOLUME_BASELINE_DAYS = 28
VOLUME_MIN_DAYS = 14
VOLUME_MIN_MEDIAN = 20
ROWS_DROP = 0.3
ROWS_JUMP = 3.0
GAPS_SHOWN = 10
@dataclass(frozen=True)
class Obs:
"""一次观察:kind、口径(basis_key)、观察到的(data)、什么时候(at)。"""
kind: str
basis_key: str
data: Mapping[str, Any]
at: datetime | None = None
@dataclass(frozen=True)
class Finding:
"""比出来的一条取值变化。key 是去重指纹的一部分;revoke_complete:要撤销码值项的「已列全」。"""
kind: str
severity: str
before: Any = None
after: Any = None
key: Any = None
revoke_complete: bool = False
extra: dict[str, Any] = field(default_factory=dict)
def comparable(current: Obs, history: Iterable[Obs]) -> tuple[list[Obs], str | None]:
"""同口径的历史(新的在前,照传进来的顺序)和一句说明:有历史、但口径都不同时说「口径不同,不比」;没有历史为 None。"""
items = list(history)
same = [h for h in items if h.basis_key == current.basis_key]
if items and not same:
return [], f"以前的观察口径不同({items[0].basis_key} → {current.basis_key}),这一次只建新口径的基线、不比"
return same, None
def _counts(data: Mapping[str, Any]) -> dict[str, int]:
out: dict[str, int] = {}
for pair in data.get("values") or []:
if isinstance(pair, (list, tuple)) and len(pair) == 2 and isinstance(pair[1], (int, float)):
out[str(pair[0])] = out.get(str(pair[0]), 0) + int(pair[1])
return out
def _share(n: int, total: int) -> float:
return round(n / total, 4) if total else 0.0
def psi(before: Mapping[str, int], after: Mapping[str, int]) -> float:
"""两个分布的 PSI(群体稳定性指数):Σ (q − p) · ln(q / p),p、q 是各取值占比,没出现的取值按 PSI_EPS 算。"""
total_b, total_a = sum(before.values()), sum(after.values())
if not total_b or not total_a:
return 0.0
value = 0.0
for code in set(before) | set(after):
p = max(before.get(code, 0) / total_b, PSI_EPS)
q = max(after.get(code, 0) / total_a, PSI_EPS)
value += (q - p) * math.log(q / p)
return value
def codes(current: Obs, history: Iterable[Obs], *, known: Iterable[str] = (), complete: bool = False) -> list[Finding]:
"""码值列的这一次和同口径历史比:新码值、比例剧变。known 是目录里码值项列出的取值;complete 是它标了「已列全」。"""
same, _note = comparable(current, history)
now = _counts(current.data)
rows = int(current.data.get("rows") or sum(now.values()))
listed = {str(k) for k in known}
out: list[Finding] = []
if complete:
fresh = [c for c in now if c not in listed]
if fresh:
out.append(Finding("codes_new", "action", before={"known": sorted(listed), "complete": True},
after={"codes": fresh, "shares": {c: _share(now[c], rows) for c in fresh}},
key=tuple(sorted(fresh)), revoke_complete=True))
elif same and not current.data.get("truncated") and not same[0].data.get("truncated"):
seen = listed | {c for h in same for c in _counts(h.data)}
fresh = [c for c, n in now.items() if c not in seen and rows and n / rows >= NEW_CODE_SHARE]
if fresh:
out.append(Finding("codes_new", "warning", before={"known": sorted(seen)},
after={"codes": fresh, "shares": {c: _share(now[c], rows) for c in fresh}},
key=tuple(sorted(fresh))))
if same:
prev = same[0]
then = _counts(prev.data)
prev_rows = int(prev.data.get("rows") or sum(then.values()))
if (rows >= PSI_MIN_ROWS and prev_rows >= PSI_MIN_ROWS and not current.data.get("truncated")
and not prev.data.get("truncated")):
value = psi(then, now)
if value >= PSI_INFO:
top = sorted(set(then) | set(now),
key=lambda c: -abs(_share(now.get(c, 0), rows) - _share(then.get(c, 0), prev_rows)))
moved = [[c, _share(then.get(c, 0), prev_rows), _share(now.get(c, 0), rows)] for c in top[:PSI_TOP]]
out.append(Finding("codes_shift", "warning" if value >= PSI_WARN else "info",
before={"rows": prev_rows}, after={"psi": round(value, 4), "rows": rows, "top": moved},
key=(current.basis_key, current.at.isoformat() if current.at else None)))
return out
def _day(value: Any) -> date | None:
try:
return date.fromisoformat(str(value)[:10])
except ValueError:
return None
def freshness(current: Obs, history: Iterable[Obs], *, today: date) -> list[Finding]:
"""最新业务日期没前进、而且离今天太久(断档)。"""
same, _note = comparable(current, history)
now = _day(current.data.get("max"))
if now is None or not same:
return []
previous = [d for d in (_day(h.data.get("max")) for h in same) if d is not None]
if not previous or now > max(previous):
return []
distinct = sorted(set(previous) | {now})
gaps = [(b - a).days for a, b in zip(distinct, distinct[1:])]
if not gaps:
return []
median = statistics.median(gaps)
threshold = max(2 * median, STALL_MIN_DAYS)
age = (today - now).days
if age <= threshold:
return []
return [Finding("date_stalled", "warning", before={"max": max(previous).isoformat(), "median_days": median},
after={"max": now.isoformat(), "age_days": age, "threshold_days": threshold},
key=now.isoformat())]
def _series(data: Mapping[str, Any]) -> dict[date, int]:
out: dict[date, int] = {}
for raw, n in (data.get("days") or {}).items():
d = _day(raw)
if d is not None and isinstance(n, (int, float)):
out[d] = out.get(d, 0) + int(n)
return out
def _holes(days: Mapping[date, int]) -> list[date]:
if not days:
return []
first, last = min(days), max(days)
return [first + timedelta(days=i) for i in range((last - first).days + 1)
if days.get(first + timedelta(days=i), 0) == 0]
def daily(current: Obs, history: Iterable[Obs], *, today: date) -> list[Finding]:
"""按日行数:缺天(要和同口径的历史比)、量跌(只看这一次的窗口)。"""
same, _note = comparable(current, history)
days = _series(current.data)
out: list[Finding] = []
holes = _holes(days)
if holes and same and all(not _holes(_series(h.data)) for h in same):
out.append(Finding("date_gap", "warning", before={"gaps": 0},
after={"gaps": len(holes), "days": [d.isoformat() for d in holes[:GAPS_SHOWN]]},
key=tuple(d.isoformat() for d in holes)))
if days:
last = max(days)
if last >= today:
last -= timedelta(days=1)
base = [days.get(last - timedelta(days=i), 0) for i in range(1, VOLUME_BASELINE_DAYS + 1)]
present = sum(1 for n in base if n > 0)
median = statistics.median(base) if base else 0
value = days.get(last, 0)
if present >= VOLUME_MIN_DAYS and median >= VOLUME_MIN_MEDIAN and value < VOLUME_RATIO * median:
out.append(Finding("volume_drop", "warning", before={"median": median, "days": VOLUME_BASELINE_DAYS},
after={"day": last.isoformat(), "rows": value, "ratio": round(value / median, 4)},
key=last.isoformat()))
return out
def _exact(data: Mapping[str, Any]) -> int | None:
rows = data.get("rows")
return int(rows) if isinstance(rows, int) and not isinstance(rows, bool) and rows >= 0 else None
def rows(current: Obs, history: Iterable[Obs]) -> list[Finding]:
"""行数:跌 30% 以上、涨到 3 倍以上。统计估算的要连续两次(都和跌、涨之前那一次比)。"""
same, _note = comparable(current, history)
now = _exact(current.data)
if now is None or not same:
return []
estimated = current.data.get("method") == "stats"
if estimated:
if len(same) < 2:
return []
prev, ref = _exact(same[0].data), _exact(same[1].data)
if prev is None or ref is None or not ref:
return []
dropped = now <= (1 - ROWS_DROP) * ref and prev <= (1 - ROWS_DROP) * ref
jumped = now >= ROWS_JUMP * ref and prev >= ROWS_JUMP * ref
else:
ref = _exact(same[0].data)
if ref is None or not ref:
return []
dropped = now <= (1 - ROWS_DROP) * ref
jumped = now >= ROWS_JUMP * ref
if dropped:
return [Finding("rows_drop", "warning", before={"rows": ref}, after={"rows": now, "estimated": estimated},
key=(current.basis_key, ref))]
if jumped:
return [Finding("rows_jump", "info", before={"rows": ref}, after={"rows": now, "estimated": estimated},
key=(current.basis_key, ref))]
return []
def compare(current: Obs, history: Iterable[Obs], *, today: date, known: Iterable[str] = (),
complete: bool = False) -> list[Finding]:
"""按种类分派。"""
if current.kind == "codes_observe":
return codes(current, history, known=known, complete=complete)
if current.kind == "freshness":
return freshness(current, history, today=today)
if current.kind == "daily_volume":
return daily(current, history, today=today)
if current.kind == "rows":
return rows(current, history)
return []
__all__ = ["Finding", "NEW_CODE_SHARE", "Obs", "PSI_INFO", "PSI_MIN_ROWS", "PSI_WARN", "codes", "comparable",
"compare", "daily", "freshness", "psi", "rows"]
+4
View File
@@ -25,3 +25,7 @@ SELECT status, COUNT(*) AS cnt FROM sales.orders WHERE paid_at >= DATE '2026-08-
SELECT DATE(paid_at) AS d, COUNT(*) AS n FROM sales.orders WHERE paid_at >= DATE '2026-08-27' AND paid_at < DATE '9999-12-31' GROUP BY DATE(paid_at) ORDER BY d;
-- sample
-- (这种方言没有这类查询)
-- max_date
SELECT MAX(paid_at) FROM sales.orders WHERE paid_at >= DATE '0001-01-02' AND paid_at < DATE '9999-12-31';
-- max_date_text
SELECT MAX(biz_day) FROM sales.orders WHERE biz_day >= '0001-01-02' AND biz_day < '9999-12-31';
+4
View File
@@ -25,3 +25,7 @@ SELECT status, COUNT(*) AS cnt FROM sales.orders WHERE paid_at >= DATE '2026-08-
SELECT DATE(paid_at) AS d, COUNT(*) AS n FROM sales.orders WHERE paid_at >= DATE '2026-08-27' AND paid_at < DATE '9999-12-31' GROUP BY DATE(paid_at) ORDER BY d;
-- sample
-- (这种方言没有这类查询)
-- max_date
SELECT MAX(paid_at) FROM sales.orders WHERE paid_at >= DATE '0001-01-02' AND paid_at < DATE '9999-12-31';
-- max_date_text
SELECT MAX(biz_day) FROM sales.orders WHERE biz_day >= '0001-01-02' AND biz_day < '9999-12-31';
+4
View File
@@ -25,3 +25,7 @@ SELECT * FROM (SELECT status, COUNT(*) AS cnt FROM sales.orders WHERE paid_at >=
SELECT TRUNC(paid_at) AS d, COUNT(*) AS n FROM sales.orders WHERE paid_at >= DATE '2026-08-27' AND paid_at < DATE '9999-12-31' GROUP BY TRUNC(paid_at) ORDER BY d;
-- sample
SELECT * FROM (SELECT status, COUNT(*) AS cnt FROM sales.orders SAMPLE BLOCK (1) WHERE status IS NOT NULL GROUP BY status ORDER BY cnt DESC, status) WHERE ROWNUM <= 21;
-- max_date
SELECT MAX(paid_at) FROM sales.orders WHERE paid_at >= DATE '0001-01-02' AND paid_at < DATE '9999-12-31';
-- max_date_text
SELECT MAX(biz_day) FROM sales.orders WHERE biz_day >= '0001-01-02' AND biz_day < '9999-12-31';
+4
View File
@@ -25,3 +25,7 @@ SELECT status, COUNT(*) AS cnt FROM sales.orders WHERE paid_at >= DATE '2026-08-
SELECT CAST(paid_at AS DATE) AS d, COUNT(*) AS n FROM sales.orders WHERE paid_at >= DATE '2026-08-27' AND paid_at < DATE '9999-12-31' GROUP BY CAST(paid_at AS DATE) ORDER BY d;
-- sample
SELECT status, COUNT(*) AS cnt FROM sales.orders TABLESAMPLE SYSTEM (1) WHERE status IS NOT NULL GROUP BY status ORDER BY cnt DESC, status LIMIT 21;
-- max_date
SELECT MAX(paid_at) FROM sales.orders WHERE paid_at >= DATE '0001-01-02' AND paid_at < DATE '9999-12-31';
-- max_date_text
SELECT MAX(biz_day) FROM sales.orders WHERE biz_day >= '0001-01-02' AND biz_day < '9999-12-31';
+4
View File
@@ -25,3 +25,7 @@ SELECT status, COUNT(*) AS cnt FROM sales.orders WHERE paid_at >= '2026-08-27' A
SELECT substr(paid_at, 1, 10) AS d, COUNT(*) AS n FROM sales.orders WHERE paid_at >= '2026-08-27' AND paid_at < '9999-12-31' GROUP BY substr(paid_at, 1, 10) ORDER BY d;
-- sample
-- (这种方言没有这类查询)
-- max_date
SELECT MAX(paid_at) FROM sales.orders WHERE paid_at >= '0001-01-02' AND paid_at < '9999-12-31';
-- max_date_text
SELECT MAX(biz_day) FROM sales.orders WHERE biz_day >= '0001-01-02' AND biz_day < '9999-12-31';
+291
View File
@@ -0,0 +1,291 @@
"""取值观察:剖析引擎的四类观察检查、口径、撤销「已列全」、遮罩(next-round 03 B2)。
SQLite 夹具(虚构的店铺订单表:状态码、支付日、金额;支付日打头有索引)走真实的后台剖析任务(范围 observe):
- 四类观察各记一条,口径照实(小表整表;大表有索引的业务日期走 35 天窗口,没有就按存储顺序的前 N 行);
- 确认过、标了已列全的码值列也观察;插进一个新取值后再观察:记一条 codes_new(要处理)、撤销已列全(目录版本 +1,署名
「取值观察」,值、含义、状态都不动),SQL 检查不再按旧码值表提示 unknown_code;
- 口径不同不比;遮罩列不出现在任何一条观察查询里(看留痕);
- PostgreSQL / Oracle 的大表没有可用的窗口时生成块采样 SQL,MySQL 退回前 N 行(假的执行上下文,只看拼出来的 SQL 和口径)。
"""
from __future__ import annotations
import sqlite3
import uuid
from datetime import date, timedelta
from types import SimpleNamespace
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select, update
from app.data import catalog, catalog_profile, catalog_profile_jobs, catalog_profile_observe as observe, sqlcheck
from app.data import table_versions
from app.data.engine import MASK_COLUMNS_OPTION
from app.db.base import SessionLocal
from app.db.models import CatalogProfileTask, DataSource
from app.db.models_sources import SourceChange, TableObservation
from app.main import app
from tests.fixtures.sources import drop_source, introspect_now
TODAY = date.today()
def _build(path, *, rows: int = 60, index: bool = True) -> None:
db = sqlite3.connect(path)
db.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, status VARCHAR(10), paid_on DATE, amount NUMERIC(10, 2),"
" secret VARCHAR(10))")
if index:
db.execute("CREATE INDEX ix_orders_paid_on ON orders (paid_on)")
for i in range(1, rows + 1):
day = (TODAY - timedelta(days=i % 30 + 1)).isoformat()
db.execute("INSERT INTO orders VALUES (?, ?, ?, ?, ?)", (i, "void" if i % 4 == 0 else "paid", day, i, "x"))
db.commit()
db.close()
@pytest.fixture
async def client():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
async def _make(client, path, *, max_scan_rows: int = 100_000, masked: list[str] | None = None) -> str:
options = {"catalog_profile": {"enabled": True, "max_scan_rows": max_scan_rows, "sample_size": 10}}
if masked:
options[MASK_COLUMNS_OPTION] = masked
r = await client.post("/api/datasources", json={"name": f"obs_{uuid.uuid4().hex[:8]}", "kind": "sqlite",
"database": str(path), "readonly": True})
assert r.status_code == 201, r.text
sid = r.json()["id"]
await introspect_now(client, sid)
async with SessionLocal() as session:
row = await session.get(DataSource, sid)
row.options = {**(row.options or {}), **options}
await session.commit()
await catalog.write_entry(session, sid, "orders", {
"business_date": catalog.make_item({"column": "paid_on"}, "human"),
"columns": {"status": {"codes": catalog.make_item({"paid": "已支付", "void": "作废"}, "human",
complete=True)},
"secret": {"codes": catalog.make_item({"x": "保密"}, "human")}},
"valid_filter": catalog.make_item("status <> 'void'", "human"),
}, if_version=0, actor="测试员")
return sid
@pytest.fixture
async def shop(client, tmp_path):
path = tmp_path / "obs.db"
_build(path)
sid = await _make(client, path)
yield sid, path
await drop_source(sid)
async def _observe(sid: str, scope: str = "observe") -> str:
async with SessionLocal() as session:
row = await session.get(DataSource, sid)
view = await table_versions.resolve_source(session, row)
job, _ = await catalog_profile_jobs.create_job(session, row, view, tables=None, scope=scope,
settings=catalog_profile.ensure_enabled(row), actor="测试员")
catalog_profile_jobs.manager.start(job.id)
await catalog_profile_jobs.manager.wait(job.id, timeout=60)
return job.id
async def _obs(sid: str) -> list[TableObservation]:
async with SessionLocal() as session:
return list((await session.execute(select(TableObservation).where(TableObservation.source_id == sid)
.order_by(TableObservation.at))).scalars())
async def _age(sid: str, hours: int = 13) -> None:
"""把以前的观察挪到 hours 小时前:观察到期了才会再观察。"""
async with SessionLocal() as session:
for r in (await session.execute(select(TableObservation).where(TableObservation.source_id == sid))).scalars():
r.at = r.at - timedelta(hours=hours)
await session.commit()
async def test_four_observations_with_basis_and_no_change_on_first_run(shop):
sid, _ = shop
job_id = await _observe(sid)
got = {(o.kind, o.column_name): o for o in await _obs(sid)}
assert set(got) == {("rows", ""), ("codes_observe", "status"), ("codes_observe", "secret"),
("freshness", "paid_on"), ("daily_volume", "paid_on")}
assert got[("rows", "")].data["rows"] == 60 and got[("rows", "")].basis_key == "count"
status = got[("codes_observe", "status")]
assert status.basis_key == "full" and dict(map(tuple, status.data["values"])) == {"paid": 45, "void": 15}
assert got[("freshness", "paid_on")].data["max"] == (TODAY - timedelta(days=1)).isoformat()
days = got[("daily_volume", "paid_on")].data["days"]
assert sum(days.values()) == 60 and got[("daily_volume", "paid_on")].basis_key == "window:paid_on:35"
assert all(o.job_id == job_id for o in got.values())
assert got[("codes_observe", "status")].basis["query_refs"], "留痕:这条观察是哪几条查询得出的"
async with SessionLocal() as session:
changes = list((await session.execute(select(SourceChange).where(SourceChange.source_id == sid))).scalars())
[task] = (await session.execute(select(CatalogProfileTask).where(CatalogProfileTask.job_id == job_id))).scalars()
assert changes == [], "第一次只建基线"
assert {c["kind"] for c in task.checks} == set(observe.OBSERVE_KINDS), "只观察:不做关系、码值、日期的剖析"
assert task.status == "ok"
async def test_new_code_in_a_complete_list_revokes_complete(shop):
sid, path = shop
await _observe(sid)
db = sqlite3.connect(path)
db.execute("INSERT INTO orders VALUES (999, 'held', ?, 1, 'x')", ((TODAY - timedelta(days=2)).isoformat(),))
db.commit()
db.close()
async with SessionLocal() as session:
before = await catalog.read_entry(session, sid, "orders")
# 没到期不再观察
with pytest.raises(catalog_profile_jobs.ProfileNothing):
await _observe(sid)
await _age(sid)
await _observe(sid)
async with SessionLocal() as session:
[change] = (await session.execute(select(SourceChange).where(
SourceChange.source_id == sid, SourceChange.kind == "codes_new"))).scalars()
after = await catalog.read_entry(session, sid, "orders")
row = await session.get(DataSource, sid)
assert change.column_name == "status" and change.severity == "action" and change.after["codes"] == ["held"]
assert change.after["complete_revoked"] is True and change.after["catalog_version"] == after.version
assert change.affects["rules_touched"] is True, "有效记录条件用到了这一列"
assert change.detected_by["job_kind"] == "profile" and change.basis["key"] == "full"
assert after.version == before.version + 1 and after.updated_by == observe.OBSERVER
item = after.notes["columns"]["status"]["codes"]
assert "complete" not in item and item["value"] == {"paid": "已支付", "void": "作废"}, "值、含义不动"
assert (item["source"], item["status"]) == ("human", "confirmed"), "状态、来源不动"
checker = sqlcheck.SqlChecker(kind="sqlite", schema_cache=row.schema_cache, notes={"orders": after.notes},
source_name=row.name)
assert not [c for c in checker.check("SELECT COUNT(*) FROM orders WHERE status = 'held'")
if c.code == "unknown_code"], "已列全撤销以后,SQL 检查不再照旧码值表提示"
old = sqlcheck.SqlChecker(kind="sqlite", schema_cache=row.schema_cache, notes={"orders": before.notes},
source_name=row.name)
assert [c for c in old.check("SELECT COUNT(*) FROM orders WHERE status = 'held'") if c.code == "unknown_code"]
# 同一个新码值再测到:不另起一条
await _age(sid)
await _observe(sid)
async with SessionLocal() as session:
same = list((await session.execute(select(SourceChange).where(
SourceChange.source_id == sid, SourceChange.kind == "codes_new"))).scalars())
assert len(same) == 1
async def test_masked_columns_never_appear_in_observation_queries(client, tmp_path):
path = tmp_path / "masked.db"
_build(path)
sid = await _make(client, path, masked=["secret"])
try:
job_id = await _observe(sid)
async with SessionLocal() as session:
[task] = (await session.execute(select(CatalogProfileTask).where(
CatalogProfileTask.job_id == job_id))).scalars()
assert task.query_log and not any("secret" in q["sql"].lower() for q in task.query_log)
[skip] = [c for c in task.checks if c["kind"] == "codes_observe" and c["target"] == "secret"]
assert skip["state"] == "skipped" and skip["reason"] == "masked"
assert ("codes_observe", "secret") not in {(o.kind, o.column_name) for o in await _obs(sid)}
finally:
await drop_source(sid)
async def test_large_table_basis_window_or_first_rows_and_no_cross_basis_compare(client, tmp_path):
with_index, without = tmp_path / "big1.db", tmp_path / "big2.db"
_build(with_index, rows=60, index=True)
_build(without, rows=60, index=False)
a = await _make(client, with_index, max_scan_rows=20)
b = await _make(client, without, max_scan_rows=20)
try:
await _observe(a)
await _observe(b)
got_a = {(o.kind, o.column_name): o for o in await _obs(a)}
got_b = {(o.kind, o.column_name): o for o in await _obs(b)}
assert got_a[("codes_observe", "status")].basis_key == "window:paid_on:35", "大表、业务日期打头有索引:窗口"
assert got_a[("rows", "")].data["at_least"] == 21
assert got_b[("codes_observe", "status")].basis_key.startswith("first_rows:"), \
"SQLite 没有块采样:按存储顺序的前 N 行,照实写口径"
assert ("freshness", "paid_on") not in got_b and ("daily_volume", "paid_on") not in got_b, \
"大表、业务日期没有索引:不看最新业务日期和按日行数"
# 改了口径(整表统计上限调大,变成小表):和以前的不比
async with SessionLocal() as session:
row = await session.get(DataSource, b)
row.options = {**row.options, "catalog_profile": {**row.options["catalog_profile"], "max_scan_rows": 1000}}
await session.commit()
db = sqlite3.connect(without)
db.executemany("INSERT INTO orders VALUES (?, 'void', ?, 1, 'x')",
[(1000 + i, (TODAY - timedelta(days=1)).isoformat()) for i in range(40)])
db.commit()
db.close()
await _age(b)
await _observe(b)
async with SessionLocal() as session:
shifts = list((await session.execute(select(SourceChange).where(
SourceChange.source_id == b, SourceChange.kind == "codes_shift"))).scalars())
assert shifts == [], "口径从前 N 行换成了整表:不跨口径比"
finally:
await drop_source(a)
await drop_source(b)
class _FakeCx:
"""只看拼出来的 SQL:大表、业务日期没有索引。"""
def __init__(self, kind: str) -> None:
self.dialect = catalog_profile.SqlDialect(kind)
self.settings = catalog_profile.ProfileSettings(enabled=True, max_scan_rows=1000, sample_size=100)
self.sql: list[str] = []
self.table_notes = {"business_date": {"value": {"column": "paid_on"}, "source": "human", "status": "confirmed"}}
def is_masked(self, column: str) -> bool:
return False
def scan_cap(self, size):
return 1000
async def run(self, sql, *, max_rows=1, **kw):
self.sql.append(sql)
return SimpleNamespace(rows=[("A", 900), ("B", 100)], row_count=2)
@pytest.mark.parametrize("kind,method", [("postgresql", "sample"), ("oracle", "sample"), ("mysql", "first_rows")])
async def test_large_table_without_window_samples_on_pg_and_oracle(kind, method):
cx = _FakeCx(kind)
meta = {"columns": [{"name": "status", "type": "VARCHAR(2)"}, {"name": "paid_on", "type": "DATE"}],
"index_first_columns": ["id"]}
result = SimpleNamespace(observations=[], query_log=[])
size = catalog_profile.TableSize(500_000, "stats")
await observe._codes(cx, "orders", "status", meta, size, result)
[obs] = result.observations
assert obs["basis"]["method"] == method
if kind == "postgresql":
assert "TABLESAMPLE SYSTEM (0.2)" in cx.sql[0] and obs["basis_key"] == "sample:0.2"
elif kind == "oracle":
assert "SAMPLE BLOCK (0.2)" in cx.sql[0] and "ROWNUM" in cx.sql[0]
else:
assert "LIMIT 1000" in cx.sql[0] and obs["basis_key"] == "first_rows:1000"
assert observe.sample_percent(500_000, 1000) == 0.2 and observe.sample_percent(800, 1000) is None
async def test_observation_is_not_the_last_profile_and_reaches_the_table_detail(shop):
"""只做了观察的那一项不算「上次剖析」(不顶掉剖析事实);观察结论经 schema_view 和剖析事实同一节进表详情(X7),
遮罩在渲染那一刻再核。"""
from app.data import schema_view
sid, _ = shop
await _observe(sid)
async with SessionLocal() as session:
lasts = await catalog_profile_jobs.last_profiles(session, sid)
facts = await catalog_profile_jobs.last_facts(session, sid)
row = await session.get(DataSource, sid)
assert "orders" not in lasts, "只观察的那一项不是一次剖析"
observed = facts["orders"]["observed"]
assert observed["freshness"][0]["column"] == "paid_on" and observed["rows"]["rows"] == 60
lines = schema_view.profile_lines(facts["orders"], set(), None)
latest = (TODAY - timedelta(days=1)).isoformat()
assert lines[0].strip().startswith("取值观察(") and f"最新业务日期 paid_on 到 {latest}" in lines[0], lines
assert "60 行(逐行数出)" in lines[0] and "不能作为回答里的数" in lines[1]
assert not any("paid_on" in line for line in schema_view.profile_lines(facts["orders"], {"paid_on"}, None)), \
"观察之后才加遮罩的列:渲染那一刻滤掉"
detail = schema_view.table_detail(row, "orders", profile=facts)
assert f"最新业务日期 paid_on 到 {latest}" in detail
+4 -1
View File
@@ -3,7 +3,7 @@
三层,各挡各的:
1. **金样**:每种方言(mysql、mariadb、postgresql、oracle、sqlite)× 每类查询(统计信息、有上限的计数、取不同键值、
对键值、唯一性、取值分布、日期最小最大、时间窗口里的取值分布、按日行数、块采样),完整 SQL 钉在
对键值、唯一性、取值分布、日期最小最大、时间窗口里的取值分布、按日行数、块采样、最新业务日期),完整 SQL 钉在
tests/fixtures/dialect/<方言>.sql,人审过一次;以后拼法有任何改动都会出 diff。要更新金样:
``UPDATE_DIALECT_GOLDEN=1 pytest tests/test_profile_dialect_golden.py``,再逐行看 git diff。
2. **sqlglot 逐条解析**:按方言读一遍(mariadb 按 mysql 读),挡住语法写坏了(少括号、关键字拼错)。
@@ -71,6 +71,9 @@ def _queries(kind: str) -> list[tuple[str, str | None]]:
"2026-08-27", 21)),
("day_volume", d.day_volume_sql(orders, d.column(META["orders"], "paid_at"), "2026-08-27")),
("sample", d.sample_value_counts_sql(orders, status, 1, 21)),
# 第 3 波:取值观察的最新业务日期(文字存的日期按字符串比)
("max_date", d.max_date_sql(orders, d.column(META["orders"], "paid_at"))),
("max_date_text", d.max_date_sql(orders, d.column(META["orders"], "biz_day"), text=True)),
]
return out
+114
View File
@@ -0,0 +1,114 @@
"""取值观察的比较器(app/data/value_drift.py,next-round 03 B2 ④):纯函数。
守的是阈值两侧:已列全的码值表出现任何新值都算、要撤销「已列全」;没列全的新值要占 0.5% 以上、第一次只建基线;PSI 刚好落在
0.1 / 0.25 两侧;断档要超过 max(2 × 中位间隔, 3 天);缺天要以前的窗口都没有过;量跌按前 28 天的中位数;行数估算要连续两次;
口径不同不比。取值都是虚构的。
"""
from __future__ import annotations
from datetime import date, datetime, timedelta, timezone
from app.data import value_drift as vd
AT = datetime(2026, 10, 10, 3, 0, tzinfo=timezone.utc)
TODAY = date(2026, 10, 10)
def _codes(values: dict[str, int], basis: str = "full", **extra) -> vd.Obs:
return vd.Obs("codes_observe", basis, {"values": [[k, v] for k, v in values.items()],
"rows": sum(values.values()), **extra}, AT)
def test_complete_code_table_flags_any_new_value_and_revokes_complete():
[f] = vd.codes(_codes({"paid": 900, "void": 99, "held": 1}), [], known=["paid", "void"], complete=True)
assert f.kind == "codes_new" and f.severity == "action" and f.revoke_complete is True
assert f.after["codes"] == ["held"], "只出现一次也算:它说的是「只有这些取值」"
assert vd.codes(_codes({"paid": 900, "void": 100}), [], known=["paid", "void"], complete=True) == []
def test_not_complete_needs_history_and_half_a_percent():
base = _codes({"paid": 900, "void": 100})
assert vd.codes(_codes({"paid": 900, "void": 95, "held": 5}), [], known=[]) == [], "第一次只建基线"
[f] = vd.codes(_codes({"paid": 900, "void": 95, "held": 5}), [base], known=[])
assert f.kind == "codes_new" and f.severity == "warning" and not f.revoke_complete
assert vd.codes(_codes({"paid": 996, "void": 100, "held": 4}), [base], known=[]) == [], "不到 0.5%:不算"
seen = _codes({"paid": 900, "void": 90, "held": 10})
assert [x.kind for x in vd.codes(_codes({"paid": 900, "void": 95, "held": 5}), [seen, base])] == [], "历史里见过的不算新"
def test_psi_on_both_sides_of_the_thresholds():
before = _codes({"a": 500, "b": 500})
# PSI(0.5/0.5 → 0.6/0.4) ≈ 0.0405:不报
assert vd.codes(_codes({"a": 600, "b": 400}), [before]) == []
# 0.5/0.5 → 0.66/0.34 ≈ 0.1035:info
[info] = vd.codes(_codes({"a": 660, "b": 340}), [before])
assert info.kind == "codes_shift" and info.severity == "info" and 0.1 <= info.after["psi"] < 0.25
# 0.5/0.5 → 0.75/0.25 ≈ 0.2747:warning,列出变化最大的取值
[warn] = vd.codes(_codes({"a": 750, "b": 250}), [before])
assert warn.severity == "warning" and warn.after["psi"] >= 0.25 and warn.after["top"][0][0] in ("a", "b")
small = _codes({"a": 50, "b": 50})
assert vd.codes(_codes({"a": 90, "b": 10}), [small]) == [], "两次都要至少 1,000 行"
def test_different_basis_is_not_compared():
before = _codes({"a": 500, "b": 500}, basis="first_rows:1000")
assert vd.codes(_codes({"a": 900, "b": 100}, basis="window:paid_on:35"), [before]) == []
same, note = vd.comparable(_codes({}, basis="window:paid_on:35"), [before])
assert same == [] and "口径不同" in note
def _fresh(day: str) -> vd.Obs:
return vd.Obs("freshness", "full", {"max": day}, AT)
def test_stalled_date_needs_more_than_twice_the_median_interval_and_three_days():
history = [_fresh("2026-10-05"), _fresh("2026-10-04"), _fresh("2026-10-03")] # 每天前进一次
assert vd.freshness(_fresh("2026-10-05"), history, today=date(2026, 10, 8)) == [], "3 天:没超过 max(2, 3)"
[f] = vd.freshness(_fresh("2026-10-05"), history, today=date(2026, 10, 9))
assert f.kind == "date_stalled" and f.after["age_days"] == 4 and f.after["threshold_days"] == 3
assert vd.freshness(_fresh("2026-10-08"), history, today=date(2026, 10, 30)) == [], "前进了就不算断档"
weekly = [_fresh("2026-09-28"), _fresh("2026-09-21")]
assert vd.freshness(_fresh("2026-09-28"), weekly, today=date(2026, 10, 10)) == [], "每周一次:14 天内不算"
assert vd.freshness(_fresh("2026-10-05"), [_fresh("2026-10-05")], today=date(2026, 10, 30)) == [], \
"没见过前进,不知道它多久前进一次"
def _days(start: date, counts: list[int], basis: str = "window:paid_on:35") -> vd.Obs:
return vd.Obs("daily_volume", basis, {"days": {(start + timedelta(days=i)).isoformat(): n
for i, n in enumerate(counts) if n}}, AT)
def test_gap_only_when_previous_windows_had_none():
full = _days(date(2026, 9, 1), [100] * 30)
holed = _days(date(2026, 9, 2), [100] * 10 + [0, 0] + [100] * 18)
[f] = vd.daily(holed, [full], today=TODAY)
assert f.kind == "date_gap" and f.after["gaps"] == 2
assert not [x for x in vd.daily(holed, [holed], today=TODAY) if x.kind == "date_gap"], "以前就有缺天:不算新问题"
assert not [x for x in vd.daily(holed, [], today=TODAY) if x.kind == "date_gap"], "没有历史:只建基线"
def test_volume_drop_against_the_previous_28_days():
start = TODAY - timedelta(days=31)
normal = _days(start, [100] * 30) # 最后一天是昨天
assert vd.daily(normal, [], today=TODAY) == []
[f] = vd.daily(_days(start, [100] * 29 + [20]), [], today=TODAY)
assert f.kind == "volume_drop" and f.after["rows"] == 20 and f.before["median"] == 100
assert vd.daily(_days(start, [100] * 29 + [35]), [], today=TODAY) == [], "35% 不算跌"
partial = _days(TODAY - timedelta(days=30), [100] * 30 + [5]) # 今天还在写入:往前退一天
assert vd.daily(partial, [], today=TODAY) == []
def _rows(n: int, method: str = "count") -> vd.Obs:
return vd.Obs("rows", method, {"rows": n, "method": method}, AT)
def test_rows_drop_and_jump_estimates_need_two_in_a_row():
assert [f.kind for f in vd.rows(_rows(600), [_rows(1000)])] == ["rows_drop"]
assert vd.rows(_rows(750), [_rows(1000)]) == [], "跌 25%:不报"
assert [f.kind for f in vd.rows(_rows(3000), [_rows(1000)])] == ["rows_jump"]
est = lambda n: _rows(n, "stats") # noqa: E731
assert vd.rows(est(600), [est(1000), est(1000)]) == [], "估算值跌了一次:先不报"
assert [f.kind for f in vd.rows(est(600), [est(650), est(1000)])] == ["rows_drop"], "连续两次都跌:报"
assert vd.rows(_rows(600), [_rows(1000, "stats")]) == [], "数出来的和估算的口径不同:不比"
assert vd.rows(vd.Obs("rows", "count", {"rows": None, "at_least": 1001}, AT), [_rows(5000)]) == [], \
"只知道「至少多少行」的不比"