Files
AgentLab/backend/tests/test_cluster_fence.py
T

321 lines
14 KiB
Python

"""自我隔离(cluster/fence.py,复核 R-b1):本进程卡住了、续不上租约,别的进程已经把它在跑的运行接手了——它缓过来之后不再写:
不写事件(不和接手的一边撞号)、不写断点、不写终态(不把接手的结果盖掉)。在一个进程里装出来:把「上次续上租约」拨到一个租约
时长以前,再把运行改成接手之后的样子。两种库都跑。"""
from __future__ import annotations
import asyncio
import contextlib
import os
import pytest
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from app.cluster import fence
from app.cluster.workers import lease_s
from app.db.base import SessionLocal, new_id
from app.db.models import Run, RunEvent
from app.engine import compiler
from app.engine.runner import run_manager
from app.engine.schema import NodeType
@pytest.fixture(autouse=True)
async def engine_up():
await run_manager.setup()
fence.activate()
yield
fence.deactivate()
await run_manager.shutdown()
def node(nid, ntype, **config):
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}}
GRAPH = {"nodes": [node("start", "input", fields=[{"name": "question"}]),
node("slow", "transform", mode="template", template="慢的这步"),
node("next", "transform", mode="template", template="后面这步"),
node("out", "output", fields=[{"name": "r", "value": "{{ nodes.next }}"}])],
"edges": [{"source": "start", "target": "slow"}, {"source": "slow", "target": "next"},
{"source": "next", "target": "out"}]}
@pytest.fixture
def slow(monkeypatch):
"""slow 那一步:第一次跑时卡住,等测试放行(好在它卡着的时候装出「续不上租约、被接手」);之后立刻返回。"""
calls = {"n": 0}
gate = asyncio.Event()
entered = asyncio.Event()
original = compiler.RUNNERS[NodeType.TRANSFORM]
async def runner(state, ctx):
if ctx.node.id == "slow":
calls["n"] += 1
if calls["n"] == 1:
entered.set()
await gate.wait()
return await original(state, ctx)
monkeypatch.setitem(compiler.RUNNERS, NodeType.TRANSFORM, runner)
return {"calls": calls, "gate": gate, "entered": entered}
async def _taken_over(run_id: str) -> None:
"""别的进程看它的租约过期了、接手:标中断、属主清空(同 run_manager.reap 做的)。"""
from sqlalchemy import update
async with SessionLocal() as session:
await session.execute(update(Run).where(Run.id == run_id).values(
status="interrupted", owner=None, error="服务重启,运行已挂起,可从断点继续运行"))
await session.commit()
async def _events(run_id: str) -> list[RunEvent]:
async with SessionLocal() as session:
return list((await session.execute(select(RunEvent).where(RunEvent.run_id == run_id)
.order_by(RunEvent.seq))).scalars())
async def _no_repeated_seq(run_id: str) -> None:
async with SessionLocal() as session:
rows = (await session.execute(select(RunEvent.seq, func.count()).where(RunEvent.run_id == run_id)
.group_by(RunEvent.seq).having(func.count() > 1))).all()
assert rows == [], f"撞号:{rows}"
async def _checkpointed_before_slow(run_id: str) -> None:
"""等 slow 前面那一步的断点落了库(LangGraph 存断点和下一步并行:慢一点的库上 slow 已经开始、断点还在写)。"""
config = {"configurable": {"thread_id": run_id}}
for _ in range(300):
tup = await run_manager.checkpointer.aget_tuple(config)
if tup is not None and int((tup.metadata or {}).get("step", -1)) >= 1:
return
await asyncio.sleep(0.02)
raise AssertionError("slow 前面那一步的断点一直没落库")
async def _stall_and_take_over(slow) -> str:
run = await run_manager.start(graph=GRAPH, input_payload={"question": "q"})
await asyncio.wait_for(slow["entered"].wait(), timeout=15)
await _checkpointed_before_slow(run.id)
before = fence.generation()
fence.renewed(ago=lease_s() + 1) # 卡了一个多租约时长
await _taken_over(run.id)
slow["gate"].set() # 缓过来,手上的执行接着往下走
for _ in range(200):
if not run_manager.is_active(run.id):
break
await asyncio.sleep(0.05)
assert not run_manager.is_active(run.id), "执行停了"
assert fence.generation() == before + 1, "发现续晚了:隔离了一次"
return run.id
async def test_a_stalled_executor_does_not_overwrite_the_takeover(slow, caplog):
import gc
import logging
caplog.set_level(logging.ERROR, logger="asyncio")
run_id = await _stall_and_take_over(slow)
await asyncio.sleep(0.1)
gc.collect()
await asyncio.sleep(0)
noisy = [r.getMessage() for r in caplog.records if r.name == "asyncio"]
assert noisy == [], f"隔离时 asyncio 不该记回调异常、没人取的任务异常(复核 R-b8):{noisy}"
async with SessionLocal() as session:
row = await session.get(Run, run_id)
assert row.status == "interrupted" and row.owner is None, "接手之后的样子没被盖掉"
events = await _events(run_id)
assert not [e for e in events if e.type in ("run.finished", "run.failed", "run.cancelled")]
assert not [e for e in events if e.node_id == "next"], "缓过来之后一条都没写"
await _no_repeated_seq(run_id)
async def test_a_run_continued_elsewhere_finishes_once_and_the_stalled_side_stays_quiet(slow):
run_id = await _stall_and_take_over(slow)
fence.renewed() # 换了身份重新登记之后,这一世代照常
await run_manager.continue_failed(run_id) # 接手的那一边接着跑
for _ in range(300):
async with SessionLocal() as session:
row = await session.get(Run, run_id)
if row.status in ("succeeded", "failed") and row.manifest_hash:
break
await asyncio.sleep(0.05)
assert row.status == "succeeded", row.error
events = await _events(run_id)
assert len([e for e in events if e.type == "run.finished"]) == 1
assert len([e for e in events if e.type == "node.started" and e.node_id == "next"]) == 1, "后面那一步只跑了一次"
await _no_repeated_seq(run_id)
@pytest.mark.parametrize("jump", [3600.0, -3600.0])
async def test_a_wall_clock_jump_does_not_fence_a_running_run(slow, monkeypatch, jump):
"""复核 R-b14:执行到一半本机墙钟往前 / 往后跳一小时(NTP 一步校时、手动改时间)——计入休眠的钟没问题,不隔离,运行照常跑完。"""
import time as _time
monkeypatch.setattr(fence, "SLEEP_AWARE", True)
run = await run_manager.start(graph=GRAPH, input_payload={"question": "q"})
await asyncio.wait_for(slow["entered"].wait(), timeout=15)
before = fence.generation()
monkeypatch.setattr(fence, "_wall", lambda: _time.time() + jump)
slow["gate"].set()
for _ in range(300):
async with SessionLocal() as session:
row = await session.get(Run, run.id)
if row.status in ("succeeded", "failed", "interrupted") and not run_manager.is_active(run.id):
break
await asyncio.sleep(0.05)
assert row.status == "succeeded", row.error
assert fence.generation() == before, "没有隔离"
async def test_a_run_that_cannot_start_after_fencing_is_failed_with_the_real_reason(monkeypatch):
"""复核 R-b15:隔离之后新身份一直没登记上,新发起的运行等不到(fence.ready)——这一行照实收成失败(写明是隔离后没能重新
登记,不是「服务重启时还在排队」),run.failed 记下来,执行任务也从 run_manager 里摘掉。"""
from app.cluster import workers
from app.engine.runner import NOT_REREGISTERED_QUEUED
monkeypatch.setattr(workers, "lease_s", lambda: 0.3)
fence.trip("测试") # 隔离了,新身份还没登记上
run = await run_manager.start(graph=GRAPH, input_payload={"question": "q"})
for _ in range(100):
if run.id not in run_manager._tasks:
break
await asyncio.sleep(0.05)
assert run.id not in run_manager._tasks, "执行任务摘掉了"
async with SessionLocal() as session:
row = await session.get(Run, run.id)
assert row.status == "failed" and row.error == NOT_REREGISTERED_QUEUED and row.owner is None
events = await _events(run.id)
assert [e.type for e in events if e.type == "run.failed"] == ["run.failed"]
assert events[-1].data["error"] == NOT_REREGISTERED_QUEUED
fence.renewed()
async def test_the_final_write_checks_the_owner():
async with SessionLocal() as session:
run = Run(status="running", owner="peer-host:1:abcd", graph={}, input={})
session.add(run)
await session.commit()
await run_manager._finalize(run.id, "succeeded", None, {}, 10, owner="mine:1:ffff")
async with SessionLocal() as session:
row = await session.get(Run, run.id)
assert row.status == "running" and row.finished_at is None, "属主已经不是自己:不写终态"
async def test_a_repeated_seq_is_refused_by_the_database():
async with SessionLocal() as session:
run = Run(status="running", graph={}, input={})
session.add(run)
await session.flush()
session.add_all([RunEvent(id=new_id(), run_id=run.id, seq=1, type="log", ts=0, data={}),
RunEvent(id=new_id(), run_id=run.id, seq=1, type="log", ts=0, data={})])
with pytest.raises(IntegrityError):
await session.commit()
# ---------------- 复核 R-b9:谁会被打停、谁不会 ----------------
async def test_only_executions_bound_to_a_generation_are_stopped_by_check():
"""续晚之后第一个问 check() 的不一定是运行本身:没绑定世代的调用者(HTTP 处理、调度那一拍)只隔离、不抛;绑定了的抛。
隔离之后新身份还没登记上,新开始的执行在 ready() 上等,等不到抛 Fenced;登记上了就放行。"""
gate = asyncio.Event()
seen: list[str] = []
async def bound_writer() -> None:
fence.bind()
await gate.wait()
try:
fence.check()
seen.append("wrote")
except fence.Fenced:
seen.append("fenced")
task = asyncio.create_task(bound_writer())
await asyncio.sleep(0)
before = fence.generation()
fence.renewed(ago=lease_s() + 1)
fence.check() # 没绑定世代:只隔离
assert fence.generation() == before + 1 and fence.pending()
gate.set()
await task
assert seen == ["fenced"], "绑定在隔离之前的那一段执行:不许再写"
with pytest.raises(fence.Fenced):
await fence.ready(timeout=0.1) # 新身份还没登记上:新开始的执行等不到就不开始
waiting = asyncio.create_task(fence.ready(timeout=5))
await asyncio.sleep(0.1)
assert not waiting.done()
fence.renewed() # 登记上了
await asyncio.wait_for(waiting, timeout=2)
fence.bind()
fence.check() # 新世代照常
async def test_the_scheduler_loop_survives_a_fenced_tick(monkeypatch):
from app.scheduler import loop as sloop
calls = {"n": 0}
async def fire_due(now=None):
calls["n"] += 1
if calls["n"] == 1:
raise fence.Fenced()
return []
monkeypatch.setattr(sloop, "fire_due", fire_due)
await sloop.runner.start(interval=0.02)
try:
for _ in range(200):
if calls["n"] >= 3:
break
await asyncio.sleep(0.02)
assert calls["n"] >= 3 and sloop.runner.running(), "被 Fenced 打到的那一拍之后照常往下"
finally:
await sloop.runner.stop()
async def test_the_lease_task_keeps_going_when_registering_again_fails(monkeypatch):
"""隔离之后重新登记:库一抖、版本守卫拦下都不让续租的任务退出,退避着重试;登记上了照常续租。"""
from app.cluster import identity, service as service_mod, workers
from app.cluster.version import VersionMismatch
monkeypatch.setattr(identity, "OWNER", f"{identity.host()}:{os.getpid()}:retired1") # 测试完换回原来的身份
monkeypatch.setattr(workers, "heartbeat_s", lambda: 0.01)
monkeypatch.setattr(workers, "lease_s", lambda: 5.0)
svc = service_mod._Service()
tries = {"register": 0, "renew": 0}
async def register(*, exclusive: bool = False) -> None: # noqa: ARG001
tries["register"] += 1
if tries["register"] == 1:
raise VersionMismatch("库里还有版本不同的进程活着")
if tries["register"] == 2:
raise OSError("库一时连不上")
async def renew() -> bool:
tries["renew"] += 1
return True
monkeypatch.setattr(workers, "register", register)
monkeypatch.setattr(svc, "renew", renew)
old = identity.owner()
fence.trip("测试")
svc._on_fenced()
assert identity.owner() != old and svc.pending_since is not None
task = asyncio.create_task(svc._lease_loop())
try:
for _ in range(300):
if tries["renew"] >= 2:
break
await asyncio.sleep(0.01)
assert not task.done(), "续租的任务还在"
assert tries["register"] == 3 and svc.pending_since is None and not fence.pending()
assert tries["renew"] >= 2, "登记上之后照常续租"
finally:
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task