sched: server scheduler wiring, config section, transport lifespan, CLI flag (T12)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user