fix: honor live scheduling revisions at dispatch

This commit is contained in:
lda
2026-09-09 19:53:12 +07:00 Verified
parent b7c5f4e0eb
commit ecb5a76b12
11 changed files with 134 additions and 27 deletions
+30 -2
View File
@@ -13,8 +13,8 @@ from tests.scheduling.controlled import (
)
from wf_artifacts.runs.models import StoredRunStatus
from wf_artifacts.runs.store import FileRunStore
from wf_scheduling.calendar import OneShotSource
from wf_scheduling.models import Schedule
from wf_scheduling.calendar import CronSource, OneShotSource
from wf_scheduling.models import CronTrigger, Schedule
from wf_scheduling.ownership import SchedulerOwnership
from wf_scheduling.poll import SCAN_CAP, Scheduler
from wf_scheduling.prepare import SchedulePreparer
@@ -249,6 +249,34 @@ def test_schedule_edit_between_poll_snapshot_and_admission_cannot_overwrite_term
sched.ownership.release()
def test_trigger_edit_refreshes_managed_calendar_before_polling(tmp_path: Path) -> None:
"""A trigger edit cannot be paired with the previous tick's source."""
sched, store, runs, sources = _harness(tmp_path, script={"*": "complete"})
start = ts(2026, 9, 8, 11, 0)
now = ts(2026, 9, 8, 12, 0)
_add(
sched,
store,
sources,
"a",
CronSource("0 * * * *", "UTC"),
start,
)
old_trigger = store.get_schedule("a").trigger
sched.set_source_definitions({"a": old_trigger})
edited = store.get_schedule("a")
edited.revision = 2
edited.trigger = CronTrigger(
expression="0 13 * * *",
timezone="UTC",
)
store.update_schedule(edited, expected_revision=1)
assert sched.poll(now) == {"a": "idle"}
assert runs.list_admissions() == []
sched.ownership.release()
def test_parallel_limits_and_interrupted_slots() -> None:
import tempfile
+24 -1
View File
@@ -56,9 +56,15 @@ def _scheduler(
) -> tuple[Scheduler, FileScheduleStore, FileRunStore]:
sched_store = FileScheduleStore(tmp_path / "sched")
run_store = FileRunStore(tmp_path / "runs")
def revisioned_fixture_environment(sched: Any) -> Any:
environment = fixture_environment(sched)
deployment = environment.deployment.model_copy(update={"revision": 3})
return environment.model_copy(update={"deployment": deployment})
preparer = SchedulePreparer(
DictDeployments({"dep-1": {"rev": 3, "required": []}}),
fixture_environment,
revisioned_fixture_environment,
)
sched = Scheduler(
schedule_store=sched_store,
@@ -159,6 +165,23 @@ def test_unknown_deployment_rejects_without_a_run(tmp_path: Path) -> None:
sched.ownership.release()
def test_deployment_revision_mismatch_rejects_pinned_environment() -> None:
"""Preparation fails when directory metadata no longer matches the pin."""
preparer = SchedulePreparer(
DictDeployments({"dep-1": {"rev": 2, "required": []}}),
fixture_environment,
)
result = preparer.prepare(
sched=_sched_model("s"),
intended=ts(2026, 9, 8, 12, 0),
now=ts(2026, 9, 8, 12, 0),
)
assert isinstance(result, PreparationRejected)
assert result.reason == "deployment-changed"
def test_missing_required_input_rejects_without_a_run(tmp_path: Path) -> None:
sched_store = FileScheduleStore(tmp_path / "sched")
run_store = FileRunStore(tmp_path / "runs")
+1 -1
View File
@@ -248,7 +248,7 @@ def test_changed_contract_rejects_before_admission(tmp_path: Path) -> None:
)
sched.preparer = SchedulePreparer(
DictDeployments({"dep-1": {"rev": 2, "required": []}}), changed_env
DictDeployments({"dep-1": {"rev": 1, "required": []}}), changed_env
)
assert sched.poll(ts(2026, 9, 8, 12, 0)) == {"s": "admit:None"}
_rejected_entry(store)
+2 -2
View File
@@ -494,7 +494,7 @@ def test_rpc_server_cli_enable_scheduler_with_store_root_builds_app(
assert result.exit_code == 0, result.output
assert captured["server"] is not None
assert captured["app"] is not None
assert captured["lifespan"] is not None
assert callable(captured["lifespan"])
def test_rpc_server_cli_config_scheduler_section_enables_without_flag(
@@ -528,7 +528,7 @@ def test_rpc_server_cli_config_scheduler_section_enables_without_flag(
result = CliRunner().invoke(app, ["--config", str(config_path)])
assert result.exit_code == 0, result.output
assert captured["lifespan"] is not None
assert callable(captured["lifespan"])
def test_rpc_server_cli_flag_overrides_disabled_config_scheduler(
@@ -18,6 +18,7 @@ from wf_server.scheduling import (
scheduler_lifespan,
server_scheduler_config,
)
from wf_transport_rpc_http import create_rpc_app
def test_server_scheduler_config_disabled_by_default() -> None:
@@ -147,3 +148,25 @@ async def test_scheduler_lifespan_releases_lock_on_exit(tmp_path: Path) -> None:
assert probe.held is True
finally:
probe.release()
async def test_rpc_scheduler_lifespan_factory_starts_and_stops_service(
tmp_path: Path,
) -> None:
"""The RPC app receives a callable that lazily owns scheduler startup."""
server = build_local_static_workflow_server(tmp_path)
config = SchedulerServiceConfig(auto_tick=False)
app = create_rpc_app(
server,
lifespan=lambda _app: scheduler_lifespan(server, config),
)
async with app.router.lifespan_context(app):
assert server.api.schedules._schedule_store().root == tmp_path
probe = SchedulerOwnership(tmp_path, owner="probe")
probe.acquire()
try:
assert probe.held is True
finally:
probe.release()