Files
codex-app-server/cloud/chv/experimental_pool.py
T

294 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""快照预热池 WarmPool(纯实验模块,非生产路径)。
本轨(feat-chv-arch-maint-fe)破环性清理:WarmPool 从
cloud/chv/pool.py 移到本文件,cloud/chv/__init__ 与
cloud/sandbox.py 不再 re-export 它,生产代码 import 本
模块即违规(见 docs/chv-architecture.md ADR-12 的禁
import 清单与测试钉住)。实验用法只走
`from cloud.chv.experimental_pool import WarmPool`。
生产残留清理已彻底分文件:见 cloud/chv/orphans.py 的
reconcile_orphans_for_executor(),它不 import 本模块。
定案(架构决策,见 docs/chv-architecture.md ADR-5):WarmPool
不再宣称为生产快速启动特性。原因:预热 VM 的磁盘是干净快照
盘,不是任务盘;dispatch 前查池若要真正接线,必须在派任务
前给已暂停 VM 重新供给任务盘(热换盘/二盘挂载/host.py 磁盘
语义三者都未实现),把预热盘直接当任务盘用会击穿“任务盘
隔离”不变量。acquire 内 resume 只是实验性接口,worker
dispatch 永远走冷启动(wake_vm),_run_with_vm 绝不查池。
本模块的 reconcile_orphans() 只留给实验/旧测试直接调用;
生产启动/关闭走 orphans facade,不再经本类。
warm_pool_wired_to_dispatch 恒为 False,任何把它改成
True 的改动都必须先实现任务盘重供给并补不变量测试,
否则不得合入。
隔离红线(与 #216 一致,绝不妥协):预热条目是一次性的
干净快照 VM,acquire 消耗掉它,release 一律销毁,绝不把
一个任务用过的 VM(带任务文件、带 agent 痕迹)翻出来给
下一个任务/租户复用;池子空了就回落冷启动(wake_vm),
绝不因为池空就降级 Docker/Local。
Registry(本轨新增):
- 文件:state_dir/warm-pool.json(registry_path 可覆盖),
内容为 {<合成名>: {"state", "vm", "task_id", "snapshot_url"}}。
每次 prewarm/acquire/release 后立即落盘(临时文件 + 原子
rename),worker 崩溃后重启仍能发现上一世的条目。
- 载入时绝不复用:新池实例从 registry 读到的条目一律标为
orphan(不计入 warm_count,acquire 绝不发给任务),因为
旧 VMM 进程已随旧 worker 消失,无法验证它还是当初那个
干净快照 VM。它们只能走 reconcile_orphans() 清理。
- reconcile_orphans():worker 启动/清理时的显式入口,对
每个 orphan/assigned 条目尽力调 executor.shutdown_vm
(清 socket 与残留登记),条目清掉并落盘,返回已清理的
VM 名列表。registry 里没有的残留 socket 仍由执行器的
cleanup_stale_vms() 扫 state_dir 处理,两者互补,不互相
替代;reconcile 绝不在构造时自动执行(同一 state_dir 只
能有一个活池,构造时自动清理会误杀另一个活池的 VM)。
- 损坏 fail-safe:registry 不是合法 JSON / 顶层不是 dict 时,
整个文件 quarantine 成 <path>.corrupt(尽力而为),池子
从空开始,绝不用损坏数据复用 VM;单条目结构非法时只
丢该条(记 registry_corrupt_entries),不连坐其它条目。
registry 写失败时只记日志警告、不炸任务流(池子内存态
仍可用,重启后孤儿改由 socket 扫描兜底)。
"""
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from cloud.chv.executor import CloudHypervisorExecutor
import os
from cloud.chv.orphans import _REGISTRY_FILENAME, _read_registry, _write_registry
class WarmPool:
"""快照预热池(实验性,见模块头定案)。
wired_to_dispatch = False:生产 dispatch 不查池。
目标:冷启动(preflight + 起 VMM + create + boot + SSH
就绪)是慢路径;预热池提前把干净 VM 起好并暂停,任务
来时 acquire 直接 resume,省掉冷启动的大头。
状态机:cold(未预热)→ prewarming(wake 中)→ warm
(已暂停待分配)→ assigned(已分配给某任务)→
running(已 resume)→ recycled/destroyed(release 后)。
prewarm 的实现:对每个条目调 executor.wake_vm(冷启动)
再 pause_vm,得到 paused 的干净 VM;mock 单测里 wake/
pause 均可注入假实现,真机上这一步需要 KVM 宿主(见
就绪清单“需 KVM 宿主实测”)。snapshot_url 未设时
prewarm 返回 [](无基准快照可预热,不是静默造快照)。
"""
wired_to_dispatch = False
def __init__(self, executor: "CloudHypervisorExecutor", size: int | None = None,
snapshot_url: str | None = None,
registry_path: str | None = None) -> None:
self.executor = executor
self.size = int(size if size is not None
else getattr(executor, "warm_pool_size", 0))
self.snapshot_url = snapshot_url \
if snapshot_url is not None \
else getattr(executor, "snapshot_url", None)
self._entries: dict = {} # 合成名 -> {"state", "vm", "task", ...}
self.registry_path = registry_path
if self.registry_path is None:
state_dir = getattr(executor, "state_dir", None)
if state_dir:
self.registry_path = os.path.join(
state_dir, _REGISTRY_FILENAME)
self.registry_corrupt = False
self.registry_corrupt_entries = 0
self._load_registry()
# -- registry ------------------------------------------------------
def _load_registry(self) -> None:
"""载入 registry:读到的条目一律标 orphan(见模块
docstring,绝不直接当 warm 发给任务)。无文件时为空
池;损坏时 quarantine 并从空开始。文件 IO 与损坏
语义唯一来源是 cloud.chv.orphans(生产模块),本
方法只做“条目 → 内存 orphan 态”的映射。"""
if not self.registry_path:
return
data, corrupt = _read_registry(self.registry_path)
if corrupt:
self.registry_corrupt = True
return
if data is None:
return
for name, entry in data.items():
if not isinstance(entry, dict) \
or not entry.get("vm") \
or entry.get("state") not in ("warm", "assigned"):
self.registry_corrupt_entries += 1
continue
self._entries[name] = {
"state": "orphan",
"vm": entry["vm"],
"task": {"id": entry.get("task_id") or name},
"snapshot_url": entry.get("snapshot_url"),
}
def _save_registry(self) -> None:
"""落盘当前条目(只写 warm/assigned 态;orphan 是
待清理的上一世条目,reconcile 后即删,不写回)。
写失败只记日志,不炸任务流。落盘 IO 委托 orphans。"""
if not self.registry_path:
return
data = {}
for name, entry in self._entries.items():
if entry["state"] not in ("warm", "assigned"):
continue
task = entry.get("task") or {}
task_id = task.get("id") if isinstance(task, dict) \
else None
data[name] = {
"state": entry["state"],
"vm": entry.get("vm"),
"task_id": task_id,
"snapshot_url": entry.get("snapshot_url"),
}
_write_registry(self.registry_path, data)
def reconcile_orphans(self) -> list:
"""清理本池内存里的 orphan/assigned 条目(实验入口)。
对每个条目尽力 shutdown_vm,条目清掉并落盘,返回
已清理的 VM 名列表。生产启动/关闭不走本方法,走
cloud.chv.orphans.reconcile_orphans_for_executor()。"""
cleaned = []
for name, entry in list(self._entries.items()):
if entry["state"] not in ("orphan", "assigned"):
continue
vm = entry.get("vm") or name
try:
self.executor.shutdown_vm(vm)
except Exception:
pass
self._entries.pop(name, None)
cleaned.append(vm)
if cleaned:
self._save_registry()
try:
self.executor._emit(
"warm_pool_reconcile", {"id": None},
"cleaned=%s" % ",".join(cleaned))
except Exception:
pass
return cleaned
# -- 池操作 ----------------------------------------------------------
def warm_count(self) -> int:
return sum(1 for e in self._entries.values()
if e["state"] == "warm")
def orphan_count(self) -> int:
"""上一世残留条目数(待 reconcile,不可分配)。"""
return sum(1 for e in self._entries.values()
if e["state"] in ("orphan", "assigned"))
def prewarm(self, count: int | None = None) -> list:
"""预热到目标数:已有 warm 条目不重复建;每个新条目
用一个合成任务名 wake + pause(合成任务 id 为
warm-<序号>,绝不与真实任务 id 撞名)。返回新增的
warm VM 名列表。"""
if self.snapshot_url is None:
return []
target = int(count) if count is not None else self.size
added = []
i = 0
while self.warm_count() < target:
i += 1
name = "warm-%d" % i
if name in self._entries:
continue
task = {"id": name}
self._entries[name] = {"state": "prewarming",
"task": task}
try:
self.executor.wake_vm(task)
self.executor.pause_vm(task)
except Exception:
self._entries.pop(name, None)
raise
vm = self.executor.vm_name(task)
self._entries[name] = {
"state": "warm", "task": task, "vm": vm,
"snapshot_url": self.snapshot_url}
added.append(name)
if added:
self._save_registry()
return added
def acquire(self, task: dict) -> dict:
"""分配一个预热 VM 给任务(feat-chv-fix,P1-3 接线):
有 warm 条目时先调 executor.resume_vm() 把暂停的 VM
真正唤醒(resume 失败即条目作废、继续找下一个,
绝不把 paused 的 VM 当 running 发给任务),再标
assigned 并返回 mode=warm;resume 全部失败或无 warm
条目时返回 mode=cold(调用方回落 wake_vm 冷启动)。
注意:本方法只解决“acquire 不再悬空 resume 责任”
这一半;worker dispatch 前查池的另一半仍未接线
(见执行器/就绪文档的“未接线缺口”标注),预热收益
在生产路径上仍不是已交付特性。
feat-chv-secfix(L5,接线时的盘对应关系定义——接线
本身仍未做,不假装已接):预热 VM 的磁盘是干净快照
盘,不是任务盘。将来 dispatch 前查池接线时必须做到:
acquire 返回的 VM 在派任务前仍须按任务重新供给磁盘
(provision_task_disk 的 copy 盘,或校验快照来源与该
任务的对应关系后才能用快照盘),绝不能把预热盘直接
当任务盘用;接线时须补“任务盘来源”不变量的测试,
否则 acquire 的 warm 路径不得合入生产路径。"""
for name, entry in list(self._entries.items()):
if entry["state"] != "warm":
continue
resume = getattr(self.executor, "resume_vm", None)
if resume is not None:
try:
resume(entry.get("task") or task)
except Exception:
self._entries.pop(name, None)
self._save_registry()
continue
# 无 resume_vm 的鸭子型执行器(旧测试替身):
# 保持旧 assigned 语义,不因此丢弃条目;真实
# CloudHypervisorExecutor 一律有 resume_vm,走
# 上面分支,责任不悬空。
entry["state"] = "assigned"
entry["task"] = task
self._save_registry()
try:
self.executor._emit(
"warm_pool_acquire", task,
"vm=%s" % name)
except Exception:
pass
return {"mode": "warm", "vm": name,
"pool_task": entry["task"]}
return {"mode": "cold", "vm": None, "pool_task": None}
def release(self, task: dict, pool_vm: str | None = None) -> bool:
"""释放:无论 warm 还是 cold 路径,VM 一律销毁
(executor.shutdown_vm),池条目清掉;绝不把用过
的 VM 放回 warm 池。返回 True 表示确实销毁了一个
条目或 VM。"""
destroyed = False
if pool_vm and pool_vm in self._entries:
self._entries.pop(pool_vm, None)
destroyed = True
try:
if self.executor.shutdown_vm(task):
destroyed = True
except Exception:
pass
self._save_registry()
self.executor._emit(
"warm_pool_release", task,
"vm=%s destroyed=%s" % (pool_vm, destroyed))
return destroyed