fix: close scheduler persistence and capacity gaps
This commit is contained in:
@@ -392,6 +392,185 @@ def test_manual_and_other_schedule_runs_are_overlap_independent(
|
||||
sched.ownership.release()
|
||||
|
||||
|
||||
def test_manual_admission_does_not_consume_scheduler_capacity(tmp_path: Path) -> None:
|
||||
"""Manual runs bypass scheduler capacity; scheduled work still dispatches."""
|
||||
from wf_api.run_lifecycle import materialize_admitted_view, persist_admission
|
||||
|
||||
sched, store, runs, sources = _harness(tmp_path, capacity=1, script={"*": "hang"})
|
||||
t0 = ts(2026, 9, 8, 12, 0)
|
||||
manual = persist_admission(
|
||||
store=runs,
|
||||
run_id=runs.allocate_run_id(),
|
||||
environment=fixture_environment(object()),
|
||||
resolved_input={},
|
||||
max_steps=None,
|
||||
)
|
||||
materialize_admitted_view(store=runs, admission=manual)
|
||||
assert sched._task_load() == 0
|
||||
|
||||
_add(sched, store, sources, "a", OneShotSource(t0), t0 - timedelta(hours=1))
|
||||
assert sched.poll(t0)["a"].startswith("admit:run-")
|
||||
sched.ownership.release()
|
||||
|
||||
|
||||
def test_existing_admission_rebuilds_missing_view_and_pending_marker(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A history tear after admission remains dispatchable on the next poll."""
|
||||
|
||||
class FailHistoryOnce(FileScheduleStore):
|
||||
def __init__(self, root: Path) -> None:
|
||||
super().__init__(root)
|
||||
self.armed = True
|
||||
|
||||
def append_history(self, record: Any) -> None:
|
||||
if self.armed:
|
||||
self.armed = False
|
||||
raise OSError("injected history failure")
|
||||
super().append_history(record)
|
||||
|
||||
sched_store = FailHistoryOnce(tmp_path / "sched")
|
||||
run_store = FileRunStore(tmp_path / "runs")
|
||||
sources: dict[str, Any] = {}
|
||||
t0 = ts(2026, 9, 8, 12, 0)
|
||||
sched = Scheduler(
|
||||
schedule_store=sched_store,
|
||||
run_store=run_store,
|
||||
sources=sources,
|
||||
capacity=1,
|
||||
preparer=SchedulePreparer(
|
||||
DictDeployments({"dep-1": {"rev": 1, "required": []}}),
|
||||
fixture_environment,
|
||||
),
|
||||
dispatcher=ScriptedDispatcher({"*": "complete"}),
|
||||
ownership=SchedulerOwnership(tmp_path, owner="test").acquire(),
|
||||
)
|
||||
try:
|
||||
_add(
|
||||
sched, sched_store, sources, "a", OneShotSource(t0), t0 - timedelta(hours=1)
|
||||
)
|
||||
try:
|
||||
sched.poll(t0)
|
||||
raise AssertionError("history failure must propagate")
|
||||
except OSError as exc:
|
||||
assert "injected history failure" in str(exc)
|
||||
[admission] = run_store.list_admissions()
|
||||
assert run_store.list_runs() == []
|
||||
assert not run_store.is_pending_dispatch(admission.id)
|
||||
|
||||
result = sched.poll(t0 + timedelta(seconds=1))
|
||||
assert result["a"] == f"admit:{admission.id}"
|
||||
assert run_store.get_run(admission.id).status.value == "admitted"
|
||||
assert run_store.is_pending_dispatch(admission.id)
|
||||
|
||||
sched.poll(t0 + timedelta(seconds=2))
|
||||
assert run_store.get_run(admission.id).status.value == "completed"
|
||||
assert not run_store.is_pending_dispatch(admission.id)
|
||||
finally:
|
||||
sched.ownership.release()
|
||||
|
||||
|
||||
def test_oneshot_terminal_bookkeeping_is_idempotent_after_torn_write(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A torn exhausted transition does not duplicate history or retain a candidate."""
|
||||
|
||||
class FailScheduleOnce(FileScheduleStore):
|
||||
def __init__(self, root: Path) -> None:
|
||||
super().__init__(root)
|
||||
self.armed = False
|
||||
|
||||
def save_schedule(self, schedule: Any) -> None:
|
||||
if self.armed:
|
||||
self.armed = False
|
||||
raise OSError("injected schedule failure")
|
||||
super().save_schedule(schedule)
|
||||
|
||||
sched_store = FailScheduleOnce(tmp_path / "sched")
|
||||
run_store = FileRunStore(tmp_path / "runs")
|
||||
sources: dict[str, Any] = {}
|
||||
t0 = ts(2026, 9, 8, 12, 0)
|
||||
sched = Scheduler(
|
||||
schedule_store=sched_store,
|
||||
run_store=run_store,
|
||||
sources=sources,
|
||||
capacity=1,
|
||||
preparer=SchedulePreparer(
|
||||
DictDeployments({"dep-1": {"rev": 1, "required": []}}),
|
||||
fixture_environment,
|
||||
),
|
||||
dispatcher=ScriptedDispatcher({"*": "complete"}),
|
||||
ownership=SchedulerOwnership(tmp_path, owner="test").acquire(),
|
||||
)
|
||||
try:
|
||||
_add(
|
||||
sched, sched_store, sources, "a", OneShotSource(t0), t0 - timedelta(hours=1)
|
||||
)
|
||||
from wf_scheduling.models import PendingCandidate
|
||||
|
||||
sched_store.save_candidate(
|
||||
PendingCandidate(schedule_id="a", intended_at=t0, revision=1),
|
||||
schedule_id="a",
|
||||
)
|
||||
sched_store.armed = True
|
||||
try:
|
||||
sched.poll(t0 + timedelta(minutes=5))
|
||||
raise AssertionError("schedule failure must propagate")
|
||||
except OSError as exc:
|
||||
assert "injected schedule failure" in str(exc)
|
||||
assert (
|
||||
len([r for r in _history(sched_store, "a") if r["kind"] == "exhausted"])
|
||||
== 1
|
||||
)
|
||||
assert sched_store.get_candidate("a") is not None
|
||||
|
||||
assert sched.poll(t0 + timedelta(minutes=6))["a"] == "exhausted"
|
||||
assert (
|
||||
len([r for r in _history(sched_store, "a") if r["kind"] == "exhausted"])
|
||||
== 1
|
||||
)
|
||||
assert sched_store.get_candidate("a") is None
|
||||
finally:
|
||||
sched.ownership.release()
|
||||
|
||||
|
||||
def test_corrupt_unattributed_view_isolated_from_healthy_schedules(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A view without an admission fails closed without blocking siblings."""
|
||||
from wf_artifacts import ResumeReadiness, WorkflowRunRecord
|
||||
from wf_artifacts.runs.models import StoredRunStatus
|
||||
|
||||
sched, store, runs, sources = _harness(tmp_path, script={"*": "complete"})
|
||||
t0 = ts(2026, 9, 8, 12, 0)
|
||||
_add(sched, store, sources, "a", OneShotSource(t0), t0 - timedelta(hours=1))
|
||||
_add(sched, store, sources, "b", OneShotSource(t0), t0 - timedelta(hours=1))
|
||||
corrupt_id = runs.allocate_run_id()
|
||||
runs.save_run(
|
||||
WorkflowRunRecord(
|
||||
id=corrupt_id,
|
||||
status=StoredRunStatus.ADMITTED,
|
||||
resume_readiness=ResumeReadiness.NOT_APPLICABLE,
|
||||
environment=fixture_environment(object()),
|
||||
latest_checkpoint_id=None,
|
||||
created_at=t0,
|
||||
updated_at=t0,
|
||||
)
|
||||
)
|
||||
|
||||
results = sched.poll(t0)
|
||||
|
||||
assert results["a"].startswith("admit:run-")
|
||||
assert results["b"].startswith("admit:run-")
|
||||
assert runs.get_run(corrupt_id).status.value == "failed"
|
||||
assert all(
|
||||
runs.get_run(run.id).status.value == "completed"
|
||||
for run in runs.list_runs()
|
||||
if run.id != corrupt_id
|
||||
)
|
||||
sched.ownership.release()
|
||||
|
||||
|
||||
def test_record_resumed_stopped_result_is_idempotent_and_attributed(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
|
||||
Reference in New Issue
Block a user