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
+2
View File
@@ -12,6 +12,7 @@ from .models import (
PythonSourceConfig,
RpcHttpTargetConfig,
RpcHttpTransportConfig,
SchedulerConfig,
ServerConfig,
ServerStoresConfig,
SourceConfigOwnership,
@@ -32,6 +33,7 @@ __all__ = [
"PythonSourceConfig",
"RpcHttpTargetConfig",
"RpcHttpTransportConfig",
"SchedulerConfig",
"ServerConfig",
"ServerStoresConfig",
"SourceConfigOwnership",
+15
View File
@@ -162,6 +162,20 @@ SourceConfig = Annotated[
]
class SchedulerConfig(WorkflowConfigModel):
"""Opt-in same-server scheduler tuning (T12).
No filesystem paths live in this section, so the config loader leaves
it untouched. ``enabled`` defaults off: scheduling never runs unless
the config section or the server CLI flag turns it on.
"""
enabled: bool = False
poll_interval_s: float = Field(default=1.0, gt=0)
max_concurrent_runs: int = Field(default=4, ge=1)
drain_grace_s: float = Field(default=30.0, ge=0)
class ServerStoresConfig(WorkflowConfigModel):
"""Optional role-specific store overrides.
@@ -181,6 +195,7 @@ class ServerConfig(WorkflowConfigModel):
stores: ServerStoresConfig = Field(default_factory=ServerStoresConfig)
transports: list[ServerTransportConfig] = Field(default_factory=list)
sources: list[SourceConfig] = Field(default_factory=list)
scheduler: SchedulerConfig | None = None
@model_validator(mode="after")
def validate_unique_source_ids(self) -> ServerConfig:
+17 -1
View File
@@ -9,6 +9,7 @@ from dotenv import load_dotenv
from wf_config import (
FilesystemStoreConfig,
RpcHttpTransportConfig,
WorkflowConfigFile,
load_workflow_config,
)
from wf_server.config import (
@@ -16,6 +17,7 @@ from wf_server.config import (
build_workflow_server_from_workflow_config,
)
from wf_server.context import build_local_static_workflow_server
from wf_server.scheduling import scheduler_lifespan, server_scheduler_config
from wf_transport_rpc_http import create_rpc_app
app = typer.Typer(add_completion=False)
@@ -50,6 +52,11 @@ def serve(
max=65535,
help="Override RPC bind port; defaults to config or 8765.",
),
enable_scheduler: bool = typer.Option(
False,
"--enable-scheduler",
help="Enable the opt-in same-server scheduler (or set server.scheduler.enabled).",
),
) -> None:
"""Serve WorkflowApi over JSON-RPC HTTP."""
if mcp_config is not None and store_root is not None:
@@ -61,6 +68,7 @@ def serve(
resolved_rpc_path = "/rpc"
server = None
workflow_config: WorkflowConfigFile | None = None
if mcp_config is not None:
server = build_workflow_server_from_legacy_mcp_config(mcp_config)
@@ -109,7 +117,15 @@ def serve(
# endpoint; opt into its persistence explicitly at this boundary.
server = build_local_static_workflow_server(resolved_store_root, drafts=True)
rpc_app = create_rpc_app(server, rpc_path=resolved_rpc_path, drafts=True)
sched_config = server_scheduler_config(workflow_config, enable_scheduler)
rpc_app = create_rpc_app(
server,
rpc_path=resolved_rpc_path,
drafts=True,
lifespan=scheduler_lifespan(server, sched_config)
if sched_config is not None
else None,
)
uvicorn.run(
rpc_app,
host=resolved_host or "127.0.0.1",
+84
View File
@@ -0,0 +1,84 @@
"""Opt-in same-server scheduler wiring (T12).
Maps neutral config plus the server CLI flag to a
:class:`~wf_scheduling.lifecycle.SchedulerService` over the server's own
stores, and exposes the transport lifespan that starts/stops the service.
Scheduling stays fully off unless enabled: :func:`server_scheduler_config`
returns ``None`` and the transport runs exactly as before.
"""
from __future__ import annotations
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from wf_config import WorkflowConfigFile
from wf_scheduling.lifecycle import SchedulerService, SchedulerServiceConfig
from wf_scheduling.ownership import SchedulerOwnership
from wf_scheduling.store import FileScheduleStore
from .context import WorkflowServer
SCHEDULER_OWNER = "wf-server-scheduler"
def build_scheduler_service(
server: WorkflowServer, config: SchedulerServiceConfig
) -> SchedulerService:
"""Build the scheduler service over the server's own stores.
The schedule store is a sibling of the run store under the same
composition root (server run data lives at ``<store_root>/runs``,
schedules at ``<store_root>/schedules``), so one lock at
``<store_root>/scheduler.lock`` covers both stores.
"""
return SchedulerService(
schedule_store=FileScheduleStore(server.config.store_root),
run_store=server.stores.run_store,
runtime=server.context.runtime,
artifact_store=server.stores.artifact_store,
ownership=SchedulerOwnership(server.config.store_root, owner=SCHEDULER_OWNER),
config=config,
)
def server_scheduler_config(
workflow_config: WorkflowConfigFile | None,
cli_enabled: bool = False,
) -> SchedulerServiceConfig | None:
"""Resolve the server scheduler config, or ``None`` when disabled.
The file section's ``enabled`` flag OR the CLI flag enables
scheduling; file values map ``poll_interval_s`` /
``capacity=max_concurrent_runs`` / ``drain_grace_s``. The server
always ticks on its own event loop, so ``auto_tick`` is always True.
"""
section = workflow_config.server.scheduler if workflow_config is not None else None
enabled = cli_enabled or (section.enabled if section is not None else False)
if not enabled:
return None
if section is None:
return SchedulerServiceConfig(auto_tick=True)
return SchedulerServiceConfig(
poll_interval_s=section.poll_interval_s,
capacity=section.max_concurrent_runs,
drain_grace_s=section.drain_grace_s,
auto_tick=True,
)
@asynccontextmanager
async def scheduler_lifespan(
server: WorkflowServer, config: SchedulerServiceConfig
) -> AsyncIterator[SchedulerService]:
"""Transport lifespan owning the scheduler service.
A failed ``start`` releases the lock (per the service contract) and
propagates before yielding, so the server never runs unprotected.
"""
service = build_scheduler_service(server, config)
await service.start()
try:
yield service
finally:
await service.stop()
+6 -2
View File
@@ -4,6 +4,8 @@ Return annotations stay eagerly evaluated because fastapi-jsonrpc captures them
while registering nested handlers for response validation and OpenRPC output.
"""
from typing import Any
import fastapi_jsonrpc as jsonrpc
from wf_api.models import HealthResult
@@ -29,17 +31,19 @@ def create_rpc_app(
*,
rpc_path: str = "/rpc",
drafts: bool = False,
lifespan: Any = None,
) -> jsonrpc.API:
"""Build a JSON-RPC HTTP app over an existing WorkflowServer.
Transport code owns only JSON-RPC envelope handling. Workflow semantics stay
behind server.api, so this package remains swappable with WebSocket/MCP
transports later.
transports later. ``lifespan`` (e.g. the opt-in scheduler lifespan) is
passed through to the ASGI app; ``None`` preserves existing behavior.
"""
if not rpc_path.startswith("/"):
raise ValueError("rpc_path must start with '/'")
app = jsonrpc.API()
app = jsonrpc.API(lifespan=lifespan)
entrypoint = jsonrpc.Entrypoint(rpc_path)
@app.get("/healthz")