Skip to content

feat: 数据库层方言可插拔(SQLite → 高斯/MySQL/达梦易扩展) - #39

Open
tianling536 wants to merge 9 commits into
OpenBMB:mainfrom
tianling536:feat/multi-db-clean
Open

feat: 数据库层方言可插拔(SQLite → 高斯/MySQL/达梦易扩展)#39
tianling536 wants to merge 9 commits into
OpenBMB:mainfrom
tianling536:feat/multi-db-clean

Conversation

@tianling536

@tianling536 tianling536 commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

概述

将数据库层从"SQLite 实现"升级为方言可插拔:默认走 ORM/标准 SQL(所有数据库通用),无法通用的少数点收口到方言提供者接口。新增一种数据库 = 注册一个小适配器(锁/日期/能力声明),业务代码零改动。高斯(openGauss)经 PostgreSQL 协议直接可用,MySQL/达梦按 docstring 中的扩展方式接入。

设计

新模块 backend/app/db/dialect.py

  • DatabaseDialect 协议:engine_kwargsday_bucket、JSON 配置读改写、acquire/release/check_advisory_locksupports_partial_index / session_scoped_advisory_lock 能力声明;
  • 注册表:sqlite → SQLiteDialectpostgresql → PostgresDialect(高斯直接复用)、未知名 → GenericDialect(默认实现);
  • 默认实现即通用实现(ORM 读改写、cast(..., Date)),无适配器的新后端也能先跑起来;但安全语义不降级:文件锁仅对 SQLite 文件库有效,其它后端无原生锁实现时响亮拒绝;部分唯一索引只在 sqlite/postgresql 方言创建,其它后端由启动校验兜底。

改造点

  1. 引擎创建get_dialect(backend).engine_kwargs(url)(SQLite 行为逐字不变);
  2. 收编方言 SQL:api/channels.py 配置补丁与 wechat.py::_patch_runtime_config(JSON1)→ ORM 读-改-写 + SELECT ... FOR UPDATE 行锁(双向互斥,关闭丢写窗口;SQLite 自动省略);knowledge.pyCAST AS BLOB → 方言分支;投递日志日分桶 → 方言助手(PG 支持 app_timezone);
  3. 索引:会话唯一索引并入 models;默认模型部分唯一索引移出 metadata、按方言 DDL 事件创建(防止 MySQL/达梦静默退化为全量唯一索引),无部分索引后端由 init_db 启动校验兜底;
  4. 渠道连接器方言化:connector 锁走方言(PG=双 int4 advisory lock、acquire 即提交避免 idle-in-transaction、pg_locks 定期存活校验、断连静默失效时看门狗主动降级;SQLite=文件锁);飞书 binding 锁同为方言 advisory lock(锁身份=数据库作用域+binding key,跨 HOME/容器互斥,不再用含密码 URL 指纹);
  5. 依赖:psycopg[binary] 为可选依赖 postgres 组(默认 SQLite 桌面端不背 ~20MB 二进制),缺驱动时启动给明确安装指引。

验证

  • SQLite 零回归:全量 1214 passed / ruff 零告警(唯一失败为 main 预存在的 test_verification_runs_bounded_text_stream_and_json_probes,纯净 main 同样复现,与本 PR 无关);
  • 真 PostgreSQL 16(Docker)实证(scripts/smoke_postgres.sh):create_all、部分索引存在、advisory lock acquire→check→release 闭环、FOR UPDATE 补丁路径、日分桶、知识库、应用启动 + /api/health,全部通过;
  • openGauss:本机 Apple Silicon 无法运行官方镜像、fork 账号 CI runner 不可用,未能实测真机;已附 .github/workflows/gaussdb-smoke.yml(x86 CI 一键起 openGauss 容器跑全套方言冒烟,锁调用已带 session、依赖组已对齐),烦请维护者在贵方 runner dispatch 一次。

当前限制(已在 README 明示)

  • PostgreSQL/openGauss 仅支持全新库(create_all 初始化),无存量 schema 迁移通路(Alembic 为后续任务),启动期有告警;
  • APP_TIMEZONE 仅对 PostgreSQL 日分桶生效,SQLite 恒为服务器本地时区(配置了会告警);
  • 每个数据库只允许一个 connector 进程(PG 会话级 advisory lock + 存活校验守护)。

后续(登记项,不在本 PR)

  • openGauss 真机/CI 验证;
  • MySQLDialect / DMDialect 适配器实现(骨架与能力说明已在 dialect.py docstring);
  • Alembic 迁移通路;
  • 存量 SQLite 数据向 GaussDB 搬迁的一次性导数(非代码改造)。

