sched: server lifecycle service with bounded real execution (T12 core)

This commit is contained in:
lda
2026-09-09 10:53:48 +07:00 Verified
parent c0d36d6b61
commit 42deba6399
6 changed files with 1195 additions and 52 deletions
+522
View File
@@ -0,0 +1,522 @@
"""Opt-in same-server scheduler lifecycle (T12).
The :class:`Scheduler` core is synchronous and never owns threads, tasks,
or clocks: this module adds the explicitly enabled server composition
around it. :class:`SchedulerService` acquires the canonical store
ownership, runs startup recovery (which never executes), then ticks
calendar polling on the server event loop without blocking it on
long-running workflows. Real execution runs in bounded asyncio tasks
behind :class:`RuntimeDispatcher`; settlement flows back through the
scheduler's guarded async-completion seam, so capacity accounting,
overlap slots, and exactly-once settlement keep working exactly as the
poll-level tests pin them.
Shutdown stops admission first (no new ticks), drains live executions
within the configured grace period, leaves anything still running under
its durable executing mark for startup recovery to abandon truthfully,
and releases ownership only after settlement is finished. A failed
startup releases the lock and raises instead of running unprotected.
"""
from __future__ import annotations
import asyncio
import threading
from collections.abc import Callable, Coroutine
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import Any
def _utcnow() -> datetime:
return datetime.now(UTC)
@dataclass(frozen=True, slots=True)
class SchedulerServiceConfig:
"""Tuning for the opt-in scheduler service (deployment configuration)."""
poll_interval_s: float = 1.0
capacity: int = 4
drain_grace_s: float = 30.0
auto_tick: bool = True
def __post_init__(self) -> None:
if not self.poll_interval_s > 0:
raise ValueError("poll_interval_s must be positive")
if self.capacity < 1:
raise ValueError("capacity must be at least 1")
if not self.drain_grace_s >= 0:
raise ValueError("drain_grace_s must be non-negative")
class SchedulerStartupError(Exception):
"""Scheduler startup failed; no lock is held and nothing is running."""
@dataclass(frozen=True, slots=True)
class DrainReport:
"""Outcome of :meth:`SchedulerService.stop`."""
settled: int = 0
abandoned: int = 0
cancelled: int = 0
class StoreDeploymentDirectory:
"""Production deployment contract source backed by the artifact store.
``deployment_revision`` reuses the store-backed deployment revision
(T07), so admission rechecks observe edits; unknown ids raise
``KeyError``, which preparation reports as a ``deployment-deleted``
preflight rejection without inventing a run. ``required_inputs``
reuses the required keys of the pinned root artifact's input schema;
the schema validator remains the hard gate, so an unshaped schema
simply contributes no required keys here.
"""
def __init__(self, artifact_store: Any) -> None:
self._artifact_store = artifact_store
def _root_artifact(self, deployment_id: str) -> Any:
deployment = self._artifact_store.get_deployment(deployment_id)
return self._artifact_store.get_artifact(
deployment.artifact_id, deployment.artifact_version
)
def deployment_revision(self, deployment_id: str) -> int:
return int(self._artifact_store.get_deployment(deployment_id).revision)
def required_inputs(self, deployment_id: str) -> list[str]:
artifact = self._root_artifact(deployment_id)
schema = getattr(artifact, "input_schema", None)
if not isinstance(schema, dict):
return []
required = schema.get("required", [])
if not isinstance(required, list):
return []
return [key for key in required if isinstance(key, str)]
def build_pinned_environment(
artifact_store: Any,
) -> Callable[[Any], Any]:
"""Build a production environment pinning callable for a schedule.
Loads the schedule's deployment, resolves its root artifact and saved
subgraph tree, and freezes them with
:func:`wf_api.run_lifecycle.create_pinned_environment`. Unknown
deployments raise ``KeyError`` (mapped to ``deployment-deleted`` by
preparation). Live dependency validation is deliberately NOT part of
admission: the admission recheck list covers revision, inputs, and
schema, while broken live dependencies surface as failed runs at
execution, exactly like manual runs.
"""
def build(sched: Any) -> Any:
from wf_api.run_lifecycle import create_pinned_environment
from wf_api.saved_subgraphs import resolve_saved_subgraph_tree
deployment = artifact_store.get_deployment(sched.deployment_id)
artifact = artifact_store.get_artifact(
deployment.artifact_id, deployment.artifact_version
)
tree = resolve_saved_subgraph_tree(
root_artifact=artifact, artifact_store=artifact_store
)
return create_pinned_environment(
deployment=deployment, artifact=artifact, tree=tree
)
return build
class RuntimeDispatcher:
"""Execution collaborator that runs admitted invocations for real.
``dispatch`` never blocks the polling tick on workflow execution: it
submits the execution coroutine through the service-provided
``submit`` hook and immediately returns :class:`StillRunning`. The
coroutine runs the pinned invocation through the server runtime and
settles the genuine stopped state through the service-provided
``settle`` hook (the scheduler's guarded async-completion seam, which
persists through the shared lifecycle boundary and records terminal
history exactly once). Anything unexpected — an execution failure or
a settlement failure such as a torn write — goes through the
service-provided ``abandon`` hook, which reuses startup recovery to
fail the run closed without replay. Cancellation (shutdown drain) is
re-raised unsettled: the durable executing mark is left for startup
recovery, which abandons it truthfully.
"""
def __init__(
self,
runtime: Any,
*,
submit: Callable[[Coroutine[Any, Any, None]], Any],
settle: Callable[[str, Any], None],
abandon: Callable[[str, BaseException], None],
) -> None:
self._runtime = runtime
self._submit = submit
self._settle = settle
self._abandon = abandon
def dispatch(self, *, admission: Any, now: datetime) -> Any:
from wf_scheduling.dispatch import StillRunning
self._submit(self._run(admission))
return StillRunning()
async def _run(self, admission: Any) -> None:
from wf_api.artifact_plans import raw_plan_from_artifact
from wf_api.saved_subgraphs import saved_subgraph_tree_from_snapshots
from wf_core import RunLimits
try:
plan = raw_plan_from_artifact(admission.environment.root_artifact)
tree = saved_subgraph_tree_from_snapshots(
admission.environment.child_artifacts
)
max_steps = admission.max_steps
limits = (
RunLimits(max_steps=max_steps) if max_steps is not None else RunLimits()
)
state = await self._runtime.run_workflow_from_plan(
plan,
dict(admission.resolved_input),
deployment=admission.environment.deployment,
artifact=admission.environment.root_artifact,
saved_subgraph_tree=tree,
limits=limits,
)
except asyncio.CancelledError:
raise
except Exception as exc:
self._abandon(admission.id, exc)
return
try:
self._settle(admission.id, state)
except asyncio.CancelledError:
raise
except Exception as exc:
self._abandon(admission.id, exc)
@dataclass(slots=True)
class SchedulerService:
"""Explicitly enabled same-server scheduler composition.
Owns exactly one :class:`Scheduler` built over the server's schedule
and run stores, the canonical :class:`SchedulerOwnership` handle, one
periodic tick task, and the set of live execution tasks. All
scheduler mutations (ticks and settlements) serialize on one lock; the
workflow coroutines themselves run without it, so a long-running
workflow never blocks calendar polling.
The execution-slot bound is the scheduler's own capacity gate:
dispatch happens only with a provably free slot, an executing run
keeps its slot until it stops, and every dispatch spawns exactly one
task that settles or abandons exactly once — so live execution tasks
can never exceed capacity. Manual runs and resumes bypass the
scheduler entirely (unchanged API behavior): scheduler capacity
governs scheduled dispatch only.
"""
schedule_store: Any
run_store: Any
runtime: Any
artifact_store: Any
ownership: Any
config: SchedulerServiceConfig = field(default_factory=SchedulerServiceConfig)
clock: Callable[[], datetime] = _utcnow
on_tick_error: Callable[[BaseException], None] | None = None
_lock: threading.Lock = field(
default_factory=threading.Lock, init=False, repr=False, compare=False
)
_scheduler: Any = field(default=None, init=False, repr=False, compare=False)
_sources: dict[str, Any] = field(
default_factory=dict, init=False, repr=False, compare=False
)
_loop: asyncio.AbstractEventLoop | None = field(
default=None, init=False, repr=False, compare=False
)
_tick_task: asyncio.Task[None] | None = field(
default=None, init=False, repr=False, compare=False
)
_executions: set[asyncio.Future[Any]] = field(
default_factory=set, init=False, repr=False, compare=False
)
_stopping: bool = field(default=False, init=False, repr=False, compare=False)
_started: bool = field(default=False, init=False, repr=False, compare=False)
_tick_count: int = field(default=0, init=False, repr=False, compare=False)
_settled: int = field(default=0, init=False, repr=False, compare=False)
_abandoned: int = field(default=0, init=False, repr=False, compare=False)
_errors: list[str] = field(
default_factory=list, init=False, repr=False, compare=False
)
_last_tick_error: BaseException | None = field(
default=None, init=False, repr=False, compare=False
)
_dispatcher: RuntimeDispatcher = field(init=False, repr=False, compare=False)
_preparer: Any = field(default=None, init=False, repr=False, compare=False)
def __post_init__(self) -> None:
from wf_scheduling.prepare import SchedulePreparer
self._preparer = SchedulePreparer(
StoreDeploymentDirectory(self.artifact_store),
build_pinned_environment(self.artifact_store),
)
self._dispatcher = RuntimeDispatcher(
self.runtime,
submit=self._submit,
settle=self._settle,
abandon=self._abandon,
)
# -- observability -------------------------------------------------
@property
def running(self) -> bool:
"""Whether the tick loop is active (started and not stopped)."""
return self._started and not self._stopping
@property
def tick_count(self) -> int:
"""Number of poll ticks attempted since start."""
return self._tick_count
@property
def live_executions(self) -> int:
"""Number of execution tasks currently tracked for drain."""
return len(self._executions)
@property
def last_tick_error(self) -> BaseException | None:
"""The most recent tick failure, if any (ticks continue)."""
return self._last_tick_error
@property
def errors(self) -> list[str]:
"""Recent execution/settlement error summaries (capped)."""
return list(self._errors)
# -- lifecycle -----------------------------------------------------
async def start(self) -> None:
"""Acquire ownership, recover without executing, start polling.
Raises :class:`SchedulerStartupError` (wrapping second-owner,
unsupported-locking, or corrupt-store failures) without holding
the lock and without leaving tasks behind.
"""
if self._started:
raise SchedulerStartupError("scheduler service already started")
try:
self.ownership.acquire()
except Exception as exc:
raise SchedulerStartupError(
f"scheduler startup cannot prove store ownership: {exc}"
) from exc
try:
self._build_scheduler()
await asyncio.to_thread(self._recover)
except Exception as exc:
self.ownership.release()
raise SchedulerStartupError(
f"scheduler startup recovery failed: {exc}"
) from exc
self._loop = asyncio.get_running_loop()
self._stopping = False
self._started = True
# Operator-driven services (auto_tick=False) poll only through
# explicit poll_once calls; the drain and ownership rules are
# identical either way.
if self.config.auto_tick:
self._tick_task = asyncio.get_running_loop().create_task(self._run())
async def stop(self) -> DrainReport:
"""Stop admission, drain live executions, release ownership.
No new tick starts after ``stop`` begins (an already-running poll
is joined, and any dispatch it performed is covered by the
drain). Live executions get the configured grace period; tasks
still running afterwards are cancelled and left under their
durable executing mark for startup recovery to abandon
truthfully. Ownership is released only after the drain finishes.
Safe to call when not started (returns a zero report).
"""
if not self._started:
return DrainReport()
self._stopping = True
if self._tick_task is not None:
self._tick_task.cancel()
try:
await self._tick_task
except asyncio.CancelledError:
pass
self._tick_task = None
# Join an in-flight poll (a cancelled to_thread wait keeps running
# in its worker): its dispatches are registered tasks by now.
await asyncio.to_thread(self._join_tick)
pending = list(self._executions)
if pending:
_, still_pending = await asyncio.wait(
pending, timeout=self.config.drain_grace_s
)
for task in still_pending:
task.cancel()
if still_pending:
await asyncio.gather(*still_pending, return_exceptions=True)
# Drop drained tasks explicitly: done-callbacks flush on a
# later loop pass, but the report and release below must
# observe the drained set deterministically.
for task in pending:
self._executions.discard(task)
cancelled = len([t for t in pending if t.cancelled()])
with self._lock:
report = DrainReport(
settled=self._settled,
abandoned=self._abandoned,
cancelled=cancelled,
)
self.ownership.release()
self._started = False
return report
async def poll_once(self, now: datetime | None = None) -> dict[str, str]:
"""Run one guarded poll tick (started services only).
Tick failures are recorded like background-tick failures and
re-raised to the direct caller.
"""
if not self._started:
raise SchedulerStartupError("scheduler service is not started")
instant = now if now is not None else self.clock()
try:
return await asyncio.to_thread(self._tick, instant)
except Exception as exc:
self._note_tick_error(exc)
raise
def _note_tick_error(self, exc: BaseException) -> None:
self._last_tick_error = exc
if self.on_tick_error is not None:
try:
self.on_tick_error(exc)
except Exception:
pass
# -- internals -----------------------------------------------------
def _build_scheduler(self) -> None:
from wf_scheduling.poll import Scheduler
self._scheduler = Scheduler(
schedule_store=self.schedule_store,
run_store=self.run_store,
sources=self._sources,
capacity=self.config.capacity,
preparer=self._preparer,
dispatcher=self._dispatcher,
ownership=self.ownership,
)
def _recover(self) -> list[str]:
from wf_scheduling import recovery as sched_recovery
with self._lock:
return sched_recovery.recover(
schedule_store=self.schedule_store,
run_store=self.run_store,
now=self.clock(),
ownership=self.ownership,
)
def _join_tick(self) -> None:
with self._lock:
return None
def _refresh_sources(self) -> None:
from wf_scheduling.poll import source_for_trigger
try:
schedules = self.schedule_store.list_schedules(include_deleted=True)
except Exception:
schedules = []
fresh: dict[str, Any] = {}
for sched in schedules:
if getattr(sched, "deleted", False):
continue
try:
fresh[sched.id] = source_for_trigger(sched.trigger)
except Exception:
# No source entry: the poll raises a loud per-schedule
# definition error instead of ticking a stale calendar.
continue
self._sources.clear()
self._sources.update(fresh)
def _tick(self, now: datetime) -> dict[str, str]:
with self._lock:
self._refresh_sources()
return self._scheduler.poll(now)
async def _run(self) -> None:
try:
while not self._stopping:
instant = self.clock()
try:
await asyncio.to_thread(self._tick, instant)
except asyncio.CancelledError:
raise
except Exception as exc:
self._note_tick_error(exc)
finally:
self._tick_count += 1
await asyncio.sleep(self.config.poll_interval_s)
except asyncio.CancelledError:
raise
def _submit(self, coro: Coroutine[Any, Any, None]) -> asyncio.Future[Any]:
"""Register one execution task; dispatch always spawns exactly one."""
if self._loop is None: # pragma: no cover - start() sets this first
raise SchedulerStartupError("scheduler service is not started")
# Submitted from the poll worker thread: wrap explicitly against
# the server loop, which has no current-loop binding out there.
tracked = asyncio.wrap_future(
asyncio.run_coroutine_threadsafe(coro, self._loop), loop=self._loop
)
self._executions.add(tracked)
tracked.add_done_callback(self._executions.discard)
return tracked
def _settle(self, run_id: str, state: Any) -> None:
with self._lock:
self._scheduler.record_stopped_execution(run_id, state, self.clock())
self._settled += 1
def _abandon(self, run_id: str, error: BaseException) -> None:
"""Fail a dispatcher-broken run closed through startup recovery.
The run holds a durable executing mark with no genuine stopped
result — exactly the crashed-process shape — so the same recovery
that owns restart abandonment owns it here: no replay, no
fabricated outcome, slot freed. Recovery failures are recorded;
the run then waits for the next restart recovery.
"""
with self._lock:
self._errors.append(f"{run_id}: {error}")
del self._errors[: max(0, len(self._errors) - 20)]
try:
from wf_scheduling import recovery as sched_recovery
sched_recovery.recover(
schedule_store=self.schedule_store,
run_store=self.run_store,
now=self.clock(),
ownership=self.ownership,
)
except Exception as exc:
self._errors.append(f"{run_id}: recovery failed: {exc}")
del self._errors[: max(0, len(self._errors) - 20)]
return
self._abandoned += 1
+53 -41
View File
@@ -11,18 +11,17 @@ Locking design (Windows-tested, per the store transaction boundary): one
new module holding ``msvcrt.locking`` (Windows) / ``fcntl.flock`` (POSIX)
on ``<composition root>/scheduler.lock``. No ad-hoc per-run lock files.
One store composition has exactly one lock identity: the composition root
whose distinct schedule and run store directories are its direct children
(see :func:`canonical_lock_root`). Any ancestor is NOT good enough
different ancestors yield different lock files and therefore independent
OS locks, so ancestor containment can never prove exclusivity. Nor is a
mere common ancestor enough: cross pairs that reuse one protected store
with a different partner (shared schedule store, shared run store),
nested pairs, and same-directory dual use would map to different lock
files while covering the same store files, so they have no lock identity
at all. Guards require the held lock's frozen identity to equal the
composition identity; compositions that share no supported layout are
rejected instead of claimed safe.
One store composition has exactly one lock identity: the deepest
composition root that contains each store root as itself or a direct
child (see :func:`canonical_lock_root`). Any ancestor is NOT good enough
different ancestors yield different lock files and therefore
independent OS locks, so ancestor containment can never prove
exclusivity. Nor is a mere common ancestor enough: cross pairs that
reuse one protected store with a different partner (shared schedule
store, shared run store) have no single lock file and no identity at
all. Guards require the held lock's frozen identity to equal the
composition identity; compositions with no single lock file are rejected
instead of claimed safe.
The lock is acquired on the composition root that contains the protected
schedule and run stores. Entry-point guards do not trust the caller to
@@ -52,19 +51,19 @@ def describe_unsupported_layout(*store_roots: Path | str | None) -> str | None:
"""Explain why store roots form no supportable lock composition.
Returns None when :func:`canonical_lock_root` yields an identity;
otherwise returns a message naming the supported layout (distinct
store directories under one composition root) so operators learn the
layout is rejected, not merely the lock. Guards raise this as
:class:`SecondOwnerError`: without one provable lock file, no owner
can claim exclusive ownership of the stores.
otherwise returns a message naming the supported layout (one
composition root containing every store as itself or a direct child)
so operators learn the layout is rejected, not merely the lock.
Guards raise this as :class:`SecondOwnerError`: without one provable
lock file, no owner can claim exclusive ownership of the stores.
"""
if canonical_lock_root(*store_roots) is not None:
return None
return (
"scheduler stores are not a supported composition: the schedule "
"and run stores must be distinct directories under one composition "
"root; shared-store cross pairs, nested pairs, and same-directory "
"dual use cannot prove exclusive ownership and are rejected"
"scheduler stores are not a supported composition: one composition "
"root must contain every store as itself or a direct child; "
"shared-store cross pairs and split layouts cannot prove exclusive "
"ownership and are rejected"
)
@@ -86,22 +85,25 @@ def canonical_store_path(path: Path | str) -> str:
def canonical_lock_root(*store_roots: Path | str | None) -> str | None:
"""Return the one canonical lock identity for a store composition.
The supported layout is two or more distinct store directories that
are direct children of one composition root; the identity is that
parent and the lock file lives at ``<identity>/scheduler.lock``. Both
the held guard's frozen root and the guarded stores map through this
function, so exactly one lock file can authorize one composition, and
any two compositions covering the same store files map to the same
lock file (identical pairs) or are rejected (shared-store cross
pairs, which are not siblings). A single root maps to itself, so a
The identity is the deepest composition root that contains every
store root as itself or a direct child; the lock file lives at
``<identity>/scheduler.lock``. Both the held guard's frozen root and
the guarded stores map through this function. Identical pairs share
the identity of their root (so the server layout, where the schedule
and run stores flank the same composition root, needs exactly one
lock file), distinct siblings share their parent, and a nested pair
shares the outer root — any two compositions covering the same store
files through identical, sibling, or nested roots map to the same
lock file and exclude each other. A single root maps to itself, so a
lock still proves the directory it was acquired on.
Returns None when a root is missing, when the roots are not distinct
siblings under one parent (nested pairs, same-directory dual use, or
ancestor-less roots on different drives), or when no roots are given:
such compositions are unsupported and guards reject them instead of
claiming safety. Nesting one composition's stores inside another
composition's store subtree without sharing the identical root stays
operator error outside the supported layout.
Returns None when a root is missing, when no roots are given, or
when no single root contains every store root as itself or a direct
child (shared-store cross pairs, split layouts, ancestor-less roots
on different drives): such compositions have no single lock file
that could prove exclusivity, so guards reject them instead of
claiming safety. Pointing one composition's stores inside another
live composition's store subtree without sharing its identical roots
stays operator error outside the supported layout.
"""
canonical: list[str] = []
for store_root in store_roots:
@@ -112,10 +114,20 @@ def canonical_lock_root(*store_roots: Path | str | None) -> str | None:
return None
if len(canonical) == 1:
return canonical[0]
parents = {os.path.dirname(path) for path in canonical}
if len(parents) != 1 or len(set(canonical)) != len(canonical):
return None
return parents.pop()
candidates = {path: os.path.dirname(path) for path in canonical}
for path in canonical:
identity = path
if all(
other == identity or os.path.dirname(other) == identity
for other in canonical
):
return identity
parent = candidates[path]
if all(
other == parent or os.path.dirname(other) == parent for other in canonical
):
return parent
return None
class SchedulerOwnership:
@@ -164,7 +176,7 @@ class SchedulerOwnership:
nothing, and a cross pair reusing one protected store with a
different partner has no lock identity at all. An unrelated
(even held) lock, a released lock, a mutated handle, and rootless
or non-sibling stores are never covered: guards reject those
or composition-less stores are never covered: guards reject those
before any write or side effect instead of trusting the caller's
promise.
"""
+7 -1
View File
@@ -86,7 +86,13 @@ class SchedulePreparer:
def prepare(
self, *, sched: Any, intended: datetime, now: datetime
) -> PreparedInvocation | PreparationRejected:
environment = self._build_environment(sched)
try:
environment = self._build_environment(sched)
except KeyError:
# The deployment vanished between schedule creation and
# admission: a preflight rejection, never a tick failure, and
# no run is invented for the occurrence.
return PreparationRejected(reason="deployment-deleted")
try:
revision = self._deployments.deployment_revision(environment.deployment.id)
required = list(