Postgres real backend: psycopg adapter, schema, migration

Replace the PostgresDatabase skeleton with a real implementation:
psycopg (v3) first, psycopg2 second, lazy load, missing driver
still raises PostgresDriverMissingError with no silent SQLite
fallback. A sqlite3-style adapter (PgConnection/PgCursor/PgRow)
translates ? placeholders to %s outside string literals, maps
UNIQUE/FK violations, lock errors and connection failures to
explicit Postgres error types, and handles INSERT OR REPLACE,
PRAGMA table_info, AUTOINCREMENT and FTS5 refusal. POSTGRES_SCHEMA
covers all business tables with tasks result columns; server
init_db uses it under CLOUD_DB_BACKEND=postgres, SQLite path
unchanged. migrate_sqlite_to_postgres.py copies rows with
ON CONFLICT DO NOTHING and reseeds identity sequences.
Tests: cloud 239 (235 pass, 4 skipped without a real server),
host 27 pass; real-server tests skip unless CLOUD_TEST_PG_DSN
is set.
This commit is contained in:
host committed 2026-10-26 03:23:06 +08:00
1 parent a19e77ee44
commit dc3f572b2b
10 files changed
+1542 -66

No files matched your search

+79 -14
View File
@@ -21,7 +21,7 @@ cloud/server.py(控制面,ThreadingHTTPServer)
├─ 推送订阅与分发:cloud/push.py(订阅表 + 偏好过滤)
├─ 认证 / 权限 / 审计:cloud/auth.py(AuthStore)
├─ 数据库接口:cloud/db.py(Database 接口 + SQLite 实现,
│ WAL + busy_timeout + 有限重试;Postgres 只是骨架,见下)
│ WAL + busy_timeout + 有限重试;Postgres 已接通,见下)
├─ 并发:cloud/db.py(WAL + busy_timeout + 有限重试)
└─ webhook 入站:cloud/webhooks.py(验签 + 解析,只解析不执行)
```
@@ -126,8 +126,22 @@ server 的连接统一走 `cloud/db.py`:WAL 模式、busy_timeout 5000ms、
WAL / busy_timeout / 有界重试;模块级函数 `connect` /
`execute_with_retry` / `read_all` / `transaction` /
`is_locked_error` 仍可用,行为不变。
- `PostgresDatabase`:骨架,不是可用后端。即使驱动已装,其
`connect` / 执行 / 查询 / 事务方法仍抛 `NotImplementedError`。
- `PostgresDatabase`:真实 Postgres 实现。调用方仍写 sqlite3
风格 SQL(`?` 占位符、`row["col"]`、`conn.execute` /
`fetchall` / `commit` / `rollback` / `close`),由
`PgConnection` / `PgCursor` / `PgRow` 适配层翻译成 Postgres
方言(`?` -> `%s`,字符串字面量里的 `?` 不动;
`INSERT OR REPLACE` -> `ON CONFLICT DO UPDATE`;
`PRAGMA table_info` -> information_schema;
`AUTOINCREMENT` -> `GENERATED BY DEFAULT AS IDENTITY`;
FTS5 虚拟表在 PG 上明确报错、KBStore 走 LIKE 分支)。
驱动异常映射成 `PostgresIntegrityError` /
`PostgresOperationalError` / `PostgresConnectionError`,
绝不把驱动异常原样漏成 sqlite3 异常。模块级 `connect()`
按 `CLOUD_DB_BACKEND` 分发,因此 server / AuthStore /
MemoryStore / KBStore / CostStore 等沿用 `connect()` 的
调用点无需重构即可走 Postgres;但 `backup.py` 的 SQLite
在线备份 API 仍只适用于 SQLite,Postgres 备份请用 pg_dump。
- 工厂 `create_database(backend, db_path)` / `database_from_env()`:
两个后端提供同一组接口方法与签名,见
`cloud/tests/test_db_backend.py` 的接口契约测试。
@@ -150,14 +164,65 @@ psycopg,写明“需安装 psycopg”),绝不静默回落到 SQLite,
也绝不假装已接通 Postgres。`create_server` 在建表之前就做这
一步校验,所以失败时不会先建出一个 SQLite 库假装可用。
> 高并发换 Postgres 是下一阶段的运维项,不是本轮代码一改就
> 自动变生产。接口层 + 骨架只解决了“以后能换”,真正上生产还
> 需要:真实 Postgres 部署(含备份、连接数与权限)、安装
> psycopg 驱动、写迁移脚本并把现有 SQLite 表结构与数据迁过去、
> 把 server / AuthStore / MemoryStore / KBStore / CostStore 等
> 目前仍直连 SQLite 的调用点逐一接到新后端并重测。在这些完成
> 之前,即使把 `CLOUD_DB_BACKEND` 设成 `postgres`,server 也只会
> 在启动时明确报错,不会跑起来——这是有意为之的诚实边界。
### Postgres 部署步骤(不要只改一个环境变量)
改 `CLOUD_DB_BACKEND=postgres` 本身不会迁移任何数据,也不会
建库——只改它、数据还在旧 SQLite 文件里,新库是空的。完整
步骤是:
1. 装驱动(psycopg v3 优先):
```bash
pip install "psycopg[binary]"
# 其次才用 psycopg2:pip install psycopg2-binary
```
2. 在 Postgres 里建库与用户(示例库名与用户名请按实际部署改,
密码绝不写进仓库文件):
```sql
CREATE DATABASE cloud_control_plane;
CREATE USER cloud_app WITH PASSWORD '<仅在此处输入>';
GRANT ALL PRIVILEGES ON DATABASE cloud_control_plane TO cloud_app;
```
注意:表由应用启动时 `init_postgres_schema` 建(
`cloud/db.py` 的 `POSTGRES_SCHEMA`,与 SQLite 表名/列名
一一对应,含 tasks 的 6 个结果列;KB 在 PG 上无 FTS 表,
走 LIKE 搜索)。若数据库用户不是库 owner,另需对
public schema 授权(`GRANT ALL ON SCHEMA public TO cloud_app`),
否则建表会报权限错——这是部署侧问题,不是代码 bug。
3. 设环境变量并启动(`--db` 参数在 PG 模式下被忽略,连接
参数只走环境变量;`--db-backend postgres` 与环境变量等价):
```bash
export CLOUD_DB_BACKEND=postgres
export CLOUD_DB_HOST=127.0.0.1 CLOUD_DB_PORT=5432
export CLOUD_DB_NAME=cloud_control_plane
export CLOUD_DB_USER=cloud_app
export CLOUD_DB_PASSWORD='<仅在此处输入>'
python3 -m cloud.server --host 127.0.0.1 --port 8765
```
4. 迁旧数据(可选,须在启动建表之后、切流量之前跑;跑两次
不重复插,第二次各表 inserted 应为 0):
```bash
python3 -m cloud.migrate_sqlite_to_postgres \
--sqlite /path/to/cloud-control-plane.db
# 输出示例:projects: 3 / tasks: 12 / ... / total inserted: N
# (只打印表名与行数,不打印任何密码或密钥)
```
迁移脚本逻辑在 `cloud/migrations.py`(
`migrate_sqlite_to_postgres`),身份列的序列会在迁完后
拨到当前最大 id,避免后续自动生成撞主键。
5. 回退到 SQLite:把 `CLOUD_DB_BACKEND` 改回 `sqlite`(或
删掉它,默认即 sqlite),并把 `--db` 指回原来的 SQLite
文件即可。但注意:切到 Postgres 之后新写的数据只在
Postgres 里,SQLite 文件里没有——回退不会自动把 PG 数据
导回 SQLite;如需回迁,须另写 PG -> SQLite 脚本(本轮
未提供,不要假装存在)。
6. 备份:Postgres 侧用 `pg_dump`,不要用 `cloud/backup.py`
(那是 SQLite 在线备份 API,只适用于 SQLite 文件)。
> 诚实边界(仍需在真实服务器上验证,见下文“未验证清单”):
> 本机 VM 既无 psycopg 驱动、也无 Postgres 服务器,因此真实
> PG 路径在本机只能 skip(见测试一节),不能谎称已在真实
> PG 上跑通过。已验证的是:适配层翻译逻辑(fake 驱动)、
> schema 语句覆盖、SQLite 全量回归不坏。
| 方法与路径 | 权限 | 说明 |
| --- | --- | --- |
@@ -277,8 +342,8 @@ interrupted 且终态任务 cancel 返回 400、心跳后 sweep 不扫刚心跳
端点(state + 授权 URL + 回调),但真实应用注册、回调公网地址、
已有账号绑定仍缺;推送已接成订阅 + 分发端点,但 Web Push /
APNs / FCM 的真实凭证与发送端仍缺;单机 SQLite 在高并发写下
仍有上限,生产量上来后需换 Postgres(接口层与骨架已就绪,
但真实部署、驱动安装与迁移脚本仍缺,见上文“数据库后端选择”);
仍有上限,生产量上来后需换 Postgres(Postgres 后端已接通,
但真实服务器部署与验证仍缺,见上文“数据库后端选择”);
worker 执行已接通(见下文“worker 常驻与真取消”),云端执行、
真实通知与真实模型回合仍受 key 供给与云账号卡点限制。
@@ -311,7 +376,7 @@ interrupted 且终态任务 cancel 返回 400、心跳后 sweep 不扫刚心跳
失败明确报错不静默;备份文件本身就是可打开的 SQLite 库。
- DB 接口迁移:server 及 auth/memory/kb/cost/oauth/push/webhooks
的 Store 连接已统一走 cloud.db 的 WAL + busy_timeout 连接
(Postgres 后端仍是骨架,驱动缺失时启动即失败、不先建 SQLite
(Postgres 后端已接通,驱动缺失时启动即失败、不先建 SQLite
文件,这一点迁移后未退化)。
## Docker 沙箱执行器(本轮新增)