Comment on lines +8 to +109
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Start openGauss
run: |
docker run -d --name opengauss \
-e GS_PASSWORD='Staffdeck@123' \
-p 5432:5432 \
enmotech/opengauss:5.0.0
for i in $(seq 1 120); do
if docker exec opengauss su - omm -c "gsql -d postgres -c 'select 1'" >/dev/null 2>&1; then
echo "openGauss ready after ${i}s"
break
fi
sleep 2
if [ "$i" -eq 120 ]; then
echo "openGauss failed to become ready"
docker logs opengauss
exit 1
fi
done
docker exec opengauss su - omm -c "gsql -d postgres -c 'select version()'"

- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Install backend deps
run: |
python -m venv .venv
.venv/bin/pip install -q -e backend[dev] 2>/dev/null || (cd backend && ../.venv/bin/pip install -q -e . && ../.venv/bin/pip install -q pytest pytest-asyncio ruff httpx)

- name: create_all + dialect smoke on openGauss
env:
DATABASE_URL: postgresql+psycopg://postgres:Staffdeck%40123@127.0.0.1:5432/postgres
STAFFDECK_ROLE: all
working-directory: backend
run: |
set -e
../.venv/bin/python - <<'PY'
from app.db.database import init_db
init_db()
print("create_all on openGauss: OK")
from sqlalchemy import inspect
from app.db import engine
idx = {i["name"] for t in ("sessions", "model_configs") for i in inspect(engine).get_indexes(t)}
assert "uq_sessions_agent_channel_extconv" in idx, "missing session index"
assert "uq_model_configs_tenant_default" in idx, "missing default-model partial index"
print("indexes: OK")
from app.db.dialect import get_dialect
from sqlmodel import Session
d = get_dialect(engine.url.get_backend_name())
print("dialect:", d.name)
assert d.acquire_advisory_lock("staffdeck-connector")
print("advisory lock acquire: OK")
d.release_advisory_lock("staffdeck-connector")
print("advisory lock release: OK")
with Session(engine) as db:
from app.db.models import User
db.exec(__import__("sqlmodel").select(User)).all()
print("basic query: OK")
PY

