feat: 数据库层方言可插拔(SQLite → 高斯/MySQL/达梦易扩展) - #39
Conversation
| 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 |
dd9add0 to
7945867
Compare
|
已 rebase 到最新 main(cd64f92,含渠道加固合并),冲突已解决,当前 MERGEABLE。 本次 rebase 要点:
验证:后端全量 1201 passed、ruff 通过。 另注: |
dxyhymn
left a comment
There was a problem hiding this comment.
总体意见
方言收口的结构和"默认实现即通用实现"的取舍我认可,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-325 用 sqlite_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_route、qrcode_redirect_baseurl)并不递增 config_revision。于是存在这个交错:
- connector 在
wechat.py:95读到 config,revision = R - API 侧
_patch_binding_config_key补上auto_route,revision 仍是 R - connector 在
wechat.py:110-119把整份 config 写回,WHERE config_revision = R成立 → rowcount 1 - 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:35 用 render_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注册进全局_DIALECTS,get_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 持锁会话常驻,不是凑数的测试。
|
感谢如此细致的复核,特别是三处非 SQLite 后端的静默错误——全部认可并已修复(commit 4b6783c)。逐条回复: 必须修1. 部分唯一索引在 MySQL/达梦退化为全量唯一 2. GenericDialect 文件锁用错路径 3. PG 持锁会话 idle-in-transaction + 无失效检测 4. _patch_runtime_config 丢写窗口 5. gaussdb-smoke 工作流跑不通 设计对齐PG 迁移通路:选择显式约束方案——README(中英)新增 Database Backends 章节写明"仅支持全新库、无迁移通路、Alembic 为后续任务", 飞书 binding 锁:已方言化。锁身份 = 数据库作用域 + psycopg 可选依赖:移入 明文密码扩散:指纹随 binding 锁方言化整体消失(轮换密码不再改变锁身份);子进程建连仍必须持有完整 URL,这是凭据传递的固有需求,未再扩散到其它位置。 小问题
验证后端全量 1214 passed(1174→1214,新增 13 个用例)、ruff 零告警;真实 PG16 冒烟增强后全过(advisory lock 闭环、部分索引存在、FOR UPDATE 补丁、日分桶、/api/health)。 另: |
dxyhymn
left a comment
There was a problem hiding this comment.
复核结论(4b6783c)
已确认修好(实测):部分唯一索引改按方言 DDL 事件创建、metadata 不再挂载,MySQL/达梦不再退化为全量唯一;BaseDialect 对非 SQLite 库响亮拒绝;with_for_update() 在 pg 编译出 FOR UPDATE、sqlite 自动省略;workflow 锁调用签名补齐;文档/可选依赖/小项均已落实。本地 1177 passed,无回归。
但 #3 的修法引入两个新缺陷,建议先处理。 根因同一个:acquire_advisory_lock(dialect.py:223)末尾新增的 session.commit() 让锁与连接脱钩——PG 的 advisory lock 属于连接,而 Session 在事务结束时会把连接交还池。
A. 飞书 binding 级锁在 PG 上零互斥(比修复前更糟)
_DialectBindingLock(feishu_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。
另外三处数据库层问题(本轮新排查)
- MySQL 下所有文本列会变成 VARCHAR(255)(建议列为阻塞项)。SQLModel
AutoString对无长度str列在 MySQL 有 255 默认长度兜底,实测messages.content、knowledge_chunks.content/summary均编译为VARCHAR(255):严格模式报错、非严格模式静默截断,而create_all成功、应用照常启动。这比索引退化严重,且不是"注册一个小适配器"能解决的——需先给长文本列显式Text。 - 达梦/Oracle 系 42 张表里 32 张连建表 DDL 都编译不出来,原因是 JSON 列无 Oracle 方言实现。达梦走 dmPython 自有方言故数字仅供参考,但 JSON 列是本 schema 最根本的可移植性障碍,落地前需先定类型策略(如 CLOB + TypeDecorator)。
- 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(契约测试运行时桩代码,不在生产路径)。
|
感谢复核——A/B 两个回归完全认可: A/B: advisory lock 改专属 AUTOCOMMIT Connection采纳建议方案:锁操作不再使用调用方 Session,改由方言内部常驻一条
桩测试同步重写:验证 acquire/check/release 全程同一连接、AUTOCOMMIT 已设置、Session 零 commit/rollback、重入不新建连接、竞争即关连接、fork 防护。 三处数据库层问题1. MySQL VARCHAR(255)(阻塞项):已按列语义全面收编——51 个长文本列显式 2. JSON 列(达梦/Oracle 编译不出):新增 3. PG 池参数: 次要两条
验证
另注:头像功能 PR(#36)后续若合并,其 |
- 新增 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 抽验同步覆盖
2f7f16e to
3877a55
Compare
|
跟进上轮回复末尾报备的跨 PR 尾巴:头像功能 PR(#36)已合并进 main,本分支已 rebase 到 3fc9653 并完成对齐(commit 3877a55):
rebase 无冲突;全量 1240 passed、ruff 零告警、真实 PG16 冒烟(含 user_avatars 新表)全过。当前 PR 与 main 无冲突,保持 MERGEABLE。 |
概述
将数据库层从"SQLite 实现"升级为方言可插拔:默认走 ORM/标准 SQL(所有数据库通用),无法通用的少数点收口到方言提供者接口。新增一种数据库 = 注册一个小适配器(锁/日期/能力声明),业务代码零改动。高斯(openGauss)经 PostgreSQL 协议直接可用,MySQL/达梦按 docstring 中的扩展方式接入。
设计
新模块
backend/app/db/dialect.pyDatabaseDialect协议:engine_kwargs、day_bucket、JSON 配置读改写、acquire/release/check_advisory_lock、supports_partial_index/session_scoped_advisory_lock能力声明;sqlite → SQLiteDialect、postgresql → PostgresDialect(高斯直接复用)、未知名 →GenericDialect(默认实现);cast(..., Date)),无适配器的新后端也能先跑起来;但安全语义不降级:文件锁仅对 SQLite 文件库有效,其它后端无原生锁实现时响亮拒绝;部分唯一索引只在 sqlite/postgresql 方言创建,其它后端由启动校验兜底。改造点
get_dialect(backend).engine_kwargs(url)(SQLite 行为逐字不变);api/channels.py配置补丁与wechat.py::_patch_runtime_config(JSON1)→ ORM 读-改-写 + SELECT ... FOR UPDATE 行锁(双向互斥,关闭丢写窗口;SQLite 自动省略);knowledge.py的CAST AS BLOB→ 方言分支;投递日志日分桶 → 方言助手(PG 支持app_timezone);init_db启动校验兜底;psycopg[binary]为可选依赖postgres组(默认 SQLite 桌面端不背 ~20MB 二进制),缺驱动时启动给明确安装指引。验证
test_verification_runs_bounded_text_stream_and_json_probes,纯净 main 同样复现,与本 PR 无关);scripts/smoke_postgres.sh):create_all、部分索引存在、advisory lock acquire→check→release 闭环、FOR UPDATE 补丁路径、日分桶、知识库、应用启动 + /api/health,全部通过;.github/workflows/gaussdb-smoke.yml(x86 CI 一键起 openGauss 容器跑全套方言冒烟,锁调用已带 session、依赖组已对齐),烦请维护者在贵方 runner dispatch 一次。当前限制(已在 README 明示)
create_all初始化),无存量 schema 迁移通路(Alembic 为后续任务),启动期有告警;APP_TIMEZONE仅对 PostgreSQL 日分桶生效,SQLite 恒为服务器本地时区(配置了会告警);后续(登记项,不在本 PR)