Files
AgentLab/backend/tests/pgpool.py
T
ThearchyHelios 2e7d6103ea 应用库可移植到 PostgreSQL:AGENTLAB_DATABASE_URL 设成 postgresql+asyncpg://… 时整个应用库用 PostgreSQL,断点用官方的 langgraph-checkpoint-postgres(同一个库,AGENTLAB_CHECKPOINT_URL 可另给);不设时照旧是数据目录里的 SQLite,行为不变
- 配置:database_url、checkpoint_url(启动时校验写法,只认 PostgreSQL)、连接池四项;启动日志写用的是哪种库(主机、库名、用户,不写密码)
- 引擎(db/base.make_engine):SQLite 照旧 WAL、外键、忙等;PostgreSQL 用连接池、探活、会话时区 UTC,装上方言调整(db/postgres.py):建表时 String(n) 是不限长的 VARCHAR(SQLite 从不查长度,导过来的存量也可能超长)、ORDER BY 里可空列的 NULL 位置照 SQLite 补写(升序在前、降序在后,窗口函数里同样);Integer 列在 PostgreSQL 上是 BIGINT(列挂到表上时换成 with_variant,绑定参数跟着列按 BIGINT 转换);JSON 列两边都存原文(PostgreSQL 的 json,不用 JSONB:JSONB 重排对象里的键,快照的列序、表的先后读回来就变了);JSON 里的 NaN 写成 null、NUL 去掉(PostgreSQL 不收)
- 迁移按方言:已有的列和索引用 inspector 认;PostgreSQL 上补列的类型取模型里这一列的、缺省值取迁移表里的(布尔 0/1 换成 false/true),ADD COLUMN IF NOT EXISTS;去重语句里的布尔按方言写;部分唯一索引补 postgresql_where;init_db 在 PostgreSQL 上拿咨询锁(几个进程同时启动不撞)
- 应用库上的 SQLite 专用写法改成按方言编译的表达式(db/portable.py):json_extract 的十几处(口径召回、出具、对账、个人应用、幂等键、访问控制、数据源变化通知、剖析任务、待办超时)换成 json_eq / json_flag / json_text,json_type 换成 json_is_string(两种库同一套带类型的比较);能力登记的 sqlite insert 换成按会话方言的 insert_ignore;能力使用次数的 json_each 按方言分支;附件「没绑记录」的条件不靠 json 的等号;按报错文字认唯一约束的地方换成 unique_violation(按表和列认,两种库的报错都认得)
- 断点(engine/checkpoints.py):PostgreSQL 时开 psycopg 连接池、在咨询锁里 setup;runner 只认 BaseCheckpointSaver,读断点仍只走 saver 的公开接口
- 依赖:新 extra postgres(asyncpg、psycopg[binary] 3、psycopg-pool、langgraph-checkpoint-postgres 3.1.2);langgraph-checkpoint 卡在 <4.3(断点格式就在这个包里,postgres 包新版要 4.3,不动已装的 4.2.0);锁文件补上新包
- 运行收尾:记 action.unknown、安排核实做完才把运行摘掉(以前先摘掉,等「不在跑了」再读事件的一方在库慢时读不到这一条)
- 测试:AGENTLAB_TEST_DATABASE_URL 时跑在 PostgreSQL 上(每个 xdist 进程建自己的库、按事件循环分的连接池 tests/pgpool.py),不设时盖掉环境里的数据库地址照旧用 SQLite;scripts/test-postgres.sh 起临时的 postgres:16 容器跑全量;sqlite_only 标记;另开连接改库的几个竞态测试、触发器测试改成两种库都能跑(tests/appdb.py);新增 test_db_portable 守着方言调整、JSON 键序、超长字符串、大整数、NULL 排序、时区、PostgreSQL 老库迁移
2026-10-19 09:52:09 +08:00

124 lines
4.9 KiB
Python

