sched: remediate R4 ownership, identity-match, dispatch-mark, and pending atomicity
This commit is contained in:
+8
-1
@@ -188,7 +188,14 @@ class WorkflowRunApi:
|
|||||||
trace_range: TraceRangeLike | None,
|
trace_range: TraceRangeLike | None,
|
||||||
) -> RunResult:
|
) -> RunResult:
|
||||||
trace_values = _trace_range_values(trace_range)
|
trace_values = _trace_range_values(trace_range)
|
||||||
record, stopped_run = restore_interrupted_run(self._run_store(), run_id)
|
store = self._run_store()
|
||||||
|
pre_attempt = store.get_resume_attempt(run_id)
|
||||||
|
if pre_attempt is not None and pre_attempt.state == "ACTIVE":
|
||||||
|
raise ValueError(
|
||||||
|
f"workflow run {run_id!r} has an ambiguous active resume attempt; "
|
||||||
|
"recovery must fail it closed before retry"
|
||||||
|
)
|
||||||
|
record, stopped_run = restore_interrupted_run(store, run_id)
|
||||||
environment = record.environment
|
environment = record.environment
|
||||||
diagnostics = validate_pinned_resume_environment(
|
diagnostics = validate_pinned_resume_environment(
|
||||||
record=record,
|
record=record,
|
||||||
|
|||||||
@@ -55,6 +55,15 @@ class RunStore:
|
|||||||
def get_resume_attempt(self, run_id: str) -> ResumeAttempt | None:
|
def get_resume_attempt(self, run_id: str) -> ResumeAttempt | None:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def mark_pending_dispatch(self, run_id: str) -> None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def clear_pending_dispatch(self, run_id: str) -> None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def is_pending_dispatch(self, run_id: str) -> bool:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
class FileRunStore(RunStore):
|
class FileRunStore(RunStore):
|
||||||
"""JSON file-backed admitted- and stopped-run store for local dev/tests.
|
"""JSON file-backed admitted- and stopped-run store for local dev/tests.
|
||||||
@@ -186,6 +195,26 @@ class FileRunStore(RunStore):
|
|||||||
return None
|
return None
|
||||||
return ResumeAttempt.model_validate_json(path.read_text(encoding="utf-8"))
|
return ResumeAttempt.model_validate_json(path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
def mark_pending_dispatch(self, run_id: str) -> None:
|
||||||
|
"""Atomically flag a materialized view as pending-dispatch."""
|
||||||
|
with self._lock:
|
||||||
|
self._write_json(self._pending_path(run_id), {"pending": True})
|
||||||
|
|
||||||
|
def clear_pending_dispatch(self, run_id: str) -> None:
|
||||||
|
with self._lock:
|
||||||
|
try:
|
||||||
|
path = self._pending_path(run_id)
|
||||||
|
except ValueError:
|
||||||
|
return
|
||||||
|
if path.exists():
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
def is_pending_dispatch(self, run_id: str) -> bool:
|
||||||
|
try:
|
||||||
|
return self._pending_path(run_id).exists()
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
def _write_json(self, path: Path, payload: object) -> None:
|
def _write_json(self, path: Path, payload: object) -> None:
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
temp_path = path.with_suffix(".json.tmp")
|
temp_path = path.with_suffix(".json.tmp")
|
||||||
@@ -209,5 +238,8 @@ class FileRunStore(RunStore):
|
|||||||
def _resume_attempt_path(self, run_id: str) -> Path:
|
def _resume_attempt_path(self, run_id: str) -> Path:
|
||||||
return self._run_directory(run_id) / "resume_attempt.json"
|
return self._run_directory(run_id) / "resume_attempt.json"
|
||||||
|
|
||||||
|
def _pending_path(self, run_id: str) -> Path:
|
||||||
|
return self._run_directory(run_id) / "pending_dispatch.json"
|
||||||
|
|
||||||
def _checkpoint_path(self, run_id: str, sequence: int) -> Path:
|
def _checkpoint_path(self, run_id: str, sequence: int) -> Path:
|
||||||
return self._run_directory(run_id) / "checkpoints" / f"{sequence:06d}.json"
|
return self._run_directory(run_id) / "checkpoints" / f"{sequence:06d}.json"
|
||||||
|
|||||||
@@ -310,8 +310,21 @@ class Scheduler:
|
|||||||
self.schedule_store.save_consumed(sched.id, intended)
|
self.schedule_store.save_consumed(sched.id, intended)
|
||||||
from wf_api.run_lifecycle import materialize_admitted_view
|
from wf_api.run_lifecycle import materialize_admitted_view
|
||||||
|
|
||||||
|
# Dispatch mark precedes the view so a crash after admission but
|
||||||
|
# before/during first dispatch stays pending (not abandoned). Cleared
|
||||||
|
# after dispatch returns regardless of outcome (hang still dispatched).
|
||||||
|
try:
|
||||||
|
self.run_store.mark_pending_dispatch(run_id)
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
materialize_admitted_view(store=self.run_store, admission=admission)
|
materialize_admitted_view(store=self.run_store, admission=admission)
|
||||||
self._dispatch(run_id, now)
|
try:
|
||||||
|
self._dispatch(run_id, now)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
self.run_store.clear_pending_dispatch(run_id)
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
return run_id
|
return run_id
|
||||||
|
|
||||||
def _dispatch(self, run_id: str, now: datetime) -> None:
|
def _dispatch(self, run_id: str, now: datetime) -> None:
|
||||||
|
|||||||
@@ -97,18 +97,31 @@ def recover(
|
|||||||
elif status == StoredRunStatus.COMPLETED.value or status == "completed":
|
elif status == StoredRunStatus.COMPLETED.value or status == "completed":
|
||||||
if active:
|
if active:
|
||||||
assert attempt is not None
|
assert attempt is not None
|
||||||
from wf_artifacts.runs.models import ResumeAttempt
|
try:
|
||||||
|
latest = run_store.get_latest_checkpoint(run.id)
|
||||||
|
completed_attempt = latest.attempt_id
|
||||||
|
except KeyError:
|
||||||
|
completed_attempt = None
|
||||||
|
if (
|
||||||
|
completed_attempt is not None
|
||||||
|
and completed_attempt == attempt.attempt_id
|
||||||
|
):
|
||||||
|
from wf_artifacts.runs.models import ResumeAttempt
|
||||||
|
|
||||||
run_store.save_resume_attempt(
|
run_store.save_resume_attempt(
|
||||||
ResumeAttempt(
|
ResumeAttempt(
|
||||||
run_id=run.id,
|
run_id=run.id,
|
||||||
attempt_id=attempt.attempt_id,
|
attempt_id=attempt.attempt_id,
|
||||||
state="DONE",
|
state="DONE",
|
||||||
created_at=attempt.created_at,
|
created_at=attempt.created_at,
|
||||||
updated_at=now,
|
updated_at=now,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
diags.append(f"{run.id}:attempt-reconciled")
|
||||||
diags.append(f"{run.id}:attempt-reconciled")
|
else:
|
||||||
|
_fail_run(run_store, run, AMBIGUOUS_REASON, now, record_history)
|
||||||
|
diags.append(f"{run.id}:failed-closed")
|
||||||
|
continue
|
||||||
if record_history is not None and not _has_terminal(
|
if record_history is not None and not _has_terminal(
|
||||||
schedule_store, run.id, "completed"
|
schedule_store, run.id, "completed"
|
||||||
):
|
):
|
||||||
@@ -154,23 +167,20 @@ def _has_terminal(schedule_store: Any, run_id: str, kind: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def _mark_pending(run_store: Any, run_id: str) -> None:
|
def _mark_pending(run_store: Any, run_id: str) -> None:
|
||||||
path = run_store._run_directory(run_id) / "pending_dispatch"
|
run_store.mark_pending_dispatch(run_id)
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
path.write_text("pending", encoding="utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def _is_pending(run_store: Any, run_id: str) -> bool:
|
def _is_pending(run_store: Any, run_id: str) -> bool:
|
||||||
try:
|
try:
|
||||||
return (run_store._run_directory(run_id) / "pending_dispatch").exists()
|
return bool(run_store.is_pending_dispatch(run_id))
|
||||||
except ValueError:
|
except AttributeError:
|
||||||
|
# Legacy stores without the pending protocol: treat as not pending.
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def clear_pending(run_store: Any, run_id: str) -> None:
|
def clear_pending(run_store: Any, run_id: str) -> None:
|
||||||
"""Clear the pending-dispatch marker after the poll sweep dispatches."""
|
"""Clear the pending-dispatch marker after the poll sweep dispatches."""
|
||||||
try:
|
try:
|
||||||
path = run_store._run_directory(run_id) / "pending_dispatch"
|
run_store.clear_pending_dispatch(run_id)
|
||||||
except ValueError:
|
except AttributeError:
|
||||||
return
|
return
|
||||||
if path.exists():
|
|
||||||
path.unlink()
|
|
||||||
|
|||||||
Reference in New Issue
Block a user