Skip to content

Commit 35adcb6

Browse files
committed
PG 没起时 API 先探库、自动拉起本地实例、一行报错;修 engine_glue 直读进程配置(测试把沙盒工作区写进 data/workspaces)与文档 uvicorn --reload 未限定监视目录(沙盒写 .py 反复重启 API)
起因:2026-09-02 20:40 一次会话收尾把常驻的本地 PostgreSQL 一并 `pg-dev.ps1 stop` 了,之后按文档 「分开启动」手起的 uvicorn 每次都在 lifespan 的 create_all 抛百行 psycopg ConnectionTimeout traceback、`Application startup failed`,前端所有 /api/* 经 vite 代理 ECONNREFUSED 8000。 dev-local.mjs 的自动拉库只照顾 `npm run dev` 这条路,直接跑 uvicorn 的人拿不到。 - 新 omm_api/db_ready.py:启动先 Database.ping()(PG 引擎加 connect_timeout=5,库没起时快速 失败而不是拖到驱动超时);连不上且目标是 tools/pg-dev.ps1 管的本地实例(Windows、 127.0.0.1/localhost:5433、脚本在场、postgresql 后端)就自动 `pg-dev.ps1 start` 一次再探; 仍失败抛 DatabaseUnavailableError,一行说清「连不上哪 / 怎么起」,不带凭据不带 traceback。 Docker 5432、远端库、非 Windows 一律不插手;OMM_LOCAL_PG_AUTOSTART=false 可关。 - engine_glue 加 set_settings()/runtime_settings() 进程级绑定(与 set_blobstore 同模式), create_app 注入;_build_tool_invoker 与 get_blobstore 兜底改读它。此前直读 get_settings() (.env / 环境)忽略 conftest 传入的 workspaces_dir=tmp_path,每跑一次 backend/api 测试就往 真实 backend/api/data/workspaces 写几十个 run_* 目录(累计 1689 个,库里只有 40 个 run), 还会触发监视该目录的开发 API --reload 反复重启。 - README / backend/api/README 的 uvicorn 命令加 --reload-dir backend/api/omm_api --reload-dir agents: 不加时 --reload 监视整个 cwd(含 data/),沙盒每写一个 .py(steps/*/main.py、experiment.py) API 就重启一次,真跑任务时会在实验 / 验证阶段把 RunnerThread 连进程一起打断。 环境变量表补 OMM_LOCAL_PG_AUTOSTART。 - 测试:tests/test_db_ready.py 六条(URL 判定边界、报错文案与不泄露凭据、自动拉起一次后复探、 关开关 / 拉起失败 / 复探仍败照样抛、create_app 绑定的工作区根);全链 e2e 加断言 experiment.py 落在测试自己的 tmp 工作区且不污染 SERVICE_ROOT/data/workspaces。 backend/api 302 passed / 2 skipped(原 296)。 实跑核实:按新命令起的 --reload 只监视 omm_api 与 agents,往 data/workspaces 写 .py 触发 reload 0 次;PG 已起时 start_local_pg() 走完整 subprocess 路径返回 True。
1 parent b4c1f1c commit 35adcb6

9 files changed

Lines changed: 349 additions & 13 deletions

File tree

README.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,15 +82,18 @@ Invoke-RestMethod http://127.0.0.1:8000/api/health
8282
统一入口诊断时可分别启动两个进程:
8383

8484
```powershell
85-
# 终端 A:API(--timeout-graceful-shutdown 必带:页面的 SSE 长连接永不排空,
86-
# 不设上限时 --reload 的优雅停机会无限等待,表现为改代码后 API 失联)
87-
.venv\Scripts\python -m uvicorn omm_api.asgi:app --app-dir backend/api --reload --timeout-graceful-shutdown 5 --port 8000
85+
# 终端 A:API
86+
# --timeout-graceful-shutdown 必带:页面的 SSE 长连接永不排空,不设上限时 --reload 的
87+
# 优雅停机会无限等待,表现为改代码后 API 失联;
88+
# --reload-dir 必带:不加时 --reload 监视整个当前目录(含 backend/api/data/),沙盒每写
89+
# 一个 .py(steps/*/main.py、experiment.py)API 就重启一次,会把运行中的任务打断。
90+
.venv\Scripts\python -m uvicorn omm_api.asgi:app --app-dir backend/api --reload --reload-dir backend/api/omm_api --reload-dir agents --timeout-graceful-shutdown 5 --port 8000
8891
8992
# 终端 B:Web
9093
npm run dev:web
9194
```
9295

