137 lines
4.6 KiB
Python
137 lines
4.6 KiB
Python
"""Executable scheduling example: one deployment run on a timer.
|
|
|
|
This is the runnable companion to
|
|
``docs/deployment_scheduling.md`` ("hypothetically used as follows"):
|
|
a local server plus the opt-in scheduler admit and complete one
|
|
one-shot scheduled run of a constant workflow, then show its occurrence
|
|
history. Run it with::
|
|
|
|
uv run pytest -q tests/examples/test_scheduled_deployment_example.py
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from datetime import UTC, datetime, timedelta
|
|
from typing import Any
|
|
|
|
from wf_api.models import RawWorkflowPlan
|
|
from wf_core import END
|
|
from wf_scheduling.lifecycle import SchedulerServiceConfig
|
|
from wf_scheduling.store import FileScheduleStore
|
|
from wf_server import build_local_static_workflow_server
|
|
from wf_server.scheduling import build_scheduler_service
|
|
|
|
|
|
def _constant_plan() -> RawWorkflowPlan:
|
|
return RawWorkflowPlan.model_validate(
|
|
{
|
|
"name": "scheduled_hello",
|
|
"input_schema": {"type": "object", "properties": {}},
|
|
"state_schema": {
|
|
"type": "object",
|
|
"properties": {"result": {"type": "string"}},
|
|
},
|
|
"output_schema": {
|
|
"type": "object",
|
|
"properties": {"result": {"type": "string"}},
|
|
"required": ["result"],
|
|
},
|
|
"outcomes": ["ok"],
|
|
"start": "constant",
|
|
"nodes": [
|
|
{
|
|
"id": "constant",
|
|
"type": "node",
|
|
"node": "wf.std.constant",
|
|
"input": [
|
|
{
|
|
"value": "hello on a schedule",
|
|
"target": {"root": "local", "parts": ["value"]},
|
|
}
|
|
],
|
|
"output": [
|
|
{
|
|
"source": {"root": "local", "parts": ["value"]},
|
|
"target": {"root": "state", "parts": ["result"]},
|
|
}
|
|
],
|
|
}
|
|
],
|
|
"edges": [{"from": "constant", "outcome": "ok", "to": END}],
|
|
"output": [
|
|
{
|
|
"path": {"root": "state", "parts": ["result"]},
|
|
"target": {"root": "local", "parts": ["result"]},
|
|
}
|
|
],
|
|
}
|
|
)
|
|
|
|
|
|
async def _wait_for(condition: Any, timeout: float = 30.0) -> None:
|
|
async with asyncio.timeout(timeout):
|
|
while not condition():
|
|
await asyncio.sleep(0.02)
|
|
|
|
|
|
async def test_scheduled_deployment_completes_on_a_timer(tmp_path: Any) -> None:
|
|
root = tmp_path / "store"
|
|
server = build_local_static_workflow_server(root, schedules=True)
|
|
await server.api.create_artifact_from_plan(
|
|
artifact_id="scheduled_hello",
|
|
version=1,
|
|
title="Scheduled Hello",
|
|
plan=_constant_plan(),
|
|
outcomes=["ok"],
|
|
source_bindings={},
|
|
)
|
|
await server.api.save_deployment(
|
|
{
|
|
"id": "scheduled_hello.default",
|
|
"artifact_id": "scheduled_hello",
|
|
"artifact_version": 1,
|
|
"bindings": {},
|
|
}
|
|
)
|
|
due = datetime.now(UTC) + timedelta(seconds=0.5)
|
|
created = await server.api.schedules.create_schedule(
|
|
schedule_id="hello-once",
|
|
deployment_id="scheduled_hello.default",
|
|
trigger={"kind": "oneshot", "at": due.isoformat()},
|
|
)
|
|
assert created["revision"] == 1
|
|
|
|
service = build_scheduler_service(
|
|
server,
|
|
SchedulerServiceConfig(poll_interval_s=0.05, capacity=2),
|
|
)
|
|
try:
|
|
await service.start()
|
|
|
|
def _completed() -> bool:
|
|
runs = list(server.stores.run_store.list_runs())
|
|
return any(run.status.value == "completed" for run in runs)
|
|
|
|
await _wait_for(_completed)
|
|
run_id = next(
|
|
run.id
|
|
for run in server.stores.run_store.list_runs()
|
|
if run.status.value == "completed"
|
|
)
|
|
inspected = await server.api.inspect_run(run_id=run_id)
|
|
assert inspected["status"] == "completed"
|
|
assert (inspected["output"] or {})["result"] == "hello on a schedule"
|
|
|
|
page = await server.api.schedules.list_schedule_occurrences(
|
|
schedule_id="hello-once"
|
|
)
|
|
kinds = [row["kind"] for row in page["occurrences"]]
|
|
assert "admitted" in kinds
|
|
assert "completed" in kinds
|
|
finally:
|
|
await service.stop()
|
|
|
|
# The schedule definition survives its run; history is retained.
|
|
assert FileScheduleStore(root).get_schedule("hello-once").exhausted is True
|