97 lines
3.8 KiB
Python
97 lines
3.8 KiB
Python
"""Registry and orchestration helpers for distributed workload contracts."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Mapping, Sequence
|
|
|
|
from .models import CompletedPartial, DistributedPlan, FinalResult, _workload_name
|
|
from .workload import DistributedWorkload
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class WorkloadDescription:
|
|
"""Safe metadata that a future coordinator or UI may display."""
|
|
|
|
name: str
|
|
description: str
|
|
|
|
|
|
class DistributedWorkloadRegistry:
|
|
"""Collect distributed workloads without coupling them to the CLI registry."""
|
|
|
|
def __init__(self) -> None:
|
|
self._workloads: dict[str, DistributedWorkload] = {}
|
|
|
|
def register(self, workload: DistributedWorkload) -> None:
|
|
name = _workload_name(workload.name)
|
|
if name in self._workloads:
|
|
raise ValueError(f"distributed workload already registered: {name}")
|
|
if not isinstance(workload.description, str) or not workload.description.strip():
|
|
raise ValueError("distributed workload description must be non-empty")
|
|
self._workloads[name] = workload
|
|
|
|
def require(self, name: str) -> DistributedWorkload:
|
|
try:
|
|
return self._workloads[_workload_name(name)]
|
|
except KeyError as error:
|
|
raise ValueError(f"unknown distributed workload: {name}") from error
|
|
|
|
def descriptions(self) -> tuple[WorkloadDescription, ...]:
|
|
return tuple(
|
|
WorkloadDescription(name, workload.description)
|
|
for name, workload in sorted(self._workloads.items())
|
|
)
|
|
|
|
|
|
class PlanningService:
|
|
"""Small bridge-safe orchestration around a distributed workload registry.
|
|
|
|
It writes neither jobs nor artifacts. A Go coordinator bridge can therefore
|
|
validate and produce a plan before opening its own all-or-nothing persistence
|
|
transaction; CTX-08/09 will implement that concrete bridge and reducers.
|
|
"""
|
|
|
|
def __init__(self, registry: DistributedWorkloadRegistry) -> None:
|
|
self._registry = registry
|
|
|
|
def plan(
|
|
self,
|
|
workload_name: str,
|
|
input_path: Path,
|
|
input_artifact_id: str,
|
|
parameters: Mapping[str, object],
|
|
shard_rows: int,
|
|
workspace: Path,
|
|
) -> DistributedPlan:
|
|
if isinstance(shard_rows, bool) or not isinstance(shard_rows, int) or shard_rows < 1:
|
|
raise ValueError("shard_rows must be a positive integer")
|
|
workload = self._registry.require(workload_name)
|
|
workload.validate_job(parameters)
|
|
plan = workload.plan(input_path, input_artifact_id, parameters, shard_rows, workspace)
|
|
if not isinstance(plan, DistributedPlan):
|
|
raise ValueError("distributed planner must return a DistributedPlan")
|
|
if plan.workload != workload.name:
|
|
raise ValueError("distributed planner returned a plan for another workload")
|
|
# Round-trip through the strict wire schema now, before a future bridge
|
|
# persists anything. This catches non-JSON values and undeclared fields.
|
|
return DistributedPlan.from_json(plan.to_json())
|
|
|
|
def reduce(
|
|
self,
|
|
workload_name: str,
|
|
partial_results: Sequence[CompletedPartial],
|
|
parameters: Mapping[str, object],
|
|
workspace: Path,
|
|
) -> FinalResult:
|
|
workload = self._registry.require(workload_name)
|
|
indexes = [partial.chunk_index for partial in partial_results]
|
|
if len(indexes) != len(set(indexes)):
|
|
raise ValueError("partial results must have unique chunk_index values")
|
|
ordered = tuple(sorted(partial_results, key=lambda partial: partial.chunk_index))
|
|
result = workload.reduce(ordered, parameters, workspace)
|
|
if not isinstance(result, FinalResult):
|
|
raise ValueError("distributed reducer must return a FinalResult")
|
|
return result
|