加直连地址 AGENTLAB_DATABASE_DIRECT_URL 和 PgBouncer 事务模式开关 AGENTLAB_DB_PGBOUNCER(用户拍板):依赖会话状态的连接——收发进程间通知的那一条(LISTEN)、跨进程的锁(会话级咨询锁,另建一个直连的小池子)、断点库(缺省)——走直连地址,普通查询走 AGENTLAB_DATABASE_URL,不设就全用同一个;开关打开时普通地址不用客户端连接池、关掉 SQLAlchemy 和 asyncpg 的预备语句缓存、预备语句用随机名字(照 SQLAlchemy 文档),事务级咨询锁照常成立;开关打开却没给直连地址时拒绝启动。README 写三种部署(直连、PgBouncer 会话模式、事务模式加直连地址)和要配的 server_reset_query,照实说没在真的 PgBouncer 上跑过。补测试:地址解析和规范化、断点库缺省走直连、事务模式关掉的几样、LISTEN 和跨进程锁走直连、事务模式没给直连地址拒绝启动

This commit is contained in:
ThearchyHelios
2026-10-19 13:01:21 +08:00
parent 01af07b193
commit d68ca9774f
7 changed files with 193 additions and 26 deletions
+6 -4
View File
@@ -55,9 +55,9 @@ class ClusterLock:
try:
from sqlalchemy import text
from app.db.base import engine
from app.db.base import session_engine
conn = await engine.connect()
conn = await session_engine().connect()
try:
conn = await conn.execution_options(isolation_level="AUTOCOMMIT")
got = bool(await conn.scalar(text("SELECT pg_try_advisory_lock(:k)"), {"k": self.key}))
@@ -86,9 +86,11 @@ class ClusterLock:
try:
from sqlalchemy import text
from app.db.base import engine
from app.db.base import session_engine
conn = await engine.connect()
# 会话级的锁:走直连(AGENTLAB_DATABASE_DIRECT_URL,没设就是应用库)——PgBouncer 事务模式下取锁、放锁可能落在
# 两条不同的服务端连接上
conn = await session_engine().connect()
try:
conn = await conn.execution_options(isolation_level="AUTOCOMMIT")
await conn.execute(text("SELECT pg_advisory_lock(:k)"), {"k": self.key})
+9 -2
View File
@@ -37,6 +37,14 @@ RETRY_S = 2.0
Handler = Callable[[list[str]], Any]
def listen_dsn() -> str:
"""收发通知那条专用连接的地址:LISTEN 要一条一直开着的会话,设了直连地址(AGENTLAB_DATABASE_DIRECT_URL)就走它——普通地址
可能是 PgBouncer 的事务模式,那里收不到通知。"""
from app.db.urls import libpq_conninfo
return libpq_conninfo(settings.session_db_url)
def _tag() -> str:
return identity.owner().rsplit(":", 1)[-1]
@@ -126,9 +134,8 @@ class Signals:
import asyncpg
from app.db.base import APPLICATION_NAME
from app.db.urls import libpq_conninfo
dsn = libpq_conninfo(settings.database_url)
dsn = listen_dsn()
first = True
while not self._stopping:
lost = asyncio.Event()
+17 -4
View File
@@ -56,6 +56,13 @@ class Settings(BaseSettings):
# LangGraph 的断点也放进同一个库;checkpoint_url 另给就放那里。数据目录照旧要有:工件、上传表格、沙箱等仍是文件
database_url: str = ""
checkpoint_url: str = ""
# 直连地址(可选,写法同 database_url):普通查询走 database_url(可以是 PgBouncer),依赖会话状态的连接走这里——进程间通知的
# LISTEN 那一条、跨进程锁(会话级咨询锁)、断点库(预备语句、建表时的会话锁)。不设就全用 database_url
database_direct_url: str = ""
# database_url 是 PgBouncer 的事务模式(AGENTLAB_DB_PGBOUNCER=1):应用库不用客户端连接池(交给 PgBouncer)、关掉 asyncpg 和
# SQLAlchemy 的预备语句缓存、预备语句用不会撞名的名字。这时必须另给 database_direct_url(事务模式下 LISTEN、会话级咨询锁不成立),
# 启动时检查
db_pgbouncer: bool = False
# PostgreSQL 的连接池(SQLite 不用这几项)。pool_size=0 表示不用连接池、每次新建连接(前面有 PgBouncer 时用,
# 测试里也这么用:每个测试一个事件循环,asyncpg 的连接不能跨循环)
db_pool_size: int = 10
@@ -221,7 +228,7 @@ class Settings(BaseSettings):
return True
return value
@field_validator("database_url", "checkpoint_url", mode="before")
@field_validator("database_url", "checkpoint_url", "database_direct_url", mode="before")
@classmethod
def _valid_postgres_url(cls, value: object, info: Any) -> object:
# 写错了启动时就报出来,别等到第一次查库。只认 PostgreSQL:SQLite 就是不设
@@ -232,7 +239,7 @@ class Settings(BaseSettings):
from app.db.urls import normalize_database_url, normalize_checkpoint_url
name = f"AGENTLAB_{(info.field_name or '').upper()}"
normalize = normalize_database_url if info.field_name == "database_url" else normalize_checkpoint_url
normalize = normalize_checkpoint_url if info.field_name == "checkpoint_url" else normalize_database_url
try:
return normalize(value.strip())
except ValueError as e:
@@ -293,15 +300,21 @@ class Settings(BaseSettings):
"""LangGraph checkpointer 用独立的库文件,避免和业务表抢写锁。只在 SQLite 时用。"""
return self.data_dir / "checkpoints.db"
@property
def session_db_url(self) -> str:
"""依赖会话状态的连接(LISTEN、会话级咨询锁、断点库)用的应用库地址:设了直连地址用它,否则同 database_url。"""
return self.database_direct_url or self.database_url
@property
def checkpoint_conninfo(self) -> str | None:
"""PostgreSQL 断点库的 libpq 连接串(psycopg 用):checkpoint_url 优先,否则由 database_url 换算。SQLite 时 None。"""
"""PostgreSQL 断点库的 libpq 连接串(psycopg 用):checkpoint_url 优先,否则由直连地址(没设就是 database_url)换算。
SQLite 时 None。断点库用预备语句、建表时取会话级的锁,所以缺省走直连。"""
if self.checkpoint_url:
return self.checkpoint_url
if self.database_url:
from app.db.urls import libpq_conninfo
return libpq_conninfo(self.database_url)
return libpq_conninfo(self.session_db_url)
return None
@property
+66 -10
View File
@@ -188,12 +188,45 @@ def pg_json_dumps(value: Any) -> str:
APPLICATION_NAME = "agentlab"
def make_engine(url: str | None = None, *, application_name: str = APPLICATION_NAME) -> AsyncEngine:
def pg_engine_kwargs(*, application_name: str = APPLICATION_NAME, pgbouncer: bool = False,
pool_size: int | None = None) -> dict[str, Any]:
"""PostgreSQL 引擎的连接池和连接参数。pgbouncer:这个地址是 PgBouncer 的事务模式(AGENTLAB_DB_PGBOUNCER)——
- 不用客户端的连接池(NullPool,连接池交给 PgBouncer;SQLAlchemy 的说明里同样这么建议);
- SQLAlchemy 的预备语句缓存(prepared_statement_cache_size)和 asyncpg 自己的(statement_cache_size)都关掉:事务模式下同一个
客户端连接的下一条语句可能落在另一条服务端连接上,缓存着的预备语句在那边不存在;
- asyncpg 每条语句仍要先预备再执行(SQLAlchemy 的 asyncpg 方言一律这么做,同一个事务里,落在同一条服务端连接上),名字用随机的
(prepared_statement_name_func),不和别的客户端在同一条服务端连接上留下的撞名。PgBouncer 要配 server_reset_query=DISCARD ALL
(且 server_reset_query_always=1)收掉留下的预备语句,或用 1.21 以上开 max_prepared_statements。
事务级的咨询锁(pg_advisory_xact_lock,发名额、同源队、建表时用)在事务模式下照常成立:取锁和放锁都在同一个事务里。"""
connect_args: dict[str, Any] = {
# 会话时区定成 UTC:时间列是不带时区的 UTC(UTCDateTime),库里 now() 之类的也按 UTC 算
"server_settings": {"application_name": application_name, "timezone": "UTC"}}
size = settings.db_pool_size if pool_size is None else pool_size
if pgbouncer:
connect_args.update(statement_cache_size=0, prepared_statement_cache_size=0,
prepared_statement_name_func=_unique_statement_name)
return {"poolclass": NullPool, "connect_args": connect_args}
if size <= 0:
return {"poolclass": NullPool, "connect_args": connect_args}
return {"pool_size": size, "max_overflow": max(settings.db_max_overflow, 0), "pool_timeout": settings.db_pool_timeout,
"pool_recycle": settings.db_pool_recycle, "connect_args": connect_args}
def _unique_statement_name() -> str:
return f"__agentlab_{uuid.uuid4().hex}__"
def make_engine(url: str | None = None, *, application_name: str = APPLICATION_NAME, pgbouncer: bool | None = None,
pool_size: int | None = None) -> AsyncEngine:
"""应用库的引擎。SQLite:WAL、外键、忙等,单个文件;PostgreSQL:连接池、方言调整(db/postgres.py)。
copy-to-postgres 建目标库的引擎也走这里,两边的列类型、排序规则是同一套。
"""
url = url or settings.db_url
if pgbouncer is None:
# 只有应用库的普通地址按 AGENTLAB_DB_PGBOUNCER 处理:直连地址、copy-to-postgres 给的目标库照常
pgbouncer = bool(settings.db_pgbouncer) and url == settings.database_url
if url.startswith("sqlite"):
# pool_pre_ping:取连接前先探一下活。取消异常打进 SQLAlchemy 的清理流程时
# (_close_connection 记完日志会把 CancelledError 原样抛出,跳过了把连接记录
@@ -206,15 +239,9 @@ def make_engine(url: str | None = None, *, application_name: str = APPLICATION_N
from app.db import postgres
_require_postgres_driver("asyncpg")
pool: dict[str, Any] = (
{"poolclass": NullPool} if settings.db_pool_size <= 0 else {
"pool_size": settings.db_pool_size, "max_overflow": max(settings.db_max_overflow, 0),
"pool_timeout": settings.db_pool_timeout, "pool_recycle": settings.db_pool_recycle})
made = create_async_engine(
url, echo=False, future=True, pool_pre_ping=True, json_serializer=pg_json_dumps,
# 会话时区定成 UTC:时间列是不带时区的 UTC(UTCDateTime),库里 now() 之类的也按 UTC 算
connect_args={"server_settings": {"application_name": application_name, "timezone": "UTC"}},
**pool)
made = create_async_engine(url, echo=False, future=True, pool_pre_ping=True, json_serializer=pg_json_dumps,
**pg_engine_kwargs(application_name=application_name, pgbouncer=pgbouncer,
pool_size=pool_size))
postgres.install(made.sync_engine.dialect)
event.listen(made.sync_engine, "before_cursor_execute", postgres.strip_nul, retval=True)
return made
@@ -250,6 +277,35 @@ engine = make_engine()
SessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
_session_engine: AsyncEngine | None = None
def session_engine() -> AsyncEngine:
"""依赖会话状态的连接用的引擎(跨进程锁的会话级咨询锁,app/cluster/mutex):设了 AGENTLAB_DATABASE_DIRECT_URL 时是直连那个库的
一个小池子,否则就是应用库的引擎。"""
global _session_engine
if not settings.database_direct_url or settings.database_direct_url == settings.database_url:
return engine
if _session_engine is None:
_session_engine = make_engine(settings.database_direct_url, pgbouncer=False, pool_size=max(2, min(settings.db_pool_size or 5, 10)))
return _session_engine
async def dispose_session_engine() -> None:
"""关停时关掉直连那个小池子(没另建就什么都不做)。"""
global _session_engine
if _session_engine is not None:
await _session_engine.dispose()
_session_engine = None
def check_pgbouncer() -> None:
"""AGENTLAB_DB_PGBOUNCER=1 却没给直连地址:拒绝启动(事务模式下 LISTEN、会话级咨询锁不成立,跨进程的锁会锁不住、放不掉)。"""
if settings.db_pgbouncer and settings.db_dialect == "postgresql" and not settings.database_direct_url:
raise RuntimeError("AGENTLAB_DB_PGBOUNCER=1(应用库地址是 PgBouncer 的事务模式)时要另给 AGENTLAB_DATABASE_DIRECT_URL:"
"进程间通知(LISTEN)、跨进程的锁(会话级咨询锁)、断点库要直连 PostgreSQL(或 PgBouncer 的会话模式)。"
"见 README「用 PostgreSQL」里的三种部署")
async def get_session() -> AsyncIterator[AsyncSession]:
"""FastAPI 依赖注入用的会话。"""
+6 -1
View File
@@ -58,6 +58,10 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
# SQLite 只支持单进程:同一个数据目录已经有一个进程在用(uvicorn --workers、两个服务指着同一个目录)就拒绝启动、照实说
# (app/cluster/guard.py)。PostgreSQL 可以多进程,什么都不做
guard.acquire()
# PgBouncer 事务模式(AGENTLAB_DB_PGBOUNCER=1)要另给直连地址,没给就拒绝启动(db/base.check_pgbouncer)
from app.db.base import check_pgbouncer
check_pgbouncer()
await init_db()
# 多进程协调(app/cluster):登记这个工作进程、开始心跳和进程间通知、争调度当班。要在下面的启动整理之前:整理只碰属主不在了的
# 行,要先知道别的进程谁还活着
@@ -150,9 +154,10 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
await cluster.stop()
# 应用库的连接全关掉:SQLite 最后一个连接关掉时才把 -wal 并回、删掉(copy-to-postgres 按它认服务停没停),
# PostgreSQL 的连接池也正常关
from app.db.base import engine as db_engine
from app.db.base import dispose_session_engine, engine as db_engine
await db_engine.dispose()
await dispose_session_engine()
logger.info("已停止")
+69
View File
@@ -0,0 +1,69 @@
"""直连地址和 PgBouncer 事务模式(AGENTLAB_DATABASE_DIRECT_URL、AGENTLAB_DB_PGBOUNCER):地址怎么解析、哪些连接走直连、事务模式下
关掉了哪些东西。只建引擎、不连库,两种库都跑。"""
from __future__ import annotations
import pytest
from sqlalchemy.pool import NullPool
from app.core.config import Settings, settings
APP = "postgres://agentlab:secret@pgbouncer.internal:6432/agentlab"
DIRECT = "postgresql://agentlab:secret@db.internal:5432/agentlab"
def test_the_direct_url_is_normalized_and_used_for_session_bound_connections():
s = Settings(database_url=APP, database_direct_url=DIRECT, _env_file=None)
assert s.database_url.startswith("postgresql+asyncpg://") and "pgbouncer.internal:6432" in s.database_url
assert s.database_direct_url.startswith("postgresql+asyncpg://") and "db.internal:5432" in s.database_direct_url
assert s.session_db_url == s.database_direct_url
assert "db.internal" in s.checkpoint_conninfo and s.checkpoint_conninfo.startswith("postgresql://"), "断点库缺省走直连"
plain = Settings(database_url=APP, _env_file=None)
assert plain.session_db_url == plain.database_url and "pgbouncer.internal" in plain.checkpoint_conninfo, "不设就全用同一个"
with pytest.raises(ValueError, match="AGENTLAB_DATABASE_DIRECT_URL"):
Settings(database_url=APP, database_direct_url="mysql://x@y/z", _env_file=None)
def test_transaction_mode_turns_off_client_pooling_and_statement_caches():
from app.db.base import pg_engine_kwargs
kw = pg_engine_kwargs(pgbouncer=True)
assert kw["poolclass"] is NullPool
args = kw["connect_args"]
assert args["statement_cache_size"] == 0 and args["prepared_statement_cache_size"] == 0
names = {args["prepared_statement_name_func"]() for _ in range(3)}
assert len(names) == 3, "预备语句的名字每次都不一样,不和别的客户端撞名"
direct = pg_engine_kwargs(pgbouncer=False, pool_size=5)
assert direct["pool_size"] == 5 and "statement_cache_size" not in direct["connect_args"]
def test_listen_and_cluster_locks_go_direct(monkeypatch):
from app.cluster.signal import listen_dsn
from app.db import base
monkeypatch.setattr(settings, "database_url", Settings(database_url=APP, _env_file=None).database_url)
monkeypatch.setattr(settings, "database_direct_url", Settings(database_url=APP, database_direct_url=DIRECT,
_env_file=None).database_direct_url)
monkeypatch.setattr(base, "_session_engine", None)
assert "db.internal:5432" in listen_dsn() and listen_dsn().startswith("postgresql://")
made = base.session_engine()
try:
assert made is not base.engine and made.url.host == "db.internal" and made.url.port == 5432
assert base.session_engine() is made, "直连那个池子只建一个"
finally:
monkeypatch.setattr(base, "_session_engine", None)
made.sync_engine.dispose()
monkeypatch.setattr(settings, "database_direct_url", "")
assert base.session_engine() is base.engine and "pgbouncer.internal" in listen_dsn()
def test_transaction_mode_without_a_direct_url_refuses_to_start(monkeypatch):
from app.db import base
monkeypatch.setattr(settings, "database_url", Settings(database_url=APP, _env_file=None).database_url)
monkeypatch.setattr(settings, "db_pgbouncer", True)
monkeypatch.setattr(settings, "database_direct_url", "")
with pytest.raises(RuntimeError, match="AGENTLAB_DATABASE_DIRECT_URL"):
base.check_pgbouncer()
monkeypatch.setattr(settings, "database_direct_url", Settings(database_url=APP, database_direct_url=DIRECT,
_env_file=None).database_direct_url)
base.check_pgbouncer()