Skip to content

Commit 79d6080

Browse files
authored
🎨 web-server: speeds up calls to service/{key}/version/{version}/input:match by adding a cache mechanism (#7802)
1 parent 3b558ec commit 79d6080

File tree

5 files changed

+133
-84
lines changed

5 files changed

+133
-84
lines changed

services/web/server/src/simcore_service_webserver/catalog/_catalog_rest_client_service.py

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@
22

33
import logging
44
import urllib.parse
5-
from collections.abc import Iterator
5+
from collections.abc import Callable, Iterator
66
from contextlib import contextmanager
7-
from typing import Any
7+
from typing import Any, Final
88

9+
from aiocache import Cache, cached # type: ignore[import-untyped]
910
from aiohttp import ClientSession, ClientTimeout, web
1011
from aiohttp.client_exceptions import (
1112
ClientConnectionError,
@@ -15,7 +16,9 @@
1516
from models_library.api_schemas_catalog.service_access_rights import (
1617
ServiceAccessRightsGet,
1718
)
19+
from models_library.products import ProductName
1820
from models_library.services_resources import ServiceResourcesDict
21+
from models_library.services_types import ServiceKey, ServiceVersion
1922
from models_library.users import UserID
2023
from pydantic import TypeAdapter
2124
from servicelib.aiohttp import status
@@ -29,6 +32,16 @@
2932

3033
_logger = logging.getLogger(__name__)
3134

35+
# Cache settings
36+
_SECOND = 1 # in seconds
37+
_MINUTE = 60 * _SECOND
38+
_CACHE_TTL: Final = 1 * _MINUTE
39+
40+
41+
def _create_service_cache_key(_f: Callable[..., Any], *_args, **kw):
42+
assert len(_args) == 1, f"Expected only app, got {_args}" # nosec
43+
return f"get_service_{kw['user_id']}_{kw['service_key']}_{kw['service_version']}_{kw['product_name']}"
44+
3245

3346
@contextmanager
3447
def _handle_client_exceptions(app: web.Application) -> Iterator[ClientSession]:
@@ -103,12 +116,19 @@ async def get_services_for_user_in_product(
103116
return body
104117

105118

119+
@cached(
120+
ttl=_CACHE_TTL,
121+
key_builder=_create_service_cache_key,
122+
cache=Cache.MEMORY,
123+
# SEE https://github.com/ITISFoundation/osparc-simcore/pull/7802
124+
)
106125
async def get_service(
107126
app: web.Application,
127+
*,
108128
user_id: UserID,
109-
service_key: str,
110-
service_version: str,
111-
product_name: str,
129+
service_key: ServiceKey,
130+
service_version: ServiceVersion,
131+
product_name: ProductName,
112132
) -> dict[str, Any]:
113133
settings: CatalogSettings = get_plugin_settings(app)
114134
url = URL(

services/web/server/src/simcore_service_webserver/catalog/_service.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,11 @@ async def list_service_inputs(
173173
service_key: ServiceKey, service_version: ServiceVersion, ctx: CatalogRequestContext
174174
) -> list[ServiceInputGet]:
175175
service = await _catalog_rest_client_service.get_service(
176-
ctx.app, ctx.user_id, service_key, service_version, ctx.product_name
176+
ctx.app,
177+
user_id=ctx.user_id,
178+
service_key=service_key,
179+
service_version=service_version,
180+
product_name=ctx.product_name,
177181
)
178182
return [
179183
await ServiceInputGetFactory.from_catalog_service_api_model(
@@ -190,7 +194,11 @@ async def get_service_input(
190194
ctx: CatalogRequestContext,
191195
) -> ServiceInputGet:
192196
service = await _catalog_rest_client_service.get_service(
193-
ctx.app, ctx.user_id, service_key, service_version, ctx.product_name
197+
ctx.app,
198+
user_id=ctx.user_id,
199+
service_key=service_key,
200+
service_version=service_version,
201+
product_name=ctx.product_name,
194202
)
195203
service_input: ServiceInputGet = (
196204
await ServiceInputGetFactory.from_catalog_service_api_model(
@@ -249,7 +257,11 @@ async def list_service_outputs(
249257
ctx: CatalogRequestContext,
250258
) -> list[ServiceOutputGet]:
251259
service = await _catalog_rest_client_service.get_service(
252-
ctx.app, ctx.user_id, service_key, service_version, ctx.product_name
260+
ctx.app,
261+
user_id=ctx.user_id,
262+
service_key=service_key,
263+
service_version=service_version,
264+
product_name=ctx.product_name,
253265
)
254266
return [
255267
await ServiceOutputGetFactory.from_catalog_service_api_model(
@@ -266,7 +278,11 @@ async def get_service_output(
266278
ctx: CatalogRequestContext,
267279
) -> ServiceOutputGet:
268280
service = await _catalog_rest_client_service.get_service(
269-
ctx.app, ctx.user_id, service_key, service_version, ctx.product_name
281+
ctx.app,
282+
user_id=ctx.user_id,
283+
service_key=service_key,
284+
service_version=service_version,
285+
product_name=ctx.product_name,
270286
)
271287
return cast( # mypy -> aiocache is not typed.
272288
ServiceOutputGet,

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1682,7 +1682,11 @@ async def is_service_deprecated(
16821682
product_name: str,
16831683
) -> bool:
16841684
service = await catalog_service.get_service(
1685-
app, user_id, service_key, service_version, product_name
1685+
app,
1686+
user_id=user_id,
1687+
service_key=service_key,
1688+
service_version=service_version,
1689+
product_name=product_name,
16861690
)
16871691
if deprecation_date := service.get("deprecated"):
16881692
deprecation_date_bool: bool = datetime.datetime.now(

services/web/server/src/simcore_service_webserver/resource_usage/_pricing_plans_admin_service.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,11 @@ async def connect_service_to_pricing_plan(
164164
) -> PricingPlanToServiceGet:
165165
# Check whether service key and version exists
166166
await catalog_service.get_service(
167-
app, user_id, service_key, service_version, product_name
167+
app,
168+
user_id=user_id,
169+
service_key=service_key,
170+
service_version=service_version,
171+
product_name=product_name,
168172
)
169173

170174
rpc_client = get_rabbitmq_rpc_client(app)

services/web/server/tests/unit/with_dbs/01/test_catalog_handlers__services.py

Lines changed: 78 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import re
66
import urllib.parse
7+
from typing import Any
78

89
import pytest
910
from aiohttp.test_utils import TestClient
@@ -68,6 +69,30 @@ def mocked_catalog_rpc_api(mocker: MockerFixture) -> dict[str, MockType]:
6869
}
6970

7071

72+
@pytest.fixture
73+
def mocked_catalog_rest_api(aioresponses_mocker: AioResponsesMock) -> dict[str, Any]:
74+
"""Fixture that mocks catalog service responses for tests"""
75+
url_pattern = re.compile(r"http://catalog:8000/v0/services/.*")
76+
service_payload = ServiceGetV2.model_json_schema()["examples"][0]
77+
78+
# Mock multiple responses as needed by tests
79+
for _ in range(6): # Increased to accommodate all tests
80+
aioresponses_mocker.get(
81+
url_pattern,
82+
status=status.HTTP_200_OK,
83+
payload=service_payload,
84+
)
85+
86+
service_key = "simcore/services/comp/itis/sleeper"
87+
service_version = "0.1.0"
88+
89+
return {
90+
"service_key": service_key,
91+
"service_version": service_version,
92+
"service_payload": service_payload,
93+
}
94+
95+
7196
@pytest.mark.parametrize(
7297
"user_role",
7398
[UserRole.USER],
@@ -101,20 +126,16 @@ async def test_list_services_latest(
101126
[UserRole.USER],
102127
)
103128
async def test_list_inputs(
104-
client: TestClient, logged_user: UserInfoDict, aioresponses_mocker: AioResponsesMock
129+
client: TestClient,
130+
logged_user: UserInfoDict,
131+
mocked_catalog_rest_api: dict[str, Any],
105132
):
133+
assert client.app
134+
assert client.app.router
106135

107-
url_pattern = re.compile(r"http://catalog:8000/v0/services/.*")
108-
service_payload = ServiceGetV2.model_json_schema()["examples"][0]
109-
aioresponses_mocker.get(
110-
url_pattern,
111-
status=status.HTTP_200_OK,
112-
payload=service_payload,
113-
)
136+
service_key = mocked_catalog_rest_api["service_key"]
137+
service_version = mocked_catalog_rest_api["service_version"]
114138

115-
service_key = "simcore/services/comp/itis/sleeper"
116-
service_version = "0.1.0"
117-
assert client.app and client.app.router
118139
url = client.app.router["list_service_inputs"].url_for(
119140
service_key=urllib.parse.quote(service_key, safe=""),
120141
service_version=service_version,
@@ -130,20 +151,16 @@ async def test_list_inputs(
130151
[UserRole.USER],
131152
)
132153
async def test_list_outputs(
133-
client: TestClient, logged_user: UserInfoDict, aioresponses_mocker: AioResponsesMock
154+
client: TestClient,
155+
logged_user: UserInfoDict,
156+
mocked_catalog_rest_api: dict[str, Any],
134157
):
158+
assert client.app
159+
assert client.app.router
135160

136-
url_pattern = re.compile(r"http://catalog:8000/v0/services/.*")
137-
service_payload = ServiceGetV2.model_json_schema()["examples"][0]
138-
aioresponses_mocker.get(
139-
url_pattern,
140-
status=status.HTTP_200_OK,
141-
payload=service_payload,
142-
)
161+
service_key = mocked_catalog_rest_api["service_key"]
162+
service_version = mocked_catalog_rest_api["service_version"]
143163

144-
service_key = "simcore/services/comp/itis/sleeper"
145-
service_version = "0.1.0"
146-
assert client.app and client.app.router
147164
url = client.app.router["list_service_outputs"].url_for(
148165
service_key=urllib.parse.quote(service_key, safe=""),
149166
service_version=service_version,
@@ -159,20 +176,17 @@ async def test_list_outputs(
159176
[UserRole.USER],
160177
)
161178
async def test_get_outputs(
162-
client: TestClient, logged_user: UserInfoDict, aioresponses_mocker: AioResponsesMock
179+
client: TestClient,
180+
logged_user: UserInfoDict,
181+
mocked_catalog_rest_api: dict[str, Any],
163182
):
183+
assert client.app
184+
assert client.app.router
164185

165-
url_pattern = re.compile(r"http://catalog:8000/v0/services/.*")
166-
service_payload = ServiceGetV2.model_json_schema()["examples"][0]
167-
aioresponses_mocker.get(
168-
url_pattern,
169-
status=status.HTTP_200_OK,
170-
payload=service_payload,
171-
)
186+
service_key = mocked_catalog_rest_api["service_key"]
187+
service_version = mocked_catalog_rest_api["service_version"]
188+
service_payload = mocked_catalog_rest_api["service_payload"]
172189

173-
service_key = "simcore/services/comp/itis/sleeper"
174-
service_version = "0.1.0"
175-
assert client.app and client.app.router
176190
url = client.app.router["get_service_output"].url_for(
177191
service_key=urllib.parse.quote(service_key, safe=""),
178192
service_version=service_version,
@@ -189,19 +203,17 @@ async def test_get_outputs(
189203
[UserRole.USER],
190204
)
191205
async def test_get_inputs(
192-
client: TestClient, logged_user: UserInfoDict, aioresponses_mocker: AioResponsesMock
206+
client: TestClient,
207+
logged_user: UserInfoDict,
208+
mocked_catalog_rest_api: dict[str, Any],
193209
):
194-
url_pattern = re.compile(r"http://catalog:8000/v0/services/.*")
195-
service_payload = ServiceGetV2.model_json_schema()["examples"][0]
196-
aioresponses_mocker.get(
197-
url_pattern,
198-
status=status.HTTP_200_OK,
199-
payload=service_payload,
200-
)
210+
assert client.app
211+
assert client.app.router
212+
213+
service_key = mocked_catalog_rest_api["service_key"]
214+
service_version = mocked_catalog_rest_api["service_version"]
215+
service_payload = mocked_catalog_rest_api["service_payload"]
201216

202-
service_key = "simcore/services/comp/itis/sleeper"
203-
service_version = "0.1.0"
204-
assert client.app and client.app.router
205217
url = client.app.router["get_service_input"].url_for(
206218
service_key=urllib.parse.quote(service_key, safe=""),
207219
service_version=service_version,
@@ -217,20 +229,17 @@ async def test_get_inputs(
217229
[UserRole.USER],
218230
)
219231
async def test_get_compatible_inputs_given_source_outputs(
220-
client: TestClient, logged_user: UserInfoDict, aioresponses_mocker: AioResponsesMock
232+
client: TestClient,
233+
logged_user: UserInfoDict,
234+
mocked_catalog_rest_api: dict[str, Any],
221235
):
222-
url_pattern = re.compile(r"http://catalog:8000/v0/services/.*")
223-
service_payload = ServiceGetV2.model_json_schema()["examples"][0]
224-
for _ in range(2):
225-
aioresponses_mocker.get(
226-
url_pattern,
227-
status=status.HTTP_200_OK,
228-
payload=service_payload,
229-
)
236+
assert client.app
237+
assert client.app.router
230238

231-
service_key = "simcore/services/comp/itis/sleeper"
232-
service_version = "0.1.0"
233-
assert client.app and client.app.router
239+
service_key = mocked_catalog_rest_api["service_key"]
240+
service_version = mocked_catalog_rest_api["service_version"]
241+
242+
# Get compatible inputs given source outputs
234243
url = (
235244
client.app.router["get_compatible_inputs_given_source_output"]
236245
.url_for(
@@ -239,8 +248,8 @@ async def test_get_compatible_inputs_given_source_outputs(
239248
)
240249
.with_query(
241250
{
242-
"fromService": "simcore/services/comp/itis/sleeper",
243-
"fromVersion": "0.1.0",
251+
"fromService": service_key,
252+
"fromVersion": service_version,
244253
"fromOutput": "output_1",
245254
}
246255
)
@@ -253,21 +262,17 @@ async def test_get_compatible_inputs_given_source_outputs(
253262
"user_role",
254263
[UserRole.USER],
255264
)
256-
async def test_get_compatible_outputs_given_target_inptuts(
257-
client: TestClient, logged_user: UserInfoDict, aioresponses_mocker: AioResponsesMock
265+
async def test_get_compatible_outputs_given_target_inputs(
266+
client: TestClient,
267+
logged_user: UserInfoDict,
268+
mocked_catalog_rest_api: dict[str, Any],
258269
):
259-
url_pattern = re.compile(r"http://catalog:8000/v0/services/.*")
260-
service_payload = ServiceGetV2.model_json_schema()["examples"][0]
261-
for _ in range(2):
262-
aioresponses_mocker.get(
263-
url_pattern,
264-
status=status.HTTP_200_OK,
265-
payload=service_payload,
266-
)
270+
assert client.app
271+
assert client.app.router
272+
273+
service_key = mocked_catalog_rest_api["service_key"]
274+
service_version = mocked_catalog_rest_api["service_version"]
267275

268-
service_key = "simcore/services/comp/itis/sleeper"
269-
service_version = "0.1.0"
270-
assert client.app and client.app.router
271276
url = (
272277
client.app.router["get_compatible_outputs_given_target_input"]
273278
.url_for(
@@ -276,8 +281,8 @@ async def test_get_compatible_outputs_given_target_inptuts(
276281
)
277282
.with_query(
278283
{
279-
"toService": "simcore/services/comp/itis/sleeper",
280-
"toVersion": "0.1.0",
284+
"toService": service_key,
285+
"toVersion": service_version,
281286
"toInput": "input_1",
282287
}
283288
)

0 commit comments

Comments
 (0)