Commit 63b3b770 authored by Data Governance Dev's avatar Data Governance Dev

feat(web3): 查询改分页拉取 + 中断按钮 + 后端 session 隔离

把原来 /api/queries/run 单次同步全表扫描改成 3 个端点:
  POST /api/queries/start    COUNT + 编译规则 + 建 session(UUID)
  POST /api/queries/page     按页号拉一页,跑规则,只返回不合规的行
  POST /api/queries/cancel   摘掉 session(best-effort 中断)

session_manager.py 用模块级 dict + Lock 存 QuerySession,TTL 30min 懒清理。
每页新开 DBConnection → fetchall(避开 cursor 生命周期管理),~50ms 重连可接受。

dialect 兼容:
  mysql    : LIMIT <n> OFFSET <m>
  dameng   : OFFSET <m> ROWS FETCH NEXT <n> ROWS ONLY (SQL:2008)
  oracle   : ROWNUM 三层嵌套(兼容 11g/10g;T4 任务实测 11g 服务端用 OFFSET/FETCH NEXT 报 ORA-00933)
  postgres / sqlserver : 同 dameng

前端友好的关键点:
  - /start 一次返回 fields(含 rule_list + field_comment),前端 resultFields 不再变
  - 后端给每行注入 __row_index = (page_no-1)*page_size + idx,给前端做 Vue row-key
  - /page 失败不摘 session,让前端可以重试或走 /cancel

