sched: server scheduler wiring, config section, transport lifespan, CLI flag (T12)

This commit is contained in:
lda
2026-09-09 11:01:20 +07:00 Verified
parent 42deba6399
commit 53899d0d75
8 changed files with 440 additions and 9 deletions
+48
View File
@@ -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
View File
@@ -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()