Skip to content

Commit d4e6096

Browse files
authored
🐛 Fixes cache issue in web-server services i/o model (#6176)
1 parent 83fa6fe commit d4e6096

File tree

6 files changed

+70
-96
lines changed

6 files changed

+70
-96
lines changed

services/web/server/requirements/_base.in

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ aiohttp-swagger[performance]
3030
aiopg[sa] # db
3131
aiosmtplib # email
3232
asyncpg # db
33-
cachetools # caching for sync functions
3433
captcha
3534
cryptography # security
3635
faker # Only used in dev-mode for proof-of-concepts

services/web/server/requirements/_base.txt

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,8 +101,6 @@ attrs==21.4.0
101101
# openapi-core
102102
bidict==0.22.0
103103
# via python-socketio
104-
cachetools==5.3.2
105-
# via -r requirements/_base.in
106104
captcha==0.5.0
107105
# via -r requirements/_base.in
108106
certifi==2023.7.22

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

Lines changed: 20 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import asyncio
21
import logging
32
from collections.abc import Iterator
43
from typing import Any
@@ -66,8 +65,7 @@ async def _safe_replace_service_input_outputs(
6665
service: dict[str, Any], unit_registry: UnitRegistry
6766
):
6867
try:
69-
await asyncio.to_thread(
70-
replace_service_input_outputs,
68+
await replace_service_input_outputs(
7169
service,
7270
unit_registry=unit_registry,
7371
**RESPONSE_MODEL_POLICY,
@@ -133,8 +131,7 @@ async def dev_get_service(
133131
)
134132

135133
data = jsonable_encoder(service, exclude_unset=True)
136-
await asyncio.to_thread(
137-
replace_service_input_outputs,
134+
await replace_service_input_outputs(
138135
data,
139136
unit_registry=unit_registry,
140137
**RESPONSE_MODEL_POLICY,
@@ -163,8 +160,7 @@ async def dev_update_service(
163160
)
164161

165162
data = jsonable_encoder(service, exclude_unset=True)
166-
await asyncio.to_thread(
167-
replace_service_input_outputs,
163+
await replace_service_input_outputs(
168164
data,
169165
unit_registry=unit_registry,
170166
**RESPONSE_MODEL_POLICY,
@@ -194,8 +190,7 @@ async def get_service(
194190
service = await client.get_service(
195191
ctx.app, ctx.user_id, service_key, service_version, ctx.product_name
196192
)
197-
await asyncio.to_thread(
198-
replace_service_input_outputs,
193+
await replace_service_input_outputs(
199194
service,
200195
unit_registry=ctx.unit_registry,
201196
**RESPONSE_MODEL_POLICY,
@@ -217,8 +212,7 @@ async def update_service(
217212
ctx.product_name,
218213
update_data,
219214
)
220-
await asyncio.to_thread(
221-
replace_service_input_outputs,
215+
await replace_service_input_outputs(
222216
service,
223217
unit_registry=ctx.unit_registry,
224218
**RESPONSE_MODEL_POLICY,
@@ -232,13 +226,12 @@ async def list_service_inputs(
232226
service = await client.get_service(
233227
ctx.app, ctx.user_id, service_key, service_version, ctx.product_name
234228
)
235-
inputs = []
236-
for input_key in service["inputs"]:
237-
service_input: ServiceInputGet = (
238-
ServiceInputGetFactory.from_catalog_service_api_model(service, input_key)
229+
return [
230+
await ServiceInputGetFactory.from_catalog_service_api_model(
231+
service=service, input_key=input_key
239232
)
240-
inputs.append(service_input)
241-
return inputs
233+
for input_key in service["inputs"]
234+
]
242235

243236

244237
async def get_service_input(
@@ -251,7 +244,9 @@ async def get_service_input(
251244
ctx.app, ctx.user_id, service_key, service_version, ctx.product_name
252245
)
253246
service_input: ServiceInputGet = (
254-
ServiceInputGetFactory.from_catalog_service_api_model(service, input_key)
247+
await ServiceInputGetFactory.from_catalog_service_api_model(
248+
service=service, input_key=input_key
249+
)
255250
)
256251

257252
return service_input
@@ -307,14 +302,12 @@ async def list_service_outputs(
307302
service = await client.get_service(
308303
ctx.app, ctx.user_id, service_key, service_version, ctx.product_name
309304
)
310-
311-
outputs = []
312-
for output_key in service["outputs"]:
313-
service_output = ServiceOutputGetFactory.from_catalog_service_api_model(
314-
service, output_key, None
305+
return [
306+
await ServiceOutputGetFactory.from_catalog_service_api_model(
307+
service=service, output_key=output_key, ureg=None
315308
)
316-
outputs.append(service_output)
317-
return outputs
309+
for output_key in service["outputs"]
310+
]
318311

319312

320313
async def get_service_output(
@@ -326,12 +319,10 @@ async def get_service_output(
326319
service = await client.get_service(
327320
ctx.app, ctx.user_id, service_key, service_version, ctx.product_name
328321
)
329-
service_output: ServiceOutputGet = (
330-
ServiceOutputGetFactory.from_catalog_service_api_model(service, output_key)
322+
return await ServiceOutputGetFactory.from_catalog_service_api_model(
323+
service=service, output_key=output_key
331324
)
332325

333-
return service_output
334-
335326

336327
async def get_compatible_outputs_given_target_input(
337328
service_key: ServiceKey,

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

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,5 @@
11
from typing import Any
22

3-
from models_library.api_schemas_webserver.catalog import (
4-
ServiceInputGet,
5-
ServiceOutputGet,
6-
)
73
from models_library.services import BaseServiceIOModel, ServiceInput, ServiceOutput
84
from pint import PintError, UnitRegistry
95

@@ -37,28 +33,33 @@ def _can_convert_units(from_unit: str, to_unit: str, ureg: UnitRegistry) -> bool
3733
return can
3834

3935

40-
def replace_service_input_outputs(
36+
async def replace_service_input_outputs(
4137
service: dict[str, Any],
4238
*,
4339
unit_registry: UnitRegistry | None = None,
4440
**export_options,
4541
):
4642
"""Thin wrapper to replace i/o ports in returned service model"""
4743
# This is a fast solution until proper models are available for the web API
48-
for input_key in service["inputs"]:
49-
new_input: ServiceInputGet = (
50-
ServiceInputGetFactory.from_catalog_service_api_model(
51-
service, input_key, unit_registry
52-
)
44+
new_inputs = [
45+
await ServiceInputGetFactory.from_catalog_service_api_model(
46+
service=service, input_key=input_key, ureg=unit_registry
5347
)
54-
service["inputs"][input_key] = new_input.dict(**export_options)
48+
for input_key in service["inputs"]
49+
]
5550

56-
for output_key in service["outputs"]:
57-
new_output: ServiceOutputGet = (
58-
ServiceOutputGetFactory.from_catalog_service_api_model(
59-
service, output_key, unit_registry
60-
)
51+
new_outputs = [
52+
await ServiceOutputGetFactory.from_catalog_service_api_model(
53+
service=service, output_key=output_key, ureg=unit_registry
6154
)
55+
for output_key in service["outputs"]
56+
]
57+
58+
# replace if above is successful
59+
for input_key, new_input in zip(service["inputs"], new_inputs, strict=True):
60+
service["inputs"][input_key] = new_input.dict(**export_options)
61+
62+
for output_key, new_output in zip(service["outputs"], new_outputs, strict=True):
6263
service["outputs"][output_key] = new_output.dict(**export_options)
6364

6465

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

Lines changed: 26 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import logging
2-
import os
32
from dataclasses import dataclass
4-
from typing import Any, Final
3+
from typing import Any, Callable, Final
54

6-
import cachetools
5+
from aiocache import cached
76
from models_library.api_schemas_webserver.catalog import (
87
ServiceInputGet,
98
ServiceInputKey,
@@ -54,43 +53,28 @@ def get_html_formatted_unit(
5453
#
5554
# Transforms from catalog api models -> webserver api models
5655
#
57-
58-
59-
# Caching: https://cachetools.readthedocs.io/en/latest/index.html#cachetools.TTLCache
60-
# - the least recently used items will be discarded first to make space when necessary.
56+
# Uses aiocache (async) instead of cachetools (sync) in order to handle concurrency better
57+
# SEE https://github.com/ITISFoundation/osparc-simcore/pull/6169
6158
#
59+
_SECOND = 1 # in seconds
60+
_MINUTE = 60 * _SECOND
61+
_CACHE_TTL: Final = 1 * _MINUTE
6262

63-
_CACHE_MAXSIZE: Final = int(
64-
os.getenv("CACHETOOLS_CACHE_MAXSIZE", "100")
65-
) # number of items i.e. ServiceInputGet/ServiceOutputGet instances
66-
_CACHE_TTL: Final = int(os.getenv("CACHETOOLS_CACHE_TTL_SECS", "60")) # secs
67-
68-
69-
def _hash_inputs(
70-
service: dict[str, Any],
71-
input_key: str,
72-
*args, # noqa: ARG001 # pylint: disable=unused-argument
73-
**kwargs, # noqa: ARG001 # pylint: disable=unused-argument
74-
):
75-
return f"{service['key']}/{service['version']}/{input_key}"
76-
77-
78-
def _cachetools_cached(*args, **kwargs):
79-
def decorator(func):
80-
if os.getenv("CACHETOOLS_DISABLE", "0") == "0":
81-
return cachetools.cached(*args, **kwargs)(func)
82-
_logger.warning("cachetools disabled")
83-
return func
8463

85-
return decorator
64+
def _hash_inputs(_f: Callable[..., Any], *_args, **kw):
65+
assert not _args # nosec
66+
service: dict[str, Any] = kw["service"]
67+
return f"ServiceInputGetFactory_{service['key']}_{service['version']}_{kw['input_key']}"
8668

8769

8870
class ServiceInputGetFactory:
8971
@staticmethod
90-
@_cachetools_cached(
91-
cachetools.TTLCache(ttl=_CACHE_TTL, maxsize=_CACHE_MAXSIZE), key=_hash_inputs
72+
@cached(
73+
ttl=_CACHE_TTL,
74+
key_builder=_hash_inputs,
9275
)
93-
def from_catalog_service_api_model(
76+
async def from_catalog_service_api_model(
77+
*,
9478
service: dict[str, Any],
9579
input_key: ServiceInputKey,
9680
ureg: UnitRegistry | None = None,
@@ -110,29 +94,27 @@ def from_catalog_service_api_model(
11094
return port
11195

11296

113-
def _hash_outputs(
114-
service: dict[str, Any],
115-
output_key: str,
116-
*args, # noqa: ARG001 # pylint: disable=unused-argument
117-
**kwargs, # noqa: ARG001 # pylint: disable=unused-argument
118-
):
119-
return f"{service['key']}/{service['version']}/{output_key}"
97+
def _hash_outputs(_f: Callable[..., Any], *_args, **kw):
98+
assert not _args # nosec
99+
service: dict[str, Any] = kw["service"]
100+
return f"ServiceOutputGetFactory_{service['key']}/{service['version']}/{kw['output_key']}"
120101

121102

122103
class ServiceOutputGetFactory:
123104
@staticmethod
124-
@_cachetools_cached(
125-
cachetools.TTLCache(ttl=_CACHE_TTL, maxsize=_CACHE_MAXSIZE), key=_hash_outputs
105+
@cached(
106+
ttl=_CACHE_TTL,
107+
key_builder=_hash_outputs,
126108
)
127-
def from_catalog_service_api_model(
109+
async def from_catalog_service_api_model(
110+
*,
128111
service: dict[str, Any],
129112
output_key: ServiceOutputKey,
130113
ureg: UnitRegistry | None = None,
131114
) -> ServiceOutputGet:
132115
data = service["outputs"][output_key]
133116
# NOTE: prunes invalid field that might have remained in database
134-
if "defaultValue" in data:
135-
data.pop("defaultValue")
117+
data.pop("defaultValue", None)
136118

137119
# NOTE: this call must be validated if port property type is "ref_contentSchema"
138120
port = ServiceOutputGet(key_id=output_key, **data)

services/web/server/tests/unit/isolated/test_catalog_models.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
# pylint: disable=unused-argument
33
# pylint: disable=unused-variable
44

5+
import asyncio
56
import json
67
from copy import deepcopy
78

@@ -69,14 +70,16 @@ def test_from_catalog_to_webapi_service(
6970
"owner": "[email protected]",
7071
}
7172

72-
def _run():
73+
def _run_async_test():
7374
s = deepcopy(catalog_service)
74-
replace_service_input_outputs(
75-
s, unit_registry=unit_registry, **RESPONSE_MODEL_POLICY
75+
asyncio.get_event_loop().run_until_complete(
76+
replace_service_input_outputs(
77+
s, unit_registry=unit_registry, **RESPONSE_MODEL_POLICY
78+
)
7679
)
7780
return s
7881

79-
result = benchmark(_run)
82+
result = benchmark(_run_async_test)
8083

8184
# check result
8285
got = json.dumps(result, indent=1)

0 commit comments

Comments
 (0)