Files
AgentLab/backend/app/db/models_sources.py
T
ThearchyHelios 2e7d6103ea 应用库可移植到 PostgreSQL:AGENTLAB_DATABASE_URL 设成 postgresql+asyncpg://… 时整个应用库用 PostgreSQL,断点用官方的 langgraph-checkpoint-postgres(同一个库,AGENTLAB_CHECKPOINT_URL 可另给);不设时照旧是数据目录里的 SQLite,行为不变
- 配置:database_url、checkpoint_url(启动时校验写法,只认 PostgreSQL)、连接池四项;启动日志写用的是哪种库(主机、库名、用户,不写密码)
- 引擎(db/base.make_engine):SQLite 照旧 WAL、外键、忙等;PostgreSQL 用连接池、探活、会话时区 UTC,装上方言调整(db/postgres.py):建表时 String(n) 是不限长的 VARCHAR(SQLite 从不查长度,导过来的存量也可能超长)、ORDER BY 里可空列的 NULL 位置照 SQLite 补写(升序在前、降序在后,窗口函数里同样);Integer 列在 PostgreSQL 上是 BIGINT(列挂到表上时换成 with_variant,绑定参数跟着列按 BIGINT 转换);JSON 列两边都存原文(PostgreSQL 的 json,不用 JSONB:JSONB 重排对象里的键,快照的列序、表的先后读回来就变了);JSON 里的 NaN 写成 null、NUL 去掉(PostgreSQL 不收)
- 迁移按方言:已有的列和索引用 inspector 认;PostgreSQL 上补列的类型取模型里这一列的、缺省值取迁移表里的(布尔 0/1 换成 false/true),ADD COLUMN IF NOT EXISTS;去重语句里的布尔按方言写;部分唯一索引补 postgresql_where;init_db 在 PostgreSQL 上拿咨询锁(几个进程同时启动不撞)
- 应用库上的 SQLite 专用写法改成按方言编译的表达式(db/portable.py):json_extract 的十几处(口径召回、出具、对账、个人应用、幂等键、访问控制、数据源变化通知、剖析任务、待办超时)换成 json_eq / json_flag / json_text,json_type 换成 json_is_string(两种库同一套带类型的比较);能力登记的 sqlite insert 换成按会话方言的 insert_ignore;能力使用次数的 json_each 按方言分支;附件「没绑记录」的条件不靠 json 的等号;按报错文字认唯一约束的地方换成 unique_violation(按表和列认,两种库的报错都认得)
- 断点(engine/checkpoints.py):PostgreSQL 时开 psycopg 连接池、在咨询锁里 setup;runner 只认 BaseCheckpointSaver,读断点仍只走 saver 的公开接口
- 依赖:新 extra postgres(asyncpg、psycopg[binary] 3、psycopg-pool、langgraph-checkpoint-postgres 3.1.2);langgraph-checkpoint 卡在 <4.3(断点格式就在这个包里,postgres 包新版要 4.3,不动已装的 4.2.0);锁文件补上新包
- 运行收尾:记 action.unknown、安排核实做完才把运行摘掉(以前先摘掉,等「不在跑了」再读事件的一方在库慢时读不到这一条)
- 测试:AGENTLAB_TEST_DATABASE_URL 时跑在 PostgreSQL 上(每个 xdist 进程建自己的库、按事件循环分的连接池 tests/pgpool.py),不设时盖掉环境里的数据库地址照旧用 SQLite;scripts/test-postgres.sh 起临时的 postgres:16 容器跑全量;sqlite_only 标记;另开连接改库的几个竞态测试、触发器测试改成两种库都能跑(tests/appdb.py);新增 test_db_portable 守着方言调整、JSON 键序、超长字符串、大整数、NULL 排序、时区、PostgreSQL 老库迁移
2026-10-19 09:52:09 +08:00

236 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""数据源的对象索引、探查范围、后台探查任务(docs/design/next-round/03-数据接入与目录维护.md 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, 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")
#: 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"]