mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 09:31:59 +00:00
4e3623885b
# Conflicts: # backend/app/api/schedules.py # backend/app/main.py
1074 lines
58 KiB
Python
1074 lines
58 KiB
Python
"""数据源的对象索引、探查范围、表结构的物化与按需加入(docs/design/next-round/03-数据接入与目录维护.md A1、A2)。
|
||
|
||
几千张表的库以前用不了:探查结构每个源最多取 200 个对象,按数据库列出的顺序截断,多出来的表目录、look、剖析、SQL 检查都
|
||
看不到,还会被说成「表已不在」。现在分三层:
|
||
|
||
- **对象索引**(list_objects / refresh_index):每种方言一条字典查询,拿全库对象的名字、类型、注释、行数估算(SQLite 查
|
||
sqlite_master,MySQL 查 information_schema.TABLES,PostgreSQL 查 pg_class 加 obj_description,Oracle 查 ALL_OBJECTS 加
|
||
ALL_TAB_COMMENTS)。字典查询走数据库查询的同一个入口(engine.run_query:只读守卫、单条时限);查不了退回 Inspector 的
|
||
get_table_names / get_view_names,只是没有注释。最多收 INDEX_LIMIT 个对象(按名字排序取前面的),超了照实记 capped,
|
||
这时不判删除(没收进来的不等于不在了)。升级前同步探查过的源,对象索引第一次建起来时把老缓存里的表结构接进来
|
||
(legacy_seed):不然物化出来的会缩成这一次已经读完的那几张。
|
||
- **探查范围**(ScopeRules / plan_scope):数据源 options.introspect_scope 的规则——{mode: all|rules, include: [通配],
|
||
exclude: [通配], tables: [显式], removed: [显式移出], views: bool, max_tables}。按 names.name_key 不分大小写匹配,排除优先。
|
||
有数据目录的表、已发布模板写死的 SQL 用到的表一律在范围里(pinned),规则排不掉,只有显式移出才行。规则命中(含 pinned、
|
||
显式)超过上限时拒绝开始并说出命中了多少张,不悄悄截断。按需加入的表另算,总数不超过 SCOPE_MAX。
|
||
- **物化**(rebuild_schema_cache):schema_cache 由这一个函数从范围内已反射的行物化出来,形状不变(tables、total、
|
||
truncated、schema、synced_at),另加 scope 摘要(in_scope、cap、out_of_scope……)。total 是对象索引里的对象总数,truncated
|
||
的意思变成「有范围外的对象」:两百多处读 schema_cache 的地方不用改。
|
||
|
||
**按需加入**(add_to_scope):look 点名或挑中范围外的表、运行时 db_schema__ 点名范围外的表、目录页「加入范围」,都当场只读
|
||
反射这一张、落盘(scope_reason on_demand / explicit),记下谁、经哪个入口、哪一轮或哪次运行、需求原文。写 schema_cache 只走
|
||
rebuild_schema_cache,按源加进程内锁(source_lock):和后台探查任务、别的按需加入互斥。前提和回收锁、SourceLanes 一样是
|
||
单进程部署;多进程部署要换成库里的锁。
|
||
|
||
**三种「不在」**(absent_reasons):表已不在(对象索引里没有了,removed_at)、在探查范围外、在范围里却读不到结构,分开说。
|
||
只有第一种说「表已不在」。
|
||
|
||
只读:只发字典查询和反射,不写业务库。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import fnmatch
|
||
import hashlib
|
||
import json
|
||
import logging
|
||
import weakref
|
||
from collections.abc import Iterable, Mapping
|
||
from dataclasses import dataclass, field
|
||
from datetime import datetime, timezone
|
||
from typing import Any
|
||
|
||
from sqlalchemy import bindparam, delete, func, insert, or_, select, update
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.core.errors import describe_exception, first_line
|
||
from app.data.names import name_key
|
||
from app.db.base import SessionLocal, utcnow
|
||
from app.db.models_sources import SourceObject
|
||
from app.db.portable import contains_ci
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
#: options 里探查范围的键(由 PUT /api/datasources/{id}/introspect-scope 维护,数据源表单不显示、不提交它)
|
||
SCOPE_OPTION = "introspect_scope"
|
||
#: 对象索引最多收几个对象(目录-D3)
|
||
INDEX_LIMIT = 50_000
|
||
#: 探查范围缺省、最多几张(目录-D3):500 张 × 25 列约 1 MB 的 schema_cache,每次读数据源都整份加载
|
||
SCOPE_DEFAULT = 500
|
||
SCOPE_MAX = 2_000
|
||
#: 范围规则里每种最多几条、每条最长几个字;显式列出、显式移出的表最多几张
|
||
RULES_MAX = 100
|
||
RULE_CHARS = 200
|
||
#: 后台探查一批读几张:PostgreSQL 的批量反射一批一次往返,MySQL 每表一次往返;一批也是停止的最小单位
|
||
REFLECT_BATCH = 25
|
||
#: 每次「加入范围」最多几张(目录页手工加)
|
||
ADD_LIMIT = 20
|
||
|
||
# 按需加入的入口
|
||
VIA_LOOK = "look"
|
||
VIA_SCHEMA_TOOL = "db_schema"
|
||
VIA_CATALOG = "catalog"
|
||
|
||
# 三种「不在」(目录清单的 absent_reason、look 和 db_schema 的说法都按它),另有「从来没有过」和「说不清」
|
||
ABSENT_REMOVED = "removed"
|
||
ABSENT_OUT_OF_SCOPE = "out_of_scope"
|
||
ABSENT_REFLECT_FAILED = "reflect_failed"
|
||
#: 对象索引里从来没有过(名字写错了,或者是对象索引刷新之后才建的)
|
||
ABSENT_MISSING = "missing"
|
||
#: 升级前探查的源(还没有对象索引)、被截断的老缓存:说不清是哪一种
|
||
ABSENT_UNKNOWN = "unknown"
|
||
|
||
SCOPE_REASONS = ("pinned", "explicit", "rule", "on_demand")
|
||
|
||
|
||
# ==========================================================================
|
||
# 对象索引:每种方言一条字典查询
|
||
# ==========================================================================
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class IndexedObject:
|
||
"""对象索引里的一个对象:名字是 Inspector 规整过的写法(schema_cache 的键)。quote:Oracle 库里本来就是小写的名字
|
||
(SQLAlchemy 反射回来是 quoted_name(quote=True)),反射时要原样带引号。"""
|
||
|
||
name: str
|
||
kind: str = "table"
|
||
comment: str | None = None
|
||
est_rows: int | None = None
|
||
quote: bool = False
|
||
|
||
|
||
@dataclass
|
||
class IndexResult:
|
||
objects: list[IndexedObject]
|
||
#: dictionary 字典查询 | inspector 退回 Inspector(没有注释)
|
||
method: str
|
||
#: 数据库里一共几个(超过 INDEX_LIMIT 时 objects 只收了前面的)
|
||
total: int
|
||
capped: bool = False
|
||
#: 字典查询失败、退回 Inspector 时的原因(留痕;不挡探查)
|
||
note: str | None = None
|
||
|
||
|
||
def _literal(value: str) -> str:
|
||
return "'" + value.replace("'", "''") + "'"
|
||
|
||
|
||
def index_sql(kind: str | None, schema: str | None) -> str | None:
|
||
"""拿全库对象清单的那条字典查询:四列(名字、类型、注释、行数估算),按名字排序。schema 是要查的 schema(Oracle 已经
|
||
按数据字典的写法转好大写);为 None 时查连接的当前 schema。不认识的数据库类型返回 None(退回 Inspector)。"""
|
||
k = (kind or "").lower()
|
||
if k == "sqlite":
|
||
# SQLite 一个文件一个 main(写了 schema 就查那个库的 sqlite_master:写错了照实报「没有这个库」);临时表不进来;
|
||
# sqlite_ 开头的是内部表(Inspector 也排掉)
|
||
master = ('"' + schema.replace('"', '""') + '".sqlite_master') if schema else "sqlite_master"
|
||
return (f"SELECT name, type, NULL AS comment, NULL AS est_rows FROM {master} "
|
||
"WHERE type IN ('table', 'view') AND substr(name, 1, 7) <> 'sqlite_' ORDER BY name")
|
||
if k in ("mysql", "mariadb"):
|
||
where = _literal(schema) if schema else "DATABASE()"
|
||
return ("SELECT TABLE_NAME, TABLE_TYPE, TABLE_COMMENT, TABLE_ROWS FROM information_schema.TABLES "
|
||
f"WHERE TABLE_SCHEMA = {where} ORDER BY TABLE_NAME")
|
||
if k in ("postgres", "postgresql"):
|
||
where = _literal(schema) if schema else "current_schema()"
|
||
# r 普通表、p 分区表的主表、f 外部表、v 视图、m 物化视图;分区的子表不进(relispartition):几千个按月分区的子表
|
||
# 会把真正要找的表淹掉,查主表就能查到子表的数据
|
||
return ("SELECT c.relname, c.relkind, obj_description(c.oid, 'pg_class'), c.reltuples "
|
||
"FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace "
|
||
f"WHERE n.nspname = {where} AND c.relkind IN ('r', 'p', 'f', 'v', 'm') AND NOT c.relispartition "
|
||
"ORDER BY c.relname")
|
||
if k == "oracle":
|
||
owner = _literal(schema) if schema else "SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA')"
|
||
# generated = 'N' 排掉系统生成的对象(IOT 溢出段这类),BIN$ 是回收站里的表
|
||
return ("SELECT o.object_name, o.object_type, c.comments, t.num_rows FROM all_objects o "
|
||
"LEFT JOIN all_tab_comments c ON c.owner = o.owner AND c.table_name = o.object_name "
|
||
"LEFT JOIN all_tables t ON t.owner = o.owner AND t.table_name = o.object_name "
|
||
f"WHERE o.owner = {owner} AND o.object_type IN ('TABLE', 'VIEW') AND o.generated = 'N' "
|
||
"AND o.object_name NOT LIKE 'BIN$%' ORDER BY o.object_name")
|
||
return None
|
||
|
||
|
||
_VIEW_TYPES = frozenset({"view", "v", "m", "system view"})
|
||
|
||
|
||
def _object_kind(raw: Any) -> str:
|
||
return "view" if str(raw or "").strip().lower() in _VIEW_TYPES else "table"
|
||
|
||
|
||
def _est_rows(raw: Any) -> int | None:
|
||
"""数据字典的行数估算:≤0(PostgreSQL 没分析过的表是 -1)、读不出来都是不知道。"""
|
||
try:
|
||
n = int(float(str(raw)))
|
||
except (TypeError, ValueError):
|
||
return None
|
||
return n if n > 0 else None
|
||
|
||
|
||
def _comment(raw: Any, kind: str) -> str | None:
|
||
text = str(raw).strip() if raw is not None else ""
|
||
# MySQL 的视图在 TABLE_COMMENT 里写的是字面的「VIEW」
|
||
if not text or (kind == "view" and text.upper() == "VIEW"):
|
||
return None
|
||
return text
|
||
|
||
|
||
async def list_objects(source: Any, schema: str | None) -> IndexResult:
|
||
"""全库的对象清单(不落盘)。字典查询失败退回 Inspector;两条路都走不通抛出原来的异常(调用方照实记成探查失败)。"""
|
||
from app.data import engine as data_engine
|
||
from app.data.guard import QueryLimits
|
||
|
||
kind = (getattr(source, "kind", None) or "").lower()
|
||
engine = await data_engine.engines.get(source)
|
||
dialect = engine.dialect
|
||
owner = schema
|
||
if schema and kind == "oracle":
|
||
owner = dialect.denormalize_name(schema)
|
||
sql = index_sql(kind, owner)
|
||
note: str | None = None
|
||
if sql is not None:
|
||
limits = QueryLimits(max_rows=INDEX_LIMIT + 1, max_bytes=64 * 1024 * 1024,
|
||
timeout_seconds=data_engine.query_timeout(source))
|
||
try:
|
||
result = await data_engine.run_query(source, sql, limits=limits)
|
||
except Exception as e: # noqa: BLE001 - 字典查询不认(老版本、权限不够):退回 Inspector
|
||
note = first_line(e) or describe_exception(e)
|
||
logger.info("数据源 %s 的对象清单字典查询失败,退回 Inspector:%s", getattr(source, "name", "?"), note)
|
||
else:
|
||
objects: list[IndexedObject] = []
|
||
for row in result.rows:
|
||
if not row or row[0] is None:
|
||
continue
|
||
raw_name = str(row[0])
|
||
name: Any = dialect.normalize_name(raw_name) if kind == "oracle" else raw_name
|
||
kind_of = _object_kind(row[1] if len(row) > 1 else None)
|
||
objects.append(IndexedObject(
|
||
name=str(name), kind=kind_of, comment=_comment(row[2] if len(row) > 2 else None, kind_of),
|
||
est_rows=_est_rows(row[3] if len(row) > 3 else None), quote=_forced(name)))
|
||
capped = len(objects) > INDEX_LIMIT or bool(getattr(result, "truncated", False))
|
||
return IndexResult(objects=objects[:INDEX_LIMIT], method="dictionary",
|
||
total=len(objects), capped=capped)
|
||
|
||
from sqlalchemy import inspect as sa_inspect
|
||
|
||
def _names(sync_conn: Any) -> tuple[list[Any], list[Any]]:
|
||
inspector = sa_inspect(sync_conn)
|
||
return list(inspector.get_table_names(schema=schema)), list(inspector.get_view_names(schema=schema))
|
||
|
||
async with engine.connect() as conn:
|
||
tables, views = await conn.run_sync(_names)
|
||
merged = sorted([IndexedObject(str(n), "table", quote=_forced(n)) for n in tables]
|
||
+ [IndexedObject(str(n), "view", quote=_forced(n)) for n in views], key=lambda o: name_key(o.name))
|
||
return IndexResult(objects=merged[:INDEX_LIMIT], method="inspector", total=len(merged),
|
||
capped=len(merged) > INDEX_LIMIT, note=note)
|
||
|
||
|
||
def _forced(name: Any) -> bool:
|
||
return getattr(name, "quote", None) is True
|
||
|
||
|
||
# ==========================================================================
|
||
# 探查范围
|
||
# ==========================================================================
|
||
|
||
|
||
@dataclass
|
||
class ScopeRules:
|
||
"""数据源的探查范围规则(options.introspect_scope)。缺省 mode=all:全部对象(视图也算),上限 500 张。"""
|
||
|
||
mode: str = "all"
|
||
include: list[str] = field(default_factory=list)
|
||
exclude: list[str] = field(default_factory=list)
|
||
tables: list[str] = field(default_factory=list)
|
||
removed: list[str] = field(default_factory=list)
|
||
views: bool = True
|
||
max_tables: int = SCOPE_DEFAULT
|
||
updated_by: str | None = None
|
||
updated_at: str | None = None
|
||
|
||
@classmethod
|
||
def from_options(cls, options: Mapping[str, Any] | None) -> ScopeRules:
|
||
raw = (options or {}).get(SCOPE_OPTION)
|
||
return cls.from_dict(raw if isinstance(raw, Mapping) else {})
|
||
|
||
@classmethod
|
||
def from_dict(cls, raw: Mapping[str, Any]) -> ScopeRules:
|
||
"""存着的规则 → ScopeRules。不合规的项按缺省读(存进去之前已经核对过:这里只防手改过的库)。"""
|
||
def words(key: str) -> list[str]:
|
||
value = raw.get(key)
|
||
items = value if isinstance(value, list) else []
|
||
return list(dict.fromkeys(str(x).strip() for x in items if isinstance(x, (str, int)) and str(x).strip()))
|
||
|
||
cap = raw.get("max_tables")
|
||
cap = cap if isinstance(cap, int) and not isinstance(cap, bool) and 1 <= cap <= SCOPE_MAX else SCOPE_DEFAULT
|
||
return cls(mode="rules" if raw.get("mode") == "rules" else "all", include=words("include"),
|
||
exclude=words("exclude"), tables=words("tables"), removed=words("removed"),
|
||
views=raw.get("views") is not False, max_tables=cap,
|
||
updated_by=raw.get("updated_by") if isinstance(raw.get("updated_by"), str) else None,
|
||
updated_at=raw.get("updated_at") if isinstance(raw.get("updated_at"), str) else None)
|
||
|
||
def to_dict(self) -> dict[str, Any]:
|
||
out: dict[str, Any] = {"mode": self.mode, "include": list(self.include), "exclude": list(self.exclude),
|
||
"tables": list(self.tables), "removed": list(self.removed), "views": self.views,
|
||
"max_tables": self.max_tables}
|
||
if self.updated_by is not None:
|
||
out["updated_by"] = self.updated_by
|
||
if self.updated_at is not None:
|
||
out["updated_at"] = self.updated_at
|
||
return out
|
||
|
||
def rules_only(self) -> dict[str, Any]:
|
||
"""留痕用的规则本身(不含谁改的)。"""
|
||
return {k: v for k, v in self.to_dict().items() if k not in ("updated_by", "updated_at")}
|
||
|
||
def matches(self, name: str, kind: str) -> bool:
|
||
"""规则命中:mode=all 时全部(包含列表不看),rules 时命中包含里的一条;命中排除的一律不算(排除优先);
|
||
views=false 时视图不算。显式列出、pinned、按需加入不经这里。"""
|
||
if kind == "view" and not self.views:
|
||
return False
|
||
key = name_key(name)
|
||
if any(_glob(key, p) for p in self.exclude):
|
||
return False
|
||
if self.mode == "all":
|
||
return True
|
||
return any(_glob(key, p) for p in self.include)
|
||
|
||
|
||
def _glob(key: str, pattern: str) -> bool:
|
||
"""通配(* ? [...])按 name_key 比;写了 schema 前缀的(sales.ord_*)只看表名那一段。"""
|
||
pat = name_key(pattern.rsplit(".", 1)[-1])
|
||
return fnmatch.fnmatchcase(key, pat)
|
||
|
||
|
||
def rules_problem(raw: Any) -> str | None:
|
||
"""PUT /introspect-scope 交来的规则合不合规:不合规返回一句中文,合规返回 None。"""
|
||
if not isinstance(raw, Mapping):
|
||
return "探查范围要是一个对象:{mode, include, exclude, tables, removed, views, max_tables}"
|
||
if raw.get("mode", "all") not in ("all", "rules"):
|
||
return "探查范围的方式只能是「全部对象」(all)或「按规则」(rules)"
|
||
for key, label, limit in (("include", "包含的规则", RULES_MAX), ("exclude", "排除的规则", RULES_MAX),
|
||
("tables", "显式列出的表", SCOPE_MAX), ("removed", "显式移出的表", SCOPE_MAX)):
|
||
value = raw.get(key, [])
|
||
if not isinstance(value, list) or not all(isinstance(x, str) for x in value):
|
||
return f"{label}要是一组文字"
|
||
if len(value) > limit:
|
||
return f"{label}最多 {limit} 条,当前 {len(value)} 条"
|
||
if any(len(x) > RULE_CHARS for x in value):
|
||
return f"{label}每条最长 {RULE_CHARS} 个字"
|
||
cap = raw.get("max_tables", SCOPE_DEFAULT)
|
||
if not isinstance(cap, int) or isinstance(cap, bool) or not 1 <= cap <= SCOPE_MAX:
|
||
return f"探查范围的上限要在 1 到 {SCOPE_MAX:,} 张之间"
|
||
if "views" in raw and not isinstance(raw["views"], bool):
|
||
return "是否包含视图要是 true 或 false"
|
||
return None
|
||
|
||
|
||
@dataclass
|
||
class ScopePlan:
|
||
"""按规则圈出的范围。in_scope:{名字: 原因},按名字排序;counts 各原因几张;capped_count 是计上限的那部分(规则、
|
||
pinned、显式;按需加入另算)。"""
|
||
|
||
in_scope: dict[str, str]
|
||
counts: dict[str, int]
|
||
cap: int
|
||
index_total: int
|
||
out_of_scope: int
|
||
|
||
@property
|
||
def capped_count(self) -> int:
|
||
return sum(n for r, n in self.counts.items() if r != "on_demand")
|
||
|
||
@property
|
||
def over(self) -> bool:
|
||
return self.capped_count > self.cap or len(self.in_scope) > SCOPE_MAX
|
||
|
||
def summary(self, rules: ScopeRules | None = None) -> dict[str, Any]:
|
||
out: dict[str, Any] = {"in_scope": len(self.in_scope), "hits": self.capped_count, "cap": self.cap,
|
||
"out_of_scope": self.out_of_scope, "index_total": self.index_total,
|
||
**{r: self.counts.get(r, 0) for r in SCOPE_REASONS}}
|
||
if rules is not None:
|
||
out["rules"] = rules.rules_only()
|
||
return out
|
||
|
||
|
||
def plan_scope(objects: Iterable[tuple[str, str]], rules: ScopeRules, *, pinned: set[str],
|
||
on_demand: set[str]) -> ScopePlan:
|
||
"""objects 是对象索引里还在的对象 (名字, 类型);pinned、on_demand 是 name_key 的集合。原因的先后:显式移出 > pinned >
|
||
显式列出 > 规则 > 按需加入。"""
|
||
removed = {name_key(n.rsplit(".", 1)[-1]) for n in rules.removed}
|
||
explicit = {name_key(n.rsplit(".", 1)[-1]) for n in rules.tables}
|
||
picked: dict[str, str] = {}
|
||
total = 0
|
||
for name, kind in objects:
|
||
total += 1
|
||
key = name_key(name)
|
||
if key in removed:
|
||
continue
|
||
if key in pinned:
|
||
picked[name] = "pinned"
|
||
elif key in explicit:
|
||
picked[name] = "explicit"
|
||
elif rules.matches(name, kind):
|
||
picked[name] = "rule"
|
||
elif key in on_demand:
|
||
picked[name] = "on_demand"
|
||
counts = {r: 0 for r in SCOPE_REASONS}
|
||
for reason in picked.values():
|
||
counts[reason] += 1
|
||
ordered = dict(sorted(picked.items(), key=lambda kv: name_key(kv[0])))
|
||
return ScopePlan(in_scope=ordered, counts=counts, cap=rules.max_tables, index_total=total,
|
||
out_of_scope=total - len(ordered))
|
||
|
||
|
||
def too_large_message(plan: ScopePlan) -> str:
|
||
"""规则命中超过上限时的整句:命中多少张、其中一律在范围里的几张、上限、怎么办。"""
|
||
pinned = plan.counts.get("pinned", 0)
|
||
held = f"(其中有数据目录或被已发布模板用到、一律在范围内的 {pinned:,} 张)" if pinned else ""
|
||
if len(plan.in_scope) > SCOPE_MAX and plan.capped_count <= plan.cap:
|
||
return (f"探查范围里已有 {len(plan.in_scope):,} 张表(含按需加入的 {plan.counts.get('on_demand', 0):,} 张),"
|
||
f"超过最多 {SCOPE_MAX:,} 张。请在「探查范围」里移出一些按需加入的表,再探查")
|
||
return (f"按探查范围的规则命中 {plan.capped_count:,} 张表{held},超过上限 {plan.cap:,} 张,没有开始探查。"
|
||
f"请在「探查范围」里收窄规则(加包含、排除的通配),或者把上限调高(最多 {SCOPE_MAX:,} 张)")
|
||
|
||
|
||
# ==========================================================================
|
||
# 数据源的 schema 与锁
|
||
# ==========================================================================
|
||
|
||
|
||
def index_schema(row: Any) -> str:
|
||
"""对象索引按哪个 schema 记:数据源配置的 schema(一个数据源一个 schema,目录-D2),没配是 ""(连接的默认 schema)。"""
|
||
return str((getattr(row, "options", None) or {}).get("schema") or "").strip()
|
||
|
||
|
||
def target_schema(row: Any) -> str | None:
|
||
"""反射、字典查询用的 schema:配置了就是它,没配是 None(当前用户的默认 schema)。"""
|
||
return index_schema(row) or None
|
||
|
||
|
||
def source_lock(source_id: str) -> Any:
|
||
"""写一个源的对象索引和 schema_cache 时持的锁:后台探查的开头和收尾、按需加入、加入范围都持它。跨进程的锁
|
||
(app/cluster/mutex:本进程按事件循环各一把 asyncio 锁,PostgreSQL 时另取咨询锁):多进程时按需探查、加入范围也是
|
||
一个接一个,schema_cache、结构版本不会被两个进程各写一半。"""
|
||
from app.cluster import mutex
|
||
|
||
return mutex.lock(f"source-objects:{source_id}")
|
||
|
||
|
||
def _scope_filter(source_id: str, schema: str) -> tuple[Any, ...]:
|
||
return (SourceObject.source_id == source_id, SourceObject.schema_name == schema)
|
||
|
||
|
||
async def has_index(session: AsyncSession, source_id: str, schema: str) -> bool:
|
||
"""这个源在这个 schema 下有没有对象索引(升级前探查的源、上传表格都没有)。"""
|
||
return (await session.execute(
|
||
select(SourceObject.id).where(*_scope_filter(source_id, schema)).limit(1))).first() is not None
|
||
|
||
|
||
# ==========================================================================
|
||
# 对象索引落盘
|
||
# ==========================================================================
|
||
|
||
|
||
@dataclass
|
||
class RefreshStats:
|
||
total: int
|
||
added: int
|
||
removed: int
|
||
method: str
|
||
capped: bool
|
||
note: str | None = None
|
||
#: 第一次建对象索引时从升级前的老缓存接进来几张(legacy_seed)
|
||
adopted: int = 0
|
||
#: 这次新出现的、不在了(真删除)的对象名(结构变化按它记 table_added / table_removed,next-round 03 B1);
|
||
#: first_build:这个 schema 的对象索引是这一次才建起来的——全是新的,只建基线、不记「新出现的表」
|
||
added_names: list[str] = field(default_factory=list)
|
||
removed_names: list[str] = field(default_factory=list)
|
||
first_build: bool = False
|
||
|
||
|
||
def _chunks(items: list[Any], size: int = 500) -> Iterable[list[Any]]:
|
||
for i in range(0, len(items), size):
|
||
yield items[i:i + size]
|
||
|
||
|
||
def legacy_seed(cache: Mapping[str, Any] | None, schema: str,
|
||
names: Iterable[str]) -> dict[str, dict[str, Any]]:
|
||
"""升级前同步探查写的老缓存里、对象索引第一次建起来时要接进来的表:{对象索引里的名字: 这一行要带上的列}。
|
||
|
||
为什么要接:schema_cache 只从对象索引物化(rebuild_schema_cache),老缓存里的表结构却从来没进过对象索引。不接的话,
|
||
升级后第一次探查只要中途停止、连接断开、服务重启,或者排队时有人按需加入、探查失败要记一笔,物化出来的就只剩已经读完
|
||
的那几张:老缓存里还没重读的表静默消失,助手和问数据看到的表突然变少。接进来以后它们就是那时的探查范围(老缓存是按
|
||
「前 200 个对象」探的):在范围里、算规则命中,带着老结构和它的指纹;结构版本记 0(还没按对象索引读过),第一次读到
|
||
记 1,不把反射格式的差异当成一次结构变化。老缓存里读不到结构的(skipped)照样记成范围里读不到结构。第一次探查任务
|
||
开始执行时照这一次的规则重圈(_IntrospectRun._apply),规则外的照常移出;没重读到的沿用老结构,和以后每一次探查中途
|
||
停下时一样。
|
||
|
||
只认老缓存:带 scope 摘要的是从对象索引物化的,不再接;缓存的 schema 和这一次的对不上(以前真探查能临时换 schema)
|
||
也不接,免得把别的 schema 的结构当成这一个的。按名字对,对不上的按 name_key 对(只认唯一的);对象索引里没有的不接:
|
||
库里已经没有这张表了。"""
|
||
if not isinstance(cache, Mapping) or "scope" in cache:
|
||
return {}
|
||
if str(cache.get("schema") or "").strip() != schema:
|
||
return {}
|
||
tables = cache.get("tables") if isinstance(cache.get("tables"), Mapping) else {}
|
||
skipped = cache.get("skipped") if isinstance(cache.get("skipped"), Mapping) else {}
|
||
if not tables and not skipped:
|
||
return {}
|
||
old: dict[str, tuple[str, Any]] = {str(n): ("meta", m) for n, m in tables.items() if isinstance(m, dict) and m}
|
||
for n, why in skipped.items():
|
||
old.setdefault(str(n), ("error", why))
|
||
by_key: dict[str, list[str]] = {}
|
||
for n in old:
|
||
by_key.setdefault(name_key(n), []).append(n)
|
||
out: dict[str, dict[str, Any]] = {}
|
||
for name in names:
|
||
hit = name if name in old else None
|
||
if hit is None and len(by_key.get(name_key(name), [])) == 1:
|
||
hit = by_key[name_key(name)][0]
|
||
if hit is None:
|
||
continue
|
||
what, value = old[hit]
|
||
seed: dict[str, Any] = {"in_scope": True, "scope_reason": "rule"}
|
||
if what == "meta":
|
||
seed.update(meta=value, fingerprint=fingerprint(value))
|
||
else:
|
||
seed["reflect_error"] = str(value or "") or "读不到列"
|
||
out[name] = seed
|
||
return out
|
||
|
||
|
||
async def refresh_index(session: AsyncSession, source_id: str, schema: str, result: IndexResult, *,
|
||
legacy: Mapping[str, Any] | None = None) -> RefreshStats:
|
||
"""把字典查询的结果写进对象索引(不提交):新出现的对象加一行;还在的更新类型、注释、行数估算、last_seen_at(删了又
|
||
出现的清掉 removed_at);不在了的记 removed_at。索引被截断(capped)时不判删除:没收进来的不等于不在了。
|
||
|
||
legacy 是数据源此刻的 schema_cache:这个 schema 的对象索引第一次建起来时,老缓存里的表结构接进对应的行(legacy_seed)。"""
|
||
now = utcnow()
|
||
existing = {r.name: r for r in (await session.execute(
|
||
select(SourceObject.id, SourceObject.name, SourceObject.removed_at, SourceObject.kind, SourceObject.comment,
|
||
SourceObject.est_rows).where(*_scope_filter(source_id, schema)))).all()}
|
||
seeds = legacy_seed(legacy, schema, (o.name for o in result.objects)) if not existing else {}
|
||
seen: set[str] = set()
|
||
new_rows: list[dict[str, Any]] = []
|
||
changed: list[dict[str, Any]] = []
|
||
revived: list[str] = []
|
||
for obj in result.objects:
|
||
if obj.name in seen:
|
||
continue
|
||
seen.add(obj.name)
|
||
old = existing.get(obj.name)
|
||
if old is None:
|
||
new_rows.append({"source_id": source_id, "schema_name": schema, "name": obj.name,
|
||
"name_key": name_key(obj.name), "kind": obj.kind, "comment": obj.comment,
|
||
"est_rows": obj.est_rows, "quote_name": obj.quote, "first_seen_at": now,
|
||
"last_seen_at": now, **seeds.get(obj.name, {})})
|
||
continue
|
||
if old.removed_at is not None:
|
||
revived.append(old.id)
|
||
if (old.kind, old.comment, old.est_rows) != (obj.kind, obj.comment, obj.est_rows):
|
||
changed.append({"_id": old.id, "kind": obj.kind, "comment": obj.comment, "est_rows": obj.est_rows})
|
||
_SO = SourceObject
|
||
for chunk in _chunks(new_rows):
|
||
await session.execute(insert(_SO), chunk)
|
||
if changed:
|
||
# 按表(不按 ORM 实体)的 executemany:ORM 的按主键批量更新要求参数里写主键列的名字
|
||
table = SourceObject.__table__
|
||
stmt = (update(table).where(table.c.id == bindparam("_id"))
|
||
.values(kind=bindparam("kind"), comment=bindparam("comment"), est_rows=bindparam("est_rows")))
|
||
conn = await session.connection()
|
||
for chunk in _chunks(changed):
|
||
await conn.execute(stmt, chunk)
|
||
still = [r.id for name, r in existing.items() if name in seen]
|
||
for chunk in _chunks(still):
|
||
await session.execute(update(_SO).where(_SO.id.in_(chunk)).values(last_seen_at=now)
|
||
.execution_options(synchronize_session=False))
|
||
for chunk in _chunks(revived):
|
||
await session.execute(update(_SO).where(_SO.id.in_(chunk)).values(removed_at=None)
|
||
.execution_options(synchronize_session=False))
|
||
gone: list[str] = []
|
||
gone_names: list[str] = []
|
||
if not result.capped:
|
||
gone_names = [name for name, r in existing.items() if name not in seen and r.removed_at is None]
|
||
gone = [existing[name].id for name in gone_names]
|
||
for chunk in _chunks(gone):
|
||
# 真删除:不在范围里了,结构(meta)留着——结构差异(data/schema_diff)要拿它和以前比、算影响面
|
||
await session.execute(update(_SO).where(_SO.id.in_(chunk)).values(removed_at=now, in_scope=False)
|
||
.execution_options(synchronize_session=False))
|
||
return RefreshStats(total=len(seen), added=len(new_rows), removed=len(gone), method=result.method,
|
||
capped=result.capped, note=result.note, adopted=len(seeds),
|
||
added_names=[r["name"] for r in new_rows] if existing else [], removed_names=gone_names,
|
||
first_build=not existing)
|
||
|
||
|
||
async def live_objects(session: AsyncSession, source_id: str, schema: str) -> list[tuple[str, str]]:
|
||
"""对象索引里还在的对象 (名字, 类型),按名字。"""
|
||
return [(r.name, r.kind) for r in (await session.execute(
|
||
select(SourceObject.name, SourceObject.kind).where(*_scope_filter(source_id, schema),
|
||
SourceObject.removed_at.is_(None))
|
||
.order_by(SourceObject.name_key))).all()]
|
||
|
||
|
||
async def pinned_keys(session: AsyncSession, row: Any, names: Iterable[str]) -> set[str]:
|
||
"""一律在范围里的表(name_key):有数据目录的表,加上已发布模板写死的 SQL 用到的这个源的表(catalog_impact._published
|
||
+ graph_sql_tables,和影响面同一份)。图解析不了的版本跳过。"""
|
||
from app.data.catalog_impact import _published, graph_sql_tables
|
||
from app.db.models import CatalogNote
|
||
from app.engine.schema import GraphSpec
|
||
|
||
keys = {name_key(t) for t in (await session.execute(
|
||
select(CatalogNote.table_name).where(CatalogNote.source_id == row.id))).scalars()}
|
||
known = list(names)
|
||
for _workflow, version in await _published(session):
|
||
try:
|
||
spec = GraphSpec.model_validate(version.graph or {})
|
||
except Exception: # noqa: BLE001 - 解析不了的版本发起不了正式运行
|
||
continue
|
||
hits = graph_sql_tables(spec, {row.name: known}).get(row.name)
|
||
if hits:
|
||
keys |= {name_key(t) for t in hits}
|
||
return keys
|
||
|
||
|
||
async def on_demand_keys(session: AsyncSession, source_id: str, schema: str) -> set[str]:
|
||
return {name_key(n) for n in (await session.execute(
|
||
select(SourceObject.name).where(*_scope_filter(source_id, schema), SourceObject.scope_reason == "on_demand",
|
||
SourceObject.in_scope.is_(True)))).scalars()}
|
||
|
||
|
||
async def plan_for(session: AsyncSession, row: Any, rules: ScopeRules | None = None) -> ScopePlan:
|
||
"""按规则(不给是数据源存着的)和这一刻的对象索引圈范围。"""
|
||
schema = index_schema(row)
|
||
rules = rules or ScopeRules.from_options(getattr(row, "options", None))
|
||
objects = await live_objects(session, row.id, schema)
|
||
return plan_scope(objects, rules, pinned=await pinned_keys(session, row, [n for n, _ in objects]),
|
||
on_demand=await on_demand_keys(session, row.id, schema))
|
||
|
||
|
||
async def apply_scope(session: AsyncSession, source_id: str, schema: str, plan: ScopePlan) -> None:
|
||
"""把圈好的范围写进对象索引(不提交):离开范围的清掉结构(指纹和版本留着),范围内的按原因记。"""
|
||
current = {r.name: r for r in (await session.execute(
|
||
select(SourceObject.id, SourceObject.name, SourceObject.scope_reason).where(
|
||
*_scope_filter(source_id, schema), SourceObject.in_scope.is_(True)))).all()}
|
||
leaving = [r.id for name, r in current.items() if name not in plan.in_scope]
|
||
for chunk in _chunks(leaving):
|
||
await session.execute(update(SourceObject).where(SourceObject.id.in_(chunk))
|
||
.values(in_scope=False, scope_reason=None, meta=None, reflect_error=None)
|
||
.execution_options(synchronize_session=False))
|
||
by_reason: dict[str, list[str]] = {}
|
||
for name, reason in plan.in_scope.items():
|
||
old = current.get(name)
|
||
if old is None or old.scope_reason != reason:
|
||
by_reason.setdefault(reason, []).append(name)
|
||
for reason, names in by_reason.items():
|
||
for chunk in _chunks(names):
|
||
await session.execute(update(SourceObject).where(*_scope_filter(source_id, schema),
|
||
SourceObject.name.in_(chunk))
|
||
.values(in_scope=True, scope_reason=reason)
|
||
.execution_options(synchronize_session=False))
|
||
|
||
|
||
# ==========================================================================
|
||
# 反射与结构版本
|
||
# ==========================================================================
|
||
|
||
|
||
def fingerprint(meta: Mapping[str, Any]) -> str:
|
||
"""表结构的指纹:反射到的整份结构的规范 JSON(键排序)的 sha256。"""
|
||
text = json.dumps(meta, sort_keys=True, ensure_ascii=False, separators=(",", ":"), default=str)
|
||
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||
|
||
|
||
@dataclass
|
||
class Reflected:
|
||
tables: dict[str, dict[str, Any]]
|
||
skipped: dict[str, str]
|
||
|
||
|
||
async def reflect(source: Any, names: list[tuple[str, bool]], *, views: set[str],
|
||
timeout: float | None = None) -> Reflected:
|
||
"""只读反射这几个对象(名字, 要不要强制引号)。连接本身出错抛出原来的异常;单个对象读不到的进 skipped。"""
|
||
from sqlalchemy.sql.elements import quoted_name
|
||
|
||
from app.data import engine as data_engine
|
||
from app.data import introspect
|
||
|
||
engine = await data_engine.engines.get(source)
|
||
target = target_schema(source)
|
||
picked = [quoted_name(n, True) if quote else n for n, quote in names]
|
||
|
||
def _go(sync_conn: Any) -> dict[str, Any]:
|
||
return introspect.reflect_names(sync_conn, target, picked, views)
|
||
|
||
async def _run() -> dict[str, Any]:
|
||
async with engine.connect() as conn:
|
||
return await conn.run_sync(_go)
|
||
|
||
got = await (asyncio.wait_for(_run(), timeout=timeout) if timeout else _run())
|
||
return Reflected(tables={str(k): v for k, v in got["tables"].items()},
|
||
skipped={str(k): v for k, v in got["skipped"].items()})
|
||
|
||
|
||
async def save_reflected(session: AsyncSession, source_id: str, schema: str, got: Reflected) -> dict[str, int]:
|
||
"""反射结果写进对象索引的行(不提交):读到的换上新结构,指纹变了结构版本加 1(第一次读到是 1);读不到的记原因,
|
||
结构留着上一次读到的。返回 {名字: 结构版本}。"""
|
||
names = list(got.tables) + list(got.skipped)
|
||
if not names:
|
||
return {}
|
||
rows = {r.name: r for r in (await session.execute(
|
||
select(SourceObject.id, SourceObject.name, SourceObject.fingerprint, SourceObject.struct_version).where(
|
||
*_scope_filter(source_id, schema), SourceObject.name.in_(names)))).all()}
|
||
now = utcnow()
|
||
versions: dict[str, int] = {}
|
||
for name, meta in got.tables.items():
|
||
row = rows.get(name)
|
||
if row is None:
|
||
continue
|
||
fp = fingerprint(meta)
|
||
version = row.struct_version or 0
|
||
if fp != row.fingerprint or not version:
|
||
version += 1
|
||
versions[name] = version
|
||
await session.execute(update(SourceObject).where(SourceObject.id == row.id).values(
|
||
meta=meta, fingerprint=fp, struct_version=version, reflect_error=None, reflected_at=now)
|
||
.execution_options(synchronize_session=False))
|
||
for name, why in got.skipped.items():
|
||
row = rows.get(name)
|
||
if row is None:
|
||
continue
|
||
versions[name] = row.struct_version or 0
|
||
await session.execute(update(SourceObject).where(SourceObject.id == row.id).values(reflect_error=why)
|
||
.execution_options(synchronize_session=False))
|
||
return versions
|
||
|
||
|
||
# ==========================================================================
|
||
# 物化 schema_cache
|
||
# ==========================================================================
|
||
|
||
#: schema_cache 里由探查失败那一刻写的键:下一次物化时(探查成功)清掉,按需加入时原样留着
|
||
_FAILURE_KEYS = ("failed", "error", "available_schemas")
|
||
|
||
|
||
async def rebuild_schema_cache(session: AsyncSession, row: Any, *, failure: Mapping[str, Any] | None = None,
|
||
keep_failure: bool = False, synced: bool = False,
|
||
index: RefreshStats | None = None,
|
||
candidates: list[str] | None = None) -> dict[str, Any]:
|
||
"""从范围内已反射的行物化 schema_cache(唯一写它的地方),写进 row(不提交),返回新的缓存。
|
||
|
||
形状和以前一样:tables(表在前、视图在后,各按名字)、truncated(有范围外的对象)、total(对象索引里的对象总数)、
|
||
schema、synced_at;范围里却读不到结构的进 skipped(同以前的「跳过的对象」);另加 scope 摘要。failure 给了的记上
|
||
探查失败(failed、error、available_schemas,表结构照旧留着:助手还能按上一次读到的写 SQL);keep_failure 时沿用缓存里
|
||
原有的失败标记(按需加入不改它)。synced:这次是一次探查完成(synced_at 换成现在),否则沿用原来的。index:这次刷新
|
||
对象索引的结果(字典查询还是 Inspector、有没有被截断),不给沿用缓存里原来记的。candidates:对象索引一个对象都没有时
|
||
这台服务器上能选的 schema(建任务时拿的,这里不连库),写进 available_schemas——schema 填错多半是这样,不是失败,
|
||
failed 不置。"""
|
||
from app.data.source_objects import ScopeRules as _Rules
|
||
|
||
schema = index_schema(row)
|
||
rows = (await session.execute(
|
||
select(SourceObject.name, SourceObject.kind, SourceObject.in_scope, SourceObject.scope_reason,
|
||
SourceObject.meta, SourceObject.reflect_error, SourceObject.removed_at)
|
||
.where(*_scope_filter(row.id, schema)).order_by(SourceObject.name_key))).all()
|
||
old = dict(getattr(row, "schema_cache", None) or {})
|
||
tables: dict[str, Any] = {}
|
||
views: dict[str, Any] = {}
|
||
skipped: dict[str, str] = {}
|
||
counts = {r: 0 for r in SCOPE_REASONS}
|
||
total = in_scope = failed = 0
|
||
for r in rows:
|
||
if r.removed_at is not None:
|
||
continue
|
||
total += 1
|
||
if not r.in_scope:
|
||
continue
|
||
in_scope += 1
|
||
if r.scope_reason in counts:
|
||
counts[r.scope_reason] += 1
|
||
if isinstance(r.meta, dict) and r.meta:
|
||
(views if r.kind == "view" else tables)[r.name] = r.meta
|
||
if r.reflect_error and not (isinstance(r.meta, dict) and r.meta):
|
||
skipped[r.name] = r.reflect_error
|
||
failed += 1
|
||
rules = _Rules.from_options(getattr(row, "options", None))
|
||
old_scope = old.get("scope") if isinstance(old.get("scope"), dict) else {}
|
||
index_info = ({"index_method": index.method, "index_capped": index.capped, "index_at": utcnow().isoformat()}
|
||
if index is not None else {k: old_scope[k] for k in ("index_method", "index_capped", "index_at")
|
||
if k in old_scope})
|
||
cache: dict[str, Any] = {
|
||
"tables": {**tables, **views},
|
||
"truncated": total > in_scope,
|
||
"total": total,
|
||
"schema": target_schema(row),
|
||
"synced_at": datetime.now(timezone.utc).isoformat() if synced or not old.get("synced_at")
|
||
else old.get("synced_at"),
|
||
"scope": {"in_scope": in_scope, "reflected": len(tables) + len(views), "failed": failed,
|
||
"out_of_scope": total - in_scope, "cap": rules.max_tables, "index_total": total, **counts,
|
||
**index_info},
|
||
}
|
||
if skipped:
|
||
cache["skipped"] = skipped
|
||
if candidates and not total:
|
||
cache["available_schemas"] = list(candidates)
|
||
if failure:
|
||
cache.update({k: failure[k] for k in _FAILURE_KEYS if k in failure})
|
||
elif keep_failure:
|
||
cache.update({k: old[k] for k in _FAILURE_KEYS if k in old})
|
||
row.schema_cache = cache
|
||
return cache
|
||
|
||
|
||
# ==========================================================================
|
||
# 三种「不在」、对象索引里的查找
|
||
# ==========================================================================
|
||
|
||
|
||
async def absent_reasons(session: AsyncSession, row: Any, names: Iterable[str]) -> dict[str, str]:
|
||
"""一批不在 schema_cache 里的表名 → 为什么不在(removed / missing / out_of_scope / reflect_failed / unknown)。
|
||
|
||
没有对象索引的源(升级前探查的):缓存的跳过清单里有的是读不到结构;老缓存截断过的说不清(unknown);没截断的就是
|
||
表已不在。有对象索引的:记了 removed_at 的是表已不在(真删除);索引里从来没有的是 missing(索引被截断过的说不清);
|
||
不在范围里的是范围外;在范围里的是读不到结构。"""
|
||
wanted = list(dict.fromkeys(str(n) for n in names))
|
||
if not wanted:
|
||
return {}
|
||
cache = getattr(row, "schema_cache", None) or {}
|
||
schema = index_schema(row)
|
||
if not await has_index(session, row.id, schema):
|
||
skipped = cache.get("skipped") or {}
|
||
truncated = bool(cache.get("truncated"))
|
||
return {n: ABSENT_REFLECT_FAILED if n in skipped else ABSENT_UNKNOWN if truncated else ABSENT_REMOVED
|
||
for n in wanted}
|
||
keys = {name_key(n.rsplit(".", 1)[-1]): n for n in wanted}
|
||
found = {r.name_key: r for r in (await session.execute(
|
||
select(SourceObject.name_key, SourceObject.in_scope, SourceObject.removed_at, SourceObject.meta,
|
||
SourceObject.reflect_error)
|
||
.where(*_scope_filter(row.id, schema), SourceObject.name_key.in_(list(keys))))).all()}
|
||
capped = bool((cache.get("scope") or {}).get("index_capped"))
|
||
out: dict[str, str] = {}
|
||
for key, name in keys.items():
|
||
r = found.get(key)
|
||
if r is None:
|
||
out[name] = ABSENT_UNKNOWN if capped else ABSENT_MISSING
|
||
elif r.removed_at is not None:
|
||
out[name] = ABSENT_REMOVED
|
||
elif not r.in_scope:
|
||
out[name] = ABSENT_OUT_OF_SCOPE
|
||
else:
|
||
out[name] = ABSENT_REFLECT_FAILED
|
||
return out
|
||
|
||
|
||
#: 表结构里没有的表给人看的一句(接口的 404、批量审阅逐条的错误):和界面的 CATALOG_ABSENT_TEXT、/schema 的说法
|
||
#: (schema_view.absent_detail,给模型看)同一套「不在」。{table} 是点名的表
|
||
ABSENT_MESSAGE = {
|
||
ABSENT_REMOVED: "数据源中已经没有表 {table}(已删除或改名)",
|
||
ABSENT_MISSING: "数据源中没有表 {table}(全库的对象清单里也没有)",
|
||
ABSENT_OUT_OF_SCOPE: ("表 {table} 在探查范围外:对象索引里有它,但没有读取结构。在数据源卡片的「探查范围」里加入它,"
|
||
"或在数据目录的搜索框搜索它并「加入范围」"),
|
||
ABSENT_REFLECT_FAILED: "表 {table} 在探查范围内,但上次探查读不到它的结构(权限不够或对象有问题),修好后重新探查即可",
|
||
ABSENT_UNKNOWN: ("表 {table} 不在已探查的表里:升级前的探查只取了一部分表,无法判断它是否还在,请回到数据源卡片重新"
|
||
"「探查结构」"),
|
||
}
|
||
#: 说不出是哪一种「不在」时(上传表格、记录表按版本走;没有对象索引、又没截断过的老缓存认不出名字是不是从来就没有)
|
||
ABSENT_PLAIN = "数据源中没有表 {table}"
|
||
|
||
|
||
async def absent_message(session: AsyncSession, row: Any, table: str) -> str:
|
||
"""点名的表不在表结构里时给人看的一句(ABSENT_MESSAGE)。手工接入的源按 absent_reasons 说是哪一种;上传表格、记录表
|
||
的结构随版本走,只说没有(目录清单把它们记成 removed,是说「那一版里没有」,点名一个从来没有过的名字时说不得「已删除」);
|
||
没有对象索引的老缓存同理:absent_reasons 对没截断的一律记 removed,名字写错了也会这样,只说没有。"""
|
||
if (getattr(row, "origin", None) or "manual") != "manual":
|
||
return ABSENT_PLAIN.format(table=table)
|
||
reason = (await absent_reasons(session, row, [table])).get(table)
|
||
if reason == ABSENT_REMOVED and not await has_index(session, row.id, index_schema(row)):
|
||
reason = None
|
||
return ABSENT_MESSAGE.get(reason or "", ABSENT_PLAIN).format(table=table)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class OutsideObject:
|
||
"""对象索引里在、范围外的一个对象(助手检索、目录搜索用)。"""
|
||
|
||
name: str
|
||
kind: str
|
||
comment: str | None
|
||
est_rows: int | None = None
|
||
|
||
|
||
async def outside_objects(session: AsyncSession, row: Any) -> list[OutsideObject]:
|
||
"""范围外、还在的对象,按名字。没有对象索引是空列表。"""
|
||
return [OutsideObject(r.name, r.kind, r.comment, r.est_rows) for r in (await session.execute(
|
||
select(SourceObject.name, SourceObject.kind, SourceObject.comment, SourceObject.est_rows)
|
||
.where(*_scope_filter(row.id, index_schema(row)), SourceObject.removed_at.is_(None),
|
||
SourceObject.in_scope.is_(False)).order_by(SourceObject.name_key))).all()]
|
||
|
||
|
||
async def outside_names(session: AsyncSession, row: Any) -> frozenset[str]:
|
||
"""范围外、还在的对象的 name_key(剖析分清「范围外」和「表结构里没有」、SQL 检查列「未按目录检查的表」用)。"""
|
||
return frozenset(r for r in (await session.execute(
|
||
select(SourceObject.name_key).where(*_scope_filter(row.id, index_schema(row)),
|
||
SourceObject.removed_at.is_(None),
|
||
SourceObject.in_scope.is_(False)))).scalars())
|
||
|
||
|
||
async def search_index(session: AsyncSession, row: Any, query: str, *, limit: int = 50) -> tuple[list[dict[str, Any]], int]:
|
||
"""按名字、注释在对象索引里找(不分大小写的包含),名字完全相同的在前、再是开头相同的、再是名字里有的、注释里有的。
|
||
返回 (前 limit 个 {name, kind, comment, est_rows, in_scope, scope_reason, absent_reason}, 一共命中几个)。"""
|
||
text = query.strip()
|
||
if not text:
|
||
return [], 0
|
||
key = name_key(text.rsplit(".", 1)[-1])
|
||
filt = (*_scope_filter(row.id, index_schema(row)), SourceObject.removed_at.is_(None),
|
||
# 名字比的是 name_key(ASCII 已转小写);注释要不分大小写(db/portable.contains_ci:两种库都折 Unicode 的大小写)
|
||
or_(SourceObject.name_key.contains(key, autoescape=True), contains_ci(SourceObject.comment, text)))
|
||
total = int((await session.execute(select(func.count()).select_from(SourceObject).where(*filt))).scalar() or 0)
|
||
rows = (await session.execute(
|
||
select(SourceObject.name, SourceObject.name_key, SourceObject.kind, SourceObject.comment,
|
||
SourceObject.est_rows, SourceObject.in_scope, SourceObject.scope_reason, SourceObject.meta,
|
||
SourceObject.reflect_error).where(*filt))).all()
|
||
|
||
def rank(r: Any) -> tuple[int, str]:
|
||
if r.name_key == key:
|
||
return 0, r.name_key
|
||
if r.name_key.startswith(key):
|
||
return 1, r.name_key
|
||
if key in r.name_key:
|
||
return 2, r.name_key
|
||
return 3, r.name_key
|
||
|
||
out = []
|
||
for r in sorted(rows, key=rank)[:limit]:
|
||
reflected = isinstance(r.meta, dict) and bool(r.meta)
|
||
out.append({"name": r.name, "kind": r.kind, "comment": r.comment, "est_rows": r.est_rows,
|
||
"in_scope": bool(r.in_scope), "scope_reason": r.scope_reason if r.in_scope else None,
|
||
"absent_reason": None if r.in_scope and reflected
|
||
else ABSENT_REFLECT_FAILED if r.in_scope else ABSENT_OUT_OF_SCOPE})
|
||
return out, total
|
||
|
||
|
||
async def on_demand_list(session: AsyncSession, row: Any, *, limit: int = 200) -> list[dict[str, Any]]:
|
||
"""按需加入、目录页手工加入范围的表和留痕,新的在前。"""
|
||
rows = (await session.execute(
|
||
select(SourceObject).where(*_scope_filter(row.id, index_schema(row)), SourceObject.in_scope.is_(True),
|
||
SourceObject.added_at.is_not(None))
|
||
.order_by(SourceObject.added_at.desc()).limit(limit))).scalars()
|
||
return [trail_dict(r) for r in rows]
|
||
|
||
|
||
def trail_dict(r: SourceObject) -> dict[str, Any]:
|
||
return {"name": r.name, "scope_reason": r.scope_reason, "via": r.added_via, "by": r.added_by,
|
||
"run_id": r.added_run_id, "conversation_id": r.added_conversation_id, "ref": r.added_ref,
|
||
"need": r.added_need, "at": r.added_at.isoformat() if r.added_at else None}
|
||
|
||
|
||
async def count_added_in_run(session: AsyncSession, run_id: str) -> int:
|
||
"""这次运行里按需加入了几张(各源合计):每次运行最多 ON_DEMAND_PER_RUN 张。"""
|
||
return int((await session.execute(
|
||
select(func.count()).select_from(SourceObject).where(SourceObject.added_run_id == run_id,
|
||
SourceObject.scope_reason == "on_demand")
|
||
)).scalar() or 0)
|
||
|
||
|
||
# ==========================================================================
|
||
# 按需加入、加入范围
|
||
# ==========================================================================
|
||
|
||
|
||
@dataclass
|
||
class Trail:
|
||
"""按需加入的留痕:经哪个入口、谁、哪次运行 / 哪一轮对话、哪一下(look 编号、节点 id)、需求原文。"""
|
||
|
||
via: str
|
||
actor: str | None = None
|
||
run_id: str | None = None
|
||
conversation_id: str | None = None
|
||
ref: str | None = None
|
||
need: str | None = None
|
||
|
||
|
||
@dataclass
|
||
class AddOutcome:
|
||
"""加入范围的结果。added:加进来、读到了结构的(schema_cache 的键);already:本来就在范围里、读得到的;failed:
|
||
{名字: 读不到的原因}(也加进了范围,下次探查再读);refused:{名字: 为什么没加}(对象索引里没有、范围满了);
|
||
cache:加完以后的 schema_cache(什么都没加时为 None)。"""
|
||
|
||
added: list[str] = field(default_factory=list)
|
||
already: list[str] = field(default_factory=list)
|
||
failed: dict[str, str] = field(default_factory=dict)
|
||
refused: dict[str, str] = field(default_factory=dict)
|
||
cache: dict[str, Any] | None = None
|
||
|
||
|
||
NOT_IN_INDEX = "对象索引里没有这张表(数据源里没有,或者是对象索引刷新之后才建的:重新探查结构后再试)"
|
||
REMOVED_FROM_INDEX = "对象索引里这张表已经不在了(表已删除)"
|
||
SCOPE_FULL = f"探查范围已有 {{n:,}} 张表,最多 {SCOPE_MAX:,} 张,不能再加入"
|
||
EXPLICITLY_REMOVED = "这张表被显式移出了探查范围:要用它,请在「探查范围」里把它从移出的表里去掉"
|
||
|
||
|
||
async def add_to_scope(source_id: str, names: list[str], *, trail: Trail, reason: str = "on_demand") -> AddOutcome:
|
||
"""把对象索引里范围外的这几张表加进范围:当场只读反射、落盘、记下留痕,再物化 schema_cache。reason:on_demand(助手、
|
||
运行时按需加入)或 explicit(目录页手工加入,同时记进规则的显式列出)。用自己的会话,不碰调用方会话里加载的对象。
|
||
反射时限是这个源的单条查询时限(大约 5 条字典查询,见 engine.query_timeout)。"""
|
||
from app.data import engine as data_engine
|
||
from app.db.models import DataSource
|
||
|
||
out = AddOutcome()
|
||
wanted = list(dict.fromkeys(n.strip() for n in names if n and n.strip()))
|
||
if not wanted:
|
||
return out
|
||
async with source_lock(source_id):
|
||
async with SessionLocal() as session:
|
||
row = await session.get(DataSource, source_id)
|
||
if row is None:
|
||
out.refused = dict.fromkeys(wanted, "数据源不存在,可能已被删除")
|
||
return out
|
||
schema = index_schema(row)
|
||
rules = ScopeRules.from_options(row.options)
|
||
removed = {name_key(n.rsplit(".", 1)[-1]) for n in rules.removed}
|
||
keys = {name_key(n.rsplit(".", 1)[-1]): n for n in wanted}
|
||
found = {r.name_key: r for r in (await session.execute(
|
||
select(SourceObject).where(*_scope_filter(source_id, schema),
|
||
SourceObject.name_key.in_(list(keys))))).scalars()}
|
||
in_scope = int((await session.execute(select(func.count()).select_from(SourceObject).where(
|
||
*_scope_filter(source_id, schema), SourceObject.in_scope.is_(True),
|
||
SourceObject.removed_at.is_(None)))).scalar() or 0)
|
||
todo: list[SourceObject] = []
|
||
for key, asked in keys.items():
|
||
obj = found.get(key)
|
||
if obj is None:
|
||
out.refused[asked] = NOT_IN_INDEX
|
||
elif obj.removed_at is not None:
|
||
out.refused[asked] = REMOVED_FROM_INDEX
|
||
elif obj.in_scope and isinstance(obj.meta, dict) and obj.meta:
|
||
out.already.append(obj.name)
|
||
elif key in removed and reason != "explicit":
|
||
out.refused[asked] = EXPLICITLY_REMOVED
|
||
elif not obj.in_scope and in_scope + len(todo) >= SCOPE_MAX:
|
||
out.refused[asked] = SCOPE_FULL.format(n=in_scope + len(todo))
|
||
else:
|
||
todo.append(obj)
|
||
if not todo:
|
||
return out
|
||
try:
|
||
got = await reflect(row, [(o.name, bool(o.quote_name)) for o in todo],
|
||
views={o.name for o in todo if o.kind == "view"},
|
||
timeout=data_engine.query_timeout(row) * 2)
|
||
except asyncio.TimeoutError:
|
||
for o in todo:
|
||
out.failed[o.name] = f"读表结构超过 {data_engine.query_timeout(row) * 2:g} 秒没有完成"
|
||
got = Reflected(tables={}, skipped=dict(out.failed))
|
||
except Exception as e: # noqa: BLE001 - 连不上:照实记下,不挡调用方
|
||
why = first_line(e) or describe_exception(e)
|
||
for o in todo:
|
||
out.failed[o.name] = why
|
||
got = Reflected(tables={}, skipped=dict(out.failed))
|
||
now = utcnow()
|
||
for o in todo:
|
||
was_in = bool(o.in_scope)
|
||
o.in_scope = True
|
||
if not was_in or (reason == "explicit" and o.scope_reason in (None, "on_demand")):
|
||
o.scope_reason = reason
|
||
elif was_in:
|
||
# 本来就在范围里、只是上次没读到结构(pinned、规则命中的):这次重读一遍,不算「加入」,不记留痕
|
||
continue
|
||
o.added_via, o.added_by, o.added_at = trail.via, (trail.actor or None), now
|
||
o.added_run_id, o.added_conversation_id = trail.run_id, trail.conversation_id
|
||
o.added_ref = (trail.ref or None) and str(trail.ref)[:64]
|
||
o.added_need = (trail.need or None) and str(trail.need)[:2000]
|
||
await session.flush()
|
||
await save_reflected(session, source_id, schema, got)
|
||
out.added = [o.name for o in todo if o.name in got.tables]
|
||
for name, why in got.skipped.items():
|
||
out.failed.setdefault(name, why)
|
||
if reason == "explicit":
|
||
listed = {name_key(t) for t in rules.tables}
|
||
rules.tables += [o.name for o in todo if name_key(o.name) not in listed]
|
||
rules.removed = [n for n in rules.removed
|
||
if name_key(n.rsplit(".", 1)[-1]) not in {name_key(o.name) for o in todo}]
|
||
rules.updated_by, rules.updated_at = trail.actor, now.isoformat()
|
||
row.options = {**(row.options or {}), SCOPE_OPTION: rules.to_dict()}
|
||
out.cache = await rebuild_schema_cache(session, row, keep_failure=True)
|
||
await session.commit()
|
||
logger.info("数据源 %s 按需加入范围:%s(经 %s)", source_id, "、".join(out.added) or "无", trail.via)
|
||
return out
|
||
|
||
|
||
async def delete_index(session: AsyncSession, source_id: str) -> None:
|
||
"""删掉一个源的对象索引(测试、清理用;删数据源时随外键级联删除)。"""
|
||
await session.execute(delete(SourceObject).where(SourceObject.source_id == source_id))
|
||
|
||
|
||
__all__ = [
|
||
"ABSENT_MESSAGE", "ABSENT_MISSING", "ABSENT_OUT_OF_SCOPE", "ABSENT_PLAIN", "ABSENT_REFLECT_FAILED",
|
||
"ABSENT_REMOVED", "ABSENT_UNKNOWN", "ADD_LIMIT", "AddOutcome", "absent_message",
|
||
"INDEX_LIMIT", "IndexResult", "IndexedObject", "OutsideObject", "REFLECT_BATCH", "RULES_MAX", "Reflected",
|
||
"RefreshStats", "SCOPE_DEFAULT", "SCOPE_MAX", "SCOPE_OPTION", "SCOPE_REASONS", "ScopePlan", "ScopeRules", "Trail",
|
||
"VIA_CATALOG", "VIA_LOOK", "VIA_SCHEMA_TOOL", "absent_reasons", "add_to_scope", "apply_scope",
|
||
"count_added_in_run", "fingerprint", "has_index", "index_schema", "index_sql", "legacy_seed", "list_objects",
|
||
"live_objects",
|
||
"on_demand_keys", "on_demand_list", "outside_names", "outside_objects", "pinned_keys", "plan_for", "plan_scope",
|
||
"rebuild_schema_cache", "reflect", "refresh_index", "rules_problem", "save_reflected", "search_index",
|
||
"source_lock", "target_schema", "too_large_message", "trail_dict",
|
||
]
|