sched: server scheduler wiring, config section, transport lifespan, CLI flag (T12)
This commit is contained in:
@@ -13,6 +13,7 @@ from wf_config import (
|
||||
McpSourceConfig,
|
||||
RpcHttpTargetConfig,
|
||||
RpcHttpTransportConfig,
|
||||
SchedulerConfig,
|
||||
StdioSourceTransportConfig,
|
||||
StdlibSourceConfig,
|
||||
WorkflowConfigFile,
|
||||
@@ -490,3 +491,50 @@ def test_workflow_config_parses_oauth_provider_profile() -> None:
|
||||
"access_type": "offline",
|
||||
"prompt": "consent",
|
||||
}
|
||||
|
||||
|
||||
def test_server_config_scheduler_absent_is_none() -> None:
|
||||
config = WorkflowConfigFile.model_validate({"version": 1})
|
||||
|
||||
assert config.server.scheduler is None
|
||||
|
||||
|
||||
def test_server_config_parses_scheduler_section_with_values() -> None:
|
||||
config = WorkflowConfigFile.model_validate(
|
||||
{
|
||||
"version": 1,
|
||||
"server": {
|
||||
"scheduler": {
|
||||
"enabled": True,
|
||||
"poll_interval_s": 2.5,
|
||||
"max_concurrent_runs": 8,
|
||||
"drain_grace_s": 60.0,
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert isinstance(config.server.scheduler, SchedulerConfig)
|
||||
assert config.server.scheduler.enabled is True
|
||||
assert config.server.scheduler.poll_interval_s == 2.5
|
||||
assert config.server.scheduler.max_concurrent_runs == 8
|
||||
assert config.server.scheduler.drain_grace_s == 60.0
|
||||
|
||||
|
||||
def test_server_config_scheduler_defaults_to_disabled() -> None:
|
||||
config = WorkflowConfigFile.model_validate(
|
||||
{"version": 1, "server": {"scheduler": {}}}
|
||||
)
|
||||
|
||||
assert config.server.scheduler is not None
|
||||
assert config.server.scheduler.enabled is False
|
||||
assert config.server.scheduler.poll_interval_s == 1.0
|
||||
assert config.server.scheduler.max_concurrent_runs == 4
|
||||
assert config.server.scheduler.drain_grace_s == 30.0
|
||||
|
||||
|
||||
def test_server_config_rejects_unknown_scheduler_key() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
WorkflowConfigFile.model_validate(
|
||||
{"version": 1, "server": {"scheduler": {"bogus_key": 1}}}
|
||||
)
|
||||
|
||||
+129
-6
@@ -88,7 +88,7 @@ def test_rpc_server_cli_uses_configured_store_and_transport(
|
||||
captured["drafts"] = drafts
|
||||
return object()
|
||||
|
||||
def fake_create_rpc_app(server, *, rpc_path="/rpc", drafts=False):
|
||||
def fake_create_rpc_app(server, *, rpc_path="/rpc", drafts=False, lifespan=None):
|
||||
captured["server"] = server
|
||||
captured["rpc_path"] = rpc_path
|
||||
captured["drafts"] = drafts
|
||||
@@ -135,7 +135,7 @@ def test_rpc_server_cli_uses_mcp_config_server(monkeypatch, tmp_path) -> None:
|
||||
captured["mcp_config_path"] = path
|
||||
return object()
|
||||
|
||||
def fake_create_rpc_app(server, *, rpc_path="/rpc", drafts=False):
|
||||
def fake_create_rpc_app(server, *, rpc_path="/rpc", drafts=False, lifespan=None):
|
||||
captured["server"] = server
|
||||
captured["rpc_path"] = rpc_path
|
||||
captured["drafts"] = drafts
|
||||
@@ -211,7 +211,7 @@ def test_rpc_server_cli_mcp_config_builds_registry_capable_server(
|
||||
)
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_create_rpc_app(server, *, rpc_path="/rpc", drafts=False):
|
||||
def fake_create_rpc_app(server, *, rpc_path="/rpc", drafts=False, lifespan=None):
|
||||
captured["source_registry_admin"] = server.source_registry_admin
|
||||
captured["rpc_path"] = rpc_path
|
||||
captured["drafts"] = drafts
|
||||
@@ -268,7 +268,7 @@ def test_rpc_server_cli_mcp_config_with_config_uses_transport_settings(
|
||||
)
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_create_rpc_app(server, *, rpc_path="/rpc", drafts=False):
|
||||
def fake_create_rpc_app(server, *, rpc_path="/rpc", drafts=False, lifespan=None):
|
||||
captured["server"] = server
|
||||
captured["rpc_path"] = rpc_path
|
||||
captured["drafts"] = drafts
|
||||
@@ -310,7 +310,7 @@ def test_rpc_server_cli_config_with_mcp_source_uses_mcp_builder(
|
||||
captured["build_drafts"] = drafts
|
||||
return object()
|
||||
|
||||
def fake_create_rpc_app(server, *, rpc_path="/rpc", drafts=False):
|
||||
def fake_create_rpc_app(server, *, rpc_path="/rpc", drafts=False, lifespan=None):
|
||||
captured["server"] = server
|
||||
captured["rpc_path"] = rpc_path
|
||||
captured["drafts"] = drafts
|
||||
@@ -422,7 +422,7 @@ def test_rpc_server_cli_config_uses_workflow_store_override(
|
||||
captured["build_drafts"] = drafts
|
||||
return object()
|
||||
|
||||
def fake_create_rpc_app(server, *, rpc_path="/rpc", drafts=False):
|
||||
def fake_create_rpc_app(server, *, rpc_path="/rpc", drafts=False, lifespan=None):
|
||||
captured["server"] = server
|
||||
captured["rpc_path"] = rpc_path
|
||||
captured["drafts"] = drafts
|
||||
@@ -442,3 +442,126 @@ def test_rpc_server_cli_config_uses_workflow_store_override(
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert captured["workflow_store_root"] == (tmp_path / ".workflow").resolve()
|
||||
|
||||
|
||||
def test_rpc_server_cli_help_mentions_enable_scheduler() -> None:
|
||||
result = CliRunner().invoke(app, ["--help"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "--enable-scheduler" in result.output
|
||||
|
||||
|
||||
def test_rpc_server_cli_scheduler_disabled_by_default(monkeypatch, tmp_path) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_create_rpc_app(server, *, rpc_path="/rpc", drafts=False, lifespan=None):
|
||||
captured["lifespan"] = lifespan
|
||||
return object()
|
||||
|
||||
def fake_uvicorn_run(app_obj, *, host, port, access_log):
|
||||
captured["app"] = app_obj
|
||||
|
||||
monkeypatch.setattr("wf_server.cli.create_rpc_app", fake_create_rpc_app)
|
||||
monkeypatch.setattr("wf_server.cli.uvicorn.run", fake_uvicorn_run)
|
||||
|
||||
result = CliRunner().invoke(app, ["--store-root", str(tmp_path / "store")])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert captured["app"] is not None
|
||||
assert captured["lifespan"] is None
|
||||
|
||||
|
||||
def test_rpc_server_cli_enable_scheduler_with_store_root_builds_app(
|
||||
monkeypatch, tmp_path
|
||||
) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_create_rpc_app(server, *, rpc_path="/rpc", drafts=False, lifespan=None):
|
||||
captured["server"] = server
|
||||
captured["lifespan"] = lifespan
|
||||
return object()
|
||||
|
||||
def fake_uvicorn_run(app_obj, *, host, port, access_log):
|
||||
captured["app"] = app_obj
|
||||
|
||||
monkeypatch.setattr("wf_server.cli.create_rpc_app", fake_create_rpc_app)
|
||||
monkeypatch.setattr("wf_server.cli.uvicorn.run", fake_uvicorn_run)
|
||||
|
||||
result = CliRunner().invoke(
|
||||
app, ["--store-root", str(tmp_path / "store"), "--enable-scheduler"]
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
|
||||
def test_rpc_server_cli_config_scheduler_section_enables_without_flag(
|
||||
monkeypatch, tmp_path
|
||||
) -> None:
|
||||
config_path = tmp_path / "wf.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"version": 1,
|
||||
"server": {
|
||||
"store": {"kind": "filesystem", "root": ".wf_store"},
|
||||
"scheduler": {"enabled": True},
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_create_rpc_app(server, *, rpc_path="/rpc", drafts=False, lifespan=None):
|
||||
captured["lifespan"] = lifespan
|
||||
return object()
|
||||
|
||||
def fake_uvicorn_run(app_obj, *, host, port, access_log):
|
||||
captured["app"] = app_obj
|
||||
|
||||
monkeypatch.setattr("wf_server.cli.create_rpc_app", fake_create_rpc_app)
|
||||
monkeypatch.setattr("wf_server.cli.uvicorn.run", fake_uvicorn_run)
|
||||
|
||||
result = CliRunner().invoke(app, ["--config", str(config_path)])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert captured["lifespan"] is not None
|
||||
|
||||
|
||||
def test_rpc_server_cli_flag_overrides_disabled_config_scheduler(
|
||||
monkeypatch, tmp_path
|
||||
) -> None:
|
||||
config_path = tmp_path / "wf.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"version": 1,
|
||||
"server": {
|
||||
"store": {"kind": "filesystem", "root": ".wf_store"},
|
||||
"scheduler": {"enabled": False},
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_create_rpc_app(server, *, rpc_path="/rpc", drafts=False, lifespan=None):
|
||||
captured["lifespan"] = lifespan
|
||||
return object()
|
||||
|
||||
def fake_uvicorn_run(app_obj, *, host, port, access_log):
|
||||
captured["app"] = app_obj
|
||||
|
||||
monkeypatch.setattr("wf_server.cli.create_rpc_app", fake_create_rpc_app)
|
||||
monkeypatch.setattr("wf_server.cli.uvicorn.run", fake_uvicorn_run)
|
||||
|
||||
result = CliRunner().invoke(
|
||||
app, ["--config", str(config_path), "--enable-scheduler"]
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert captured["lifespan"] is not None
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Server scheduler composition (T12): config mapping and store wiring.
|
||||
|
||||
Scheduling stays off unless the config section or the CLI flag enables
|
||||
it. When enabled, the service runs over the server's own stores behind
|
||||
one composition lock, and the transport lifespan owns start/stop.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from wf_config import WorkflowConfigFile
|
||||
from wf_scheduling.lifecycle import DrainReport, SchedulerServiceConfig
|
||||
from wf_scheduling.ownership import SchedulerOwnership
|
||||
from wf_server.context import build_local_static_workflow_server
|
||||
from wf_server.scheduling import (
|
||||
build_scheduler_service,
|
||||
scheduler_lifespan,
|
||||
server_scheduler_config,
|
||||
)
|
||||
|
||||
|
||||
def test_server_scheduler_config_disabled_by_default() -> None:
|
||||
assert server_scheduler_config(None, False) is None
|
||||
|
||||
bare = WorkflowConfigFile.model_validate({"version": 1})
|
||||
assert server_scheduler_config(bare, False) is None
|
||||
|
||||
file_disabled = WorkflowConfigFile.model_validate(
|
||||
{"version": 1, "server": {"scheduler": {"enabled": False}}}
|
||||
)
|
||||
assert server_scheduler_config(file_disabled, False) is None
|
||||
|
||||
|
||||
def test_server_scheduler_config_flag_enables_defaults_without_config() -> None:
|
||||
resolved = server_scheduler_config(None, True)
|
||||
|
||||
assert resolved is not None
|
||||
assert resolved.poll_interval_s == 1.0
|
||||
assert resolved.capacity == 4
|
||||
assert resolved.drain_grace_s == 30.0
|
||||
assert resolved.auto_tick is True
|
||||
|
||||
|
||||
def test_server_scheduler_config_maps_file_values() -> None:
|
||||
config = WorkflowConfigFile.model_validate(
|
||||
{
|
||||
"version": 1,
|
||||
"server": {
|
||||
"scheduler": {
|
||||
"enabled": True,
|
||||
"poll_interval_s": 2.5,
|
||||
"max_concurrent_runs": 8,
|
||||
"drain_grace_s": 60.0,
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
resolved = server_scheduler_config(config, False)
|
||||
|
||||
assert resolved is not None
|
||||
assert resolved.poll_interval_s == 2.5
|
||||
assert resolved.capacity == 8
|
||||
assert resolved.drain_grace_s == 60.0
|
||||
assert resolved.auto_tick is True
|
||||
|
||||
|
||||
def test_server_scheduler_config_flag_overrides_disabled_section() -> None:
|
||||
config = WorkflowConfigFile.model_validate(
|
||||
{
|
||||
"version": 1,
|
||||
"server": {
|
||||
"scheduler": {"enabled": False, "max_concurrent_runs": 2},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
resolved = server_scheduler_config(config, True)
|
||||
|
||||
assert resolved is not None
|
||||
assert resolved.capacity == 2
|
||||
assert resolved.auto_tick is True
|
||||
|
||||
|
||||
def test_build_scheduler_service_wires_server_stores(tmp_path: Path) -> None:
|
||||
server = build_local_static_workflow_server(tmp_path)
|
||||
service = build_scheduler_service(server, SchedulerServiceConfig(auto_tick=False))
|
||||
|
||||
assert service.schedule_store.root == server.config.store_root
|
||||
assert (
|
||||
service.schedule_store.schedules_dir == server.config.store_root / "schedules"
|
||||
)
|
||||
assert service.run_store is server.stores.run_store
|
||||
assert service.artifact_store is server.stores.artifact_store
|
||||
assert service.runtime is server.context.runtime
|
||||
assert service.ownership.lock_path == server.config.store_root / "scheduler.lock"
|
||||
|
||||
|
||||
async def test_scheduler_service_start_stop_on_server_stores(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
server = build_local_static_workflow_server(tmp_path)
|
||||
service = build_scheduler_service(server, SchedulerServiceConfig(auto_tick=False))
|
||||
|
||||
await service.start()
|
||||
try:
|
||||
assert service.running is True
|
||||
assert service.ownership.covers(
|
||||
service.schedule_store.root, service.run_store.root
|
||||
)
|
||||
finally:
|
||||
report = await service.stop()
|
||||
|
||||
assert isinstance(report, DrainReport)
|
||||
assert service.running is False
|
||||
# The lock is released: a fresh owner can acquire the same composition.
|
||||
probe = SchedulerOwnership(tmp_path, owner="probe")
|
||||
probe.acquire()
|
||||
try:
|
||||
assert probe.held is True
|
||||
finally:
|
||||
probe.release()
|
||||
|
||||
|
||||
async def test_scheduler_lifespan_releases_lock_on_exit(tmp_path: Path) -> None:
|
||||
server = build_local_static_workflow_server(tmp_path)
|
||||
resolved = server_scheduler_config(None, True)
|
||||
assert resolved is not None
|
||||
|
||||
async with scheduler_lifespan(server, resolved) as service:
|
||||
assert service.running is True
|
||||
|
||||
probe = SchedulerOwnership(tmp_path, owner="probe")
|
||||
probe.acquire()
|
||||
try:
|
||||
assert probe.held is True
|
||||
finally:
|
||||
probe.release()
|
||||
Reference in New Issue
Block a user