mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 19:32:01 +00:00
2e7d6103ea
- 配置: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 老库迁移
124 lines
4.9 KiB
Python
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
|