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
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@ class ExecutorSpec:
config: dict[str, Any]


@dataclass(frozen=True)
class UnavailableExecutor:
"""A configured executor that was skipped because its backend is unavailable."""

backend: str
reason: str


class ExecutorNotFoundError(KeyError):
"""Raised when no executor matches the requested name."""

Expand All @@ -42,9 +50,16 @@ class UnknownBackendTypeError(KeyError):
class ExecutorRegistry:
"""Maps executor names to configured DeploymentBackend singletons."""

def __init__(self, executors: dict[str, DeploymentBackend], *, default_executor: str | None) -> None:
def __init__(
self,
executors: dict[str, DeploymentBackend],
*,
default_executor: str | None,
unavailable: dict[str, UnavailableExecutor] | None = None,
) -> None:
self._executors = executors
self._default_executor = default_executor
self._unavailable = unavailable or {}

@classmethod
def from_config(
Expand All @@ -59,6 +74,7 @@ def from_config(
if len({spec.name for spec in specs}) != len(specs):
raise ValueError("Duplicate executor names are not allowed.")
executors: dict[str, DeploymentBackend] = {}
unavailable: dict[str, UnavailableExecutor] = {}
try:
for spec in specs:
if spec.backend not in classes:
Expand All @@ -68,10 +84,11 @@ def from_config(
except MissingBackendDependencyError as exc:
# Capability missing (optional packaging extra, unreachable Docker
# daemon, etc.): skip just that executor so the deployments service
# can still boot. Resolving a skipped name later raises
# ExecutorNotFoundError. A configured default_executor that failed
# to register still fails fast below — silent clearing hid config
# mistakes and made debugging harder.
# can still boot. Remember it so resolving the name later explains
# the missing backend instead of looking like a typo. A configured
# default_executor that failed to register still fails fast below;
# silent clearing hid config mistakes and made debugging harder.
unavailable[spec.name] = UnavailableExecutor(backend=spec.backend, reason=str(exc))
logger.warning(
"Skipping executor '%s': backend '%s' is unavailable (%s)",
spec.name,
Expand All @@ -87,7 +104,7 @@ def from_config(
for backend in executors.values():
backend.shutdown()
raise
return cls(executors, default_executor=default_executor)
return cls(executors, default_executor=default_executor, unavailable=unavailable)

@classmethod
def empty(cls) -> Self:
Expand All @@ -99,6 +116,12 @@ def resolve(self, name: str | None = None) -> DeploymentBackend:
if executor_name is None:
raise ExecutorNotFoundError("No executor specified and no default_executor configured.")
if executor_name not in self._executors:
skipped = self._unavailable.get(executor_name)
if skipped is not None:
raise ExecutorNotFoundError(
f"Executor '{executor_name}' is configured but its backend "
f"'{skipped.backend}' is unavailable: {skipped.reason}"
)
raise ExecutorNotFoundError(f"Executor '{executor_name}' is not registered.")
return self._executors[executor_name]

Expand Down
28 changes: 27 additions & 1 deletion plugins/nemo-deployments/tests/unit/test_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,10 +115,36 @@ def test_missing_executor_raises(backend_classes: dict[str, type[DeploymentBacke
[ExecutorSpec(name="a", backend="docker", config={})],
backend_classes=backend_classes,
)
with pytest.raises(ExecutorNotFoundError):
with pytest.raises(ExecutorNotFoundError, match="'missing' is not registered"):
registry.resolve("missing")


def test_unavailable_executor_reports_distinct_error(
backend_classes: dict[str, type[DeploymentBackend]],
) -> None:
# A configured executor whose backend was skipped must resolve to a distinct,
# actionable error that names the backend, not the generic 'not registered'
# message used for a genuinely unknown name.
sdk = AsyncNeMoPlatform(base_url="http://localhost:8080")
classes = {**backend_classes, "sandbox": _MissingDepBackend}
registry = ExecutorRegistry.from_config(
sdk,
[
ExecutorSpec(name="ok", backend="docker", config={}),
ExecutorSpec(name="sandbox-local", backend="sandbox", config={}),
],
backend_classes=classes,
)
with pytest.raises(ExecutorNotFoundError) as excinfo:
registry.resolve("sandbox-local")
message = str(excinfo.value)
assert "is not registered" not in message
assert "configured but" in message
assert "unavailable" in message
assert "sandbox" in message
assert "openshell extra not installed" in message
Comment thread
maxdubrinsky marked this conversation as resolved.


def test_unknown_backend_type_raises() -> None:
sdk = AsyncNeMoPlatform(base_url="http://localhost:8080")
with pytest.raises(UnknownBackendTypeError):
Expand Down
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,11 @@ core-services = [
# Includes core services plus all application services
functional-services = [
{ include-group = "core-services" },
# Ship the openshell deployment SDK in the nmp-api image only. The extra carries
# platform-restricted wheels (manylinux_2_39 / macOS 13 arm64) and is deliberately
# kept out of `enabled-plugins` so bare `uv sync` on unsupported hosts (older glibc,
# older macOS) does not hard-fail. The api image runs on a compatible base.
"nemo-deployments-plugin[openshell]",
"nmp-studio",
"nmp-guardrails",
"nemo-data-designer-plugin",
Expand Down
3 changes: 2 additions & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading