fix: honor live scheduling revisions at dispatch
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user