Files
AgentLab/backend/app/db/models_sources.py

238 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""数据源的对象索引、探查范围、后台探查任务(docs/design/next-round/03-数据接入与目录维护.md A1)。
由 app/db/models.py 末尾整模块导入,create_all 建表。
**为什么要对象索引。** 以前探查结构每个源最多取 200 个对象、按数据库列出的顺序截断,多出来的表哪儿都看不到,目录、look、
剖析还会把落在窗口外的表说成「表已不在」。现在全库每个对象一行(source_objects,一种方言一条字典查询拿名字、类型、注释,
上限 5 万个),探查范围(数据源 options.introspect_scope 的规则,缺省 500 张、最多 2,000 张)圈出要完整反射的那一部分,
只有范围内的行带着反射到的完整结构(meta)。schema_cache 由 data/source_objects.rebuild_schema_cache 从范围内已反射的行
物化出来,形状不变:消费它的两百多处不用改。
**结构版本。** 每张表一个指纹(反射到的结构的规范 JSON 的哈希)和 struct_version:指纹变了才加 1。第 3 波的结构差异按它
钉版本、出前后差异;这一波只把版本写好,不出变化记录。
**探查任务。** 探查改成后台任务(data/introspect_jobs.py),生命周期是 core/job_runtime 那一套:和同源的起草、剖析排同一条
队(SourceLanes 的 introspect),逐表记状态,能停止,服务重启标中断、不续跑。表照剖析任务的写法:任务一行、逐表一行。
"""
from __future__ import annotations
from datetime import datetime
from typing import Any
from sqlalchemy import Boolean, ForeignKey, Index, Integer, String, Text, UniqueConstraint, text
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base, current_owner, PortableJSON, UTCDateTime, new_id, utcnow
class SourceObject(Base):
"""数据源里的一个对象(表或视图)。同一个源同一个 schema 下按名字一行(一个数据源一个 schema,目录-D2):换了 schema
的源,旧 schema 的行留着不动、不算删除,换回来接着用。
- name:schema_cache["tables"] 的键(SQLAlchemy 规整过的写法:Oracle 全大写的名字是小写);name_key 按 names.name_key,
范围规则、按名字找表都按它比;
- in_scope / scope_reason:在不在探查范围里、为什么(rule 规则命中、explicit 显式列出、pinned 有数据目录或被已发布
模板写死的 SQL 用到、on_demand 助手或运行时按需加入);
- meta:反射到的完整结构(schema_cache 里一张表的那份),只有范围内的才有;离开范围时清掉,指纹和版本留着;
- reflect_error:在范围里、却反射不出来(读不到列、权限不够)的原因;这时 meta 是上一次反射成功的那份(有的话);
- removed_at:对象索引里已经没有它了(真删除)。只有它说「表已不在」:范围外、反射失败都不是删除;
- added_*:按需加入、在目录页「加入范围」的留痕:谁、经哪个入口(look / db_schema / catalog)、哪一轮对话或哪次运行、
需求原文、什么时候。"""
__tablename__ = "source_objects"
__table_args__ = (
UniqueConstraint("source_id", "schema_name", "name"),
Index("ix_source_objects_scope", "source_id", "schema_name", "in_scope"),
Index("ix_source_objects_run", "added_run_id"),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
source_id: Mapped[str] = mapped_column(ForeignKey("data_sources.id", ondelete="CASCADE"))
#: 这一行是哪个 schema 的("" 是连接的默认 schema)
schema_name: Mapped[str] = mapped_column(String(255), default="")
name: Mapped[str] = mapped_column(String(255))
name_key: Mapped[str] = mapped_column(String(255))
#: table | view
kind: Mapped[str] = mapped_column(String(10), default="table")
comment: Mapped[str | None] = mapped_column(Text, default=None)
#: Oracle 库里本来就是小写的名字(SQLAlchemy 反射回来是 quoted_name(quote=True)):存成字符串就丢了这个标记,反射时
#: 要按它原样加引号,不然 Oracle 转成大写去找,找的是另一个对象
quote_name: Mapped[bool] = mapped_column(Boolean, default=False)
#: 数据字典里的行数估算(MySQL TABLE_ROWS、PostgreSQL reltuples、Oracle NUM_ROWS);不知道是 NULL
est_rows: Mapped[int | None] = mapped_column(Integer, default=None)
in_scope: Mapped[bool] = mapped_column(Boolean, default=False)
scope_reason: Mapped[str | None] = mapped_column(String(20), default=None)
meta: Mapped[dict[str, Any] | None] = mapped_column(default=None)
fingerprint: Mapped[str | None] = mapped_column(String(64), default=None)
struct_version: Mapped[int] = mapped_column(Integer, default=0)
reflect_error: Mapped[str | None] = mapped_column(Text, default=None)
first_seen_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow)
last_seen_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow)
removed_at: Mapped[datetime | None] = mapped_column(UTCDateTime, default=None)
reflected_at: Mapped[datetime | None] = mapped_column(UTCDateTime, default=None)
#: look | db_schema | catalog
added_via: Mapped[str | None] = mapped_column(String(20), default=None)
added_by: Mapped[str | None] = mapped_column(String(100), default=None)
added_run_id: Mapped[str | None] = mapped_column(String(32), default=None)
added_conversation_id: Mapped[str | None] = mapped_column(String(32), default=None)
#: look 记录的编号(L1…)、运行里的节点 id:同一轮、同一次运行里是哪一下加的
added_ref: Mapped[str | None] = mapped_column(String(64), default=None)
added_need: Mapped[str | None] = mapped_column(Text, default=None)
added_at: Mapped[datetime | None] = mapped_column(UTCDateTime, default=None)
class IntrospectJob(Base):
"""一次后台探查。建任务的请求里已经刷新了对象索引、按范围规则圈好了表、核过上限(超了 409,不建任务);任务只做反射:
逐表(按批)读结构、写进对象索引的行,最后由 rebuild_schema_cache 物化 schema_cache。
同一个源同时只有一个排队或进行中的探查任务(接口先查一遍,部分唯一索引兜底);和同源的起草、剖析按建立的先后排队。
挂外键、级联删除:任务是数据源的附属记录。"""
__tablename__ = "introspect_jobs"
__table_args__ = (
Index("ix_introspect_jobs_source_created", "source_id", "created_at"),
Index("ux_introspect_jobs_active", "source_id", unique=True,
sqlite_where=text("status IN ('queued', 'running')"),
postgresql_where=text("status IN ('queued', 'running')")),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
source_id: Mapped[str] = mapped_column(ForeignKey("data_sources.id", ondelete="CASCADE"))
# queued | running | succeeded | partial | failed | cancelled | interrupted
status: Mapped[str] = mapped_column(String(20), default="queued")
#: 认领这一行的工作进程(app/cluster;缺省是建它的进程)。多进程时启动整理、接手只碰属主不在了的行
owner: Mapped[str | None] = mapped_column(String(120), default=current_owner)
#: manual 人点的 | schedule 定时维护(第 3 波)
trigger: Mapped[str] = mapped_column(String(20), default="manual")
#: 这次探查的是哪个 schema("" 是默认 schema)
schema_name: Mapped[str] = mapped_column(String(255), default="")
#: 建任务那一刻的范围:{rules(规则原样), in_scope, rule, pinned, explicit, on_demand, out_of_scope, cap, index_total,
#: index_method, index_capped, removed, added}——留痕:这一次按什么规则、圈了多少张
scope: Mapped[dict[str, Any]] = mapped_column(default=dict)
created_by: Mapped[str | None] = mapped_column(String(100), default=None)
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow)
started_at: Mapped[datetime | None] = mapped_column(UTCDateTime, default=None)
finished_at: Mapped[datetime | None] = mapped_column(UTCDateTime, default=None)
#: 整个任务出错、数据源中途停用时的整句原因
error: Mapped[str | None] = mapped_column(Text, default=None)
class IntrospectTask(Base):
"""探查任务里的一张表。按 seq 排。状态:pending 排队、running 正在读、ok 读到了、failed 读不到(原因在 error)、
cancelled 停止或服务重启时还没读。"""
__tablename__ = "introspect_tasks"
__table_args__ = (UniqueConstraint("job_id", "seq"),)
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
job_id: Mapped[str] = mapped_column(ForeignKey("introspect_jobs.id", ondelete="CASCADE"), index=True)
seq: Mapped[int] = mapped_column(Integer)
table_name: Mapped[str] = mapped_column(String(255))
#: 为什么在范围里(pinned / explicit / rule / on_demand):开始执行时按它把范围写进对象索引
reason: Mapped[str | None] = mapped_column(String(20), default=None)
status: Mapped[str] = mapped_column(String(20), default="pending")
error: Mapped[str | None] = mapped_column(Text, default=None)
#: 结构版本:读完以后的(指纹没变就和以前一样)
struct_version: Mapped[int] = mapped_column(Integer, default=0)
finished_at: Mapped[datetime | None] = mapped_column(UTCDateTime, default=None)
# --------------------------------------------------------------------------
# 第 3 波:取值观察、变化记录(docs/design/next-round/03-数据接入与目录维护.md B2、B3)
# --------------------------------------------------------------------------
class TableObservation(Base):
"""一次取值观察(剖析引擎的四类观察检查,data/catalog_profile_observe.py):码值列的取值分布(codes_observe)、最新业务
日期(freshness)、最近 35 天按天的行数(daily_volume)、行数(rows)。
**为什么单独一张表。** 剖析任务的逐表一行只留最近 50 个任务,而且一张表一次剖析只有一份发现;取值漂移要拿同一口径的
历史比(新码值、比例剧变、断档、缺天、量跌、行数跌),得按「表 × 种类 × 列」留一串。每张表每种观察(码值按列)留最近
KEEP 条,更早的删掉。
**口径(basis)。** 每条都记它看的是哪一部分数据:full 整表、first_rows(按存储顺序的前 N 行)、window(业务日期最近 N 天)、
sample(块采样 p%)、stats / count(行数的来源)。basis_key 是口径的规范写法:只和 basis_key 相同的历史比,口径不同的照实说
不比。遮罩列不出现在任何一条观察查询里,取值也就不会进这张表。
挂外键、级联删除:观察是数据源的附属记录。"""
__tablename__ = "table_observations"
__table_args__ = (
Index("ix_table_observations_lookup", "source_id", "table_name", "kind", "column_name", "at"),
)
#: 每张表每种观察(码值按列)留几条
KEEP = 26
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
source_id: Mapped[str] = mapped_column(ForeignKey("data_sources.id", ondelete="CASCADE"))
table_name: Mapped[str] = mapped_column(String(255))
#: 码值、业务日期观察的那一列;表级的(行数)是空串
column_name: Mapped[str] = mapped_column(String(255), default="")
#: codes_observe | freshness | daily_volume | rows
kind: Mapped[str] = mapped_column(String(20))
basis: Mapped[dict[str, Any]] = mapped_column(default=dict)
basis_key: Mapped[str] = mapped_column(String(200), default="")
#: 观察到的:码值 {values: [[码, 行数]…], rows, truncated};最新业务日期 {max};按日 {since, until, days: {日: 行数}};
#: 行数 {rows, at_least, method}
data: Mapped[dict[str, Any]] = mapped_column(default=dict)
#: 哪个剖析任务的第几张表测到的
job_id: Mapped[str | None] = mapped_column(String(32), default=None)
task_seq: Mapped[int | None] = mapped_column(Integer, default=None)
at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow)
class SourceChange(Base):
"""数据源的一条变化(data/source_changes.py,格式 agentlab-change/1):结构变化(重新探查后的前后差异)和取值变化(取值
观察和同口径历史的比较)。全站只有这一份记录:审阅页的「变化」、数据源卡片的角标、外部通知、口径账本的召回都读它。
- seq:自增,不复用(sqlite_autoincrement),召回按它当游标拉取;id 是给界面、链接用的编号;
- kind:结构类 table_added / table_removed / table_left_scope / table_reflect_failed / column_added / column_removed /
column_type_changed / column_nullable_changed / key_changed / fk_changed / comment_changed / scope_changed;取值类
codes_new / codes_shift / date_stalled / date_gap / volume_drop / rows_drop / rows_jump / coverage_drop;
- severity:info / warning / action(现在大概率已经不对了:已确认的项、写死的 SQL 指向被删的列,或者「已列全」被撤销);
- detected_by:{job_kind, job_id, task_seq, trigger: manual|schedule|on_demand, actor};basis:{method, query_refs};
- before / after:前后各是什么(码值只记非遮罩列的);affects:{catalog: [{table, path, status, source}], templates:
[{workflow_id, name, version, impact, nodes}], rules_touched};struct_version_before / after:结构变化前后的结构版本;
- fingerprint:去重——同一个变化再次测到只更新 last_seen_at 和 seen_count;
- status:open / acknowledged / resolved / dismissed;resolution:{by, at, action, note, catalog_version, job_id}。
挂外键、级联删除:变化是数据源的附属记录。"""
__tablename__ = "source_changes"
__table_args__ = (
UniqueConstraint("source_id", "fingerprint"),
Index("ix_source_changes_source_status", "source_id", "status"),
Index("ix_source_changes_source_table", "source_id", "table_name"),
{"sqlite_autoincrement": True},
)
seq: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
id: Mapped[str] = mapped_column(String(32), unique=True, default=new_id)
source_id: Mapped[str] = mapped_column(ForeignKey("data_sources.id", ondelete="CASCADE"))
#: 测到时这个源叫什么(之后改了名,这条记录说的仍是当时的名字)
source_name: Mapped[str] = mapped_column(String(100), default="")
#: 源级的变化(范围规则改了)是空串
table_name: Mapped[str] = mapped_column(String(255), default="")
column_name: Mapped[str | None] = mapped_column(String(255), default=None)
kind: Mapped[str] = mapped_column(String(40))
severity: Mapped[str] = mapped_column(String(10), default="info")
detected_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow)
last_seen_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow)
seen_count: Mapped[int] = mapped_column(Integer, default=1)
detected_by: Mapped[dict[str, Any]] = mapped_column(default=dict)
basis: Mapped[dict[str, Any] | None] = mapped_column(default=None)
before: Mapped[Any] = mapped_column(PortableJSON, nullable=True, default=None)
after: Mapped[Any] = mapped_column(PortableJSON, nullable=True, default=None)
affects: Mapped[dict[str, Any] | None] = mapped_column(default=None)
struct_version_before: Mapped[int | None] = mapped_column(Integer, default=None)
struct_version_after: Mapped[int | None] = mapped_column(Integer, default=None)
fingerprint: Mapped[str] = mapped_column(String(64))
status: Mapped[str] = mapped_column(String(20), default="open")
resolution: Mapped[dict[str, Any] | None] = mapped_column(default=None)
#: 记录的格式(agentlab-change/1):召回按它读,字段以后改要升版本
schema_tag: Mapped[str] = mapped_column("schema", String(30), default="agentlab-change/1")
__all__ = ["IntrospectJob", "IntrospectTask", "SourceChange", "SourceObject", "TableObservation"]