93-
数据库限定 PostgreSQL:默认连 `tools/pg-dev.ps1` 的本地实例(port 5433),Docker 底座(port 5432)需显式覆盖 `OMM_DATABASE_URL`手动路径不经过统一入口的自动拉起,启动 API 前先 `.\tools\pg-dev.ps1 start`。API 文档位于 [http://127.0.0.1:8000/docs](http://127.0.0.1:8000/docs),完整配置见 [`backend/api/README.md`](./backend/api/README.md)
96+
数据库限定 PostgreSQL:默认连 `tools/pg-dev.ps1` 的本地实例(port 5433),Docker 底座(port 5432)需显式覆盖 `OMM_DATABASE_URL`。API 启动时先探库:连不上且目标是这台本地实例,会自动执行一次 `.\tools\pg-dev.ps1 start``OMM_LOCAL_PG_AUTOSTART=false` 可关);仍连不上则用一行提示退出,而不是抛驱动 traceback。首次使用仍需 `.\tools\pg-dev.ps1 init` 建库。API 文档位于 [http://127.0.0.1:8000/docs](http://127.0.0.1:8000/docs),完整配置见 [`backend/api/README.md`](./backend/api/README.md)
9497

9598
### 4. 用真实 `run_id` 验证工作台
9699

backend/api/README.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,10 @@ npm run dev
2424
py -3.12 -m venv .venv
2525
.venv\Scripts\python -m pip install -e packages/contracts -e agents/core -e agents/skills -e "backend/api[dev]"
2626
27-
# 启动:数据库为 PostgreSQL,先确保其在运行(见下节;自动拉起仅在 `npm run dev`,单独起 uvicorn 前需手动 start)
28-
.venv\Scripts\python -m uvicorn omm_api.asgi:app --app-dir backend/api --reload --port 8000
27+
# 启动:数据库为 PostgreSQL(见下节)。启动时先探库,本地 pg-dev 实例没起会自动 start 一次;
28+
# --reload-dir 必带——不加时 --reload 监视整个 cwd(含 backend/api/data/),沙盒每写一个 .py
29+
# 就把 API 重启一次;--timeout-graceful-shutdown 让 SSE 长连接不拖死重载
30+
.venv\Scripts\python -m uvicorn omm_api.asgi:app --app-dir backend/api --reload --reload-dir backend/api/omm_api --reload-dir agents --timeout-graceful-shutdown 5 --port 8000
2931
```
3032

3133
- 文档:http://127.0.0.1:8000/docs
@@ -43,7 +45,7 @@ Invoke-RestMethod http://127.0.0.1:8000/api/health
4345

4446
```powershell
4547
# A. 免安装用户级 PG(tools/pg-dev.ps1,port 5433;默认连接目标,无 Docker 即可用)
46-
.\tools\pg-dev.ps1 init # 首次;之后 npm run dev 会自动拉起(仅单独起 uvicorn 时需手动 start)
48+
.\tools\pg-dev.ps1 init # 首次建库;之后 npm run dev 与 API 启动探库都会自动 start(OMM_LOCAL_PG_AUTOSTART=false 可关
4749
4850
# B. Docker 底座(tools/dev-up.ps1,port 5432,见 infra/docker/compose.dev.yaml)——需显式覆盖连接串
4951
$env:OMM_DATABASE_URL="postgresql+psycopg://openmathmodel:openmathmodel-dev@127.0.0.1:5432/openmathmodel"
@@ -59,7 +61,8 @@ cd backend/api
5961

6062
| 变量 | 默认 | 说明 |
6163
|---|---|---|
62-
| `OMM_DATABASE_URL` | `postgresql+psycopg://openmathmodel:openmathmodel@127.0.0.1:5433/openmathmodel` | 数据库限定 PostgreSQL;仅端口/凭据不同(如 Docker 底座 5432)时覆盖 |
64+
| `OMM_DATABASE_URL` | `postgresql+psycopg://openmathmodel:openmathmodel@127.0.0.1:5433/openmathmodel` | 数据库限定 PostgreSQL;仅端口/凭据不同(如 Docker 底座 5432)时覆盖。PG 单次建连上限固定 5 秒,库没起时快速报错而不是拖到驱动超时 |
65+
| `OMM_LOCAL_PG_AUTOSTART` | `true` | 启动探库失败且目标是 `tools/pg-dev.ps1` 管的本地实例(Windows、127.0.0.1/localhost:5433)时自动 `start` 一次;Docker 5432、远端库、非 Windows 不触发 |
6366
| `OMM_SECRET_KEY` | `dev-secret-change-me` | 2FA 挑战令牌签名密钥,生产必须覆盖 |
6467
| `OMM_RUNNER_ENABLED` | `true` | API 进程内推进线程;当前由 `agents/core``SimStageNode` 驱动 |
6568
| `OMM_RUNNER_TICK_SECONDS` | `1.2` | 推进节奏 |

backend/api/omm_api/config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ class Settings(BaseSettings):
2424
# (tools/pg-dev.ps1,port 5433;Docker 底座为 5432,见 infra/docker/compose.dev.yaml)。
2525
# SQLite 不再是任何默认路径:仅测试夹具显式传入临时库,或应急排查时显式覆盖本变量。
2626
database_url: str = "postgresql+psycopg://openmathmodel:openmathmodel@127.0.0.1:5433/openmathmodel"
27+
# 启动探库失败且目标就是 tools/pg-dev.ps1 管的本地实例时自动 `start` 一次(仅 Windows、
28+
# 仅 127.0.0.1/localhost:5433),让单独起 uvicorn 与 `npm run dev` 一样不用先手动拉库。
29+
local_pg_autostart: bool = True
2730

2831
# 内嵌模拟工作流推进器(T5 将替换为 agents/core 驱动的 worker)。
2932
# tick 即模拟阶段的停留时长:每 tick 完成一个阶段。1.2s 会让审批后的

backend/api/omm_api/db.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,21 @@
77
from fastapi import Request
88
from sqlalchemy import create_engine, event, inspect, text
99
from sqlalchemy.engine import Engine
10+
from sqlalchemy.exc import DBAPIError
1011
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
1112
from sqlalchemy.schema import CreateColumn
1213

1314
logger = logging.getLogger(__name__)
1415

16+
# 非 SQLite 后端的单次建连上限(秒)。libpq 默认无限等待,本地库没起时每次探测
17+
# 都要拖到驱动自己的超时才报错;5 秒足以覆盖本机与内网,远端库可在连接串里覆盖。
18+
CONNECT_TIMEOUT_SECONDS = 5
19+
20+
21+
def _first_line(error: BaseException) -> str:
22+
text_ = str(error).strip()
23+
return text_.splitlines()[0] if text_ else type(error).__name__
24+
1525

1626
class Base(DeclarativeBase):
1727
pass
@@ -67,11 +77,24 @@ def _sqlite_pragmas(dbapi_connection, _record): # noqa: ANN001
6777
cursor.execute("PRAGMA busy_timeout=30000")
6878
cursor.close()
6979
else:
70-
self.engine = create_engine(database_url, pool_pre_ping=True)
80+
self.engine = create_engine(
81+
database_url,
82+
pool_pre_ping=True,
83+
connect_args={"connect_timeout": CONNECT_TIMEOUT_SECONDS},
84+
)
7185
self.session_factory = sessionmaker(
7286
bind=self.engine, autoflush=False, expire_on_commit=False
7387
)
7488

89+
def ping(self) -> str | None:
90+
"""探一次连接:可达返回 None,否则返回一行可读的失败原因(不带 traceback)。"""
91+
try:
92+
with self.engine.connect() as connection:
93+
connection.execute(text("SELECT 1"))
94+
except DBAPIError as exc:
95+
return _first_line(exc.orig if exc.orig is not None else exc)
96+
return None
97+
7598
def create_all(self) -> None:
7699
from . import models, orm # noqa: F401 确保任务面与账户面模型都已注册
77100

backend/api/omm_api/db_ready.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
"""启动前的数据库就绪检查(含本地免安装 PostgreSQL 的自动拉起)。
2+
3+
`npm run dev`(tools/dev-local.mjs)起 API 前会先探 5433、没起就跑 `pg-dev.ps1 start`;
4+
但按文档「分开启动」直接跑 uvicorn 的人拿不到这层照顾——PG 一停(开机后没起、或哪个
5+
会话收尾时顺手 stop 了),API 就只剩百行 psycopg 超时 traceback,前端全部接口跟着
6+
ECONNREFUSED。这里把同一判断搬进 API 启动路径:
7+
8+
1. 先 `SELECT 1` 探库;
9+
2. 连不上且目标就是 tools/pg-dev.ps1 管的本地实例(Windows、127.0.0.1/localhost:5433、
10+
脚本在场)→ 自动拉起一次再探;Docker 5432、远端库、非 Windows 一律不插手;
11+
3. 仍连不上 → 抛 `DatabaseUnavailableError`,一行说清「连不上哪、怎么起」。
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import locale
17+
import logging
18+
import subprocess
19+
import sys
20+
from pathlib import Path
21+
22+
from sqlalchemy.engine import make_url
23+
24+
from .config import SERVICE_ROOT, Settings
25+
from .db import Database
26+
27+
logger = logging.getLogger(__name__)
28+
29+
LOCAL_PG_PORT = 5433
30+
_LOCAL_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
31+
# tools/pg-dev.ps1 在仓库根;SERVICE_ROOT = backend/api
32+
PG_DEV_SCRIPT = SERVICE_ROOT.parents[1] / "tools" / "pg-dev.ps1"
33+
# pg_ctl start -w -t 60 的等待上限,再留 PowerShell 启动开销
34+
START_TIMEOUT_SECONDS = 90.0
35+
36+
37+
class DatabaseUnavailableError(RuntimeError):
38+
"""启动时数据库不可达。消息即用户可照着做的提示,不夹带驱动 traceback。"""
39+
40+
41+
def describe_target(database_url: str) -> str:
42+
"""连接串的可读目标(不含凭据):backend://host:port/database。"""
43+
try:
44+
url = make_url(database_url)
45+
except Exception: # 非法连接串也要能原样报出去
46+
return database_url
47+
backend = url.get_backend_name()
48+
if backend == "sqlite":
49+
return f"sqlite:{url.database or ':memory:'}"
50+
host = url.host or "localhost"
51+
port = f":{url.port}" if url.port else ""
52+
return f"{backend}://{host}{port}/{url.database or ''}"
53+
54+
55+
def manages_local_pg(
56+
database_url: str,
57+
*,
58+
script: Path = PG_DEV_SCRIPT,
59+
platform: str = sys.platform,
60+
) -> bool:
61+
"""连接串是否指向 tools/pg-dev.ps1 管理的本地实例(且脚本在、本机是 Windows)。"""
62+
if platform != "win32" or not script.is_file():
63+
return False
64+
try:
65+
url = make_url(database_url)
66+
except Exception:
67+
return False
68+
if url.get_backend_name() != "postgresql":
69+
return False
70+
return (url.host or "") in _LOCAL_HOSTS and url.port == LOCAL_PG_PORT
71+
72+
73+
def start_local_pg(
74+
script: Path = PG_DEV_SCRIPT, timeout_seconds: float = START_TIMEOUT_SECONDS
75+
) -> bool:
76+
"""跑一次 `pg-dev.ps1 start`;返回是否成功退出。脚本输出并入本进程日志。"""
77+
command = [
78+
"powershell",
79+
"-NoProfile",
80+
"-ExecutionPolicy",
81+
"Bypass",
82+
"-File",
83+
str(script),
84+
"start",
85+
]
86+
try:
87+
completed = subprocess.run(
88+
command, capture_output=True, timeout=timeout_seconds, check=False
89+
)
90+
except (OSError, subprocess.TimeoutExpired) as exc:
91+
logger.error("自动拉起本地 PostgreSQL 失败:%s", exc)
92+
return False
93+
output = (_decode_console(completed.stdout) + _decode_console(completed.stderr)).strip()
94+
if output:
95+
logger.info("pg-dev.ps1 start(exit=%s):%s", completed.returncode, output)
96+
return completed.returncode == 0
97+
98+
99+
def _decode_console(raw: bytes) -> str:
100+
"""PowerShell 子进程的输出编码随控制台代码页变(UTF-8 或 GBK 都可能):
101+
先按 UTF-8 严格解,解不出再按本机首选编码兜底,别把脚本的中文提示解成乱码。"""
102+
try:
103+
return raw.decode("utf-8")
104+
except UnicodeDecodeError:
105+
return raw.decode(locale.getpreferredencoding(False), errors="replace")
106+
107+
108+
def ensure_database_ready(db: Database, settings: Settings) -> None:
109+
"""探库;本地 pg-dev 实例没起就拉起一次;仍不可达则抛 DatabaseUnavailableError。"""
110+
error = db.ping()
111+
if error is None:
112+
return
113+
target = describe_target(settings.database_url)
114+
if settings.local_pg_autostart and manages_local_pg(settings.database_url):
115+
logger.warning("数据库连不上(%s:%s),尝试自动拉起本地 PostgreSQL…", target, error)
116+
if start_local_pg():
117+
error = db.ping()
118+
if error is None:
119+
logger.info("本地 PostgreSQL 已拉起,数据库就绪:%s", target)
120+
return
121+
message = (
122+
f"数据库连不上:{target}{error})。"
123+
"本地开发请先起 PostgreSQL:.\\tools\\pg-dev.ps1 start(首次先 init),"
124+
"或直接 npm run dev(会自动拉起);连接串由 OMM_DATABASE_URL / backend/api/.env 决定。"
125+
)
126+
logger.error(message)
127+
raise DatabaseUnavailableError(message)

backend/api/omm_api/engine_glue.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@
7777
)
7878