- name: Boot app and API smoke on openGauss
env:
DATABASE_URL: postgresql+psycopg://postgres:Staffdeck%40123@127.0.0.1:5432/postgres
STAFFDECK_ROLE: all
ULTRARAG_PORT: 5199
working-directory: backend
run: |
set -e
../.venv/bin/python -m uvicorn app.main:app --host 127.0.0.1 --port 5199 &
APP_PID=$!
for i in $(seq 1 60); do
if curl -sf http://127.0.0.1:5199/api/health >/dev/null 2>&1; then echo "healthy after ${i}s"; break; fi
sleep 1
if [ "$i" -eq 60 ]; then echo "app failed to start"; kill $APP_PID; exit 1; fi
done
curl -sf http://127.0.0.1:5199/api/health
TOKEN=$(curl -sf -X POST http://127.0.0.1:5199/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"tenant_id":"tenant_demo","username":"admin","password":"admin"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["token"])')
echo "login: OK"
AGENTS=$(curl -sf "http://127.0.0.1:5199/api/chat/agents?tenant_id=tenant_demo" -H "Authorization: Bearer $TOKEN")
echo "agents: $(echo "$AGENTS" | python3 -c 'import sys,json;print(len(json.load(sys.stdin)))')"
BID=$(curl -sf -X POST http://127.0.0.1:5199/api/enterprise/channels \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"tenant_id":"tenant_demo","agent_id":"agent_30b8f623c6fe445b","channel":"wecom"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')
echo "binding: $BID"
curl -sf -X POST "http://127.0.0.1:5199/api/enterprise/channels/$BID/wecom/credentials" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"tenant_id":"tenant_demo","bot_id":"smoke_bot","secret":"smoke_secret","corp_id":"smoke_corp"}' -o /dev/null
echo "credentials(JSON config patch): OK"
curl -sf "http://127.0.0.1:5199/api/enterprise/channels/$BID/deliveries/days?tenant_id=tenant_demo" \
-H "Authorization: Bearer $TOKEN" | python3 -c 'import sys,json;print("day-bucket endpoint:", json.load(sys.stdin)["total_days"], "days")'
curl -sf "http://127.0.0.1:5199/api/enterprise/knowledge-bases?tenant_id=tenant_demo" \
-H "Authorization: Bearer $TOKEN" | python3 -c 'import sys,json;print("knowledge-bases:", len(json.load(sys.stdin)))'
kill $APP_PID || true
@hm1229
hm1229 requested a review from dxyhymn July 27, 2026 03:09
@tianling536
tianling536 force-pushed the feat/multi-db-clean branch from dd9add0 to 7945867 Compare July 27, 2026 15:27
@tianling536

Copy link
Copy Markdown
Contributor Author

已 rebase 到最新 main(cd64f92,含渠道加固合并),冲突已解决,当前 MERGEABLE。

本次 rebase 要点:

  1. 冲突仅 1 处(test_channel_wechat.py):main 的 client_id 分片测试与本分支的 revision CAS 测试段落位置相邻,两边都保留。
  2. 收编 main 新增的一处 SQLite 专有 SQL(新 commit 7945867):渠道加固在企微重连清理 disconnect_alerted_at 时使用了 json_remove 裸 SQL,PG/openGauss 下会报错;已改为与微信 _patch_runtime_config 相同的 ORM 读-改-写 + dialect.json_config_remove 模式。已全仓扫描确认这是 main 新增代码中唯一未方言化的原生 SQL。
  3. 真实 PostgreSQL 16 冒烟重跑通过(create_all / 方言路径 / 应用启动 / /api/health,schema 重置后)。

验证:后端全量 1201 passed、ruff 通过。

另注:test_model_configs_api.py::test_verification_runs_bounded_text_stream_and_json_probes 失败为 main 预存在问题(纯净 main 同样复现,bounded probe 期望 max_tokens=32 实际 8192),与本 PR 无关。

@dxyhymn dxyhymn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

总体意见

方言收口的结构和"默认实现即通用实现"的取舍我认可,SQLite 侧行为逐字保留、测试也补得认真。但有 5 处我本地验证过的实质问题,其中 3 处会在非 SQLite 后端造成静默的错误行为,建议修完再合。

我的验证环境:1164 passed / 7 failed;这 7 个失败在纯净 main 上完全一致(Python 3.14 + 无法写 ~/Library 所致),与本 PR 无关。按项目基线规则(--select E4,E7,E9,F --line-length 100)ruff 零告警。


必须修

1. MySQL/达梦下 uq_model_configs_tenant_default 会退化成全量唯一索引

backend/app/db/models.py:316-325sqlite_where / postgresql_where 表达部分唯一索引,这两个 kwarg 在其它方言下会被静默丢弃。我把索引在各方言下编译出来确认了:

sqlite       -> CREATE UNIQUE INDEX uq_model_configs_tenant_default ON model_configs (tenant_id) WHERE is_default = 1
postgresql   -> CREATE UNIQUE INDEX uq_model_configs_tenant_default ON model_configs (tenant_id) WHERE is_default
mysql        -> CREATE UNIQUE INDEX uq_model_configs_tenant_default ON model_configs (tenant_id)
oracle       -> CREATE UNIQUE INDEX uq_model_configs_tenant_default ON model_configs (tenant_id)

也就是 MySQL/达梦系上变成"每租户只能有一条模型配置"。

supports_partial_index 这个能力位目前全仓没有任何一处消费(只有 dialect.py 的声明、models.py:317-318 的注释和 test_db_dialect.py 的断言引用它),models.py 注释里承诺的"由方言适配器在 DDL 层跳过"并不存在。这与 PR 主张的"新增数据库 = 注册一个小适配器,业务代码零改动"直接冲突:按 docstring 照做接一个 MySQLDialect,库就是坏的。

建议:在 create_all 前按 supports_partial_index 真正跳过该索引并落地代码层校验,或者在不支持的后端启动时直接拒绝并给出明确报错。

2. GenericDialect 的文件锁在非文件型数据库上静默用错路径

backend/app/db/dialect.py:102-109 直接把 url.database 当文件路径用,但对 MySQL/达梦它是库名。实测 mysql+pymysql://u:p@db.internal/staffdeck 得到的锁路径是:

$CWD/staffdeck.staffdeck-connector.lock

即当前工作目录下的相对路径。两个工作目录不同的 connector 进程会各自"成功抢到锁",Outbox 单实例保证失效。

改造前的代码在这里是明确拒绝启动的("渠道服务要求文件 SQLite 进程锁"),属于响亮的安全失败;现在变成了静默的错误成功,而这恰好是本 PR 主推的"未注册后端开箱可用"路径。建议 url.database 不是可用的绝对文件路径时直接返回 False。

3. PG 持锁会话是永久 idle in transaction,且没有失效检测

backend/app/db/dialect.py:191-196 只 execute 不 commit,而 backend/app/channels/__init__.py:45-52 把这个 Session 常驻整个进程生命周期。结果是一条连接永久处于 idle-in-transaction:阻塞 VACUUM、把 xmin horizon 钉住,长跑必然出问题。advisory lock 是会话级的、commit 之后依然持有,所以 acquire 里补一句 session.commit() 即可。

另一个更要紧的点:这个常驻会话没有保活和健康检查。连接被服务端掐断(PG 重启、idle 超时、LB/网络抖动)后 advisory lock 会静默释放,但进程仍以为自己持锁(_connector_lock_pid 还等于自己),于是出现双 connector 同时跑 Outbox。文件锁不存在这个失效模式,这是换成 advisory lock 后新引入的风险,建议加定期 pg_advisory_lock 状态校验或断连后主动降级。

4. _patch_runtime_config 的"三层并发保护"有一层是空的

PR 描述和 wechat.py:49 的注释都写"API 改配置必递增 revision",但 backend/app/api/channels.py:91-111_patch_binding_config_key(写 auto_routeqrcode_redirect_baseurl并不递增 config_revision。于是存在这个交错:

  1. connector 在 wechat.py:95 读到 config,revision = R
  2. API 侧 _patch_binding_config_key 补上 auto_route,revision 仍是 R
  3. connector 在 wechat.py:110-119整份 config 写回,WHERE config_revision = R 成立 → rowcount 1
  4. API 刚写的 auto_route 被静默吞掉

原来的 json_set 是单条 SQL、只动 connector 自己的 key,两边互不影响;现在改成整份 JSON 读-改-写就有了丢写窗口。而 _persist_cursor 每个长轮询周期都会走这条路,窗口是反复出现的,不是理论风险。SQLite 上因为 WAL 的快照升级冲突会抛 SQLITE_BUSY 反而相对安全,PG 的 read committed 就是静默丢写 —— 也就是说这个洞恰好只在本 PR 新支持的后端上暴露。

建议二选一:读取时加 with_for_update()(PG 生效、SQLite 无害),或者让 API 侧补丁也递增 revision(注意评估 revision 变化对 connector 重启语义的影响)。

5. 新增的 openGauss 冒烟工作流本身跑不通

.github/workflows/gaussdb-smoke.yml:67

assert d.acquire_advisory_lock("staffdeck-connector")

签名是 acquire_advisory_lock(self, session, key),少传了 session,必然 TypeError。这个 workflow 是 PR 里"openGauss 后续验证"的唯一凭据,合进去之前请至少 dispatch 跑通一次。


需要先对齐的设计问题

PG 完全没有迁移通路

backend/app/db/database.py:86-87_migrate_sqlite_skill_schema 整体挡在 SQLite 之外,PG 上只有 create_all,而 create_all 不会 ALTER 已存在的表。这意味着 PG 库一旦建好,之后任何加列/改索引都不会被应用,下一次 schema 变更就会在 PG 部署上炸。

这个 PR 把两个索引并入 models 解决了"新库 schema 完整",但没解决"存量库升级"。建议要么本 PR 引入 Alembic,要么把"PG 目前只支持全新库、无升级路径"写成显式约束(README + 启动期校验),否则不宜对外表述为"支持高斯"。

飞书 binding 级锁的隔离语义变了

backend/feishu_connector_worker.py:42-50 把锁位置从"DB 文件同目录"改成"用户数据目录"。本机单实例部署等价(DB 本来就在那),但同一个数据库被两个 HOME / 两个容器 / 两个 OS 用户的实例连接时,两边的锁互相看不见,同一 binding 会起两个 connector 重复消费消息 —— 而这恰恰是引入服务端数据库之后才会出现的部署形态。connector 级锁已经方言化成 advisory lock 了,binding 级锁建议同样处理。

顺带:test_feishu_process_spike 现在会往真实的 ~/Library/Application Support/StaffDeck/connector-locks 写锁文件(原来写 tmp_path),测试不应该污染真实用户数据目录。

psycopg[binary] 成了无条件主依赖

backend/pyproject.toml:21。这是个默认 SQLite、带 PyInstaller 打包的桌面端产品,给所有用户加约 20MB 二进制不合适,建议移到 [project.optional-dependencies]postgres 分组,按需安装。

明文密码的扩散面变大

feishu_manager.py:35render_as_string(hide_password=False),完整 URL 进入 supervisor 与子进程 spec;binding_lock_path 的指纹又取自这个含密码的 URL,导致轮换密码等于换锁身份(旧进程持的锁失效,可能双起)。建议指纹只取 drivername + host + port + database。


小问题

  • app_timezone 只对 PG 生效、SQLite 恒为服务器本地时区,配了不生效是配置陷阱,建议在 config.py 注释和文档里写明,或在 SQLite 下配了非空值时告警。
  • GenericDialect.day_bucket 是 UTC 自然日,与 SQLite 的本地自然日口径不一致,同一份数据换库后日报统计会整体平移。
  • wechat.py:53-59_runtime_patch_locks 按 binding_id 无上限累积,长跑进程会缓慢泄漏。
  • test_db_dialect.py 会把 dm 注册进全局 _DIALECTSget_dialect("mysql") 也会永久缓存 GenericDialect 实例,存在跨测试污染,建议用 fixture 还原注册表。
  • api/channels.py:100-101 的注释说 refresh 能"对齐 json_set 基于最新 JSON 值的语义",实际上 refresh 只绕过 identity map、绕不过事务快照。两个调用点恰好都先 commit/rollback 所以行为没问题,但注释是误导,建议改成"调用方须保证进入时无活跃事务"。
  • api/channels.py:111 之后少了一个空行(顶层定义间应为两个空行)。

做得好的地方

  • wecom.py:410-421 的读-改-写放在已被 UPDATE 锁住的同一事务里,并发上是正确的,不是简单照搬。
  • knowledge.py 保留 SQLite 的 CAST AS BLOB 分支、其它后端走 ORM,而 _safe_json_object 本来就能吃 dict,兼容性我核对过没问题。
  • 两个索引并入 models.py 并同步简化了 test_channel_session.py / test_model_configs_api.py 里手工建索引的脚手架,方向对。
  • 新增的方言与 connector 锁测试覆盖到了 fork 防护、锁占用冲突、PG 持锁会话常驻,不是凑数的测试。

@tianling536

Copy link
Copy Markdown
Contributor Author

感谢如此细致的复核,特别是三处非 SQLite 后端的静默错误——全部认可并已修复(commit 4b6783c)。逐条回复:

必须修

1. 部分唯一索引在 MySQL/达梦退化为全量唯一
已确认属实(sqlite_where/postgresql_where 在其它方言被静默丢弃)。修复:索引移出 metadata,改为 ModelConfig.__table__after_create DDL 事件按方言显式创建(execute_if(dialect=...));MySQL/达梦下该索引根本不会被创建。supports_partial_index 现在有真实消费方:init_db 新增启动校验,对不支持部分索引的后端核查"每租户至多一条默认模型",被破坏时响亮拒绝启动。测试:metadata 不含该索引的断言 + 启动校验拒绝/放行/跳过三组用例 + 原有 sqlite DDL 存在性断言(真实 PG 冒烟确认索引存在)。

2. GenericDialect 文件锁用错路径
属实,且确如所说把改造前的响亮失败变成了静默错误成功。修复:BaseDialect.acquire_advisory_lock 现在校验 backend_name == "sqlite"url.database 为文件路径,否则响亮返回 False(日志指明需实现原生锁适配器)。测试:mysql URL 直接拒绝的用例。

3. PG 持锁会话 idle-in-transaction + 无失效检测
两点都修:(a) 锁改为双 int4 键形式 pg_try_advisory_lock(hashtext(key), 0),acquire/release 后立即 session.commit()(advisory lock 随会话而非事务存活,提交不释放)——不再永久 idle-in-transaction;(b) 新增 check_advisory_lock:按 pg_lockslocktype='advisory' AND pid=pg_backend_pid() AND classid=hashtext(key) 核实(不用 SELECT 1,因为连接池透明重连会假健康),校验后 rollback 不留事务;channels/__init__ 新增看门狗线程(15s 周期),锁失效即主动降级——停止全部渠道服务并保留进程退出语义,释放路径对死会话容错。测试:commit/rollback 计数、pg_locks 查询形态、看门狗降级/正常退出、死会话释放容错。

4. _patch_runtime_config 丢写窗口
分析完全正确(revision 不变时 CAS 拦不住 API 侧补丁)。采用方案一:_patch_runtime_config 与 API 侧 _patch_binding_config_key 双向SELECT ... FOR UPDATE(后者同时用 populate_existing 替代 refresh),读取即锁定行直到提交,两个方向互斥;SQLite 由 SQLAlchemy 自动省略(编译期测试验证 sqlite 无 FOR UPDATE、pg 有),行为不变。wechat.py 里"API 改配置必递增 revision"的错误注释已改正。

5. gaussdb-smoke 工作流跑不通
已修:锁调用带 session(acquire → check → release),依赖安装改 backend[dev,postgres]。本地无法 dispatch(tianling536 账号 GitHub-hosted runner 不分配,此前已沟通过),烦请贵方 runner dispatch 验证一次;同一套断言已并入 scripts/smoke_postgres.sh,在真实 PG16 上通过。

设计对齐

PG 迁移通路:选择显式约束方案——README(中英)新增 Database Backends 章节写明"仅支持全新库、无迁移通路、Alembic 为后续任务",init_db 对非 SQLite 后端启动期输出同样告警。

飞书 binding 锁:已方言化。锁身份 = 数据库作用域 + staffdeck-binding-{binding_id},子进程自持(PG 常驻会话/SQLite 文件锁落 DB 旁),父进程经方言探测(失败按占用保守处理);跨 HOME/容器/用户互斥由 DB 保证。含密码 URL 指纹逻辑整体删除。spike 测试改用 tmp DB 方言探测,不再写真实用户目录。

psycopg 可选依赖:移入 [project.optional-dependencies].postgres,README 与 _ensure_driver_available 给出 pip install 'skill-agent-loop-backend[postgres]' 指引。

明文密码扩散:指纹随 binding 锁方言化整体消失(轮换密码不再改变锁身份);子进程建连仍必须持有完整 URL,这是凭据传递的固有需求,未再扩散到其它位置。

小问题

  • app_timezone:config.py 注释 + SQLite 下配置非空时启动告警(database.py);
  • Generic 日分桶口径差异:写入 dialect.py 模块 docstring(明示与 SQLite localtime 口径不同);
  • _runtime_patch_locks 泄漏:wait_binding_stopped 确认线程退出后清理该 binding 的锁(有测试);
  • 测试注册表污染:test_db_dialect.py 加 autouse fixture 快照还原 _DIALECTS;
  • refresh 误导注释:已改写为 FOR UPDATE + populate_existing 语义,并注明"调用方须保证无基于旧快照的活跃事务";
  • channels.py 空行:已补。

验证

后端全量 1214 passed(1174→1214,新增 13 个用例)、ruff 零告警;真实 PG16 冒烟增强后全过(advisory lock 闭环、部分索引存在、FOR UPDATE 补丁、日分桶、/api/health)。

另:test_verification_runs_bounded_text_stream_and_json_probes 失败为 main 预存在问题(纯净 main 复现一致,期望 32 实际 8192),未在本 PR 修复以免扩大范围,建议单独处理。

@dxyhymn dxyhymn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

复核结论(4b6783c)

已确认修好(实测):部分唯一索引改按方言 DDL 事件创建、metadata 不再挂载,MySQL/达梦不再退化为全量唯一;BaseDialect 对非 SQLite 库响亮拒绝;with_for_update() 在 pg 编译出 FOR UPDATE、sqlite 自动省略;workflow 锁调用签名补齐;文档/可选依赖/小项均已落实。本地 1177 passed,无回归。

#3 的修法引入两个新缺陷,建议先处理。 根因同一个:acquire_advisory_lockdialect.py:223)末尾新增的 session.commit() 让锁与连接脱钩——PG 的 advisory lock 属于连接,而 Session 在事务结束时会把连接交还池。

A. 飞书 binding 级锁在 PG 上零互斥(比修复前更糟)
_DialectBindingLockfeishu_connector_worker.py:122)用 NullPool,而 NullPool 的"归还"就是"关闭"。实测 commit 后底层连接已关闭(QueuePool 下不关闭),即子进程 acquire() 返回 True 的瞬间锁已释放:子进程不持锁、父进程 _binding_lock_is_free 永远报空闲、同一 binding 可重复拉起重复消费。修复前至少有本机文件锁保证同机互斥。SQLite 走文件锁不 commit,不受影响。

B. connector 锁看门狗会误判并主动停服
主 engine 是 QueuePool,commit 后连接回池,15 秒后 check_advisory_lock 会从池里取到另一条连接(实测:acquire 连接 ...288640 → check 连接 ...331552,需有并发 checkout 才暴露)。pg_locks WHERE pid = pg_backend_pid() 查不到 → 判定锁失效 → stop_channel_services()。即任何有并发库流量的 PG 部署,启动约 15 秒后渠道服务自行停止。且持锁连接在池中漂移,被 pool_recycle/pre_ping 回收时锁静默释放,正是看门狗本该捕捉的场景。

现有验证抓不到的原因:锁单测用的是计数 commit/rollback 的桩 session;PG16 冒烟与 gaussdb-smoke 都是单线程、池空闲,每次都还回同一条连接。

建议:锁改用显式 engine.connect() 拿专属 Connection 并挂 isolation_level="AUTOCOMMIT"。实测 Connection 在 commit/rollback 后仍保持 checked out 且是同一条 DBAPI 连接,既不留 idle-in-transaction,又让 pg_backend_pid() 稳定。回归测试:acquire 后先制造若干并发 engine.connect(),再断言 check_advisory_lock 为 True。


另外三处数据库层问题(本轮新排查)

  1. MySQL 下所有文本列会变成 VARCHAR(255)(建议列为阻塞项)。SQLModel AutoString 对无长度 str 列在 MySQL 有 255 默认长度兜底,实测 messages.contentknowledge_chunks.content/summary 均编译为 VARCHAR(255):严格模式报错、非严格模式静默截断,而 create_all 成功、应用照常启动。这比索引退化严重,且不是"注册一个小适配器"能解决的——需先给长文本列显式 Text
  2. 达梦/Oracle 系 42 张表里 32 张连建表 DDL 都编译不出来,原因是 JSON 列无 Oracle 方言实现。达梦走 dmPython 自有方言故数字仅供参考,但 JSON 列是本 schema 最根本的可移植性障碍,落地前需先定类型策略(如 CLOB + TypeDecorator)。
  3. PG 连接池参数为空engine_kwargs 返回 {}):无 pool_pre_ping(PG 重启/idle 超时后陈旧连接首用即 OperationalError,不再透明重连)、无 pool_recycle、无池大小配置(默认 5+10=15,而应用后台线程随 binding 数增长)。建议在 PostgresDialect.engine_kwargs 里补齐。

