启动时等库:PostgreSQL 还连不上(和库的容器一起起、库还在初始化)时按退避重试(1、2、4、5、5… 秒),每次照实打一条「应用库还没就绪」,最多等 AGENTLAB_DB_STARTUP_TIMEOUT(缺省 90)秒再把最后一次的错抛出去;密码不对、库不存在这种等也没用的(SQLSTATE 28、3D、42 类)立刻抛;SQLite 不等。新增 python -m app.ops(部署脚本在容器里以镜像用户调的几件小事,只用标准库):内置 PostgreSQL 的密码文件和 pgpass(0600,已有就沿用,密码不打印)、数据目录用哪种库的状态(sqlite / builtin / migrating)、SQLite 库的一致备份(备份接口,含 -wal 里的)和只留最近 N 份。copy-to-postgres 连不上目标库、库报错时一句话说清楚、退出码 1,不再甩一屏调用栈

This commit is contained in:
ThearchyHelios
2026-10-19 13:43:32 +08:00
parent 451c05aacb
commit 93cd32bc97
7 changed files with 491 additions and 0 deletions
+3
View File
@@ -62,6 +62,9 @@ class Settings(BaseSettings):
db_max_overflow: int = 20
db_pool_timeout: int = 30
db_pool_recycle: int = 1800
# 启动时库还连不上(和 PostgreSQL 容器一起起、库还在初始化)最多等多少秒,按退避重试、照实打日志;过了再报错退出。
# 密码不对、库不存在这种等也没用的不等
db_startup_timeout: int = 90
# --- 多进程(app/cluster,docs/design/orchestration-round §5 W2-b)---
# 几个工作进程(scripts/prod.sh、Docker 的入口按它给 uvicorn --workers)。只有应用库是 PostgreSQL 时才能大于 1:
+62
View File
@@ -542,6 +542,68 @@ async def create_schema(target: AsyncEngine) -> None:
await conn.run_sync(_migrate)
#: 连不上时等也没用的错误(SQLSTATE 的类):28 认证失败、3D 库不存在、42 语法或权限。别的(拒绝连接、库正在启动 57P03、
#: 超时、网络断了)按「还没就绪」退避重试
_NOT_TRANSIENT_SQLSTATES = ("28", "3D", "42")
def _not_transient(error: BaseException) -> bool:
from app.db.portable import _driver_error
seen: Any = error
for _ in range(4):
state = str(getattr(seen, "sqlstate", "") or "")
if state and state != "57P03":
return state.startswith(_NOT_TRANSIENT_SQLSTATES)
seen = getattr(seen, "__cause__", None) or getattr(seen, "orig", None)
if seen is None:
break
cause = _driver_error(error)
return bool(cause is not None and str(cause.sqlstate).startswith(_NOT_TRANSIENT_SQLSTATES))
async def wait_for_database(target: AsyncEngine | None = None, *, timeout: float | None = None,
sleep: Any = None, clock: Any = None) -> None:
"""PostgreSQL 还连不上时按退避(1、2、4、5、5… 秒)重试,每次照实打一条日志,等够 timeout 秒(缺省
AGENTLAB_DB_STARTUP_TIMEOUT)还不行就把最后一次的错抛出去。和数据库容器一起起、库还在初始化时,应用不至于一上来就崩。
密码不对、库不存在这种等也没用的立刻抛出。SQLite 不等。"""
import asyncio
import time
from sqlalchemy import text
from app.core.errors import first_line
target = target or engine
if target.dialect.name != "postgresql":
return
budget = settings.db_startup_timeout if timeout is None else timeout
pause = sleep or asyncio.sleep
now = clock or time.monotonic
started = now()
delay = 1.0
attempt = 0
while True:
attempt += 1
try:
async with target.connect() as conn:
await conn.execute(text("SELECT 1"))
if attempt > 1:
logger.info("应用库连上了(第 %d 次,等了 %.0f 秒)", attempt, now() - started)
return
except Exception as e: # noqa: BLE001 - 判断完是不是该等再决定抛不抛
waited = now() - started
if _not_transient(e) or waited + delay > budget:
logger.error("应用库连不上,不再等了(等了 %.0f 秒):%s", waited, first_line(e))
raise
logger.warning("应用库还没就绪(%s),%.0f 秒后重试(已等 %.0f 秒,最多 %d 秒)",
first_line(e), delay, waited, budget)
await pause(delay)
delay = min(delay * 2, 5.0)
async def init_db() -> None:
logger.info("应用库:%s", describe_database())
# 库还没起来(和 PostgreSQL 容器一起起)时先等它;建表、补列、补索引在库里的咨询锁里做,几个工作进程同时启动只做一次
await wait_for_database()
await create_schema(engine)
+7
View File
@@ -584,6 +584,13 @@ def main(argv: list[str] | None = None) -> int:
except (CopyRefused, ValueError) as e:
print(f"没有导入:{e}", file=sys.stderr)
return 2
except Exception as e: # noqa: BLE001 - 连不上、库报错:照实说一句,不甩一屏调用栈
from app.core.errors import first_line
what = "核对" if args.dry_run else "导入"
tail = "什么都没写" if args.dry_run else "已经写进目标库的行留着,处理好之后重新运行只补缺的"
print(f"没有{what}完:{type(e).__name__}:{first_line(e) or '(没有说明)'}。{tail}", file=sys.stderr)
return 1
for line in render(report):
print(line)
return 0 if report.ok else 1
+163
View File
@@ -0,0 +1,163 @@
"""部署脚本(deploy/deploy.sh、scripts/dev.sh)在容器里调的几件小事:`python -m app.ops <命令>`。
放在镜像里用 Python 做,不在宿主机上用 shell 做:数据目录归容器里的用户(uid 10001),宿主机上跑 deploy.sh 的人不一定写得
进去;生成密码、改权限、拷 SQLite、删旧备份在容器里以同一个用户做,属主和权限都对。只用标准库,不连应用库、不读设置。
postgres-secrets 内置 PostgreSQL 的密码:<数据目录>/.postgres/password(给 PostgreSQL 容器建库时读)和 pgpass(给应用
连库时读,PGPASSFILE 指过来)。已有就不动;密码不打印、不进任何参数和日志
state 数据目录用的是哪种库:没切过是 sqlite;--set builtin 记成「已切到内置 PostgreSQL」(deploy.sh 只在导入、
核对都通过之后才记,记下之前出错就还是 sqlite);migrating 是「开始往内置库里导了、还没切成」——
用的仍是 SQLite,只是让下一次 migrate-to-postgres 认得出数据卷里那半份是自己上次留下的
backup-sqlite 拷一份 agentlab.db、checkpoints.db 到 <数据目录>/backups/sqlite/<时间>/(SQLite 的备份接口,一致的一份),
只留最近 --keep 份
prune 目录里按名字排序只留最近 --keep 个(pg_dump 的备份用)
"""
from __future__ import annotations
import argparse
import secrets
import sqlite3
import sys
from datetime import datetime, timezone
from pathlib import Path
from app.core import atomic
#: 内置 PostgreSQL 的东西放在数据目录的这个子目录里
POSTGRES_DIR = ".postgres"
STATE_FILE = "state"
MODES = ("sqlite", "builtin", "migrating")
def _stamp() -> str:
return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
def postgres_secrets(data: Path, *, folder: str, host: str, port: int, database: str, user: str) -> bool:
"""建密码文件和 pgpass(都是 0600)。已有密码就沿用(库是按它初始化的),pgpass 按现在的主机名重写。交回是不是新建的密码。"""
root = data / folder
root.mkdir(parents=True, exist_ok=True)
# 0711:别人列不出目录、读不到密码(两个文件 0600),但宿主机上跑 deploy.sh 的人能按名字读到 state(0644)
root.chmod(0o711)
password_file = root / "password"
made = False
if not password_file.exists():
made = atomic.create_text(password_file, secrets.token_urlsafe(32) + "\n", mode=0o600)
password = password_file.read_text(encoding="utf-8").strip()
if not password:
raise SystemExit(f"{password_file} 是空的:删掉它再来会生成新的(库已经按旧密码初始化过的话,要连库一起重建)")
# pgpass 的格式:主机:端口:库:用户:密码,冒号和反斜杠要转义
escaped = password.replace("\\", "\\\\").replace(":", "\\:")
atomic.write_text(root / "pgpass", f"{host}:{port}:{database}:{user}:{escaped}\n", mode=0o600)
return made
def read_state(data: Path) -> str:
"""数据目录用的是哪种库:sqlite(没切过)、builtin(已切到内置 PostgreSQL)或 migrating(切到一半,用的仍是 SQLite)。"""
path = data / POSTGRES_DIR / STATE_FILE
try:
for line in path.read_text(encoding="utf-8").splitlines():
if line.startswith("mode="):
mode = line.split("=", 1)[1].strip()
return mode if mode in MODES else "sqlite"
except FileNotFoundError:
pass
return "sqlite"
def write_state(data: Path, mode: str) -> None:
if mode not in MODES:
raise SystemExit(f"认不出的模式:{mode}({' / '.join(MODES)})")
root = data / POSTGRES_DIR
if not root.exists():
root.mkdir(parents=True)
root.chmod(0o711)
# 不是密钥:宿主机上的 deploy.sh 直接读它,0644
atomic.write_text(root / STATE_FILE, f"mode={mode}\nsince={datetime.now(timezone.utc).isoformat()}\n", mode=0o644)
def _copy_sqlite(src: Path, dst: Path) -> None:
# 读写打开、只许读:WAL 模式的库用 mode=ro 打开,关掉时会留下它自己建的 -wal(同 db/to_postgres.snapshot)
conn = sqlite3.connect(src)
conn.execute("PRAGMA query_only = 1")
try:
out = sqlite3.connect(dst)
try:
conn.backup(out)
finally:
out.close()
finally:
conn.close()
def prune(folder: Path, keep: int) -> list[Path]:
"""folder 里按名字(名字里带时间,排序就是先后)只留最近 keep 个,交回删掉的。"""
if keep < 1 or not folder.is_dir():
return []
items = sorted((p for p in folder.iterdir() if not p.name.startswith(".")), key=lambda p: p.name)
gone = items[:-keep] if len(items) > keep else []
for path in gone:
if path.is_dir():
for child in sorted(path.rglob("*"), reverse=True):
child.unlink() if child.is_file() or child.is_symlink() else child.rmdir()
path.rmdir()
else:
path.unlink()
return gone
def backup_sqlite(data: Path, *, keep: int) -> Path | None:
"""agentlab.db、checkpoints.db 拷到 backups/sqlite/<时间>/。两个都没有(库在 PostgreSQL)交回 None。"""
sources = [p for p in (data / "agentlab.db", data / "checkpoints.db") if p.is_file()]
if not sources:
return None
root = data / "backups" / "sqlite"
target = root / _stamp()
target.mkdir(parents=True, exist_ok=True)
for src in sources:
_copy_sqlite(src, target / src.name)
prune(root, keep)
return target
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="python -m app.ops", description="部署脚本在容器里调的几件小事")
sub = parser.add_subparsers(dest="command", required=True)
p = sub.add_parser("postgres-secrets")
p.add_argument("--data", default="/data")
p.add_argument("--dir", default=POSTGRES_DIR)
p.add_argument("--host", required=True)
p.add_argument("--port", type=int, default=5432)
p.add_argument("--db", default="agentlab")
p.add_argument("--user", default="agentlab")
s = sub.add_parser("state")
s.add_argument("--data", default="/data")
s.add_argument("--set", choices=MODES)
b = sub.add_parser("backup-sqlite")
b.add_argument("--data", default="/data")
b.add_argument("--keep", type=int, default=5)
r = sub.add_parser("prune")
r.add_argument("--dir", required=True)
r.add_argument("--keep", type=int, default=5)
args = parser.parse_args(argv)
if args.command == "postgres-secrets":
made = postgres_secrets(Path(args.data), folder=args.dir, host=args.host, port=args.port, database=args.db,
user=args.user)
print("生成了新密码" if made else "沿用已有的密码")
elif args.command == "state":
if args.set:
write_state(Path(args.data), args.set)
print(read_state(Path(args.data)))
elif args.command == "backup-sqlite":
target = backup_sqlite(Path(args.data), keep=args.keep)
print(target if target is not None else "没有 SQLite 库,不用备份")
elif args.command == "prune":
for gone in prune(Path(args.dir), args.keep):
print(f"删掉旧备份 {gone.name}")
return 0
if __name__ == "__main__":
sys.exit(main())
+11
View File
@@ -182,6 +182,17 @@ def test_the_command_needs_a_target(tmp_path, monkeypatch, capsys):
assert "只认 PostgreSQL" in capsys.readouterr().err
def test_an_unreachable_target_is_reported_in_one_line(tmp_path, capsys):
"""目标库连不上(库的容器没起来、被停了):一句话说清楚、退出码 1,不甩一屏调用栈(deploy.sh migrate-to-postgres 照它回退)。"""
import sqlite3
sqlite3.connect(tmp_path / "agentlab.db").execute("CREATE TABLE t (x)").connection.close()
for extra, what in (([], "没有导入完"), (["--dry-run"], "没有核对完")):
assert to_postgres.main([str(tmp_path), "postgresql+asyncpg://u:p@127.0.0.1:1/agentlab", *extra]) == 1
err = capsys.readouterr().err
assert err.startswith(what) and "Traceback" not in err and len(err.strip().splitlines()) == 1
def test_the_dispatcher_knows_its_commands(capsys):
from app.db.__main__ import main
+137
View File
@@ -0,0 +1,137 @@
"""启动时等库(db/base.wait_for_database):和 PostgreSQL 容器一起起、库还没就绪时按退避重试、照实打日志,等够了再报错;
密码不对、库不存在这种等也没用的立刻报错;SQLite 不等。"""
from __future__ import annotations
import logging
import pytest
from app.db.base import make_engine, wait_for_database
class _Dialect:
def __init__(self, name: str) -> None:
self.name = name
class _Conn:
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return False
async def execute(self, _statement):
return None
class _Engine:
"""connect() 依次抛 errors 里的错,用完了就连上。"""
def __init__(self, errors: list[BaseException], dialect: str = "postgresql") -> None:
self.errors = list(errors)
self.dialect = _Dialect(dialect)
self.attempts = 0
def connect(self):
self.attempts += 1
if self.errors:
raise self.errors.pop(0)
return _Conn()
class _Clock:
def __init__(self) -> None:
self.now = 0.0
self.slept: list[float] = []
def __call__(self) -> float:
return self.now
async def sleep(self, seconds: float) -> None:
self.slept.append(seconds)
self.now += seconds
class _SqlState(Exception):
def __init__(self, sqlstate: str) -> None:
super().__init__(f"sqlstate {sqlstate}")
self.sqlstate = sqlstate
async def test_retries_with_backoff_until_the_database_answers(caplog):
clock = _Clock()
engine = _Engine([ConnectionRefusedError("拒绝连接"), OSError("名字解析不了"), _SqlState("57P03")])
with caplog.at_level(logging.INFO, logger="app.db.base"):
await wait_for_database(engine, timeout=60, sleep=clock.sleep, clock=clock)
assert engine.attempts == 4 and clock.slept == [1.0, 2.0, 4.0]
warnings = [r.getMessage() for r in caplog.records if r.levelno == logging.WARNING]
assert len(warnings) == 3 and all("应用库还没就绪" in w for w in warnings)
assert "拒绝连接" in warnings[0] and "最多 60 秒" in warnings[0]
assert any("应用库连上了(第 4 次" in r.getMessage() for r in caplog.records)
async def test_gives_up_after_the_budget_and_raises_the_last_error(caplog):
clock = _Clock()
engine = _Engine([ConnectionRefusedError(f"第 {i} 次") for i in range(100)])
with caplog.at_level(logging.INFO, logger="app.db.base"), pytest.raises(ConnectionRefusedError, match="第 5 次"):
await wait_for_database(engine, timeout=20, sleep=clock.sleep, clock=clock)
# 退避 1、2、4、5、5:等到第 17 秒,再等 5 秒就过了 20 秒,不等了,抛第 6 次(下标 5)的错
assert clock.slept == [1.0, 2.0, 4.0, 5.0, 5.0]
assert any("不再等了" in r.getMessage() for r in caplog.records if r.levelno == logging.ERROR)
@pytest.mark.parametrize("sqlstate", ["28P01", "28000", "3D000", "42501"])
async def test_errors_waiting_cannot_fix_are_raised_at_once(sqlstate):
clock = _Clock()
engine = _Engine([_SqlState(sqlstate)])
with pytest.raises(_SqlState):
await wait_for_database(engine, timeout=60, sleep=clock.sleep, clock=clock)
assert engine.attempts == 1 and clock.slept == []
async def test_sqlstate_on_the_wrapped_cause_counts():
clock = _Clock()
wrapper = RuntimeError("SQLAlchemy 包了一层")
wrapper.__cause__ = _SqlState("28P01")
with pytest.raises(RuntimeError):
await wait_for_database(_Engine([wrapper]), timeout=60, sleep=clock.sleep, clock=clock)
assert clock.slept == []
async def test_sqlite_does_not_wait():
engine = _Engine([ConnectionRefusedError("不该连")], dialect="sqlite")
await wait_for_database(engine, timeout=60)
assert engine.attempts == 0
async def test_a_real_refused_connection_is_treated_as_not_ready_yet():
"""真的 asyncpg:端口上没有服务时(库容器还没起来)算「还没就绪」,按退避重试,到时间才抛。"""
pytest.importorskip("asyncpg")
clock = _Clock()
engine = make_engine("postgresql+asyncpg://agentlab:x@127.0.0.1:1/agentlab")
try:
with pytest.raises(OSError):
await wait_for_database(engine, timeout=4, sleep=clock.sleep, clock=clock)
finally:
await engine.dispose()
assert clock.slept == [1.0, 2.0]
async def test_a_missing_database_on_a_real_server_is_raised_at_once():
"""真的 PostgreSQL(AGENTLAB_TEST_DATABASE_URL):库不存在(3D000)立刻报错,不等。"""
from app.core.config import settings
if settings.db_dialect != "postgresql":
pytest.skip("要 PostgreSQL")
base, _, _ = settings.db_url.rpartition("/")
clock = _Clock()
engine = make_engine(f"{base}/agentlab_no_such_database")
try:
with pytest.raises(Exception) as caught:
await wait_for_database(engine, timeout=60, sleep=clock.sleep, clock=clock)
finally:
await engine.dispose()
assert clock.slept == [], caught.value
assert "agentlab_no_such_database" in str(caught.value)
+108
View File
@@ -0,0 +1,108 @@
"""app/ops.py:deploy.sh、dev.sh 在容器里调的几件小事(密码文件、状态、SQLite 备份、删旧备份)。"""
from __future__ import annotations
import sqlite3
import stat
import pytest
from app import ops
def _mode(path) -> int:
return stat.S_IMODE(path.stat().st_mode)
def test_secrets_are_generated_once_with_tight_permissions(tmp_path, capsys):
assert ops.main(["postgres-secrets", "--data", str(tmp_path), "--host", "agl-postgres"]) == 0
assert capsys.readouterr().out.strip() == "生成了新密码"
folder = tmp_path / ".postgres"
password = (folder / "password").read_text().strip()
assert len(password) >= 32
assert _mode(folder / "password") == 0o600 and _mode(folder / "pgpass") == 0o600
assert _mode(folder) == 0o711, "别人列不出目录,但按名字读得到 state"
assert (folder / "pgpass").read_text() == f"agl-postgres:5432:agentlab:agentlab:{password}\n"
assert ops.main(["postgres-secrets", "--data", str(tmp_path), "--host", "other", "--port", "55432"]) == 0
out = capsys.readouterr().out
assert out.strip() == "沿用已有的密码" and password not in out, "密码不打印"
assert (folder / "password").read_text().strip() == password
assert (folder / "pgpass").read_text().startswith("other:55432:agentlab:agentlab:"), "pgpass 按现在的主机名重写"
def test_pgpass_escapes_colons_and_backslashes(tmp_path):
folder = tmp_path / ".postgres-dev"
folder.mkdir()
(folder / "password").write_text("a:b\\c\n")
assert ops.postgres_secrets(tmp_path, folder=".postgres-dev", host="127.0.0.1", port=55432, database="agentlab",
user="agentlab") is False
assert (folder / "pgpass").read_text() == "127.0.0.1:55432:agentlab:agentlab:a\\:b\\\\c\n"
def test_an_empty_password_file_is_refused(tmp_path):
(tmp_path / ".postgres").mkdir()
(tmp_path / ".postgres" / "password").write_text("\n")
with pytest.raises(SystemExit, match="是空的"):
ops.postgres_secrets(tmp_path, folder=".postgres", host="h", port=5432, database="d", user="u")
def test_state_defaults_to_sqlite_and_records_switches(tmp_path, capsys):
assert ops.read_state(tmp_path) == "sqlite"
for mode in ("migrating", "builtin", "sqlite"):
assert ops.main(["state", "--data", str(tmp_path), "--set", mode]) == 0
assert capsys.readouterr().out.strip() == mode
assert ops.read_state(tmp_path) == mode
path = tmp_path / ".postgres" / "state"
assert _mode(path) == 0o644, "宿主机上的 deploy.sh 直接读它"
assert "since=" in path.read_text()
path.write_text("mode=whatever\n")
assert ops.read_state(tmp_path) == "sqlite", "认不出的当没切过"
with pytest.raises(SystemExit):
ops.main(["state", "--data", str(tmp_path), "--set", "external"])
def test_prune_keeps_the_newest_by_name(tmp_path):
for name in ("agentlab-20260101T000000Z.dump", "agentlab-20260103T000000Z.dump", "agentlab-20260102T000000Z.dump"):
(tmp_path / name).write_bytes(b"x")
(tmp_path / "20260101T000000Z").mkdir()
(tmp_path / "20260101T000000Z" / "agentlab.db").write_bytes(b"x")
(tmp_path / ".hidden").write_bytes(b"x")
gone = ops.prune(tmp_path, 2)
assert sorted(p.name for p in gone) == ["20260101T000000Z", "agentlab-20260101T000000Z.dump"]
assert sorted(p.name for p in tmp_path.iterdir()) == [".hidden", "agentlab-20260102T000000Z.dump",
"agentlab-20260103T000000Z.dump"]
assert ops.prune(tmp_path / "missing", 2) == []
def test_backup_sqlite_copies_a_consistent_snapshot_including_the_wal(tmp_path, capsys):
db = sqlite3.connect(tmp_path / "agentlab.db")
db.execute("PRAGMA journal_mode=WAL")
db.execute("CREATE TABLE t (v TEXT)")
db.execute("INSERT INTO t VALUES ('还在 WAL 里')")
db.commit()
# 不关连接、不做检查点:新写的那行还只在 -wal 里
assert (tmp_path / "agentlab.db-wal").exists()
sqlite3.connect(tmp_path / "checkpoints.db").close()
try:
assert ops.main(["backup-sqlite", "--data", str(tmp_path), "--keep", "2"]) == 0
finally:
db.close()
target = tmp_path / "backups" / "sqlite"
(stamp,) = list(target.iterdir())
assert capsys.readouterr().out.strip() == str(stamp)
copy = sqlite3.connect(stamp / "agentlab.db")
assert copy.execute("SELECT v FROM t").fetchall() == [("还在 WAL 里",)]
copy.close()
assert (stamp / "checkpoints.db").exists()
# 只留最近 keep 份
for name in ("20200101T000000Z", "20200102T000000Z"):
(target / name).mkdir()
ops.backup_sqlite(tmp_path, keep=2)
assert len(list(target.iterdir())) == 2 and not (target / "20200101T000000Z").exists()
def test_backup_sqlite_without_databases_does_nothing(tmp_path, capsys):
assert ops.main(["backup-sqlite", "--data", str(tmp_path)]) == 0
assert "没有 SQLite 库" in capsys.readouterr().out
assert not (tmp_path / "backups").exists()