7979
from .blobstore import ArtifactBlobStore, LocalContentStore
80-
from .config import get_settings
80+
from .config import Settings, get_settings
8181
from .errors import ApiError
8282
from .events import append_event
8383
from .ids import new_id
@@ -130,6 +130,24 @@ def new_id(self, prefix: str) -> str:
130130
return new_id(prefix)
131131

132132

133+
# ── 运行时配置:进程级绑定(create_app 时注入;缺省读进程环境) ──────────────
134+
# 工作区根、沙箱时限必须与 create_app 拿到的那份 Settings 一致:测试夹具把
135+
# workspaces_dir 指到 tmp_path,这里若仍读 get_settings()(.env / 环境变量),
136+
# 沙盒就会把每个用例的代码写进真实的 backend/api/data/workspaces,既污染开发
137+
# 数据目录,又会触发监视该目录的 uvicorn --reload 反复重启。
138+
139+
_settings: Settings | None = None
140+
141+
142+
def set_settings(settings: Settings) -> None:
143+
global _settings
144+
_settings = settings
145+
146+
147+
def runtime_settings() -> Settings:
148+
return _settings if _settings is not None else get_settings()
149+
150+
133151
# ── Artifact 存储端口:进程级绑定(create_app 时注入;缺省按配置构建) ──────
134152