测试:新增 web3/tests/test_queries.py 15 条(start / page / cancel / paginate_sql / 并发 / TTL),
老的 test_run_query_only_bad_rows.py 和 test_rule_types_e2e.py 适配新端点(删 max_rows 那条,
end-to-end 改成 /start + /page 聚合)。73 条全过。
parent 9263f8d7
......@@ -121,6 +121,54 @@ def quote_ident(name: str, db_type: str) -> str:
return f'"{name}"'
# ── 分页 SQL 助手 ────────────────────────────────────────
def paginate_sql(base_sql: str, db_type: str, offset: int, limit: int) -> str:
"""在 base_sql 后追加 LIMIT/OFFSET 子句,屏蔽方言差异。
dialect:
- mysql : ``LIMIT <limit> OFFSET <offset>``
- dameng : ``OFFSET <offset> ROWS FETCH NEXT <limit> ROWS ONLY`` (SQL:2008)
- oracle : **ROWNUM 三层嵌套** —— 兼容 Oracle 11g/10g;
12c+ 也照样能用(row_limiting_clause 不需要时统一走 ROWNUM 简化分支)
- postgres : 同 dameng
- sqlserver: 同 dameng
不支持的 dialect 直接抛 ``ValueError``,让上层早 fail。
注意:``ORDER BY`` 必加,否则 OFFSET 语义在不同实现间不一致
(MySQL/Postgres 默认有顺序,但 Oracle / 达梦没保证)。调用方负责保证
``base_sql`` 已经 ORDER BY。
Oracle 分页模板(11g 兼容):
SELECT * FROM (
SELECT t.*, ROWNUM rn FROM (<base_sql>) t
WHERE ROWNUM <= :end_row
) WHERE rn > :start_row
其中 start_row = offset, end_row = offset + limit
ROWNUM 在内层 WHERE 算(结果集已包含 ORDER BY 顺序),外层只过滤 rn。
注:内层用 ``t.*`` 而非 ``*``,避免 Oracle 对 LONG RAW 列报错;
业务表一般不含这种列,无影响。
"""
dt = db_type.lower()
if dt == "mysql":
return f"{base_sql} LIMIT {int(limit)} OFFSET {int(offset)}"
if dt == "oracle":
offset_i = int(offset)
limit_i = int(limit)
end_row = offset_i + limit_i
# 嵌套子查询:内层执行 ORDER BY,中层 ROWNUM 编号,外层过滤
# ``t.*`` 而非 ``*`` 防 Oracle LONG RAW 列(业务表基本没这列)
return (
f"SELECT * FROM ("
f"SELECT t.*, ROWNUM rn FROM ({base_sql}) t "
f"WHERE ROWNUM <= {end_row}"
f") WHERE rn > {offset_i}"
)
if dt in ("dameng", "postgres", "postgresql", "sqlserver"):
return f"{base_sql} OFFSET {int(offset)} ROWS FETCH NEXT {int(limit)} ROWS ONLY"
raise ValueError(f"Unsupported db_type for pagination: {db_type!r}")
def quote_value(value: Any) -> str:
"""把 Python 值转成 SQL 字面量。仅用于已知安全的字典/枚举值。"""
if value is None:
......
"""查询 session 管理器(web3 自包含版)
为每次 ``POST /api/queries/start`` 在内存里建一个 ``QuerySession``,记录任务配置 +
DB 连接 + 分页参数的快照。前端按页号去 ``POST /api/queries/page`` 拉一页结果,可并发
拉多页,并主动 ``POST /api/queries/cancel`` 终止。
生命周期:
创建 ``/start`` 跑完 COUNT 后建 session 入 ``_REGISTRY``,UUID 作 token 返回
取消 ``/cancel`` 从 ``_REGISTRY`` pop 掉,后续 ``/page`` 立刻 409 短路
TTL 每次 ``/start`` 入口懒清理 ``>30min`` 的 session(无后台线程,简单即正义)
隔离粒度:**per-client** 而非 **per-user**。
当前无 auth 模块 → 任何人拿到 ``session_id`` 都能拉/取消对应 session;
docstring 写明这个 caveat,将来加 auth 时必须把 session_id 绑到 user_id 上。
进程假设:
单进程 uvicorn(无 ``--workers``)→ ``_REGISTRY`` 模块级 dict + ``Lock`` 足够。
改成多 worker / 跨进程时必须换 Redis / 共享 dict(这里先不实现)。
"""
from __future__ import annotations
import threading
import time
from dataclasses import dataclass, field
from typing import Optional
from web3.backend.core.db_adapter import DBConfig
# ── 模块级存储 ────────────────────────────────────────────
# 改 per-user 时记得把 get/pop 都接受 user_id 校验。
_REGISTRY_LOCK = threading.Lock()
_REGISTRY: dict[str, "QuerySession"] = {}
_TTL_SECONDS = 30 * 60
# ── 数据类 ────────────────────────────────────────────────
@dataclass
class CompiledFieldRules:
"""一个字段下的规则快照(被 QuerySession 引用)
为避免 session 把 DB session 持有太久,规则在 ``/start`` 时同步编译好缓存。
字段大小写已归一为小写(与列名对齐)。
"""
field_key: str
# 每条规则:(rule_type, regex_or_None, code_or_None, desc)
rules: list[tuple[str, Optional[str], Optional[str], str]] = field(default_factory=list)
@dataclass
class QuerySession:
"""一次查询的所有上下文。
注意:
``cancelled`` 由 ``/cancel`` 置 True 并从 ``_REGISTRY`` 摘掉;
但 ``/page`` 在并发飞过来时仍可能拿到 in-flight 副本,所以 ``/page`` 入口要
二次检查 ``_REGISTRY`` 里是否还在(不在就 404),以及 session 上的 cancelled。
"""
session_id: str
task_id: int
db_config: DBConfig
db_type: str
page_size: int
total_rows: int
total_pages: int
select_sql_base: str # SELECT cols FROM table ORDER BY first_field
compiled: list[CompiledFieldRules] = field(default_factory=list)
col_names: list[str] = field(default_factory=list)
field_list_out: list[dict] = field(default_factory=list) # 给前端表头用
cancelled: bool = False
pages_scanned: int = 0
bad_rows_total: int = 0
created_at: float = field(default_factory=time.time)
lock: threading.Lock = field(default_factory=threading.Lock)
def mark_page_done(self, bad_delta: int) -> None:
with self.lock:
self.pages_scanned += 1
self.bad_rows_total += bad_delta
# ── CRUD ──────────────────────────────────────────────────
def put(session: QuerySession) -> None:
"""插入 session,入口处顺便清一遍过期 session"""
with _REGISTRY_LOCK:
_purge_expired_locked()
_REGISTRY[session.session_id] = session
def get(session_id: str) -> Optional[QuerySession]:
"""按 session_id 取 session;已被 /cancel 摘掉就返 None"""
with _REGISTRY_LOCK:
return _REGISTRY.get(session_id)
def pop(session_id: str) -> Optional[QuerySession]:
"""原子地取走 session(被 /cancel 用);返回被取走的对象"""
with _REGISTRY_LOCK:
return _REGISTRY.pop(session_id, None)
def size() -> int:
with _REGISTRY_LOCK:
return len(_REGISTRY)
# ── 内部 ──────────────────────────────────────────────────
def _purge_expired_locked() -> None:
"""把超过 TTL 的 session 删掉。调用方必须已持有 _REGISTRY_LOCK。
Lazy cleanup —— 没有后台线程;只在 /start 入口触发一次。
单进程 uvicorn 这个开销可忽略(session 数个位数)。
"""
cutoff = time.time() - _TTL_SECONDS
expired = [sid for sid, s in _REGISTRY.items() if s.created_at < cutoff]
for sid in expired:
_REGISTRY.pop(sid, None)
\ No newline at end of file
This diff is collapsed.
This diff is collapsed.
......@@ -48,7 +48,19 @@ class _FakeDBConnection:
def __exit__(self, *exc):
return False
def fetchone(self, sql, params=None):
# 2026-08-21 分页重构:/start 需要 COUNT(*)
_FakeDBConnection.last_sql = sql
return {"n": len(FAKE_TABLE_ROWS)}
def fetchall(self, sql, params=None, batch_size=1000):
# 2026-08-21 分页重构:/page 走 fetchall(LIMIT/OFFSET)
# 这里 mock 简化处理:直接返全量(断言不依赖 LIMIT 切片,单测聚焦规则 dispatch)
_FakeDBConnection.last_sql = sql
return [dict(r) for r in FAKE_TABLE_ROWS]
def iter_rows(self, sql, params=None, batch_size=1000):
# 保留 iter_rows 以兼容老调用方(老测试可能还在用)
_FakeDBConnection.last_sql = sql
for r in FAKE_TABLE_ROWS:
yield dict(r)
......@@ -175,7 +187,7 @@ def test_default_rule_type_is_regex(client):
# ── 2) queries/run dispatch ─────────────────────────────
def test_queries_run_dispatches_by_rule_type(client):
"""queries/run 对 number/date 规则走 rule_runner,结果里该字段不合规的行被查出。"""
"""queries/start+page 对 number/date 规则走 rule_runner,结果里该字段不合规的行被查出。"""
c, _ = client
# 1) 先建一个含 number + date 规则的任务
create = c.post("/api/tasks", json={
......@@ -217,10 +229,20 @@ def test_queries_run_dispatches_by_rule_type(client):
assert create.status_code == 200, create.text
task_id = create.json()["id"]
# 2) 跑查询
r = c.post("/api/queries/run", json={"task_id": task_id})
assert r.status_code == 200, r.text
body = r.json()
# 2) 跑查询:/start + 聚合 /page(2026-08-21 分页重构,/run 端点已删)
start = c.post("/api/queries/start", json={"task_id": task_id, "page_size": 1000}).json()
assert start["ok"], start
rows: list[dict] = []
for n in range(1, start["total_pages"] + 1):
page = c.post("/api/queries/page", json={"session_id": start["session_id"], "page_no": n}).json()
assert page["ok"], page
rows.extend(page["bad_rows"])
body = {
"ok": True,
"scanned": start["total_rows"],
"rows": rows,
"field_list": start["field_list"],
}
assert body["ok"] is True
assert body["scanned"] == 4
......@@ -266,13 +288,18 @@ def test_queries_run_rule_failure_marks_row_bad_with_reason(client):
assert create.status_code == 200, create.text
task_id = create.json()["id"]
r = c.post("/api/queries/run", json={"task_id": task_id})
body = r.json()
assert body["scanned"] == 4
# 2026-08-21 分页重构:/run 已删,改用 /start + /page
start = c.post("/api/queries/start", json={"task_id": task_id, "page_size": 1000}).json()
assert start["ok"], start
rows: list[dict] = []
for n in range(1, start["total_pages"] + 1):
page = c.post("/api/queries/page", json={"session_id": start["session_id"], "page_no": n}).json()
rows.extend(page["bad_rows"])
assert start["total_rows"] == 4
# 所有行 age 字段都算不合规 → 4 行全返回
assert len(body["rows"]) == 4
assert len(rows) == 4
# 每行的 issues 都应包含「规则执行失败」字样
for row in body["rows"]:
for row in rows:
assert any("规则执行失败" in i["desc"] for i in row["issues"]), row
......
This diff is collapsed.
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment