Prhub

#1948 Pin a client to one server process with a boot uuid

原始 PR 作者 fzyzcjy 合并时间 2026-09-04 13:25 文件变更 22 提交数 1 评论 1 代码增减 +1690 / -52

执行摘要

通过启动 UUID 绑定客户端到特定服务器进程,防止服务器重启后客户端状态丢失。

动机源于解决服务器重启后客户端状态不一致的问题。commit message 明确指出:“Every response carries the serving process's boot uuid. A handle asked for a stable server records the first one it sees and sends it back as an expectation; a restarted process answers 412 and the client raises ServerRestartedError instead of silently running the call against a worker that has lost all its state.” 这直接回应了在分布式系统中如何可靠地检测和应对服务端进程重启的挑战。

这是一个设计精良且测试充分的重构 PR,强烈建议精读。其核心价值不仅在于解决了具体问题,更在于展示了一种处理分布式系统中进程身份与状态一致性的优秀模式:通过无状态协议头传递、客户端状态记忆和服务端守卫的协同工作,实现了轻量而可靠的故障检测。特别值得关注 BootUuidPin 的状态机设计及其与传输层 (RpcTransport) 的无缝集成方式。大量高质量的单元测试和端到端测试(尤其是模拟进程重启的测试)也值得学习和借鉴。

讨论亮点

本次 PR 只有一条来自 guapisolo 的批准评论,没有深入的技术讨论线程。评论内容为:“Reviewed in the #1837 stack context through safe merge checkpoint #2176. With a frozen code version and no old-checkpoint or mixed-version rollback requirement, the boot-UUID fencing holds and I found no blocking issue.” 这表明该变更在当前的代码冻结和部署上下文中是安全且符合预期的,验证了启动 UUID 围栏机制的有效性。没有发现未解决的疑虑或争议点。

实现拆解

  1. 引入协议常量与异常:在 miles/utils/workers/rpc/common/protocol.py 中定义了 BOOT_UUID_HEADER, EXPECTED_BOOT_UUID_HEADER, BOOT_UUID_MISMATCH_STATUS (412) 等协议常量。同时在 miles/utils/workers/rpc/client/misc.py 中引入了 ServerRestartedError 异常,用于明确表示服务器已重启。
  2. 增强传输层以支持启动 UUID 验证:核心变更在 RpcTransport 类。其构造函数新增 boot_uuid_pin: BootUuidPin 参数。在 request 方法中,每次 HTTP 请求前,如果 pin 有期望值(expected),则将其作为 EXPECTED_BOOT_UUID_HEADER 请求头发送。收到响应后,首先检查状态码是否为 412(BOOT_UUID_MISMATCH_STATUS),若是则直接抛出 ServerRestartedError。否则,调用 boot_uuid_pin.verify(response) 来验证响应中的 BOOT_UUID_HEADER,实现“锁定”和“拒绝”的逻辑。
  3. 实现 BootUuidPin 状态管理:新增 BootUuidPin 类,负责管理启动 UUID 的状态。它根据 required 参数决定是否启用绑定功能。核心方法 verify 在收到响应时执行:若需要绑定且尚未记录 UUID,则首次记录;若已记录但收到不同或缺失的 UUID,则抛出 ServerRestartedError。expected 属性在需要绑定时返回当前锁定的 UUID 值。needs_handshake 方法用于判断是否需要进行首次握手来获取 UUID。
  4. 在句柄层集成绑定逻辑:miles/utils/workers/rpc/client/handle.py 中的 RpcWorkerHandle 构造函数现在接受 require_stable_boot_uuid 参数,并在初始化时创建相应的 BootUuidPin 实例,传递给底层的 RpcTransport。wait_ready 等就绪逻辑也可能涉及对启动 UUID 的首次验证(握手)。
  5. 全面的测试覆盖:新增了大量测试,从单元测试(test_misc.py 的 TestBootUuidPin、test_handle.py 的 TestReadyHandshake)到端到端测试(test_lifecycle.py 的 TestRestartDetection),覆盖了绑定句柄拒绝重启服务器、未绑定句柄看到新进程、进程内 UUID 稳定性、错误响应携带 UUID 头等所有关键场景。服务端也在 app.py 中添加了 boot_uuid_guard 中间件,在路由和验证之前优先检查并拒绝陈旧的启动 UUID 期望。
文件 模块 状态 重要度
miles/utils/workers/rpc/client/misc.py RPC 客户端 modified 8.32
tests/fast/utils/workers/e2e/test_lifecycle.py 生命周期测试 added 8.08
miles/utils/workers/rpc/server/app.py RPC 服务端 modified 7.07

关键符号

BootUuidPin.__init__ BootUuidPin.expected BootUuidPin.needs_handshake BootUuidPin.verify RpcTransport.__init__ RpcTransport.request

关键源码片段

miles/utils/workers/rpc/client/misc.py core-logic

核心变更文件,实现了 `BootUuidPin` 状态管理类和 `RpcTransport` 的启动 UUID 验证逻辑,是本次 PR 功能的核心承载。

