sched: fence scheduled resumes at shutdown so none outlive ownership

This commit is contained in:
lda
2026-09-09 18:08:20 +07:00 Verified
parent 99c038a04f
commit 82dc5a80ae
6 changed files with 440 additions and 32 deletions
+278 -1
View File
@@ -43,7 +43,10 @@ from wf_platform import (
)
from wf_scheduling.lifecycle import SchedulerServiceConfig, SchedulerStartupError
from wf_scheduling.models import Schedule
from wf_scheduling.resume_gate import ScheduledCapacityBusyError
from wf_scheduling.resume_gate import (
ScheduledCapacityBusyError,
ScheduledResumeShutdownError,
)
from wf_scheduling.store import FileScheduleStore, ScheduleExistsError
from wf_server import WorkflowServer, build_local_static_workflow_server
from wf_server.scheduling import build_scheduler_service
@@ -1264,6 +1267,280 @@ async def test_service_stop_drains_inflight_scheduled_resume(tmp_path: Path) ->
await service.stop()
async def test_scheduled_resume_completes_within_grace_with_history(
tmp_path: Path,
) -> None:
"""A resume finishing inside grace stops cleanly with history (shutdown).
Operator-driven service (no background ticks): the in-flight resume
completes during the drain, stop reports no cancellation, and the
resumed completion lands in occurrence history exactly once.
"""
_gate_open.clear()
root = tmp_path / "store"
server = build_local_static_workflow_server(root, extra_sources=_gate_sources())
await _seed(
server,
"ask_gate",
"ask_gate.default",
_interrupt_then_gate_plan("ask_gate"),
["submitted"],
)
store = FileScheduleStore(root)
store.create_schedule(_one_shot("asker", "ask_gate.default", datetime.now(UTC)))
service = _scheduler(server, capacity=1, drain_grace_s=10.0, auto_tick=False)
try:
await service.start()
async with asyncio.timeout(20):
while not _run_ids(root):
await service.poll_once(datetime.now(UTC))
await asyncio.sleep(0.02)
ask_id = _run_ids(root)[0]
await _wait_for(
lambda: FileRunStore(root).get_run(ask_id).status.value == "interrupted"
)
resume_task = asyncio.create_task(
server.api.resume_run(
run_id=ask_id, resume_payload={}, resume_outcome="submitted"
)
)
await _wait_for(lambda: ask_id in service._live_resumes)
stop_task = asyncio.create_task(service.stop())
await asyncio.sleep(0.3)
# The gate is still closed: the drain must still be waiting.
assert not stop_task.done()
assert not resume_task.done()
_gate_open.set()
resumed = await asyncio.wait_for(resume_task, timeout=20.0)
report = await asyncio.wait_for(stop_task, timeout=20.0)
assert resumed["status"] == "completed"
assert report.cancelled == 0
attempt = FileRunStore(root).get_resume_attempt(ask_id)
assert attempt is not None and attempt.state == "DONE"
assert not FileRunStore(root).is_executing(ask_id)
assert await _kinds(root, "asker") == ["admitted", "interrupted", "completed"]
finally:
_gate_open.set()
await service.stop()
async def test_shutdown_timeout_fences_inflight_resume_and_restart_keeps_decision(
tmp_path: Path,
) -> None:
"""A resume outliving grace cannot overwrite the next owner (shutdown).
Stop cancels and joins the gated resume before releasing ownership:
the task is done-cancelled at stop return, durable state keeps the
exact crash shape (interrupted, executing mark, ACTIVE attempt), and
after restart recovery fails the run closed the old task cannot flip
the decision back to completed or clear the ACTIVE attempt.
"""
_gate_open.clear()
root = tmp_path / "store"
server = build_local_static_workflow_server(root, extra_sources=_gate_sources())
await _seed(
server,
"ask_gate",
"ask_gate.default",
_interrupt_then_gate_plan("ask_gate"),
["submitted"],
)
store = FileScheduleStore(root)
store.create_schedule(_one_shot("asker", "ask_gate.default", datetime.now(UTC)))
service = _scheduler(server, capacity=1, drain_grace_s=0.05, auto_tick=False)
try:
await service.start()
async with asyncio.timeout(20):
while not _run_ids(root):
await service.poll_once(datetime.now(UTC))
await asyncio.sleep(0.02)
ask_id = _run_ids(root)[0]
await _wait_for(
lambda: FileRunStore(root).get_run(ask_id).status.value == "interrupted"
)
interrupted_checkpoint = FileRunStore(root).get_run(ask_id).latest_checkpoint_id
resume_task = asyncio.create_task(
server.api.resume_run(
run_id=ask_id, resume_payload={}, resume_outcome="submitted"
)
)
await _wait_for(lambda: ask_id in service._live_resumes)
await _wait_for(lambda: FileRunStore(root).is_executing(ask_id))
# The gate stays closed: the drain must fence, not wait forever.
report = await service.stop()
assert report.cancelled == 1
assert resume_task.done()
assert resume_task.cancelled()
# Cancellation truthfulness: crash-shaped durable state, no result.
assert FileRunStore(root).get_run(ask_id).status.value == "interrupted"
assert FileRunStore(root).is_executing(ask_id)
attempt = FileRunStore(root).get_resume_attempt(ask_id)
assert attempt is not None and attempt.state == "ACTIVE"
assert await _kinds(root, "asker") == ["admitted", "interrupted"]
revived = _scheduler(server, capacity=1, auto_tick=False)
try:
await revived.start()
# Restart recovery fails the ambiguous resume closed.
assert FileRunStore(root).get_run(ask_id).status.value == "failed"
attempt2 = FileRunStore(root).get_resume_attempt(ask_id)
assert attempt2 is not None and attempt2.state == "ACTIVE"
assert not FileRunStore(root).is_executing(ask_id)
assert (
FileRunStore(root).get_run(ask_id).latest_checkpoint_id
== interrupted_checkpoint
)
assert len(_entries(root, "asker", "failed")) == 1
# The fenced old task is already dead: opening the gate and
# polling under the new owner changes nothing.
_gate_open.set()
await revived.poll_once(datetime.now(UTC))
assert FileRunStore(root).get_run(ask_id).status.value == "failed"
attempt3 = FileRunStore(root).get_resume_attempt(ask_id)
assert attempt3 is not None and attempt3.state == "ACTIVE"
assert _entries(root, "asker", "completed") == []
assert len(_entries(root, "asker", "failed")) == 1
finally:
_gate_open.set()
await revived.stop()
finally:
_gate_open.set()
await service.stop()
async def test_scheduled_resume_requested_during_drain_rejected(
tmp_path: Path,
) -> None:
"""New scheduled resumes are rejected once shutdown begins (shutdown).
With a free slot available the rejection still fires (drain, not
capacity): no dispatch, no ACTIVE attempt, no executing mark. Manual
resumes bypass the gate and complete mid-drain.
"""
_gate_open.clear()
root = tmp_path / "store"
server = build_local_static_workflow_server(root, extra_sources=_gate_sources())
await _seed(
server, "ask1", "ask1.default", _single_interrupt_plan("ask1"), ["submitted"]
)
await _seed(server, "gated", "gated.default", _gate_plan("gated"), ["ok"])
await _seed(
server,
"manual_ask",
"manual_ask.default",
_single_interrupt_plan("manual_ask"),
["submitted"],
)
store = FileScheduleStore(root)
store.create_schedule(_one_shot("blocker", "gated.default", datetime.now(UTC)))
store.create_schedule(_one_shot("asker", "ask1.default", datetime.now(UTC)))
service = _scheduler(server, capacity=2, drain_grace_s=10.0)
try:
await service.start()
await _wait_for(lambda: len(_run_ids(root)) == 2)
ids = {
FileRunStore(root).get_admission(rid).schedule_id or "": rid
for rid in _run_ids(root)
}
gate_id = ids["blocker"]
ask_id = ids["asker"]
await _wait_for(
lambda: FileRunStore(root).get_run(ask_id).status.value == "interrupted"
)
await _wait_for(lambda: FileRunStore(root).is_executing(gate_id))
asked = await server.api.run_deployment(
deployment_id="manual_ask.default", workflow_input={}
)
assert asked["status"] == "interrupted"
manual_id = asked["run_id"]
assert isinstance(manual_id, str)
# Hold the drain on the gated execution; one slot stays free, so
# only the shutdown check can reject the resume below.
stop_task = asyncio.create_task(service.stop())
await _wait_for(lambda: service._stopping)
assert not stop_task.done()
with pytest.raises(ScheduledResumeShutdownError):
await server.api.resume_run(
run_id=ask_id, resume_payload={}, resume_outcome="submitted"
)
assert FileRunStore(root).get_resume_attempt(ask_id) is None
assert not FileRunStore(root).is_executing(ask_id)
assert FileRunStore(root).get_run(ask_id).status.value == "interrupted"
manual_resumed = await server.api.resume_run(
run_id=manual_id, resume_payload={}, resume_outcome="submitted"
)
assert manual_resumed["status"] == "completed"
_gate_open.set()
report = await asyncio.wait_for(stop_task, timeout=20.0)
assert report.cancelled == 0
assert FileRunStore(root).get_run(gate_id).status.value == "completed"
finally:
_gate_open.set()
await service.stop()
async def test_shutdown_timeout_spares_healthy_sibling(tmp_path: Path) -> None:
"""A fenced resume leaves a healthy sibling's outcome/history intact."""
_gate_open.clear()
root = tmp_path / "store"
server = build_local_static_workflow_server(root, extra_sources=_gate_sources())
await _seed(
server,
"ask_gate",
"ask_gate.default",
_interrupt_then_gate_plan("ask_gate"),
["submitted"],
)
await _seed(server, "const", "const.default", _constant_plan("const"), ["ok"])
store = FileScheduleStore(root)
store.create_schedule(_one_shot("asker", "ask_gate.default", datetime.now(UTC)))
store.create_schedule(_one_shot("sib", "const.default", datetime.now(UTC)))
service = _scheduler(server, capacity=1, drain_grace_s=0.05, auto_tick=False)
try:
await service.start()
async with asyncio.timeout(20):
while len(_run_ids(root)) < 2:
await service.poll_once(datetime.now(UTC))
await asyncio.sleep(0.02)
ids = {
FileRunStore(root).get_admission(rid).schedule_id or "": rid
for rid in _run_ids(root)
}
ask_id = ids["asker"]
sib_id = ids["sib"]
await _wait_for(
lambda: FileRunStore(root).get_run(ask_id).status.value == "interrupted"
)
await _wait_for(
lambda: FileRunStore(root).get_run(sib_id).status.value == "completed"
)
resume_task = asyncio.create_task(
server.api.resume_run(
run_id=ask_id, resume_payload={}, resume_outcome="submitted"
)
)
await _wait_for(lambda: ask_id in service._live_resumes)
report = await service.stop()
assert report.cancelled == 1
assert resume_task.cancelled()
assert FileRunStore(root).get_run(sib_id).status.value == "completed"
assert await _kinds(root, "sib") == ["admitted", "completed"]
revived = _scheduler(server, capacity=1, auto_tick=False)
try:
await revived.start()
assert FileRunStore(root).get_run(ask_id).status.value == "failed"
assert FileRunStore(root).get_run(sib_id).status.value == "completed"
assert await _kinds(root, "sib") == ["admitted", "completed"]
assert _entries(root, "asker", "completed") == []
finally:
_gate_open.set()
await revived.stop()
finally:
_gate_open.set()
await service.stop()
async def _kinds(root: Path, schedule_id: str) -> list[str]:
page = FileScheduleStore(root).list_occurrences(schedule_id, limit=100)
rows = cast(list[dict[str, Any]], page["occurrences"])