次要两条:init_db() 有两个调用点,同进程串行无碍,但多 worker 部署下 PG 会并发 create_all + seed_demo_data(DuplicateTable / 唯一约束冲突),SQLite 靠写锁串行化无感;不支持部分索引的后端运行期无任何约束(_validate_default_model_invariant 仅启动时扫一遍),建议与 PG 迁移约束一并写入 README 的 Database Backends 章节。

裸 SQL 收口这块确认彻底:app/ 下除 knowledge.py 有意保留的 SQLite BLOB 分支与 database.py 的 SQLite-only 迁移外无其它 text() 使用;SQLite 专有关键字全仓仅剩 feishu_connector_worker.py:405-408(契约测试运行时桩代码,不在生产路径)。

@tianling536

Copy link
Copy Markdown
Contributor Author

感谢复核——A/B 两个回归完全认可:session.commit() 让锁与连接脱钩是根因,单线程冒烟恰好每次还回同一条连接所以没抓到。本轮全部处理(commit 2f7f16e):

A/B: advisory lock 改专属 AUTOCOMMIT Connection

采纳建议方案:锁操作不再使用调用方 Session,改由方言内部常驻一条 engine.connect() 专属 Connection 并挂 isolation_level="AUTOCOMMIT"——每条语句即提即放(不留 idle-in-transaction);Connection 不归池,commit/rollback 不脱钩,pg_backend_pid() 全程稳定。acquire/check/release 全程同一条连接,fork 防护沿用文件锁的 pid 模式(继承连接只丢弃引用)。

  • A(飞书 binding 锁零互斥):子进程持锁期间连接常驻不关闭。已用双真实进程在 PG16 上验证:子进程持锁 8s,父进程探测正确报告 LOCKED。
  • B(看门狗误判停服):check 固定在专属连接上查 pg_locks,池流量不再影响 pid。冒烟脚本新增你点名的回归场景——acquire 后制造多次并发 engine.connect()/Session checkout,再断言 check 仍为 True,已在 PG16 通过。

