sched: gate scheduled resumes on the shared execution slot, with drain tracking (B2)
This commit is contained in:
@@ -43,6 +43,7 @@ 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.store import FileScheduleStore, ScheduleExistsError
|
||||
from wf_server import WorkflowServer, build_local_static_workflow_server
|
||||
from wf_server.scheduling import build_scheduler_service
|
||||
@@ -984,3 +985,278 @@ async def test_paused_deleted_run_completion(
|
||||
finally:
|
||||
_gate_open.set()
|
||||
await deleter.stop()
|
||||
|
||||
|
||||
def _interrupt_then_fail_plan(name: str) -> RawWorkflowPlan:
|
||||
"""Approval interrupt whose resumed continuation raises (resume failure)."""
|
||||
return RawWorkflowPlan.model_validate(
|
||||
{
|
||||
"name": name,
|
||||
"input_schema": {"type": "object", "properties": {}},
|
||||
"state_schema": {"type": "object", "properties": {}},
|
||||
"output_schema": {"type": "object", "properties": {}},
|
||||
"outcomes": ["submitted"],
|
||||
"start": "ask",
|
||||
"nodes": [
|
||||
{"id": "ask", "type": "interrupt", "kind": "approval"},
|
||||
{
|
||||
"id": "fail",
|
||||
"type": "node",
|
||||
"node": "wf.std.runtime_error",
|
||||
"input": [
|
||||
{
|
||||
"value": "boom on resume",
|
||||
"target": {"root": "local", "parts": ["message"]},
|
||||
}
|
||||
],
|
||||
"output": [],
|
||||
},
|
||||
{"id": "end_submitted", "type": "end", "outcome": "submitted"},
|
||||
],
|
||||
"edges": [
|
||||
{"from": "ask", "outcome": "submitted", "to": "fail"},
|
||||
{"from": "fail", "outcome": "ok", "to": "end_submitted"},
|
||||
],
|
||||
"output": [],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _interrupt_then_gate_plan(name: str) -> RawWorkflowPlan:
|
||||
"""Approval interrupt whose resumed continuation blocks on the test gate."""
|
||||
return RawWorkflowPlan.model_validate(
|
||||
{
|
||||
"name": name,
|
||||
"input_schema": {"type": "object", "properties": {}},
|
||||
"state_schema": {
|
||||
"type": "object",
|
||||
"properties": {"result": {"type": "string"}},
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {"result": {"type": "string"}},
|
||||
},
|
||||
"outcomes": ["submitted"],
|
||||
"start": "ask",
|
||||
"nodes": [
|
||||
{"id": "ask", "type": "interrupt", "kind": "approval"},
|
||||
{
|
||||
"id": "gate",
|
||||
"type": "node",
|
||||
"node": "test.gate.wait",
|
||||
"input": [
|
||||
{
|
||||
"value": "hello",
|
||||
"target": {"root": "local", "parts": ["value"]},
|
||||
}
|
||||
],
|
||||
"output": [
|
||||
{
|
||||
"source": {"root": "local", "parts": ["value"]},
|
||||
"target": {"root": "state", "parts": ["result"]},
|
||||
}
|
||||
],
|
||||
},
|
||||
{"id": "end_submitted", "type": "end", "outcome": "submitted"},
|
||||
],
|
||||
"edges": [
|
||||
{"from": "ask", "outcome": "submitted", "to": "gate"},
|
||||
{"from": "gate", "outcome": "ok", "to": "end_submitted"},
|
||||
],
|
||||
"output": [],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def test_scheduled_resume_rejected_when_capacity_saturated(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A scheduled resume acquires the shared execution slot (B2).
|
||||
|
||||
With the only slot occupied by a gated scheduled run, resuming the
|
||||
interrupted scheduled run is rejected BEFORE any dispatch: no ACTIVE
|
||||
attempt, no executing mark, both runs untouched. Freeing the slot
|
||||
lets the same resume through, releasing the slot afterwards.
|
||||
"""
|
||||
_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"])
|
||||
store = FileScheduleStore(root)
|
||||
store.create_schedule(_one_shot("asker", "ask1.default", datetime.now(UTC)))
|
||||
service = _scheduler(server, capacity=1)
|
||||
try:
|
||||
await service.start()
|
||||
await _wait_for(lambda: len(_run_ids(root)) == 1)
|
||||
ask_id = _run_ids(root)[0]
|
||||
await _wait_for(
|
||||
lambda: FileRunStore(root).get_run(ask_id).status.value == "interrupted"
|
||||
)
|
||||
store.create_schedule(_one_shot("blocker", "gated.default", datetime.now(UTC)))
|
||||
await _wait_for(lambda: len(_run_ids(root)) == 2)
|
||||
gate_id = [rid for rid in _run_ids(root) if rid != ask_id][0]
|
||||
await _wait_for(lambda: FileRunStore(root).is_executing(gate_id))
|
||||
|
||||
with pytest.raises(ScheduledCapacityBusyError):
|
||||
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"
|
||||
assert FileRunStore(root).is_executing(gate_id)
|
||||
assert len(_entries(root, "asker", "completed")) == 0
|
||||
|
||||
_gate_open.set()
|
||||
await _wait_for(
|
||||
lambda: FileRunStore(root).get_run(gate_id).status.value == "completed"
|
||||
)
|
||||
resumed = await server.api.resume_run(
|
||||
run_id=ask_id, resume_payload={}, resume_outcome="submitted"
|
||||
)
|
||||
assert resumed["status"] == "completed"
|
||||
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)
|
||||
finally:
|
||||
_gate_open.set()
|
||||
await service.stop()
|
||||
|
||||
|
||||
async def test_scheduled_resume_releases_slot_on_reinterruption(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A scheduled resume that interrupts again frees its slot (B2).
|
||||
|
||||
The re-interruption stays durably resumable (attempt DONE, new
|
||||
checkpoint) and a second resume of the same run completes.
|
||||
"""
|
||||
root = tmp_path / "store"
|
||||
server = build_local_static_workflow_server(root)
|
||||
await _seed(
|
||||
server,
|
||||
"ask_twice",
|
||||
"ask_twice.default",
|
||||
_double_interrupt_plan("ask_twice"),
|
||||
["submitted"],
|
||||
)
|
||||
store = FileScheduleStore(root)
|
||||
store.create_schedule(_one_shot("asker", "ask_twice.default", datetime.now(UTC)))
|
||||
service = _scheduler(server, capacity=1)
|
||||
try:
|
||||
await service.start()
|
||||
await _wait_for(lambda: len(_run_ids(root)) == 1)
|
||||
ask_id = _run_ids(root)[0]
|
||||
await _wait_for(
|
||||
lambda: FileRunStore(root).get_run(ask_id).status.value == "interrupted"
|
||||
)
|
||||
first = await server.api.resume_run(
|
||||
run_id=ask_id, resume_payload={}, resume_outcome="submitted"
|
||||
)
|
||||
assert first["status"] == "interrupted"
|
||||
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)
|
||||
second = await server.api.resume_run(
|
||||
run_id=ask_id, resume_payload={}, resume_outcome="submitted"
|
||||
)
|
||||
assert second["status"] == "completed"
|
||||
assert not FileRunStore(root).is_executing(ask_id)
|
||||
finally:
|
||||
await service.stop()
|
||||
|
||||
|
||||
async def test_scheduled_resume_releases_slot_on_failure(tmp_path: Path) -> None:
|
||||
"""A scheduled resume that fails releases its slot (B2).
|
||||
|
||||
The failed stopped result is durably persisted with its attempt
|
||||
cleared, and the freed slot admits new scheduled work.
|
||||
"""
|
||||
root = tmp_path / "store"
|
||||
server = build_local_static_workflow_server(root)
|
||||
await _seed(
|
||||
server,
|
||||
"ask_fail",
|
||||
"ask_fail.default",
|
||||
_interrupt_then_fail_plan("ask_fail"),
|
||||
["submitted"],
|
||||
)
|
||||
await _seed(
|
||||
server,
|
||||
"const",
|
||||
"const.default",
|
||||
_constant_plan("const"),
|
||||
["ok"],
|
||||
)
|
||||
store = FileScheduleStore(root)
|
||||
store.create_schedule(_one_shot("asker", "ask_fail.default", datetime.now(UTC)))
|
||||
service = _scheduler(server, capacity=1)
|
||||
try:
|
||||
await service.start()
|
||||
await _wait_for(lambda: len(_run_ids(root)) == 1)
|
||||
ask_id = _run_ids(root)[0]
|
||||
await _wait_for(
|
||||
lambda: FileRunStore(root).get_run(ask_id).status.value == "interrupted"
|
||||
)
|
||||
resumed = await server.api.resume_run(
|
||||
run_id=ask_id, resume_payload={}, resume_outcome="submitted"
|
||||
)
|
||||
assert resumed["status"] == "failed"
|
||||
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)
|
||||
# The freed slot admits new scheduled work.
|
||||
store.create_schedule(_one_shot("after", "const.default", datetime.now(UTC)))
|
||||
await _wait_for(lambda: len(_run_ids(root)) == 2)
|
||||
after_id = [rid for rid in _run_ids(root) if rid != ask_id][0]
|
||||
await _wait_for(
|
||||
lambda: FileRunStore(root).get_run(after_id).status.value == "completed"
|
||||
)
|
||||
finally:
|
||||
await service.stop()
|
||||
|
||||
|
||||
async def test_service_stop_drains_inflight_scheduled_resume(tmp_path: Path) -> None:
|
||||
"""Shutdown waits for an in-flight scheduled resume (B2 drain)."""
|
||||
_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=2, drain_grace_s=10.0)
|
||||
try:
|
||||
await service.start()
|
||||
await _wait_for(lambda: len(_run_ids(root)) == 1)
|
||||
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)
|
||||
await _wait_for(lambda: FileRunStore(root).is_executing(ask_id))
|
||||
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()
|
||||
report = await asyncio.wait_for(stop_task, timeout=20.0)
|
||||
resumed = await asyncio.wait_for(resume_task, timeout=20.0)
|
||||
assert resumed["status"] == "completed"
|
||||
assert not FileRunStore(root).is_executing(ask_id)
|
||||
assert report.cancelled == 0
|
||||
finally:
|
||||
_gate_open.set()
|
||||
await service.stop()
|
||||
|
||||
Reference in New Issue
Block a user