From 0dc0c4663f5b61a182945151ebd533aa693b1411 Mon Sep 17 00:00:00 2001 From: oliver Date: Thu, 14 May 2026 15:15:08 +0800 Subject: [PATCH] docs(pg): slim migration guide to shell one-liners Migrator: only refuse import when SQLite and PG both have rows for a table (PG seed rows ok when SQLite empty). Add unit test for conflict helper. Co-authored-by: Cursor --- docs/ASSISTANT_PG_MIGRATION.md | 201 +++--------------- .../migrate_assistant_sqlite_to_postgresql.py | 51 +++-- tests/test_migrate_assistant_sqlite_to_pg.py | 10 + 3 files changed, 73 insertions(+), 189 deletions(-) diff --git a/docs/ASSISTANT_PG_MIGRATION.md b/docs/ASSISTANT_PG_MIGRATION.md index df2ae72b..b923b6ae 100644 --- a/docs/ASSISTANT_PG_MIGRATION.md +++ b/docs/ASSISTANT_PG_MIGRATION.md @@ -1,204 +1,61 @@ -# Assistant 库:SQLite → PostgreSQL 迁移指南 +# Assistant 库:SQLite → PostgreSQL -本文说明如何将 **助手主持久化**(会话、`chat_message`、设置、租户等)从默认 **SQLite** 迁到 **PostgreSQL**,以及割接前后的操作顺序与排障要点。实现上仍通过同一套 `SqliteStore` API;仅底层连接与 SQL 方言不同。 - -更完整的环境变量说明见 [`ENVIRONMENT_VARIABLES.md`](./ENVIRONMENT_VARIABLES.md) 中的「存储与迁移」一节;本文侧重 **操作步骤与脚本**。 +环境变量与字段说明见 [`ENVIRONMENT_VARIABLES.md`](./ENVIRONMENT_VARIABLES.md)。下文只列 **仓库根** 下各场景 **一条命令**(壳脚本;底层 migrator 由壳调用,不必手抄)。 --- -## 1. 何时需要读本文 - -- 生产或预发要把 **assistant 主库** 从 `data/ai_ops.sqlite`(或自定义 SQLite 路径)迁到 **PostgreSQL**。 -- 需要 **一次性导入历史数据**,再切换 `AIA_ASSISTANT_DB_BACKEND=postgresql` 上线。 -- 排查「PG 上表空 / 导入失败 / 网关连错库」等问题。 - -若只做 **新部署、无历史 SQLite 数据**:在空库上建 schema 后,直接配置 PG 与 `postgresql` 后端即可,**不必**跑数据导入脚本。 - ---- - -## 2. 核心概念 - -| 项目 | 说明 | -|------|------| -| 后端选择 | `AIA_ASSISTANT_DB_BACKEND`:`sqlite`(默认)或 `postgresql`(别名 `pg` / `postgres` 等)。 | -| PG 连接串 | `AIA_ASSISTANT_DATABASE_URL`(或 `OPS_ASSISTANT_DATABASE_URL` / `AIA_ASSISTANT_PG_DSN` 等别名)。SQLAlchemy 使用时会规范为 `postgresql+psycopg://…`。 | -| Schema 来源 | 推荐 **`alembic upgrade head`**(`alembic.ini` 的 `script_location = assistant_migrations`)。等价方式:在目标库执行 `svc/persistence/ddl/postgresql_bootstrap.sql`(与迁移首版语义一致,二选一即可,勿混用导致重复建表报错)。 | -| 数据导入 | `runtime/operations/scripts/migrate_assistant_sqlite_to_postgresql.py`:**只拷数据**,不建表;要求目标 PG **已存在 schema**。默认要求 PG 中待拷贝表 **为空**(可用 `--allow-non-empty` 跳过检查,自担重复/FK 风险)。 | -| 网关 | 进程内通过 `get_assistant_store()` 单例连库;**切换 URL 或后端后需重启网关**(必要时 `reset` 相关进程环境)。 | - ---- - -## 3. 推荐总体顺序( checklist ) - -1. **在 PostgreSQL 上创建数据库**(例如 `oclaw`),为应用用户授予 `CREATE` / `USAGE` on schema `public` 及后续表所需权限。 -2. 在**目标库**上完成 **schema**:`alembic upgrade head`(仓库根执行,且已配置指向该库的 `DATABASE_URL` / 环境变量),或执行 `postgresql_bootstrap.sql`。 -3. **不要**在未建表的空库上直接启动依赖全表的生产网关(会报错或产生不一致状态)。应完成第 2 步后再启动,或先保持 `sqlite` 仅跑导入机。 -4. 使用下文 **割接脚本** 或 **直接调用 migrator**:先做 **`--dry-run`**,确认行数与表列表无误后再实导。 -5. 将运行环境改为 **`AIA_ASSISTANT_DB_BACKEND=postgresql`** 并设置 **`AIA_ASSISTANT_DATABASE_URL`**(与导入时**同一**目标库)。 -6. **重启**网关及所有使用 `get_assistant_store()` 的 worker,使新配置生效。 -7. 验证管理台会话列表、发一条测试消息、必要时跑 `AIA_TEST_PG_URL` 相关测试(见下文)。 - ---- - -## 4. Schema:Alembic(推荐) - -仓库根: +**在目标 PostgreSQL 上建空表(schema,二选一)** ```bash -# 已设置 AIA_ASSISTANT_DATABASE_URL 指向目标 PG,且已安装依赖(含 alembic、psycopg) -cd /path/to/oclaw -export PYTHONPATH=. -alembic upgrade head +cd /path/to/oclaw && export PYTHONPATH=. && alembic upgrade head ``` -首版迁移 `assistant_migrations/versions/001_assistant_pg_initial.py` 会读取 `svc/persistence/ddl/postgresql_bootstrap.sql` 并逐条执行(仅当方言为 `postgresql` 时)。 - -**说明**:`downgrade()` 未实现;回滚依赖 **数据库备份 / 文件备份**,而非 Alembic 反向迁移。 +(若不用 Alembic:用 `psql` 等对目标库执行一次 `svc/persistence/ddl/postgresql_bootstrap.sql`,**勿**又对同一库再跑一遍 Alembic。) --- -## 5. 数据导入:`migrate_assistant_sqlite_to_postgresql.py` - -路径:`runtime/operations/scripts/migrate_assistant_sqlite_to_postgresql.py`。 - -**行为摘要** - -- 从 SQLite 读表顺序(按 `PRAGMA foreign_key_list` 拓扑),仅插入 **SQLite 与 PG `public` 中同名的共有列**。 -- 默认若任一待拷贝表在 PG 中 **已有行** 则 **退出**(避免重复);`--allow-non-empty` 可关闭该检查。 -- `--dry-run`:只统计源表行数,**不写 PG**。 - -**常用参数** - -| 参数 | 含义 | -|------|------| -| `--sqlite PATH` | 源 SQLite 文件路径。 | -| `--sqlite-from-db-path` | 使用 `db_path()`(受 `AIA_ASSISTANT_DB_PATH` 等影响)作为源库。 | -| `--load-system-env` | 合并 `_local/system.env`(与网关 `load_system_env` 思路一致,便于设备上只维护一份 env)。 | -| `--pg-url URL` | 目标 PG;不设则从环境变量读取(见脚本 `--help`)。 | -| `--dry-run` | 预演。 | -| `--allow-non-empty` | 允许目标表非空(慎用)。 | -| `--batch N` | 批量插入行数,默认 500。 | - -**示例(本机预演)** - -```bash -cd /path/to/oclaw -export PYTHONPATH=. -export AIA_ASSISTANT_DATABASE_URL='postgresql+psycopg://USER:PASS@127.0.0.1:5432/oclaw' -python runtime/operations/scripts/migrate_assistant_sqlite_to_postgresql.py \ - --load-system-env --sqlite-from-db-path --dry-run -# 确认无报错后去掉 --dry-run 再执行 -``` - -**显式路径示例** - -```bash -python runtime/operations/scripts/migrate_assistant_sqlite_to_postgresql.py \ - --sqlite data/ai_ops.sqlite \ - --pg-url 'postgresql+psycopg://postgres:pass@127.0.0.1:5432/oclaw' -``` - ---- - -## 6. 割接包装脚本(备份 + dry-run + 导入) - -在跑正式导入前,**强烈建议**备份 SQLite 文件。脚本会把副本放到 `data/pg_cutover_backups/`(可改参数或环境变量)。 - -### 6.1 Windows(PowerShell) - -`runtime/operations/scripts/cutover_sqlite_to_postgresql.ps1` - -典型用法: +**Windows:备份 SQLite → 预演 → 导入 PG**(`_local\system.env` 里已配好 `AIA_ASSISTANT_DATABASE_URL` 等) ```powershell -cd D:\path\to\oclaw -# 若 URL 写在 _local\system.env 中: -.\runtime\operations\scripts\cutover_sqlite_to_postgresql.ps1 -LoadSystemEnv -# 或显式传入: -.\runtime\operations\scripts\cutover_sqlite_to_postgresql.ps1 -PgUrl "postgresql+psycopg://..." +cd D:\path\to\oclaw; .\runtime\operations\scripts\cutover_sqlite_to_postgresql.ps1 -LoadSystemEnv ``` -常用开关:`-SqlitePath`、`-BackupDir`、`-SkipDryRun`、`-DryRunOnly`、`-NoBackup`(不推荐生产)、`-LoadSystemEnv`。 +--- -### 6.2 Linux / bash +**Linux / macOS:同上** -`runtime/operations/scripts/cutover_sqlite_to_postgresql.sh` - -- 环境变量:`SQLITE_PATH`、`BACKUP_DIR`、`NO_BACKUP`、`SKIP_DRY_RUN`、`DRY_RUN_ONLY` 等(见脚本头部注释)。 -- 默认会 `--load-system-env` 并解析 SQLite 路径。 - -仅 **导入**(不复制备份逻辑时也可用轻量包装): - -`runtime/operations/scripts/assistant_import_sqlite_to_postgresql.sh`(内部调用 migrator 的 `--load-system-env --sqlite-from-db-path`)。 +```bash +cd /path/to/oclaw && chmod +x runtime/operations/scripts/cutover_sqlite_to_postgresql.sh && ./runtime/operations/scripts/cutover_sqlite_to_postgresql.sh +``` --- -## 7. 切换运行时与网关 +**Linux / macOS:只要导入、不要割接备份流程** -导入完成后: +```bash +cd /path/to/oclaw && chmod +x runtime/operations/scripts/assistant_import_sqlite_to_postgresql.sh && ./runtime/operations/scripts/assistant_import_sqlite_to_postgresql.sh --dry-run +``` -1. 设置 **`AIA_ASSISTANT_DB_BACKEND=postgresql`**。 -2. 设置 **`AIA_ASSISTANT_DATABASE_URL`**(与导入目标一致)。 -3. 重启 **网关**、**wiki worker**、**渠道 worker** 等所有持有 DB 连接的进程。 - -**注意**:`get_assistant_store()` 在进程内会按 `(backend, 连接键)` 缓存;连接键对 PostgreSQL 使用 **URL 的哈希**(避免在内存键中携带明文口令)。改环境后必须重启进程,否则会连旧库。 +确认后去掉 `--dry-run` 再执行同一条。 --- -## 8. 验证与自动化测试 +**导入完成后切到 PG 跑网关** -- 管理台:会话列表、打开历史会话、发送一条新消息,确认读写正常。 -- 单测:设置环境变量 **`AIA_TEST_PG_URL`** 后,部分用例会对真实 PG 跑冒烟(例如 `tests/test_database_backend.py`)。详见 [`ENVIRONMENT_VARIABLES.md`](./ENVIRONMENT_VARIABLES.md) 中 `AIA_TEST_PG_URL` 说明。 +设置 `AIA_ASSISTANT_DB_BACKEND=postgresql` 与 `AIA_ASSISTANT_DATABASE_URL`(与导入目标一致),**重启**网关及所有连库的 worker。 --- -## 9. 备份与回滚 +**仅清空 PG 里会话数据(危险,确认库名)** -- **割接脚本** 默认将 SQLite 复制到 `data/pg_cutover_backups/<原名>_pre_pg_<时间戳>.sqlite`。 -- 若导入后需回退到 SQLite:恢复该备份文件为 `data/ai_ops.sqlite`(或你的 `AIA_ASSISTANT_DB_PATH`),将 `AIA_ASSISTANT_DB_BACKEND` 改回 `sqlite` 或未设置,重启服务。 -- PostgreSQL 侧回滚:依赖实例级备份(快照 / `pg_dump`),不在应用脚本内自动完成。 +```powershell +# Windows:见脚本说明 +.\runtime\operations\scripts\clear_postgres_chat_sessions.ps1 +``` ---- +```bash +# 或仓库根(须设 AIA_CONFIRM_CHAT_SESSION_WIPE=1,见脚本) +export PYTHONPATH=. && python runtime/operations/scripts/clear_all_chat_sessions.py --yes --postgresql +``` -## 10. 仅清空 PG 中的对话数据(可选) - -若要在 **PostgreSQL** 上删除所有会话及相关行(危险操作),使用: - -- `runtime/operations/scripts/clear_postgres_chat_sessions.ps1`(加载 `_local/system.env`、强制 PG、设置确认变量后调用 Python),或 -- `python runtime/operations/scripts/clear_all_chat_sessions.py --yes --postgresql` - 且必须同时设置 **`AIA_CONFIRM_CHAT_SESSION_WIPE=1`**。 - -详见脚本内说明;**勿**在未确认库名时针对生产执行。 - ---- - -## 11. 常见问题(排障) - -| 现象 | 可能原因 | 处理 | -|------|----------|------| -| 导入报「表非空」 | PG 已有数据 | 换新库或 `--allow-non-empty`(谨慎) | -| 网关仍像连 SQLite | 环境未生效 / 未重启 | 检查进程环境、`assistant_store` 单例 | -| `invalid_credentials` / 连接失败 | URL 或网络或权限 | 用 `psql` 或 `psycopg` 单独测连 | -| 部分表未拷贝 | PG 无同名表 | 先 `alembic upgrade head`;查看 migrator 打印的 `skip (not in PG public schema)` | -| 仍为 SQLite 但已填 PG URL | 后端未切到 `postgresql` | 首次解析 URL 会 `UserWarning`;确认 `AIA_ASSISTANT_DB_BACKEND` 与重启 | -| 连接 PG 长时间无响应 | 网络/防火墙 | 可调 `AIA_ASSISTANT_PG_CONNECT_TIMEOUT`(默认 10s,见 `ENVIRONMENT_VARIABLES.md`) | - ---- - -## 12. 相关文件索引 - -| 路径 | 用途 | -|------|------| -| `alembic.ini` | Alembic 配置,`script_location = assistant_migrations` | -| `assistant_migrations/` | PG schema 版本链 | -| `svc/persistence/ddl/postgresql_bootstrap.sql` | 初始 DDL(与首版迁移同源) | -| `svc/persistence/pg_adapter.py` | psycopg 连接(含 `connect_timeout`) | -| `svc/persistence/assistant_store.py` | `get_assistant_store()` 工厂 | -| `runtime/operations/scripts/migrate_assistant_sqlite_to_postgresql.py` | 数据导入 | -| `runtime/operations/scripts/cutover_sqlite_to_postgresql.ps1` / `.sh` | 备份 + 预演 + 导入 | -| `runtime/operations/scripts/clear_all_chat_sessions.py` | 清空会话(需确认 env) | -| `docs/ENVIRONMENT_VARIABLES.md` | 环境变量权威列表 | - ---- - -**维护建议**:若变更割接流程或新增迁移步骤,请同步更新本文与 `ENVIRONMENT_VARIABLES.md` 中的摘要,避免文档分叉。 +排障与超时等见 `ENVIRONMENT_VARIABLES.md`。 diff --git a/runtime/operations/scripts/migrate_assistant_sqlite_to_postgresql.py b/runtime/operations/scripts/migrate_assistant_sqlite_to_postgresql.py index 132c81f7..76e56b87 100644 --- a/runtime/operations/scripts/migrate_assistant_sqlite_to_postgresql.py +++ b/runtime/operations/scripts/migrate_assistant_sqlite_to_postgresql.py @@ -6,8 +6,10 @@ Tables are copied in **foreign-key safe order** (from SQLite ``PRAGMA foreign_key_list``), and only **columns present in both** SQLite and PostgreSQL are inserted. -By default the script aborts if any target table in ``public`` already has rows (empty PG only). -Use ``--allow-non-empty`` to skip that check (you are responsible for avoiding duplicates / FK errors). +By default the script aborts if **both** sides would supply rows for the same table: SQLite has at +least one row **and** PostgreSQL already has rows (typical after a gateway run seeded ``llm_profile`` +etc.). Empty SQLite tables may coexist with PostgreSQL seed rows. Use ``--allow-non-empty`` to skip +that check (duplicates / primary-key violations are your risk). **PostgreSQL URL** is taken from ``--pg-url`` if set; otherwise from the first non-empty environment variable among ``AIA_ASSISTANT_DATABASE_URL``, ``OPS_ASSISTANT_DATABASE_URL``, ``AIA_ASSISTANT_PG_DSN``, @@ -172,19 +174,34 @@ def _common_columns(sl: sqlite3.Connection, pg: psycopg.Connection, table: str) return [c for c in sc if c in pc and _IDENT.fullmatch(c)] -def _assert_pg_tables_empty(pg: psycopg.Connection, tables: Iterable[str]) -> None: - with pg.cursor() as cur: - for t in sorted(set(tables)): - _require_ident(t) - cur.execute(f'SELECT COUNT(*) AS n FROM "{t}"') - row = cur.fetchone() - n = int(_pg_row_first_value(row)) - if n: - raise SystemExit( - f"Refusing to import: PostgreSQL table {t!r} already has {n} row(s). " - "Use an empty schema after alembic upgrade, or pass --allow-non-empty if you " - "really intend to append (duplicates / FK failures are your risk)." - ) +def _sqlite_pg_nonempty_import_conflict_msg(table: str, sqlite_n: int, pg_n: int) -> str | None: + """Return an error message if we cannot safely INSERT SQLite rows into PG for this table.""" + if sqlite_n <= 0 or pg_n <= 0: + return None + return ( + f"Refusing to import: table {table!r} has {sqlite_n} row(s) in SQLite but PostgreSQL " + f"already has {pg_n} row(s). That usually means data or seeds already exist in PG " + "(for example the gateway was started once). Use a fresh schema after `alembic upgrade head`, " + "truncate this table on PostgreSQL, or pass --allow-non-empty knowing duplicates may cause " + "primary-key violations." + ) + + +def _assert_pg_tables_compatible_for_copy( + sl: sqlite3.Connection, + pg: psycopg.Connection, + tables: Iterable[str], +) -> None: + """Abort when SQLite would copy rows into a PG table that is not empty.""" + for t in sorted(set(tables)): + _require_ident(t) + sqlite_n = int(sl.execute(f"SELECT COUNT(*) FROM {_require_ident(t)}").fetchone()[0]) + with pg.cursor() as cur: + cur.execute(f'SELECT COUNT(*) AS n FROM "{_require_ident(t)}"') + pg_n = int(_pg_row_first_value(cur.fetchone())) + msg = _sqlite_pg_nonempty_import_conflict_msg(t, sqlite_n, pg_n) + if msg: + raise SystemExit(msg) def _sqlite_row_counts(sl: sqlite3.Connection, tables: Iterable[str]) -> dict[str, int]: @@ -320,7 +337,7 @@ def main() -> None: ap.add_argument( "--allow-non-empty", action="store_true", - help="Do not abort when target PG tables already contain rows", + help="Skip the SQLite+PG nonempty conflict check (risk of duplicate keys / bad counts)", ) ap.add_argument( "--batch", @@ -366,7 +383,7 @@ def main() -> None: order = _migration_order(sl, common) if not args.dry_run and not args.allow_non_empty: - _assert_pg_tables_empty(pg, common) + _assert_pg_tables_compatible_for_copy(sl, pg, common) src_counts = _sqlite_row_counts(sl, order) total = 0 diff --git a/tests/test_migrate_assistant_sqlite_to_pg.py b/tests/test_migrate_assistant_sqlite_to_pg.py index e1e74e62..33331067 100644 --- a/tests/test_migrate_assistant_sqlite_to_pg.py +++ b/tests/test_migrate_assistant_sqlite_to_pg.py @@ -68,6 +68,16 @@ def test_require_ident_rejects_injection(mig) -> None: mig._require_ident("app_user;drop") +def test_sqlite_pg_nonempty_import_conflict_msg(mig) -> None: + assert mig._sqlite_pg_nonempty_import_conflict_msg("t", 0, 3) is None + assert mig._sqlite_pg_nonempty_import_conflict_msg("t", 2, 0) is None + assert mig._sqlite_pg_nonempty_import_conflict_msg("t", 0, 0) is None + m = mig._sqlite_pg_nonempty_import_conflict_msg("llm_profile", 1, 2) + assert m is not None + assert "llm_profile" in m + assert "1" in m and "2" in m + + def test_pg_url_from_environ_order(monkeypatch: pytest.MonkeyPatch, mig) -> None: for k in ( "AIA_ASSISTANT_DATABASE_URL",