sched: scope live abandon repair to the broken run; torn settle reconciles genuine result (B1)
This commit is contained in:
@@ -143,8 +143,9 @@ class RuntimeDispatcher:
|
|||||||
persists through the shared lifecycle boundary and records terminal
|
persists through the shared lifecycle boundary and records terminal
|
||||||
history exactly once). Anything unexpected — an execution failure or
|
history exactly once). Anything unexpected — an execution failure or
|
||||||
a settlement failure such as a torn write — goes through the
|
a settlement failure such as a torn write — goes through the
|
||||||
service-provided ``abandon`` hook, which reuses startup recovery to
|
service-provided ``abandon`` hook, which runs the same per-run
|
||||||
fail the run closed without replay. Cancellation (shutdown drain) is
|
reconciliation as startup recovery but scoped to the broken run id:
|
||||||
|
sibling executions are never touched. Cancellation (shutdown drain) is
|
||||||
re-raised unsettled: the durable executing mark is left for startup
|
re-raised unsettled: the durable executing mark is left for startup
|
||||||
recovery, which abandons it truthfully.
|
recovery, which abandons it truthfully.
|
||||||
"""
|
"""
|
||||||
@@ -495,13 +496,19 @@ class SchedulerService:
|
|||||||
self._settled += 1
|
self._settled += 1
|
||||||
|
|
||||||
def _abandon(self, run_id: str, error: BaseException) -> None:
|
def _abandon(self, run_id: str, error: BaseException) -> None:
|
||||||
"""Fail a dispatcher-broken run closed through startup recovery.
|
"""Fail one dispatcher-broken run closed through scoped recovery.
|
||||||
|
|
||||||
The run holds a durable executing mark with no genuine stopped
|
The run holds a durable executing mark with no genuine stopped
|
||||||
result — exactly the crashed-process shape — so the same recovery
|
result — exactly the crashed-process shape — so the same per-run
|
||||||
that owns restart abandonment owns it here: no replay, no
|
reconciliation that owns restart abandonment owns it here: no
|
||||||
fabricated outcome, slot freed. Recovery failures are recorded;
|
replay, no fabricated outcome, slot freed. The repair is scoped
|
||||||
the run then waits for the next restart recovery.
|
to this run id only: healthy sibling executions keep their
|
||||||
|
markers, statuses, and histories, and a late genuine result for
|
||||||
|
a sibling still settles normally. A torn settlement (the stopped
|
||||||
|
result was persisted but history/marker writes were lost) is
|
||||||
|
reconciled as the genuine stopped result, not as a failure.
|
||||||
|
Recovery failures are recorded; the run then waits for the next
|
||||||
|
restart recovery.
|
||||||
"""
|
"""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._errors.append(f"{run_id}: {error}")
|
self._errors.append(f"{run_id}: {error}")
|
||||||
@@ -509,14 +516,33 @@ class SchedulerService:
|
|||||||
try:
|
try:
|
||||||
from wf_scheduling import recovery as sched_recovery
|
from wf_scheduling import recovery as sched_recovery
|
||||||
|
|
||||||
sched_recovery.recover(
|
diags = sched_recovery.recover(
|
||||||
schedule_store=self.schedule_store,
|
schedule_store=self.schedule_store,
|
||||||
run_store=self.run_store,
|
run_store=self.run_store,
|
||||||
now=self.clock(),
|
now=self.clock(),
|
||||||
ownership=self.ownership,
|
ownership=self.ownership,
|
||||||
|
only_run_id=run_id,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._errors.append(f"{run_id}: recovery failed: {exc}")
|
self._errors.append(f"{run_id}: recovery failed: {exc}")
|
||||||
del self._errors[: max(0, len(self._errors) - 20)]
|
del self._errors[: max(0, len(self._errors) - 20)]
|
||||||
return
|
return
|
||||||
|
if f"{run_id}:failed-closed" in diags:
|
||||||
|
self._abandoned += 1
|
||||||
|
elif any(
|
||||||
|
diag.startswith(f"{run_id}:")
|
||||||
|
and diag.split(":", 1)[1]
|
||||||
|
in {
|
||||||
|
"terminal-reconciled",
|
||||||
|
"completion-window-cleared",
|
||||||
|
"summary-reconciled",
|
||||||
|
"attempt-reconciled",
|
||||||
|
"fresh-result-resumable",
|
||||||
|
}
|
||||||
|
for diag in diags
|
||||||
|
):
|
||||||
|
# Scoped repair found a genuine stopped result (torn
|
||||||
|
# settlement): the run settled, it was not abandoned.
|
||||||
|
self._settled += 1
|
||||||
|
else:
|
||||||
self._abandoned += 1
|
self._abandoned += 1
|
||||||
|
|||||||
@@ -91,8 +91,18 @@ def recover(
|
|||||||
now: datetime,
|
now: datetime,
|
||||||
ownership: SchedulerOwnership,
|
ownership: SchedulerOwnership,
|
||||||
history: HistoryRecorder | None = None,
|
history: HistoryRecorder | None = None,
|
||||||
|
only_run_id: str | None = None,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""Reconcile durable state after a restart without executing work."""
|
"""Reconcile durable state after a restart without executing work.
|
||||||
|
|
||||||
|
With ``only_run_id``, repair exactly one live run instead of the whole
|
||||||
|
store: a dispatcher failure (or a torn settlement) for that run is
|
||||||
|
reconciled with the same checkpoint authority and torn-write rules,
|
||||||
|
while healthy sibling executions and active resumes are never
|
||||||
|
touched. Whole-store abandonment stays confined to genuine startup
|
||||||
|
recovery (``only_run_id=None``), where an executing mark truthfully
|
||||||
|
means the owner is gone.
|
||||||
|
"""
|
||||||
from wf_artifacts.runs.models import StoredRunStatus
|
from wf_artifacts.runs.models import StoredRunStatus
|
||||||
|
|
||||||
sched_root = getattr(schedule_store, "root", None)
|
sched_root = getattr(schedule_store, "root", None)
|
||||||
@@ -109,6 +119,23 @@ def recover(
|
|||||||
if history is None:
|
if history is None:
|
||||||
history = FileScheduleHistoryRecorder(schedule_store)
|
history = FileScheduleHistoryRecorder(schedule_store)
|
||||||
diags: list[str] = []
|
diags: list[str] = []
|
||||||
|
if only_run_id is not None:
|
||||||
|
# Scoped live repair: never touch admissions, views, markers, or
|
||||||
|
# histories belonging to any other run. The live poll sweep owns
|
||||||
|
# pending dispatch; this path only reconciles the one broken run.
|
||||||
|
try:
|
||||||
|
runs = [run_store.get_run(only_run_id)]
|
||||||
|
except KeyError:
|
||||||
|
try:
|
||||||
|
admission = run_store.get_admission(only_run_id)
|
||||||
|
except KeyError:
|
||||||
|
return [f"{only_run_id}:run-unknown"]
|
||||||
|
from wf_api.run_lifecycle import materialize_admitted_view
|
||||||
|
|
||||||
|
materialize_admitted_view(store=run_store, admission=admission)
|
||||||
|
_mark_pending(run_store, only_run_id)
|
||||||
|
return [f"{only_run_id}:view-completed-pending-dispatch"]
|
||||||
|
else:
|
||||||
# Admission record is the recovery authority: admitted but never
|
# Admission record is the recovery authority: admitted but never
|
||||||
# materialized views are completed here and flagged pending for the
|
# materialized views are completed here and flagged pending for the
|
||||||
# capacity-checked poll sweep (exactly once, occurrence already consumed).
|
# capacity-checked poll sweep (exactly once, occurrence already consumed).
|
||||||
@@ -121,7 +148,8 @@ def recover(
|
|||||||
materialize_admitted_view(store=run_store, admission=admission)
|
materialize_admitted_view(store=run_store, admission=admission)
|
||||||
_mark_pending(run_store, admission.id)
|
_mark_pending(run_store, admission.id)
|
||||||
diags.append(f"{admission.id}:view-completed-pending-dispatch")
|
diags.append(f"{admission.id}:view-completed-pending-dispatch")
|
||||||
for run in run_store.list_runs():
|
runs = run_store.list_runs()
|
||||||
|
for run in runs:
|
||||||
status = getattr(run.status, "value", run.status)
|
status = getattr(run.status, "value", run.status)
|
||||||
attempt = run_store.get_resume_attempt(run.id)
|
attempt = run_store.get_resume_attempt(run.id)
|
||||||
active = attempt is not None and attempt.state == "ACTIVE"
|
active = attempt is not None and attempt.state == "ACTIVE"
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ from wf_scheduling.lifecycle import (
|
|||||||
)
|
)
|
||||||
from wf_scheduling.models import Schedule
|
from wf_scheduling.models import Schedule
|
||||||
from wf_scheduling.ownership import SchedulerOwnership, SecondOwnerError
|
from wf_scheduling.ownership import SchedulerOwnership, SecondOwnerError
|
||||||
|
from wf_scheduling.poll import Scheduler
|
||||||
from wf_scheduling.store import FileScheduleStore
|
from wf_scheduling.store import FileScheduleStore
|
||||||
|
|
||||||
|
|
||||||
@@ -556,3 +557,183 @@ async def test_store_deployment_directory_contract(tmp_path: Path) -> None:
|
|||||||
directory.deployment_revision("missing.deployment")
|
directory.deployment_revision("missing.deployment")
|
||||||
with pytest.raises(KeyError):
|
with pytest.raises(KeyError):
|
||||||
directory.required_inputs("missing.deployment")
|
directory.required_inputs("missing.deployment")
|
||||||
|
|
||||||
|
|
||||||
|
async def _poll_two(
|
||||||
|
service: SchedulerService, intended: datetime
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
service.schedule_store.create_schedule(_sched_model("a", intended))
|
||||||
|
service.schedule_store.create_schedule(_sched_model("b", intended))
|
||||||
|
result = await service.poll_once(intended + timedelta(seconds=1))
|
||||||
|
hang_id = result["a"].removeprefix("admit:")
|
||||||
|
sib_id = result["b"].removeprefix("admit:")
|
||||||
|
assert hang_id.startswith("run-") and sib_id.startswith("run-")
|
||||||
|
return hang_id, sib_id
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_sibling_live(service: SchedulerService, hang_id: str) -> None:
|
||||||
|
"""The healthy sibling keeps its markers, status, and clean history."""
|
||||||
|
sibling = service.run_store.get_run(hang_id)
|
||||||
|
assert sibling.status.value == "admitted"
|
||||||
|
assert service.run_store.is_executing(hang_id)
|
||||||
|
assert not service.run_store.is_pending_dispatch(hang_id)
|
||||||
|
assert len(_entries(service.schedule_store, "a", "failed")) == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_live_runtime_failure_spares_healthy_sibling(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""A live execution failure repairs only the broken run (B1).
|
||||||
|
|
||||||
|
Whole-store recovery during live execution used to fail the still
|
||||||
|
hanging sibling too, whose late success was then rejected as
|
||||||
|
``settle non-admitted run``.
|
||||||
|
"""
|
||||||
|
intended = ts(2026, 9, 8, 12, 0)
|
||||||
|
runtime = ScriptedRuntime(lambda n: "hang" if n == 1 else "raise")
|
||||||
|
service = _service(
|
||||||
|
tmp_path,
|
||||||
|
runtime,
|
||||||
|
SchedulerServiceConfig(poll_interval_s=0.01, capacity=2, auto_tick=False),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await service.start()
|
||||||
|
hang_id, sib_id = await _poll_two(service, intended)
|
||||||
|
await _wait_for(lambda: runtime.started.is_set())
|
||||||
|
await _wait_for(
|
||||||
|
lambda: service.run_store.get_run(sib_id).status.value == "failed"
|
||||||
|
)
|
||||||
|
failed = service.run_store.get_run(sib_id)
|
||||||
|
assert failed.status.value == "failed"
|
||||||
|
assert not service.run_store.is_executing(sib_id)
|
||||||
|
assert not service.run_store.is_pending_dispatch(sib_id)
|
||||||
|
assert len(_entries(service.schedule_store, "b", "failed")) == 1
|
||||||
|
_assert_sibling_live(service, hang_id)
|
||||||
|
runtime.release.set()
|
||||||
|
await _wait_for(lambda: service.live_executions == 0)
|
||||||
|
assert service.run_store.get_run(hang_id).status.value == "completed"
|
||||||
|
assert len(_entries(service.schedule_store, "a", "completed")) == 1
|
||||||
|
assert not any("settle non-admitted run" in m for m in service.errors)
|
||||||
|
finally:
|
||||||
|
runtime.release.set()
|
||||||
|
report = await service.stop()
|
||||||
|
assert report.settled == 1
|
||||||
|
assert report.abandoned == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_live_settlement_failure_spares_healthy_sibling(
|
||||||
|
tmp_path: Path, monkeypatch: Any
|
||||||
|
) -> None:
|
||||||
|
"""A torn settle write repairs only the broken run (B1).
|
||||||
|
|
||||||
|
The settlement seam raises after genuine execution for one run while
|
||||||
|
its sibling hangs: only that run fails closed, and the sibling still
|
||||||
|
settles exactly once with its capacity slot freed.
|
||||||
|
"""
|
||||||
|
intended = ts(2026, 9, 8, 12, 0)
|
||||||
|
runtime = ScriptedRuntime(lambda n: "hang" if n == 1 else "complete")
|
||||||
|
service = _service(
|
||||||
|
tmp_path,
|
||||||
|
runtime,
|
||||||
|
SchedulerServiceConfig(poll_interval_s=0.01, capacity=2, auto_tick=False),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await service.start()
|
||||||
|
original = Scheduler.record_stopped_execution
|
||||||
|
fired = {"done": False}
|
||||||
|
|
||||||
|
def _flaky_settle(self: Any, run_id: str, state: Any, now: datetime) -> None:
|
||||||
|
try:
|
||||||
|
owned_by_b = (
|
||||||
|
self.run_store.get_admission(run_id).schedule_id == "b"
|
||||||
|
)
|
||||||
|
except KeyError:
|
||||||
|
owned_by_b = False
|
||||||
|
if owned_by_b and not fired["done"]:
|
||||||
|
fired["done"] = True
|
||||||
|
raise RuntimeError("injected settlement failure")
|
||||||
|
original(self, run_id, state, now)
|
||||||
|
|
||||||
|
monkeypatch.setattr(Scheduler, "record_stopped_execution", _flaky_settle)
|
||||||
|
hang_id, sib_id = await _poll_two(service, intended)
|
||||||
|
await _wait_for(lambda: runtime.started.is_set())
|
||||||
|
await _wait_for(
|
||||||
|
lambda: service.run_store.get_run(sib_id).status.value == "failed"
|
||||||
|
)
|
||||||
|
assert fired["done"]
|
||||||
|
assert not service.run_store.is_executing(sib_id)
|
||||||
|
assert len(_entries(service.schedule_store, "b", "failed")) == 1
|
||||||
|
_assert_sibling_live(service, hang_id)
|
||||||
|
runtime.release.set()
|
||||||
|
await _wait_for(lambda: service.live_executions == 0)
|
||||||
|
assert service.run_store.get_run(hang_id).status.value == "completed"
|
||||||
|
assert len(_entries(service.schedule_store, "a", "completed")) == 1
|
||||||
|
assert not any("settle non-admitted run" in m for m in service.errors)
|
||||||
|
finally:
|
||||||
|
runtime.release.set()
|
||||||
|
report = await service.stop()
|
||||||
|
assert report.settled == 1
|
||||||
|
assert report.abandoned == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_live_torn_settlement_reconciles_genuine_result(
|
||||||
|
tmp_path: Path, monkeypatch: Any
|
||||||
|
) -> None:
|
||||||
|
"""A torn history write keeps the genuine stopped result (B1).
|
||||||
|
|
||||||
|
The stopped result is durably persisted but the history append is
|
||||||
|
lost: scoped repair must reconcile the genuine completion (with
|
||||||
|
checkpoint authority), never fail the run, and never touch the
|
||||||
|
hanging sibling.
|
||||||
|
"""
|
||||||
|
intended = ts(2026, 9, 8, 12, 0)
|
||||||
|
runtime = ScriptedRuntime(lambda n: "hang" if n == 1 else "complete")
|
||||||
|
service = _service(
|
||||||
|
tmp_path,
|
||||||
|
runtime,
|
||||||
|
SchedulerServiceConfig(poll_interval_s=0.01, capacity=2, auto_tick=False),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await service.start()
|
||||||
|
original = Scheduler._record
|
||||||
|
fired = {"done": False}
|
||||||
|
|
||||||
|
def _flaky_record(self: Any, **kwargs: Any) -> None:
|
||||||
|
run_id = kwargs.get("run_id")
|
||||||
|
try:
|
||||||
|
owned_by_b = (
|
||||||
|
run_id is not None
|
||||||
|
and self.run_store.get_admission(run_id).schedule_id == "b"
|
||||||
|
)
|
||||||
|
except KeyError:
|
||||||
|
owned_by_b = False
|
||||||
|
if (
|
||||||
|
kwargs.get("kind") == "completed"
|
||||||
|
and owned_by_b
|
||||||
|
and not fired["done"]
|
||||||
|
):
|
||||||
|
fired["done"] = True
|
||||||
|
raise RuntimeError("injected history failure")
|
||||||
|
original(self, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(Scheduler, "_record", _flaky_record)
|
||||||
|
hang_id, sib_id = await _poll_two(service, intended)
|
||||||
|
await _wait_for(lambda: runtime.started.is_set())
|
||||||
|
await _wait_for(
|
||||||
|
lambda: len(_entries(service.schedule_store, "b", "completed")) == 1
|
||||||
|
)
|
||||||
|
assert fired["done"]
|
||||||
|
repaired = service.run_store.get_run(sib_id)
|
||||||
|
assert repaired.status.value == "completed"
|
||||||
|
assert not service.run_store.is_executing(sib_id)
|
||||||
|
assert len(_entries(service.schedule_store, "b", "failed")) == 0
|
||||||
|
_assert_sibling_live(service, hang_id)
|
||||||
|
runtime.release.set()
|
||||||
|
await _wait_for(lambda: service.live_executions == 0)
|
||||||
|
assert service.run_store.get_run(hang_id).status.value == "completed"
|
||||||
|
assert len(_entries(service.schedule_store, "a", "completed")) == 1
|
||||||
|
finally:
|
||||||
|
runtime.release.set()
|
||||||
|
report = await service.stop()
|
||||||
|
assert report.settled == 2
|
||||||
|
assert report.abandoned == 0
|
||||||
|
|||||||
Reference in New Issue
Block a user