桩测试同步重写:验证 acquire/check/release 全程同一连接、AUTOCOMMIT 已设置、Session 零 commit/rollback、重入不新建连接、竞争即关连接、fork 防护。

三处数据库层问题

1. MySQL VARCHAR(255)(阻塞项):已按列语义全面收编——51 个长文本列显式 Text(content/prompt/summary/description/error/url/credentials_enc/api_key_encrypted/skill_markdown 等,全字段逐一过)。新增两条防线测试:全 42 表 MySQL DDL 编译扫描 + 关键长文本列类型抽验(messages.content、knowledge_chunks.content、system_prompt、tools.url 等),防再退化。

2. JSON 列(达梦/Oracle 编译不出):新增 PortableJSON TypeDecorator——sqlite/pg/mysql 走原生 JSON;oracle/dm 降级 CLOB,bind 自动序列化、result 自动还原,Python 侧透明。55 处 Column(JSON) 全部切换,42 张表 oracle DDL 现全部可编译(全表扫描测试锁定)。达梦的 dmPython 方言名 dm 已纳入降级名单。

3. PG 池参数:PostgresDialect.engine_kwargspool_pre_ping=True(陈旧连接首用前 ping,不再直接 OperationalError)、pool_recycle=1800pool_size=10/max_overflow=20(后台线程随 binding 增长)。