"""应用库是 PostgreSQL 时测试用的连接池:每个事件循环一个池子,连接只给建它的那个循环用。
pytest-asyncio 每个测试一个事件循环,TestClient 在自己的线程里另起一个;asyncpg 的连接绑在建它的循环上,换个循环用
就报「attached to a different loop」。不用连接池(每次查库新建连接)能跑,但一次运行几十次查库,每次多几毫秒,
合起来让计时类的测试(审批收尾不计时长、反查的时限)在满载时越界——生产上有连接池,没有这笔开销。
这里:连接按建它的循环归池子,取的时候只从当前循环的池子里拿,还回来放回它自己的池子;循环关掉之前(测试结束、
TestClient 退出)把它池子里的连接在它自己的循环里关掉。只在 conftest 里、AGENTLAB_TEST_DATABASE_URL 时装上,
生产代码不用它。
"""
from __future__ import annotations
import asyncio
import contextlib
import threading
import weakref
from typing import Any
from sqlalchemy.pool import Pool
_LOOP = "agentlab_loop"
class LoopLocalPool(Pool):
_is_asyncio = True
def __init__(self, creator: Any, **kw: Any) -> None:
super().__init__(creator, **kw)
self._idle: weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, list[Any]] = weakref.WeakKeyDictionary()
self._lock = threading.Lock()
@classmethod
def replacing(cls, pool: Pool) -> LoopLocalPool:
"""换掉引擎原来的池子:同一个建连接的函数、同一套连接事件(方言的 on_connect 挂在上面)。"""
return cls(pool._creator, recycle=pool._recycle, echo=pool.echo, logging_name=pool._orig_logging_name,
reset_on_return=pool._reset_on_return, pre_ping=False, _dispatch=pool.dispatch,
dialect=pool._dialect)
def recreate(self) -> LoopLocalPool:
return LoopLocalPool.replacing(self)
def _bucket(self, loop: asyncio.AbstractEventLoop) -> list[Any]:
with self._lock:
return self._idle.setdefault(loop, [])
def _do_get(self) -> Any:
bucket = self._bucket(asyncio.get_running_loop())
with self._lock:
if bucket:
return bucket.pop()
record = self._create_connection()
record.info[_LOOP] = weakref.ref(asyncio.get_running_loop())
return record
def _do_return_conn(self, record: Any) -> None:
ref = record.info.get(_LOOP)
loop = ref() if ref is not None else None
if loop is None or loop.is_closed():
with contextlib.suppress(Exception):
record.close()
return
bucket = self._bucket(loop)
with self._lock:
bucket.append(record)
def take(self, loop: asyncio.AbstractEventLoop) -> list[Any]:
"""这个循环池子里空闲的连接,取出来(交给调用方在这个循环里关掉)。"""
with self._lock:
return self._idle.pop(loop, [])
def dispose(self) -> None:
"""只关当前循环的:别的循环的连接不能在这里关。没有正在跑的循环时什么都不做(各循环关掉时自己收拾)。"""
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
for record in self.take(loop):
with contextlib.suppress(Exception):
record.close()
def status(self) -> str:
with self._lock:
idle = sum(len(v) for v in self._idle.values())
return f"LoopLocalPool id:{id(self)} idle:{idle} loops:{len(self._idle)}"
def install(engine: Any) -> LoopLocalPool:
"""把应用库引擎的池子换成 LoopLocalPool,并让每个事件循环关掉之前先关它自己的连接。"""
from sqlalchemy.util import greenlet_spawn
pool = LoopLocalPool.replacing(engine.sync_engine.pool)
engine.sync_engine.pool = pool
async def close_mine(records: list[Any]) -> None:
def run() -> None:
for record in records:
with contextlib.suppress(Exception):
record.close()
await greenlet_spawn(run)
base = asyncio.get_event_loop_policy()
class Policy(type(base)): # type: ignore[misc]
def new_event_loop(self) -> asyncio.AbstractEventLoop:
loop = super().new_event_loop()
close = loop.close
def closing() -> None:
current = engine.sync_engine.pool
if isinstance(current, LoopLocalPool) and not loop.is_closed() and not loop.is_running():
records = current.take(loop)
if records:
with contextlib.suppress(Exception):
loop.run_until_complete(close_mine(records))
close()
loop.close = closing # type: ignore[method-assign]
return loop
asyncio.set_event_loop_policy(Policy())
return pool