Files
ThearchyHelios 3e922cbefb 新增 python -m app.db copy-to-postgres:把 SQLite 数据目录里的应用库和断点导入 PostgreSQL(db/to_postgres.py)
- 先停服务:来源旁边留着 agentlab.db-wal / checkpoints.db-wal(还有连接开着,或者上次是强杀),或者目标库上有 application_name=agentlab 的连接,就拒绝,什么都不写;强杀留下的 -wal 用 --assume-stopped 放过。查不到的(另一台机器上的服务)在说明里写明要自己保证
- 读快照:SQLite 备份接口拷到 --work-dir(缺省系统临时目录),在拷贝上跑一遍启动时的建表补列,老版本的库按当前表结构读;来源按读写打开、只许读(mode=ro 打开 WAL 库会留下它自己建的 -wal,下一次导入就当成服务还开着)
- 目标:同启动时的 create_schema 建表;按外键先后逐表、按主键分批 INSERT … ON CONFLICT DO NOTHING——目标上已有同一主键(或撞了别的唯一约束)的行一律不动,重跑只补没有的;整批撞了外键之类的约束就逐行重试,插不进的列出主键和原因;自增主键的序列接到导入的最大值后面
- 值:按模型的列类型读写(时间、布尔),JSON 原文照搬(SQL 的 NULL 和 JSON 的 null 分开、键的先后不变);PostgreSQL 不收的(NaN、NUL、整数列里的浮点和数字字符串、文本列里的非文本)换掉并计数打印
- 断点:用两个 saver 的公开接口逐条读出写入(含 pending writes,特殊通道和普通写入分开写,序号同来源),目标上已有的断点不重写;按线程核对断点和 pending writes 的条数
- 核对:每张表按主键数来源的行目标上有没有(导入前已有、这次补上分开数),逐表打印;有没导进去的退出码非零。--dry-run 只核对、不建表不写
- 测试:另起进程在 SQLite 数据目录里跑一次停在审批上的运行(事件、审批、待办、工件引用),导入新建的空库、再导一遍什么都不重复,再另起进程连这个 PostgreSQL、同一个数据目录批掉审批,运行从断点接着跑完、事件序号接着往下编、工件读得到、新的变化记录的序号接在导入的后面;dry-run 不写;目标上服务还连着就拒绝;来源、参数、值转换的单测
2026-10-19 10:07:06 +08:00

103 lines
4.3 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""测试里另开一条连接直接改应用库:模拟别处(另一个进程、恢复、继续运行)抢在中间提交。
应用库是 SQLite 时用 sqlite3 连数据目录里的 agentlab.db(同以前各处的写法);是 PostgreSQL 时(AGENTLAB_TEST_DATABASE_URL,
见 conftest)用 psycopg 连同一个库。SQL 一律用 ? 占位,两种库写成同一句;锁等 5 秒(同以前 sqlite3 的 timeout=5):
另一条连接要等的锁正好被调用方拿着的话,等满报错,不会把测试卡死。
"""
from __future__ import annotations
import contextlib
import re
from typing import Any
from app.core.config import settings
def is_postgres() -> bool:
return settings.db_dialect == "postgresql"
def _pg_conninfo() -> str:
from app.db.urls import libpq_conninfo
return libpq_conninfo(settings.db_url)
def execute(*statements: str | tuple[str, tuple[Any, ...]]) -> None:
"""在另一条连接上依次执行、一起提交。每一项是 SQL,或者 (SQL, 参数)。"""
items = [(s, ()) if isinstance(s, str) else (s[0], tuple(s[1])) for s in statements]
if is_postgres():
import psycopg
with psycopg.connect(_pg_conninfo(), options="-c lock_timeout=5000") as conn:
for sql, params in items:
conn.execute(re.sub(r"\?", "%s", sql), params)
conn.commit()
return
import sqlite3
db = sqlite3.connect(settings.db_path, timeout=5)
try:
for sql, params in items:
db.execute(sql, params)
db.commit()
finally:
db.close()
def query(sql: str, *params: Any) -> list[tuple[Any, ...]]:
"""在另一条连接上读:看别人这时提交了什么(不是调用方事务里的)。"""
if is_postgres():
import psycopg
with psycopg.connect(_pg_conninfo(), options="-c lock_timeout=5000") as conn:
return [tuple(r) for r in conn.execute(re.sub(r"\?", "%s", sql), params).fetchall()]
import sqlite3
db = sqlite3.connect(settings.db_path, timeout=5)
try:
return [tuple(r) for r in db.execute(sql, params).fetchall()]
finally:
db.close()
def refuse_inserts(table: str, condition: str, *, name: str) -> tuple[list[str], list[str]]:
"""建一个触发器:往 table 插入满足 condition(用 NEW.列 写)的行时报错,报的是完整性错误(IntegrityError),同 SQLite
的 RAISE(ABORT)。返回 (建的语句, 删的语句),各是一串语句,逐条在应用库的连接上执行。
PostgreSQL 要先建一个触发器函数(plpgsql)再挂上去,错误码给 23000(integrity_constraint_violation)。"""
if is_postgres():
create = [f"CREATE FUNCTION {name}_fn() RETURNS trigger AS $$ BEGIN IF {condition} THEN "
f"RAISE EXCEPTION 'refused by test' USING ERRCODE = 'integrity_constraint_violation'; END IF; "
f"RETURN NEW; END $$ LANGUAGE plpgsql",
f"CREATE TRIGGER {name} BEFORE INSERT ON {table} FOR EACH ROW EXECUTE FUNCTION {name}_fn()"]
drop = [f"DROP TRIGGER IF EXISTS {name} ON {table}", f"DROP FUNCTION IF EXISTS {name}_fn()"]
return create, drop
return ([f"CREATE TRIGGER {name} BEFORE INSERT ON {table} WHEN {condition} "
f"BEGIN SELECT RAISE(ABORT, 'refused by test'); END"], [f"DROP TRIGGER IF EXISTS {name}"])
def admin_conninfo() -> str:
"""测试用的 PostgreSQL 服务器上能建库删库的连接串(就是这个 xdist 进程自己的库,建删别的库用)。"""
return _pg_conninfo()
@contextlib.contextmanager
def fresh_postgres_database(tag: str):
"""同一台测试服务器上另建一个空库,交回它的地址(postgresql+asyncpg://…),用完删掉。只在 PostgreSQL 时能用。"""
import uuid
import psycopg
from sqlalchemy.engine import make_url
url = make_url(settings.db_url)
name = f"{url.database}_{tag}_{uuid.uuid4().hex[:8]}"
with psycopg.connect(_pg_conninfo(), autocommit=True) as conn:
conn.execute(f'CREATE DATABASE "{name}"')
try:
yield url.set(database=name).render_as_string(hide_password=False)
finally:
with psycopg.connect(_pg_conninfo(), autocommit=True) as conn:
conn.execute(f'DROP DATABASE IF EXISTS "{name}" WITH (FORCE)')