次要两条

  • 多 worker 并发 init:README(中英)Database Backends 新增"单写者启动"说明(并发 create_all+播种会撞键,PG 部署先单 worker 初始化)。多 worker 本非本桌面端产品的目标部署形态,先文档约束;如你们希望代码层也挡(init 期 advisory lock),请示下,可后续 PR 加。
  • 运行期无约束:README 同节已写明"无部分索引后端的默认模型唯一性=代码层维护+启动校验,运行期无 DB 级约束"。

验证

  • 后端全量 1219 passed / ruff 零告警(唯一失败仍为 main 预存在的探针回归);
  • 真实 PG16 冒烟全过:create_all、并发 checkout 后锁校验仍真、部分索引、FOR UPDATE、/api/health;
  • 双进程 PG 互斥实证(飞书 binding 锁场景)。

另注:头像功能 PR(#36)后续若合并,其 user_avatars.data_url 列在 MySQL 下也需显式 Text——两个 PR 合并顺序后到时我负责对齐(或你们提醒我在另一 PR 先修)。

田领 added 9 commits July 29, 2026 22:08
- 新增 app/db/dialect.py 方言提供者(协议+注册表+
  SQLite/Postgres/Generic 实现),引擎创建走方言 kwargs
- 收编 4 处生产方言 SQL:渠道配置补丁与微信运行时补丁
  改 ORM 读改写+revision CAS,知识库 BLOB 查询方言分支,
  日分桶方言助手(PG 支持 app_timezone)
- 两个索引并入 models.py,新库 create_all 即完整 schema
- 加 psycopg[binary] 驱动与 PG 冒烟脚本

全量 1006 passed,ruff 零告警
- connector 进程锁走方言提供器:PG 用 advisory lock
  (常驻会话持锁),SQLite 等保持数据目录文件锁
- 方言文件锁内置 fork 防护(pid 判别,子进程不误解父锁)
- 飞书链路传完整 SQLAlchemy URL 替代 DB 文件路径,
  放开文件 SQLite 守卫(仅拒绝内存库)

全量 1013 passed,ruff 零告警
main 合入的渠道加固在企微重连清理 disconnect_alerted_at 时用了 SQLite 专有
json_remove;改为读-改-写 + dialect.json_config_remove,与微信 _patch_runtime_config
同一模式,PG/openGauss 可用
必修:
- 部分唯一索引移出 metadata,改按方言 DDL 事件创建(sqlite/postgresql),
  MySQL/达梦不再静默退化为全量唯一索引;init_db 对无部分索引后端做
  每租户单默认模型启动校验(supports_partial_index 落地消费)
- BaseDialect 文件锁仅对 SQLite 文件库有效,MySQL/达梦响亮拒绝,
  不再把库名当文件路径在 CWD 下静默错锁
- PG advisory lock:双 int4 键 + acquire/release 即提交(不再永久
  idle-in-transaction);新增 pg_locks 存活校验 + connector 锁看门狗,
  断连静默失效时渠道服务主动降级停止
- _patch_runtime_config/_patch_binding_config_key 双向改 SELECT FOR UPDATE,
  关闭整份 JSON 读-改-写与 API 侧补丁的丢写窗口(SQLite 自动省略)
- gaussdb-smoke 工作流锁调用补 session 参数并装 [dev,postgres]

设计对齐:
- 飞书 binding 锁方言化:锁身份=数据库作用域+binding key,跨 HOME/容器
  互斥;移除含密码 URL 指纹;测试不再写真实用户目录
- psycopg[binary] 移为可选依赖 postgres 组,缺驱动时启动给明确安装指引
- README/.env 写明:PG/高斯仅支持全新库(无迁移通路)、app_timezone
  仅 PG 生效、SQLite 配了会告警

小项:wechat 补丁锁按 binding 清理防泄漏;test_db_dialect 注册表快照
还原防污染;日分桶口径差异写入 docstring;refresh 注释改正;空行补齐

验证:1214 passed(唯一失败为 main 预存在探针回归)、ruff 零告警、
真实 PG16 冒烟含 advisory lock 闭环/部分索引/FOR UPDATE 全过
OpenBMB#3 修法回归(commit 让锁与连接脱钩)重构:
- PG advisory lock 改由方言常驻专属 engine.connect()+AUTOCOMMIT Connection,
  不归池、commit/rollback 不脱钩、pg_backend_pid 全程稳定——修复飞书
  binding 锁 PG 零互斥(A)与看门狗并发 checkout 误判停服(B);fork 防护同文件锁
- PostgresDialect.engine_kwargs 补 pool_pre_ping/pool_recycle/pool_size,
  陈旧连接首用不再直接 OperationalError

数据库层新排查:
- 51 个长文本列显式 Text(content/prompt/summary/error/url/credentials_enc 等),
  MySQL 不再静默退化为 VARCHAR(255)(SQLModel AutoString 255 兜底)
- 新增 PortableJSON TypeDecorator:sqlite/pg/mysql 原生 JSON,oracle/dm 降级
  CLOB+自动序列化还原,42 张表 oracle DDL 全部可编译

测试:锁专属连接/AUTOCOMMIT/重入/fork/竞争用例,全表 mysql+oracle DDL 编译
扫描,MySQL 长文本列抽验,PortableJSON 双方言往返;真实 PG16 冒烟新增
并发 checkout 后锁校验仍真的回归场景,双进程验证 binding 锁互斥

README:多 worker 并发 init 风险提示、无部分索引后端运行期无约束说明
头像 base64 可达 ~2.8MB,MySQL 下 TEXT(64KB) 仍会截断;新增 HugeText =
Text().with_variant(MEDIUMTEXT, 'mysql')(oracle→CLOB,pg/sqlite→TEXT 均无界),
DDL 抽验同步覆盖
@tianling536
tianling536 force-pushed the feat/multi-db-clean branch from 2f7f16e to 3877a55 Compare July 29, 2026 14:13
@tianling536

Copy link
Copy Markdown
Contributor Author

跟进上轮回复末尾报备的跨 PR 尾巴:头像功能 PR(#36)已合并进 main,本分支已 rebase 到 3fc9653 并完成对齐(commit 3877a55):

  • user_avatars.data_url 显式化为 HugeText(Text().with_variant(MEDIUMTEXT(), "mysql"))——头像 base64 可达 ~2.8MB,MySQL 下 TEXT(64KB)仍会截断,VARCHAR(255) 更不必说;oracle→CLOB、pg/sqlite→TEXT 均无界;
  • DDL 抽验测试同步把 user_avatars.data_url 纳入长文本列断言。

rebase 无冲突;全量 1240 passed、ruff 零告警、真实 PG16 冒烟(含 user_avatars 新表)全过。当前 PR 与 main 无冲突,保持 MERGEABLE。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants