188 lines
7.3 KiB
Python
188 lines
7.3 KiB
Python
"""
|
|
Worker 同步工具:primary-first 初始化协调
|
|
|
|
多 worker 部署下常见需求:primary worker 负责重型初始化(如下载/加载模型),
|
|
其余 worker 等待 primary 完成后再做轻量加载。本模块通过文件标记实现跨进程协调,
|
|
仅依赖标准库,兼容 Windows(不使用 fcntl),spawn / fork 两种启动模式均可用。
|
|
|
|
典型用法::
|
|
|
|
from myboot.utils import run_primary_first
|
|
|
|
model = run_primary_first(
|
|
"sas_rec_model",
|
|
primary_fn=service.initialize_model, # primary: 下载并初始化
|
|
secondary_fn=service.load_model_from_disk # 其他 worker: 轻量加载
|
|
)
|
|
"""
|
|
|
|
import datetime
|
|
import hashlib
|
|
import os
|
|
import tempfile
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Callable, Optional, TypeVar
|
|
|
|
from loguru import logger
|
|
|
|
T = TypeVar("T")
|
|
|
|
_logger = logger.bind(name="worker_sync")
|
|
|
|
# 模块加载时间,用于陈旧标记判定(见 run_primary_first docstring)。
|
|
# spawn 模式下每个 worker 进程独立导入本模块,该时间即 worker 启动时间附近;
|
|
# fork 模式下可能继承父进程导入时刻,比 worker 启动更早——仍然早于本轮
|
|
# primary 写入标记的时刻,因此不会误拒本轮标记。
|
|
_MODULE_LOAD_TIME = time.time()
|
|
|
|
# 陈旧标记判定容差(秒):mtime 早于 (_MODULE_LOAD_TIME - 容差) 的 .done 视为陈旧
|
|
_STALE_TOLERANCE = 2.0
|
|
|
|
|
|
def _slug() -> str:
|
|
"""标记目录 slug:优先 MYBOOT_APP_NAME,否则取当前工作目录路径的 hash。
|
|
|
|
两者在 spawn / fork 模式下父子进程均一致:环境变量被子进程继承(spawn 下
|
|
multiprocessing 会复制父进程环境),工作目录同样被继承。
|
|
"""
|
|
app_name = os.environ.get("MYBOOT_APP_NAME")
|
|
if app_name:
|
|
safe = "".join(c if c.isalnum() or c in "-_" else "_" for c in app_name)
|
|
return safe[:64]
|
|
return hashlib.md5(os.getcwd().encode("utf-8")).hexdigest()[:16]
|
|
|
|
|
|
def _marker_dir() -> Path:
|
|
d = Path(tempfile.gettempdir()) / f"myboot_worker_sync_{_slug()}"
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
return d
|
|
|
|
|
|
def clear_markers(name: Optional[str] = None) -> None:
|
|
"""删除标记文件。
|
|
|
|
Args:
|
|
name: 指定时只删除该 name 的 .done / .failed;为 None 时清空整个标记目录。
|
|
"""
|
|
d = _marker_dir()
|
|
if name is not None:
|
|
patterns = [f"{name}.done", f"{name}.failed"]
|
|
files = [d / p for p in patterns]
|
|
else:
|
|
files = list(d.glob("*.done")) + list(d.glob("*.failed"))
|
|
for f in files:
|
|
try:
|
|
f.unlink()
|
|
except FileNotFoundError:
|
|
pass
|
|
except OSError as e: # pragma: no cover - 极端权限/占用情况
|
|
_logger.warning(f"无法删除标记文件 {f}: {e!r}")
|
|
|
|
|
|
def _is_fresh(path: Path) -> bool:
|
|
"""判断标记文件是否属于本轮启动(基于 mtime)。"""
|
|
try:
|
|
return path.stat().st_mtime >= _MODULE_LOAD_TIME - _STALE_TOLERANCE
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def run_primary_first(
|
|
name: str,
|
|
primary_fn: Callable[[], T],
|
|
secondary_fn: Optional[Callable[[], T]] = None,
|
|
*,
|
|
timeout: float = 300.0,
|
|
poll_interval: float = 0.5,
|
|
) -> T:
|
|
"""primary-first 初始化协调:primary worker 执行重型初始化,其余 worker 等待。
|
|
|
|
判定规则:``os.environ.get("MYBOOT_IS_PRIMARY_WORKER", "1") == "1"`` 视为
|
|
primary(单进程模式无此变量 → 默认 primary,工具退化为直接调用 primary_fn)。
|
|
|
|
- primary:先清理本 name 的旧标记,执行 primary_fn();成功写
|
|
``<标记目录>/<name>.done``(内容为 ISO 时间戳)并返回结果;
|
|
异常时写 ``<name>.failed``(内容为异常 repr)后重新抛出。
|
|
- 非 primary:以 poll_interval 间隔轮询。发现新鲜的 .done → 执行
|
|
secondary_fn()(为 None 时执行 primary_fn)并返回其结果;发现新鲜的
|
|
.failed → 抛 RuntimeError(含 primary 的异常信息);超过 timeout →
|
|
抛 TimeoutError。
|
|
|
|
陈旧标记的保证与限制(双重防护):
|
|
|
|
1. primary 在执行 primary_fn 之前删除本 name 的旧标记(clear_markers)。
|
|
2. secondary 仅认可 mtime 不早于「本模块加载时间 - 2 秒容差」的标记,
|
|
上一轮运行遗留的 .done/.failed 因 mtime 过旧会被忽略并继续等待。
|
|
|
|
保证:只要新一轮启动距上一轮结束超过容差(2 秒),secondary 不会被上一轮
|
|
遗留的标记误导;即使 secondary 比 primary 先启动并先于 primary 清理动作
|
|
看到旧标记,mtime 检查也会将其判为陈旧。
|
|
|
|
限制:
|
|
- fork 模式下模块加载时间可能取自父进程导入时刻(早于 worker fork),
|
|
若上一轮标记写入晚于父进程导入(如 2 秒内重启),理论上可能误认旧标记;
|
|
实际部署中重启间隔远大于容差,且 primary 启动后会立即删除旧标记,
|
|
该窗口极小。
|
|
- 依赖文件系统 mtime 精度与各 worker 共享同一台机器的 temp 目录;
|
|
不适用于跨机器协调。
|
|
|
|
Args:
|
|
name: 协调任务名,同一应用内唯一(用作标记文件名)。
|
|
primary_fn: primary worker 执行的初始化函数。
|
|
secondary_fn: 非 primary worker 在 primary 完成后执行的函数;
|
|
为 None 时执行 primary_fn。
|
|
timeout: 非 primary 等待 .done 的最长秒数。
|
|
poll_interval: 轮询间隔秒数。
|
|
|
|
Returns:
|
|
primary_fn 或 secondary_fn 的返回值。
|
|
|
|
Raises:
|
|
TimeoutError: 非 primary 等待超时。
|
|
RuntimeError: 非 primary 检测到 primary 初始化失败。
|
|
Exception: primary 侧 primary_fn 抛出的原始异常。
|
|
"""
|
|
is_primary = os.environ.get("MYBOOT_IS_PRIMARY_WORKER", "1") == "1"
|
|
d = _marker_dir()
|
|
done_file = d / f"{name}.done"
|
|
failed_file = d / f"{name}.failed"
|
|
|
|
if is_primary:
|
|
clear_markers(name)
|
|
_logger.info(f"[{name}] primary worker 开始初始化")
|
|
try:
|
|
result = primary_fn()
|
|
except BaseException as e:
|
|
failed_file.write_text(repr(e), encoding="utf-8")
|
|
_logger.error(f"[{name}] primary 初始化失败: {e!r}")
|
|
raise
|
|
done_file.write_text(
|
|
datetime.datetime.now().isoformat(), encoding="utf-8"
|
|
)
|
|
_logger.info(f"[{name}] primary 初始化完成,已写入标记 {done_file}")
|
|
return result
|
|
|
|
# 非 primary:轮询等待
|
|
_logger.info(f"[{name}] secondary worker 等待 primary 完成 (timeout={timeout}s)")
|
|
deadline = time.monotonic() + timeout
|
|
while True:
|
|
if done_file.exists() and _is_fresh(done_file):
|
|
_logger.info(f"[{name}] 检测到 primary 完成标记,开始 secondary 加载")
|
|
fn = secondary_fn if secondary_fn is not None else primary_fn
|
|
return fn()
|
|
if failed_file.exists() and _is_fresh(failed_file):
|
|
detail = ""
|
|
try:
|
|
detail = failed_file.read_text(encoding="utf-8")
|
|
except OSError:
|
|
pass
|
|
raise RuntimeError(
|
|
f"primary worker 初始化 '{name}' 失败: {detail}"
|
|
)
|
|
if time.monotonic() >= deadline:
|
|
raise TimeoutError(
|
|
f"等待 primary worker 完成 '{name}' 超时 ({timeout}s)"
|
|
)
|
|
time.sleep(poll_interval)
|