mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 05:01:57 +00:00
2714890d0d
README.zh-CN.md / README.md 只留:这是什么、为什么用它、主要功能(按领域一行一条,链到文档)、快速开始、部署、文档索引、 开发与测试、许可(原文不动)。原来的每一节按读者和任务分到 17 篇主题文档:ask-data、workflows、evidence、 data-sources(新增:接入数据库、只读保证、上传表格、跨库合并、遮罩列)、data-catalog、tools(新增:工具、审批档位、 能力登记、端点、网络规则——它们管所有工作流,不只应用)、apps、evaluation-and-ledger、schedules(含通知)、deployment、 configuration、postgresql-and-scaling、identity-and-security、architecture、development、upgrade-notes(按时间倒序, 每波先写要做的事)、faq。删掉的只有模块名(api/copilot_gen.py、chat/drive.py、data/schema_view.py)、重复的话和 「以前…现在…」的叙述;操作步骤、命令、配置名、限制、权限边界都有归宿。中文是原文,英文按新的中文写;英文里界面文字 只写英文名,开头注明界面目前只有简体中文,中文占位符换成英文占位符,中文数字的例子改成不依赖中文的说法。 数据剖析可以同时剖析 2–3 张表(baf23998)收进 data-catalog。 新增 scripts/check-docs.py:英文文档(README.md、docs/en/**)不许有中日韩字符和全角符号(代码块也算), README、docs/**、deploy/README.md 的相对链接和锚点都要通,docs/zh-CN 与 docs/en 文件一一对应、标题层级相同。 中文提示里指向 README 章节的地方改指新文档:cluster/guard.py、cluster/version.py、db/base.py 两处报错, data/schema_view.py 文档串,.env.example 三处,scripts/prod.sh、scripts/test-postgres.sh,terms.ts 两处、 DataSourcesTab.tsx、check-manage.mjs 注释,deploy/README.md 三处链接。deploy/agentlab.env 未动。 验收:check-docs 0 问题(英文文档 18 个 0 处中文,相对链接 369 条 0 断链,18 对标题层级一致);相关后端测试 117 通过、7 跳过;tsc -b、check-copy 通过。
226 lines
10 KiB
Python
226 lines
10 KiB
Python
"""这个进程是哪一版代码、要的是哪一版库结构;库里还有活着的、版本不同的进程时不许加入(不支持新旧版本混跑)。
|
||
|
||
**为什么要拦。** 升级时只停了一部分进程、或者新旧两批容器同时连着一个库:新版的进程启动时补列、补索引,旧版的进程不认识新的列和
|
||
规矩(认领、租约、接手的说法),两边各按各的写同一批行。以前只在 README 里写「升级要先停掉全部进程」,现在代码里拦住。
|
||
|
||
- 代码版本 code_version:包版本 + 构建号(AGENTLAB_BUILD,Docker 镜像构建时写进去)或 git 提交 + 源码指纹(app/ 下全部 .py
|
||
的内容,加上依赖锁文件 requirements.lock 的内容;Docker 镜像里也留着这个文件)。比的是源码指纹:同一份代码、同一套依赖一定相同,
|
||
改了一行、升了一个依赖就不同,取不到 git、没有构建号时也区分得开。锁文件取不到(不是从仓库或镜像装的)时换成几个关键包的版本。
|
||
- 库结构版本 schema_version:模型里各表各列(名字、类型、可空)加迁移清单的哈希。
|
||
|
||
怎么拦:PostgreSQL 时,补列之前先看一眼(preflight)——还有活着的、版本不同的进程就不启动;它的心跳停着(多半是强杀之后留下的、
|
||
租约还没到期)就等它过期再起,心跳还在走就照实报错:哪几个进程、什么版本、要先停掉再升级。登记那一下再在事务级咨询锁里查一遍
|
||
(cluster/workers.register),两个不同版本同时启动也只有先登记上的那一个起得来。SQLite 只有一个进程,不用看。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import hashlib
|
||
import logging
|
||
import os
|
||
import subprocess
|
||
from functools import lru_cache
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
from app.cluster import identity
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
_APP_DIR = Path(__file__).resolve().parents[1]
|
||
|
||
|
||
class VersionMismatch(RuntimeError):
|
||
"""库里还有活着的、版本不同的进程。"""
|
||
|
||
|
||
#: 依赖锁文件(仓库里 backend/requirements.lock;Docker 镜像里 /app/backend/requirements.lock)
|
||
LOCK_FILE = _APP_DIR.parent / "requirements.lock"
|
||
#: 锁文件取不到时拿这几个包的版本代替(升级时最容易带出行为差别的那几个)
|
||
KEY_PACKAGES = ("fastapi", "pydantic", "sqlalchemy", "asyncpg", "psycopg", "langgraph", "langgraph-checkpoint",
|
||
"langgraph-checkpoint-postgres", "langchain-core", "starlette", "uvicorn")
|
||
|
||
|
||
def dependency_marker() -> bytes:
|
||
"""依赖这一份进指纹的样子:锁文件的内容;取不到就是几个关键包的版本。"""
|
||
try:
|
||
return b"lock:" + LOCK_FILE.read_bytes()
|
||
except OSError:
|
||
pass
|
||
from importlib.metadata import PackageNotFoundError, version
|
||
|
||
parts = []
|
||
for name in KEY_PACKAGES:
|
||
try:
|
||
parts.append(f"{name}=={version(name)}")
|
||
except PackageNotFoundError:
|
||
parts.append(f"{name}==")
|
||
return ("packages:" + ";".join(parts)).encode()
|
||
|
||
|
||
@lru_cache(maxsize=1)
|
||
def source_fingerprint() -> str:
|
||
digest = hashlib.sha256()
|
||
for path in sorted(_APP_DIR.rglob("*.py")):
|
||
if "__pycache__" in path.parts:
|
||
continue
|
||
digest.update(str(path.relative_to(_APP_DIR)).encode())
|
||
try:
|
||
digest.update(path.read_bytes())
|
||
except OSError:
|
||
continue
|
||
# 依赖升级(锁文件变了)也算版本不同:代码一行没改、换了一版 SQLAlchemy / LangGraph 的进程同样不许混进来(复核 R-b13)
|
||
digest.update(b"\0deps\0" + dependency_marker())
|
||
return digest.hexdigest()[:12]
|
||
|
||
|
||
def _package() -> str:
|
||
try:
|
||
from importlib.metadata import version
|
||
|
||
return version("agentlab-backend")
|
||
except Exception: # noqa: BLE001
|
||
return "0"
|
||
|
||
|
||
def _build() -> str:
|
||
if build := (os.environ.get("AGENTLAB_BUILD") or "").strip():
|
||
return build[:40]
|
||
try:
|
||
out = subprocess.run(["git", "rev-parse", "--short=10", "HEAD"], cwd=str(_APP_DIR), capture_output=True,
|
||
text=True, timeout=3)
|
||
return out.stdout.strip() if out.returncode == 0 else ""
|
||
except (OSError, subprocess.SubprocessError):
|
||
return ""
|
||
|
||
|
||
@lru_cache(maxsize=1)
|
||
def code_version() -> str:
|
||
"""包版本+构建号或 git 提交+源码指纹,例如 0.1.0+e6a3ee8f12+3f9a…(最后一段是比较用的)。"""
|
||
return "+".join(p for p in (_package(), _build(), source_fingerprint()) if p)
|
||
|
||
|
||
def fingerprint_of(version: str | None) -> str:
|
||
return (version or "").rsplit("+", 1)[-1]
|
||
|
||
|
||
@lru_cache(maxsize=1)
|
||
def schema_version() -> str:
|
||
from app.db import models # noqa: F401 模型都登记进 metadata
|
||
from app.db.base import _COLUMN_MIGRATIONS, _INDEX_MIGRATIONS, Base
|
||
|
||
digest = hashlib.sha256()
|
||
for name in sorted(Base.metadata.tables):
|
||
table = Base.metadata.tables[name]
|
||
digest.update(name.encode())
|
||
for col in table.columns:
|
||
digest.update(f"{col.name}:{col.type!r}:{col.nullable}".encode())
|
||
digest.update(repr(_COLUMN_MIGRATIONS).encode() + repr(_INDEX_MIGRATIONS).encode())
|
||
return digest.hexdigest()[:12]
|
||
|
||
|
||
def same(code: str | None, schema: str | None) -> bool:
|
||
return fingerprint_of(code) == fingerprint_of(code_version()) and (schema or "") == schema_version()
|
||
|
||
|
||
async def _peers(conn: Any) -> list[dict[str, Any]] | None:
|
||
"""库里登记着的别的进程(id、版本、租约、心跳);表还不在(新库)交回 None。在一条连接上查(preflight 在建表之前)。"""
|
||
from sqlalchemy import inspect, text
|
||
|
||
def has(sync_conn: Any) -> tuple[bool, set[str]]:
|
||
insp = inspect(sync_conn)
|
||
if not insp.has_table("cluster_workers"):
|
||
return False, set()
|
||
return True, {c["name"] for c in insp.get_columns("cluster_workers")}
|
||
|
||
exists, cols = await conn.run_sync(has)
|
||
if not exists:
|
||
return None
|
||
extra = ", ".join(c for c in ("code_version", "schema_version") if c in cols)
|
||
rows = (await conn.execute(text(f"SELECT id, lease_until, heartbeat_at{', ' + extra if extra else ''} "
|
||
"FROM cluster_workers WHERE id <> :me"), {"me": identity.owner()})).mappings().all()
|
||
return [dict(r) for r in rows]
|
||
|
||
|
||
def _describe(peer: dict[str, Any], beating: set[str] | None = None) -> str:
|
||
code = peer.get("code_version") or "(升级前的版本,没有记版本)"
|
||
schema = peer.get("schema_version") or "未知"
|
||
state = ""
|
||
if beating is not None:
|
||
state = ",心跳在走" if peer["id"] in beating else ",心跳停着、等租约过期"
|
||
return f"{identity.describe(peer['id'])}(代码 {code},库结构 {schema}{state})"
|
||
|
||
|
||
async def preflight(*, observe_s: float | None = None) -> None:
|
||
"""PostgreSQL 时、建表补列之前:库里还有活着的、版本不同的进程就不启动(见模块说明)。observe_s:看它心跳还在不在走的时长
|
||
(缺省两拍心跳)。"""
|
||
from app.core.config import settings
|
||
|
||
if settings.db_dialect != "postgresql":
|
||
return
|
||
from app.cluster.workers import heartbeat_s, lease_s
|
||
|
||
observe = 2 * heartbeat_s() + 1.0 if observe_s is None else observe_s
|
||
loop = asyncio.get_running_loop()
|
||
started = loop.time()
|
||
first_beats: dict[str, Any] = {}
|
||
warned = False
|
||
while True:
|
||
mismatched = await _mismatched_alive()
|
||
if not mismatched:
|
||
return
|
||
for peer in mismatched:
|
||
first_beats.setdefault(peer["id"], peer.get("heartbeat_at"))
|
||
# 心跳还在走:真活着,照实报错。心跳停着的(强杀、断电留下的登记)等它们的租约过期。报错时列出全部活着的、版本不同的
|
||
# 进程(复核 R-b13:只点名心跳在走的那个,人停掉它再起又会被另一个拦下)
|
||
beating = {p["id"] for p in mismatched if p.get("heartbeat_at") != first_beats.get(p["id"])}
|
||
waited = loop.time() - started
|
||
if beating or waited >= lease_s() * 2 + observe:
|
||
raise VersionMismatch(_message(mismatched, beating=beating))
|
||
if waited >= observe and not warned:
|
||
warned = True
|
||
logger.warning("库里登记着 %d 个版本不同的进程,心跳停着(多半是强杀之后留下的):等它们的租约过期再启动",
|
||
len(mismatched))
|
||
await asyncio.sleep(min(0.5, max(0.1, heartbeat_s() / 2)))
|
||
|
||
|
||
async def _mismatched_alive(now: Any = None) -> list[dict[str, Any]]:
|
||
"""租约没过期、版本不同的别的进程。now 不给就按库的时钟(租约是按库的时钟写的,cluster/workers)。"""
|
||
from sqlalchemy import text
|
||
|
||
from app.db.base import engine
|
||
|
||
async with engine.connect() as conn:
|
||
peers = await _peers(conn)
|
||
if peers and now is None:
|
||
now = (await conn.execute(text("SELECT timezone('UTC', clock_timestamp())"))).scalar()
|
||
if not peers:
|
||
return []
|
||
naive = _naive(now)
|
||
alive = [p for p in peers if p.get("lease_until") is not None and _naive(p["lease_until"]) > naive]
|
||
return [p for p in alive if not same(p.get("code_version"), p.get("schema_version"))]
|
||
|
||
|
||
def _naive(moment: Any) -> Any:
|
||
return moment.replace(tzinfo=None) if getattr(moment, "tzinfo", None) else moment
|
||
|
||
|
||
def _message(peers: list[dict[str, Any]], *, beating: set[str] | None = None) -> str:
|
||
listed = ";".join(_describe(p, beating) for p in peers)
|
||
return (f"库里还有 {len(peers)} 个版本不同的 AgentLab 进程活着:{listed}。本进程是代码 {code_version()}、库结构 "
|
||
f"{schema_version()}。不支持新旧版本同时运行:先停掉这些进程再启动新版本(升级步骤见 docs/zh-CN/postgresql-and-scaling.md「多进程」的「升级」)")
|
||
|
||
|
||
def check_rows(rows: list[Any], now: Any) -> None:
|
||
"""登记那一下(cluster/workers.register,持着事务级咨询锁)再查一遍:别的活着的进程里有版本不同的就抛 VersionMismatch。
|
||
now 是库的时钟。"""
|
||
naive = _naive(now)
|
||
bad = [{"id": r.id, "code_version": r.code_version, "schema_version": r.schema_version} for r in rows
|
||
if r.lease_until is not None and _naive(r.lease_until) > naive and r.id != identity.owner()
|
||
and not same(r.code_version, r.schema_version)]
|
||
if bad:
|
||
raise VersionMismatch(_message(bad))
|
||
|
||
|
||
__all__ = ["VersionMismatch", "check_rows", "code_version", "preflight", "same", "schema_version", "source_fingerprint"]
|