Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions proto/buf.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@
- Consume solutions only when `result.status` is `OPTIMAL` or `FEASIBLE`.
- Set an RPC deadline. The server caps solve time, workers, concurrency, and message size.

## Errors

`Solve` application errors carry `google.rpc.Status` in the `grpc-status-details-bin`
trailer ([AIP-193](https://google.aip.dev/193)). Its `ErrorInfo.domain` is
`optimizer.fanscore.ch`; `ErrorInfo.reason` identifies the error.
Invalid input returns `INVALID_ARGUMENT`, including native `MODEL_INVALID` results.
`BadRequest.field_violations` identifies invalid fields when known.
gRPC-generated errors may omit the rich status trailer.

## Client SDKs

Install generated SDKs for your language from [the BSR module](https://buf.build/fanscore-ch/optimizer).
Expand Down
4 changes: 3 additions & 1 deletion proto/fanscore/optimizer/v1/optimizer.proto
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import "ortools/sat/sat_parameters.proto";
// Executes caller-defined OR-Tools CP-SAT models within server resource limits.
service OptimizerService {
// Solves a caller-defined model. No application data is loaded or persisted.
// Invalid input returns INVALID_ARGUMENT, with field violations when known.
rpc Solve(SolveRequest) returns (SolveResponse);
}

Expand All @@ -27,7 +28,8 @@ message SolveRequest {

// The solver's status, solution, and execution statistics.
message SolveResponse {
// Includes OPTIMAL, FEASIBLE, INFEASIBLE, MODEL_INVALID, or UNKNOWN.
// Includes OPTIMAL, FEASIBLE, INFEASIBLE, or UNKNOWN.
// MODEL_INVALID is returned as an INVALID_ARGUMENT RPC error instead.
// A solution may only be consumed for OPTIMAL or FEASIBLE.
operations_research.sat.CpSolverResponse result = 1;
}
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ requires-python = ">=3.14,<3.15"
dependencies = [
"grpcio>=1.81.1,<1.85",
"grpcio-health-checking>=1.81.1,<1.82",
"grpcio-status>=1.81.1,<1.82",
"googleapis-common-protos>=1.72,<2",
"ortools==9.15.6755",
]

Expand Down
1 change: 1 addition & 0 deletions src/fanscore/optimizer/v1/optimizer_pb2_grpc.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ class OptimizerServiceServicer:

def Solve(self, request, context):
"""Solves a caller-defined model. No application data is loaded or persisted.
Invalid input returns INVALID_ARGUMENT, with field violations when known.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
Expand Down
58 changes: 58 additions & 0 deletions src/optimizer/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
from typing import NoReturn

import grpc
from google.rpc import error_details_pb2, status_pb2
from grpc_status import rpc_status

ERROR_DOMAIN = "optimizer.fanscore.ch"


class ServiceError(Exception):
"""An application error with a stable, machine-readable identity."""

def __init__(
self,
code: grpc.StatusCode,
message: str,
reason: str,
*,
metadata: dict[str, str] | None = None,
violations: dict[str, str] | None = None,
) -> None:
super().__init__(message)
self.code = code
self.reason = reason
self.metadata = metadata or {}
self.violations = violations or {}

def abort(self, context: grpc.ServicerContext) -> NoReturn:
status = status_pb2.Status(code=self.code.value[0], message=str(self))
status.details.add().Pack(
error_details_pb2.ErrorInfo(
reason=self.reason, domain=ERROR_DOMAIN, metadata=self.metadata
)
)
if self.violations:
bad_request = error_details_pb2.BadRequest()
for field, description in self.violations.items():
bad_request.field_violations.add(field=field, description=description)
status.details.add().Pack(bad_request)
context.abort_with_status(rpc_status.to_status(status))


class InvalidArgument(ServiceError, ValueError):
def __init__(
self,
message: str,
reason: str,
*,
metadata: dict[str, str] | None = None,
violations: dict[str, str] | None = None,
) -> None:
super().__init__(
grpc.StatusCode.INVALID_ARGUMENT,
message,
reason,
metadata=metadata,
violations=violations,
)
57 changes: 45 additions & 12 deletions src/optimizer/grpc_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from fanscore.optimizer.v1 import optimizer_pb2, optimizer_pb2_grpc
from optimizer.config import Config
from optimizer.errors import InvalidArgument, ServiceError
from optimizer.solver import Solver, prepare_parameters

logger = logging.getLogger(__name__)
Expand All @@ -19,33 +20,65 @@ def __init__(self, config: Config) -> None:

def Solve(
self, request: optimizer_pb2.SolveRequest, context: grpc.ServicerContext
) -> optimizer_pb2.SolveResponse:
try:
return self._solve(request, context)
except ServiceError as error:
error.abort(context)
except Exception:
logger.exception("unexpected solve failure")
ServiceError(
grpc.StatusCode.INTERNAL,
"The solver failed unexpectedly. Contact the service operator.",
"INTERNAL_ERROR",
).abort(context)

def _solve(
self, request: optimizer_pb2.SolveRequest, context: grpc.ServicerContext
) -> optimizer_pb2.SolveResponse:
started = time.monotonic()
if not request.HasField("model"):
context.abort(grpc.StatusCode.INVALID_ARGUMENT, "model: value is required")
message = "model: value is required"
raise InvalidArgument(
message,
"MODEL_REQUIRED",
violations={"model": message},
)
if not self._slots.acquire(blocking=False):
context.abort(
grpc.StatusCode.RESOURCE_EXHAUSTED, "solver capacity exhausted"
raise ServiceError(
grpc.StatusCode.RESOURCE_EXHAUSTED,
"solver capacity exhausted",
"SOLVER_CAPACITY_EXHAUSTED",
metadata={
"maxConcurrentSolves": str(self._config.max_concurrent_solves)
},
)
try:
try:
parameters = prepare_parameters(request.parameters, self._config)
solver = Solver(request.model, parameters)
except ValueError as error:
context.abort(grpc.StatusCode.INVALID_ARGUMENT, str(error))
parameters = prepare_parameters(request.parameters, self._config)
solver = Solver(request.model, parameters)

if not context.add_callback(solver.cancel):
context.abort(grpc.StatusCode.CANCELLED, "request is no longer active")
raise ServiceError(
grpc.StatusCode.CANCELLED,
"request is no longer active",
"REQUEST_CANCELLED",
)
remaining = parameters.max_time_in_seconds - (time.monotonic() - started)
deadline = context.time_remaining()
if deadline is not None:
remaining = min(remaining, deadline)
if remaining <= 0:
context.abort(
grpc.StatusCode.DEADLINE_EXCEEDED, "solve deadline elapsed"
raise ServiceError(
grpc.StatusCode.DEADLINE_EXCEEDED,
"solve deadline elapsed",
"SOLVE_DEADLINE_EXCEEDED",
)
if not context.is_active():
context.abort(grpc.StatusCode.CANCELLED, "request is no longer active")
raise ServiceError(
grpc.StatusCode.CANCELLED,
"request is no longer active",
"REQUEST_CANCELLED",
)

result = solver.solve(remaining)
logger.info(
Expand Down
58 changes: 51 additions & 7 deletions src/optimizer/solver.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from ortools.sat.python import cp_model_helper

from optimizer.config import Config
from optimizer.errors import InvalidArgument

SUPPORTED_PARAMETERS = frozenset(
{
Expand All @@ -26,19 +27,27 @@ def prepare_parameters(
fields = {field.name for field, _ in requested.ListFields()}
unsupported = fields - SUPPORTED_PARAMETERS
if unsupported:
raise ValueError(f"unsupported parameters: {', '.join(sorted(unsupported))}")
names = ", ".join(sorted(unsupported))
message = f"unsupported parameters: {names}"
raise InvalidArgument(
message,
"UNSUPPORTED_PARAMETERS",
metadata={"parameters": names},
violations={f"parameters.{name}": message for name in sorted(unsupported)},
)
for name in ("max_time_in_seconds", "max_deterministic_time"):
if requested.HasField(name):
value = getattr(requested, name)
if not math.isfinite(value) or value <= 0:
raise ValueError(f"{name} must be finite and greater than zero")
_invalid_parameter(name, value, "finite and greater than zero")
for name in ("relative_gap_limit", "absolute_gap_limit"):
value = getattr(requested, name)
if not math.isfinite(value) or value < 0:
raise ValueError(f"{name} must be finite and nonnegative")
_invalid_parameter(name, value, "finite and nonnegative")
for name in ("num_workers", "random_seed"):
if getattr(requested, name) < 0:
raise ValueError(f"{name} must be nonnegative")
value = getattr(requested, name)
if value < 0:
_invalid_parameter(name, value, "nonnegative")

parameters = sat_parameters_pb2.SatParameters()
parameters.CopyFrom(requested)
Expand All @@ -50,6 +59,16 @@ def prepare_parameters(
return parameters


def _invalid_parameter(name: str, value: float | int, requirement: str) -> None:
message = f"{name} must be {requirement}"
raise InvalidArgument(
message,
"INVALID_PARAMETER",
metadata={"parameter": name, "value": str(value), "requirement": requirement},
violations={f"parameters.{name}": message},
)


class Solver:
def __init__(
self,
Expand All @@ -60,12 +79,22 @@ def __init__(
# through the exposed text-format API.
self._model = cp_model_helper.CpModelProto()
if not self._model.parse_text_format(text_format.MessageToString(model)):
raise ValueError("model could not be parsed by OR-Tools")
message = "model could not be parsed by OR-Tools"
raise InvalidArgument(
message,
"MODEL_PARSE_ERROR",
violations={"model": message},
)
self._parameters = cp_model_helper.SatParameters()
if not self._parameters.parse_text_format(
text_format.MessageToString(parameters)
):
raise ValueError("parameters could not be parsed by OR-Tools")
message = "parameters could not be parsed by OR-Tools"
raise InvalidArgument(
message,
"PARAMETERS_PARSE_ERROR",
violations={"parameters": message},
)
# Construct the wrapper before registering cancellation. StopSearch then
# also applies when the RPC ends immediately before Solve starts.
self._wrapper = cp_model_helper.SolveWrapper()
Expand All @@ -84,4 +113,19 @@ def solve(self, remaining_seconds: float) -> cp_model_pb2.CpSolverResponse:
self._finished.set()
result = cp_model_pb2.CpSolverResponse()
text_format.Parse(str(native_result), result)
if result.status == cp_model_pb2.MODEL_INVALID:
# CP-SAT uses MODEL_INVALID for parameter errors too. Only name the
# model field when independent model validation confirms it is bad.
model_error = cp_model_helper.CpSatHelper.validate_model(self._model)
if model_error:
raise InvalidArgument(
"The model is invalid. Correct its field violations.",
"MODEL_INVALID",
violations={"model": model_error},
)
raise InvalidArgument(
"The solve input is invalid. Check the model and solver parameters.",
"INVALID_INPUT",
metadata={"validationError": result.solution_info},
)
return result
Loading
Loading