Skip to content

Commit 0538066

Browse files
GitHKAndrei Neagu
and
Andrei Neagu
authored
✨ dynamic-services will fail if they have any required input that is not set (#5845)
Co-authored-by: Andrei Neagu <[email protected]>
1 parent 00b0944 commit 0538066

File tree

4 files changed

+237
-2
lines changed

4 files changed

+237
-2
lines changed

services/web/server/src/simcore_service_webserver/projects/_nodes_handlers.py

+3
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474
DefaultPricingUnitNotFoundError,
7575
NodeNotFoundError,
7676
ProjectInvalidRightsError,
77+
ProjectNodeRequiredInputsNotSetError,
7778
ProjectNodeResourcesInsufficientRightsError,
7879
ProjectNodeResourcesInvalidError,
7980
ProjectNotFoundError,
@@ -105,6 +106,8 @@ async def wrapper(request: web.Request) -> web.StreamResponse:
105106
raise web.HTTPConflict(reason=f"{exc}") from exc
106107
except ClustersKeeperNotAvailableError as exc:
107108
raise web.HTTPServiceUnavailable(reason=f"{exc}") from exc
109+
except ProjectNodeRequiredInputsNotSetError as exc:
110+
raise web.HTTPConflict(reason=f"{exc}") from exc
108111

109112
return wrapper
110113

services/web/server/src/simcore_service_webserver/projects/exceptions.py

+46
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import redis.exceptions
66
from models_library.projects import ProjectID
7+
from models_library.projects_nodes_io import NodeID
78
from models_library.users import UserID
89

910
from ..errors import WebServerBaseError
@@ -136,6 +137,51 @@ class ProjectNodeResourcesInsufficientRightsError(BaseProjectError):
136137
...
137138

138139

140+
class ProjectNodeRequiredInputsNotSetError(BaseProjectError):
141+
...
142+
143+
144+
class ProjectNodeConnectionsMissingError(ProjectNodeRequiredInputsNotSetError):
145+
msg_template = "Missing '{joined_unset_required_inputs}' connection(s) to '{node_with_required_inputs}'"
146+
147+
def __init__(
148+
self,
149+
*,
150+
unset_required_inputs: list[str],
151+
node_with_required_inputs: NodeID,
152+
**ctx,
153+
):
154+
super().__init__(
155+
joined_unset_required_inputs=", ".join(unset_required_inputs),
156+
unset_required_inputs=unset_required_inputs,
157+
node_with_required_inputs=node_with_required_inputs,
158+
**ctx,
159+
)
160+
self.unset_required_inputs = unset_required_inputs
161+
self.node_with_required_inputs = node_with_required_inputs
162+
163+
164+
class ProjectNodeOutputPortMissingValueError(ProjectNodeRequiredInputsNotSetError):
165+
msg_template = "Missing: {joined_start_message}"
166+
167+
def __init__(
168+
self,
169+
*,
170+
unset_outputs_in_upstream: list[tuple[str, str]],
171+
**ctx,
172+
):
173+
start_messages = [
174+
f"'{input_key}' of '{service_name}'"
175+
for input_key, service_name in unset_outputs_in_upstream
176+
]
177+
super().__init__(
178+
joined_start_message=", ".join(start_messages),
179+
unset_outputs_in_upstream=unset_outputs_in_upstream,
180+
**ctx,
181+
)
182+
self.unset_outputs_in_upstream = unset_outputs_in_upstream
183+
184+
139185
class DefaultPricingUnitNotFoundError(BaseProjectError):
140186
msg_template = "Default pricing unit not found for node '{node_uuid}' in project '{project_uuid}'"
141187

services/web/server/src/simcore_service_webserver/projects/projects_api.py

+71-2
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@
3434
from models_library.errors import ErrorDict
3535
from models_library.products import ProductName
3636
from models_library.projects import Project, ProjectID, ProjectIDStr
37-
from models_library.projects_nodes import Node
38-
from models_library.projects_nodes_io import NodeID, NodeIDStr
37+
from models_library.projects_nodes import Node, OutputsDict
38+
from models_library.projects_nodes_io import NodeID, NodeIDStr, PortLink
3939
from models_library.projects_state import (
4040
Owner,
4141
ProjectLocked,
@@ -124,6 +124,9 @@
124124
NodeNotFoundError,
125125
ProjectInvalidRightsError,
126126
ProjectLockError,
127+
ProjectNodeConnectionsMissingError,
128+
ProjectNodeOutputPortMissingValueError,
129+
ProjectNodeRequiredInputsNotSetError,
127130
ProjectNodeResourcesInvalidError,
128131
ProjectOwnerNotFoundInTheProjectAccessRightsError,
129132
ProjectStartsTooManyDynamicNodesError,
@@ -447,6 +450,56 @@ def _by_type_name(ec2: EC2InstanceTypeGet) -> bool:
447450
raise ClustersKeeperNotAvailableError from exc
448451

449452

453+
async def _check_project_node_has_all_required_inputs(
454+
db: ProjectDBAPI, user_id: UserID, project_uuid: ProjectID, node_id: NodeID
455+
) -> None:
456+
457+
project_dict, _ = await db.get_project(user_id, f"{project_uuid}")
458+
459+
nodes_map: dict[NodeID, Node] = {
460+
NodeID(k): Node(**v) for k, v in project_dict["workbench"].items()
461+
}
462+
node = nodes_map[node_id]
463+
464+
unset_required_inputs: list[str] = []
465+
unset_outputs_in_upstream: list[tuple[str, str]] = []
466+
467+
def _check_required_input(required_input_key: str) -> None:
468+
input_entry: PortLink | None = None
469+
if node.inputs:
470+
input_entry = node.inputs.get(required_input_key, None)
471+
if input_entry is None:
472+
# NOT linked to any node connect service or set value manually(whichever applies)
473+
unset_required_inputs.append(required_input_key)
474+
return
475+
476+
source_node_id: NodeID = input_entry.node_uuid
477+
source_output_key = input_entry.output
478+
479+
source_node = nodes_map[source_node_id]
480+
481+
output_entry: OutputsDict | None = None
482+
if source_node.outputs:
483+
output_entry = source_node.outputs.get(source_output_key, None)
484+
if output_entry is None:
485+
unset_outputs_in_upstream.append((source_output_key, source_node.label))
486+
487+
for required_input in node.inputs_required:
488+
_check_required_input(required_input)
489+
490+
node_with_required_inputs = node.label
491+
if unset_required_inputs:
492+
raise ProjectNodeConnectionsMissingError(
493+
unset_required_inputs=unset_required_inputs,
494+
node_with_required_inputs=node_with_required_inputs,
495+
)
496+
497+
if unset_outputs_in_upstream:
498+
raise ProjectNodeOutputPortMissingValueError(
499+
unset_outputs_in_upstream=unset_outputs_in_upstream
500+
)
501+
502+
450503
async def _start_dynamic_service(
451504
request: web.Request,
452505
*,
@@ -456,6 +509,7 @@ async def _start_dynamic_service(
456509
user_id: UserID,
457510
project_uuid: ProjectID,
458511
node_uuid: NodeID,
512+
graceful_start: bool = False,
459513
) -> None:
460514
if not _is_node_dynamic(service_key):
461515
return
@@ -464,6 +518,20 @@ async def _start_dynamic_service(
464518

465519
db: ProjectDBAPI = ProjectDBAPI.get_from_app_context(request.app)
466520

521+
try:
522+
await _check_project_node_has_all_required_inputs(
523+
db, user_id, project_uuid, node_uuid
524+
)
525+
except ProjectNodeRequiredInputsNotSetError as e:
526+
if graceful_start:
527+
log.info(
528+
"Did not start '%s' because of missing required inputs: %s",
529+
node_uuid,
530+
e,
531+
)
532+
return
533+
raise
534+
467535
save_state = False
468536
user_role: UserRole = await get_user_role(request.app, user_id)
469537
if user_role > UserRole.GUEST:
@@ -1464,6 +1532,7 @@ async def run_project_dynamic_services(
14641532
user_id=user_id,
14651533
project_uuid=project["uuid"],
14661534
node_uuid=NodeID(service_uuid),
1535+
graceful_start=True,
14671536
)
14681537
for service_uuid, is_deprecated in zip(
14691538
services_to_start_uuids, deprecated_services, strict=True

services/web/server/tests/unit/with_dbs/03/test_project_db.py

+117
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,13 @@
3333
from simcore_service_webserver.projects.db import ProjectAccessRights, ProjectDBAPI
3434
from simcore_service_webserver.projects.exceptions import (
3535
NodeNotFoundError,
36+
ProjectNodeRequiredInputsNotSetError,
3637
ProjectNotFoundError,
3738
)
3839
from simcore_service_webserver.projects.models import ProjectDict
40+
from simcore_service_webserver.projects.projects_api import (
41+
_check_project_node_has_all_required_inputs,
42+
)
3943
from simcore_service_webserver.users.exceptions import UserNotFoundError
4044
from simcore_service_webserver.utils import to_datetime
4145
from sqlalchemy.engine.result import Row
@@ -829,3 +833,116 @@ async def test_has_permission(
829833
await db_api.has_permission(second_user["id"], project_id, permission)
830834
is access_rights[permission]
831835
), f"Found unexpected {permission=} for {access_rights=} of {user_role=} and {project_id=}"
836+
837+
838+
def _fake_output_data() -> dict:
839+
return {
840+
"store": 0,
841+
"path": "9f8207e6-144a-11ef-831f-0242ac140027/98b68cbe-9e22-4eb5-a91b-2708ad5317b7/outputs/output_2/output_2.zip",
842+
"eTag": "ec3bc734d85359b660aab400147cd1ea",
843+
}
844+
845+
846+
def _fake_connect_to(output_number: int) -> dict:
847+
return {
848+
"nodeUuid": "98b68cbe-9e22-4eb5-a91b-2708ad5317b7",
849+
"output": f"output_{output_number}",
850+
}
851+
852+
853+
@pytest.fixture
854+
async def inserted_project(
855+
logged_user: dict[str, Any],
856+
insert_project_in_db: Callable[..., Awaitable[dict[str, Any]]],
857+
fake_project: dict[str, Any],
858+
downstream_inputs: dict,
859+
downstream_required_inputs: list[str],
860+
upstream_outputs: dict,
861+
) -> dict:
862+
fake_project["workbench"] = {
863+
"98b68cbe-9e22-4eb5-a91b-2708ad5317b7": {
864+
"key": "simcore/services/dynamic/jupyter-math",
865+
"version": "2.0.10",
866+
"label": "upstream",
867+
"inputs": {},
868+
"inputsUnits": {},
869+
"inputNodes": [],
870+
"thumbnail": "",
871+
"outputs": upstream_outputs,
872+
"runHash": "c6ae58f36a2e0f65f443441ecda023a451cb1b8051d01412d79aa03653e1a6b3",
873+
},
874+
"324d6ef2-a82c-414d-9001-dc84da1cbea3": {
875+
"key": "simcore/services/dynamic/jupyter-math",
876+
"version": "2.0.10",
877+
"label": "downstream",
878+
"inputs": downstream_inputs,
879+
"inputsUnits": {},
880+
"inputNodes": ["98b68cbe-9e22-4eb5-a91b-2708ad5317b7"],
881+
"thumbnail": "",
882+
"inputsRequired": downstream_required_inputs,
883+
},
884+
}
885+
886+
return await insert_project_in_db(fake_project, user_id=logged_user["id"])
887+
888+
889+
@pytest.mark.parametrize(
890+
"downstream_inputs,downstream_required_inputs,upstream_outputs,expected_error",
891+
[
892+
pytest.param(
893+
{"input_1": _fake_connect_to(1)},
894+
["input_1", "input_2"],
895+
{},
896+
"Missing 'input_2' connection(s) to 'downstream'",
897+
id="missing_connection_on_input_2",
898+
),
899+
pytest.param(
900+
{"input_1": _fake_connect_to(1), "input_2": _fake_connect_to(2)},
901+
["input_1", "input_2"],
902+
{"output_2": _fake_output_data()},
903+
"Missing: 'output_1' of 'upstream'",
904+
id="output_1_has_not_file",
905+
),
906+
],
907+
)
908+
@pytest.mark.parametrize("user_role", [(UserRole.USER)])
909+
async def test_check_project_node_has_all_required_inputs_raises(
910+
logged_user: dict[str, Any],
911+
db_api: ProjectDBAPI,
912+
inserted_project: dict,
913+
expected_error: str,
914+
):
915+
916+
with pytest.raises(ProjectNodeRequiredInputsNotSetError) as exc:
917+
await _check_project_node_has_all_required_inputs(
918+
db_api,
919+
user_id=logged_user["id"],
920+
project_uuid=UUID(inserted_project["uuid"]),
921+
node_id=UUID("324d6ef2-a82c-414d-9001-dc84da1cbea3"),
922+
)
923+
assert f"{exc.value}" == expected_error
924+
925+
926+
@pytest.mark.parametrize(
927+
"downstream_inputs,downstream_required_inputs,upstream_outputs",
928+
[
929+
pytest.param(
930+
{"input_1": _fake_connect_to(1), "input_2": _fake_connect_to(2)},
931+
["input_1", "input_2"],
932+
{"output_1": _fake_output_data(), "output_2": _fake_output_data()},
933+
id="with_required_inputs_present",
934+
),
935+
],
936+
)
937+
@pytest.mark.parametrize("user_role", [(UserRole.USER)])
938+
async def test_check_project_node_has_all_required_inputs_ok(
939+
logged_user: dict[str, Any],
940+
db_api: ProjectDBAPI,
941+
inserted_project: dict,
942+
):
943+
await _check_project_node_has_all_required_inputs(
944+
db_api,
945+
user_id=logged_user["id"],
946+
project_uuid=UUID(inserted_project["uuid"]),
947+
node_id=UUID("324d6ef2-a82c-414d-9001-dc84da1cbea3"),
948+
)

0 commit comments

Comments
 (0)