135153
_blobstore: ArtifactBlobStore | None = None
@@ -143,7 +161,7 @@ def set_blobstore(store: ArtifactBlobStore) -> None:
143161
def get_blobstore() -> ArtifactBlobStore:
144162
global _blobstore
145163
if _blobstore is None:
146-
_blobstore = LocalContentStore(get_settings().artifacts_dir)
164+
_blobstore = LocalContentStore(runtime_settings().artifacts_dir)
147165
return _blobstore
148166

149167

@@ -1513,7 +1531,7 @@ def _build_tool_invoker(
15131531
工具事件走引擎 record_external(序列分配必须留在引擎单路径上),
15141532
随 _ProjectingSink 投影成 v1 run.log,工作台执行轨迹可见每次调用。
15151533
"""
1516-
settings = get_settings()
1534+
settings = runtime_settings()
15171535
workspace = TaskWorkspace(settings.workspaces_dir, run.id)
15181536
_stage_attachment_tables(session, run, workspace)
15191537
sandbox = PythonSandbox(

backend/api/omm_api/main.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from .blobstore import LocalContentStore
1414
from .config import Settings, get_settings
1515
from .db import Database
16+
from .db_ready import ensure_database_ready
1617
from .errors import register_error_handlers
1718
from .middleware import OriginCheckMiddleware, RequestIdMiddleware
1819
from .paper_export import PaperExportProcessor, PaperExportThread
@@ -40,6 +41,9 @@ def create_app(settings: Optional[Settings] = None) -> FastAPI:
4041

4142
@asynccontextmanager
4243
async def lifespan(app: FastAPI):
44+
# 先探库再建表:连不上时给一行能照着做的提示(本地 pg-dev 实例还会自动拉起),
45+
# 而不是让 create_all 抛出百行 psycopg 超时 traceback。
46+
ensure_database_ready(db, resolved)
4347
# 开发环境用 create_all 保证可用;PostgreSQL 部署走 Alembic 迁移。
4448
db.create_all()
4549
runner: Optional[RunnerThread] = None
@@ -82,6 +86,8 @@ async def lifespan(app: FastAPI):
8286
blobs = LocalContentStore(resolved.artifacts_dir)
8387
app.state.blobs = blobs
8488
engine_glue.set_blobstore(blobs)
89+
# 沙盒工作区根等运行时配置也用这一份 Settings(测试夹具指向 tmp_path 才真隔离)
90+
engine_glue.set_settings(resolved)
8591
# 用户头像共用同一存储实现,但目录独立于运行产物(归属与回收边界不同)
8692
app.state.avatars = LocalContentStore(resolved.avatars_dir)
8793
# 测试与内部工具可直接驱动推进(runner_enabled=False 时手动 tick)

0 commit comments

Comments
 (0)