# BootUuidPin 类:管理启动 UUID 的状态,实现 " 锁定 " 与 " 拒绝 " 逻辑
class BootUuidPin:
    def __init__(self, *, required: bool, worker_cls_name: str) -> None:
        self._required = required # 是否需要绑定到特定进程
        self._worker_cls_name = worker_cls_name
        self._value: str | None = None # 当前锁定的 UUID 值
​
    @property
    def expected(self) -> str | None:
        # 如果需要绑定且已锁定,返回当前 UUID;否则返回 None(表示不检查)
        return self._value if self._required else None
​
    def needs_handshake(self) -> bool:
        # 需要绑定但尚未锁定 UUID,即需要进行首次“握手”来获取 UUID
        return self._required and self._value is None
​
    def verify(self, response: httpx.Response) -> None:
        # 核心验证逻辑
        if not self._required:
            return
​
        boot_uuid = response.headers.get(BOOT_UUID_HEADER)
        if boot_uuid is None:
            # 服务端响应缺失 UUID 头,视为进程重启(或配置错误)
            raise ServerRestartedError(f"{self._worker_cls_name} response is missing the {BOOT_UUID_HEADER} header")
        if self._value is None:
            # 首次获得 UUID,进行锁定
            self._value = boot_uuid
        elif boot_uuid != self._value:
            # UUID 不匹配,说明服务端进程已更换
            raise ServerRestartedError(
                f"{self._worker_cls_name} response came from boot uuid {boot_uuid}, expected {self._value}"
            )
​
# RpcTransport.request 方法中的关键集成点
async def request(self, method: str, path: str, *, seconds: float, response_model: type[_ResponseT], **kwargs: Any) -> _ResponseT:
    headers = dict(kwargs.pop("headers", {}))
    # 如果有期望的 UUID,将其作为请求头发送,以便服务端检查
    if self._boot_uuid_pin.expected is not None:
        headers[EXPECTED_BOOT_UUID_HEADER] = self._boot_uuid_pin.expected
    response = await asyncio.wait_for(self._client.request(..., headers=headers, ...), ...)
​
    if response.status_code == BOOT_UUID_MISMATCH_STATUS:
        # 服务端明确拒绝了陈旧的 UUID,抛出重启错误
        raise ServerRestartedError(f"rpc server restarted: {response.text}")
    # ... 其他错误处理 ...
​
    # 使用 pin 验证响应中的 UUID(可能记录或拒绝)
    self._boot_uuid_pin.verify(response)
    return response_model.model_validate(response.json())

评论区精华

没有提炼出高价值讨论线程

当前评论区没有形成足够清晰的争议点或结论,后续有更多讨论时会体现在这里。

风险与影响

  1. 核心 RPC 路径变更:RpcTransport.request 是所有 RPC 调用的基础,本次变更在此增加了额外的头部注入和响应头验证逻辑。虽然逻辑清晰,但作为核心路径的改动,任何疏漏都可能影响广泛。风险在于:a) 对所有 RPC 调用的请求/响应增加了少量开销(虽然可忽略不计);b) 引入了新的异常类型 ServerRestartedError,调用方需要正确处理。
  2. 状态依赖与初始化时序:BootUuidPin 的状态(_value)依赖于首次成功的 HTTP 交互。如果 wait_ready 握手或首次调用失败,可能导致 pin 处于 needs_handshake 状态,影响后续调用。测试中已覆盖此场景,但需确保所有初始化路径正确。
  3. 服务端兼容性:服务端需要在所有响应中正确添加 BOOT_UUID_HEADER。这是通过一个中间件 (boot_uuid_guard) 实现的,位于 miles/utils/workers/rpc/server/app.py。风险是如果该中间件未正确加载或配置,可能导致所有响应缺少该头部,使得客户端的绑定机制失效。测试中已验证了中间件行为。
  4. 测试覆盖的完备性:本次 PR 包含大量新增测试,覆盖了核心逻辑、边界条件(如缺少头、头值不匹配、状态码 412)和端到端场景(进程重启、取消调用等)。这大大降低了回归风险。
  1. 用户/调用方影响:对于使用 require_stable_boot_uuid=True 创建句柄的场景(如需要强状态一致性的长时间运行的推理任务),提供了关键保护,避免了因服务器重启导致的静默失败或重复执行。对于未启用绑定的普通调用,行为透明,无影响。引入的 ServerRestartedError 是一个清晰的故障信号,允许调用方实施特定的恢复逻辑(如创建新句柄)。
  2. 系统影响:增强了 RPC 框架在进程生命周期不确定性下的鲁棒性。为后续可能的滚动更新、故障恢复等运维操作提供了更安全的基础。协议层的扩展(添加头部)是向后兼容的。
  3. 团队影响:为分布式系统开发提供了一个可复用的“启动 UUID”模式。测试套件的扩展也为 RPC 模块建立了更高质量的回归防护网。代码结构(将 BootUuidPin 作为独立类)清晰,便于理解和维护。
核心 RPC 路径变更 引入新异常类型 头部验证逻辑

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论