Skip to content

Commit 8da6422

Browse files
authored
Bugfix/concurent opening projects (#1598)
adds usage of redis lock to prevent opening the same study at the same time
1 parent 153e9b2 commit 8da6422

File tree

13 files changed

+241
-99
lines changed

13 files changed

+241
-99
lines changed

services/web/client/source/class/osparc/component/message/FlashMessenger.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,10 @@ qx.Class.define("osparc.component.message.FlashMessenger", {
8989
* @param {*} logMessage.logger IDK
9090
*/
9191
log: function(logMessage) {
92-
let message = ("message" in logMessage.message) ? logMessage.message["message"] : logMessage.message;
92+
// TODO: This doesn't look cool
93+
let message = osparc.utils.Utils.isObject(logMessage.message) && "message" in logMessage.message ?
94+
logMessage.message.message :
95+
logMessage.message;
9396
const level = logMessage.level.toUpperCase(); // "DEBUG", "INFO", "WARNING", "ERROR"
9497
let logger = logMessage.logger;
9598
if (logger) {

services/web/client/source/class/osparc/utils/Utils.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,10 @@ qx.Class.define("osparc.utils.Utils", {
368368
const labels = [];
369369
args.forEach(arg => labels.push(qx.lang.String.firstUp(arg)));
370370
return labels.join(" ");
371+
},
372+
373+
isObject: function(v) {
374+
return typeof v === "object" && v !== null;
371375
}
372376
}
373377
});

services/web/server/requirements/_base.in

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ aiohttp_security
1414
aiohttp-swagger[performance]
1515
aiopg[sa]
1616
aioredis
17+
aioredlock
1718
aiosmtplib
1819
asyncpg
1920
celery

services/web/server/requirements/_base.txt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,15 @@ aiohttp-session[secure]==2.9.0 # via -r requirements/_base.in
1212
aiohttp-swagger[performance]==1.0.14 # via -r requirements/_base.in
1313
aiohttp==3.6.2 # via -r requirements/../../../../packages/service-library/requirements/_base.in, aiohttp-jinja2, aiohttp-security, aiohttp-session, aiohttp-swagger, aiozipkin
1414
aiopg[sa]==1.0.0 # via -r requirements/../../../../packages/service-library/requirements/_base.in, -r requirements/_base.in
15-
aioredis==1.3.1 # via -r requirements/_base.in
15+
aioredis==1.3.1 # via -r requirements/_base.in, aioredlock
16+
aioredlock==0.4.0 # via -r requirements/_base.in
1617
aiormq==3.2.2 # via aio-pika
1718
aiosmtplib==1.1.3 # via -r requirements/_base.in
1819
aiozipkin==0.6.0 # via -r requirements/../../../../packages/service-library/requirements/_base.in
1920
amqp==2.6.0 # via kombu
2021
async-timeout==3.0.1 # via aiohttp, aioredis
2122
asyncpg==0.20.1 # via -r requirements/_base.in
22-
attrs==19.3.0 # via -r requirements/../../../../packages/service-library/requirements/_base.in, aiohttp, jsonschema, openapi-core
23+
attrs==19.3.0 # via -r requirements/../../../../packages/service-library/requirements/_base.in, aiohttp, aioredlock, jsonschema, openapi-core
2324
billiard==3.6.3.0 # via celery
2425
celery==4.4.5 # via -r requirements/_base.in
2526
cffi==1.14.0 # via cryptography

services/web/server/requirements/_test.txt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,16 @@ aiohttp-session[secure]==2.9.0 # via -r requirements/_base.txt
1212
aiohttp-swagger[performance]==1.0.14 # via -r requirements/_base.txt
1313
aiohttp==3.6.2 # via -r requirements/_base.txt, aiohttp-jinja2, aiohttp-security, aiohttp-session, aiohttp-swagger, aiozipkin, pytest-aiohttp
1414
aiopg[sa]==1.0.0 # via -r requirements/_base.txt
15-
aioredis==1.3.1 # via -r requirements/_base.txt
15+
aioredis==1.3.1 # via -r requirements/_base.txt, aioredlock
16+
aioredlock==0.4.0 # via -r requirements/_base.txt
1617
aiormq==3.2.2 # via -r requirements/_base.txt, aio-pika
1718
aiosmtplib==1.1.3 # via -r requirements/_base.txt
1819
aiozipkin==0.6.0 # via -r requirements/_base.txt
1920
amqp==2.6.0 # via -r requirements/_base.txt, kombu
2021
astroid==2.4.2 # via pylint
2122
async-timeout==3.0.1 # via -r requirements/_base.txt, aiohttp, aioredis
2223
asyncpg==0.20.1 # via -r requirements/_base.txt
23-
attrs==19.3.0 # via -r requirements/_base.txt, aiohttp, jsonschema, openapi-core, pytest, pytest-docker
24+
attrs==19.3.0 # via -r requirements/_base.txt, aiohttp, aioredlock, jsonschema, openapi-core, pytest, pytest-docker
2425
billiard==3.6.3.0 # via -r requirements/_base.txt, celery
2526
celery==4.4.5 # via -r requirements/_base.txt
2627
certifi==2020.6.20 # via requests

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

Lines changed: 73 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
"""
44
import json
55
import logging
6-
from typing import Set
6+
from typing import List, Optional, Set
77

8+
import aioredlock
89
from aiohttp import web
910
from jsonschema import ValidationError
1011

@@ -163,14 +164,14 @@ async def get_project(request: web.Request):
163164
project = await get_project_for_user(
164165
request.app,
165166
project_uuid=project_uuid,
166-
user_id=request[RQT_USERID_KEY],
167+
user_id=user_id,
167168
include_templates=True,
168169
)
169170

170171
return {"data": project}
171172
except ProjectInvalidRightsError:
172173
raise web.HTTPForbidden(
173-
reason=f"User {user_id} has no right to read {project_uuid}"
174+
reason=f"You do not have sufficient rights to read project {project_uuid}"
174175
)
175176
except ProjectNotFoundError:
176177
raise web.HTTPNotFound(reason=f"Project {project_uuid} not found")
@@ -235,7 +236,7 @@ async def replace_project(request: web.Request):
235236

236237
except ProjectInvalidRightsError:
237238
raise web.HTTPForbidden(
238-
reason=f"User {user_id} has no rights to write to project {project_uuid}"
239+
reason="You do not have sufficient rights to save the project"
239240
)
240241
except ProjectNotFoundError:
241242
raise web.HTTPNotFound
@@ -256,19 +257,27 @@ async def delete_project(request: web.Request):
256257
user_id=user_id,
257258
include_templates=True,
258259
)
260+
project_users: List[int] = []
259261
with managed_resource(user_id, None, request.app) as rt:
260-
other_users = await rt.find_users_of_resource("project_id", project_uuid)
261-
if other_users:
262-
message = "Project is opened by another user. It cannot be deleted."
263-
if user_id in other_users:
264-
message = "Project is still open. It cannot be deleted until it is closed."
265-
# we cannot delete that project
266-
raise web.HTTPForbidden(reason=message)
262+
project_users = await rt.find_users_of_resource("project_id", project_uuid)
263+
if project_users:
264+
# that project is still in use
265+
if user_id in project_users:
266+
message = "Project is still open in another tab/browser. It cannot be deleted until it is closed."
267+
else:
268+
other_users = set(project_users)
269+
other_user_names = {
270+
await get_user_name(request.app, x) for x in other_users
271+
}
272+
message = f"Project is open by {other_user_names}. It cannot be deleted until the project is closed."
273+
274+
# we cannot delete that project
275+
raise web.HTTPForbidden(reason=message)
267276

268277
await projects_api.delete_project(request, project_uuid, user_id)
269278
except ProjectInvalidRightsError:
270279
raise web.HTTPForbidden(
271-
reason=f"User {user_id} has no rights to delete project"
280+
reason="You do not have sufficient rights to delete this project"
272281
)
273282
except ProjectNotFoundError:
274283
raise web.HTTPNotFound(reason=f"Project {project_uuid} not found")
@@ -289,31 +298,41 @@ async def open_project(request: web.Request) -> web.Response:
289298
project_uuid = request.match_info.get("project_id")
290299
client_session_id = await request.json()
291300
try:
292-
with managed_resource(user_id, client_session_id, request.app) as rt:
293-
# TODO: temporary hidden until get_handlers_from_namespace refactor to seek marked functions instead!
294-
from .projects_api import get_project_for_user
295-
296-
project = await get_project_for_user(
297-
request.app,
298-
project_uuid=project_uuid,
299-
user_id=user_id,
300-
include_templates=True,
301-
)
301+
# TODO: temporary hidden until get_handlers_from_namespace refactor to seek marked functions instead!
302+
from .projects_api import get_project_for_user
302303

303-
# let's check if that project is already opened by someone else
304-
other_users: Set[int] = {
305-
x
306-
for x in await rt.find_users_of_resource("project_id", project_uuid)
307-
if x != f"{user_id}"
308-
}
304+
project = await get_project_for_user(
305+
request.app,
306+
project_uuid=project_uuid,
307+
user_id=user_id,
308+
include_templates=True,
309+
)
309310

310-
if other_users:
311-
# project is already locked
312-
usernames = [
313-
await get_user_name(request.app, uid) for uid in other_users
314-
]
315-
raise HTTPLocked(reason=f"Project is already opened by {usernames}")
316-
await rt.add("project_id", project_uuid)
311+
async def try_add_project() -> Optional[Set[int]]:
312+
with managed_resource(user_id, client_session_id, request.app) as rt:
313+
try:
314+
async with await rt.get_registry_lock():
315+
other_users: Set[int] = {
316+
x
317+
for x in await rt.find_users_of_resource(
318+
"project_id", project_uuid
319+
)
320+
if x != user_id
321+
}
322+
323+
if other_users:
324+
return other_users
325+
await rt.add("project_id", project_uuid)
326+
except aioredlock.LockError:
327+
# TODO: this lock is not a good solution for long term
328+
# maybe a project key in redis might improve spped of checking
329+
raise HTTPLocked(reason="Project is locked")
330+
331+
other_users = await try_add_project()
332+
if other_users:
333+
# project is already locked
334+
usernames = [await get_user_name(request.app, uid) for uid in other_users]
335+
raise HTTPLocked(reason=f"Project is already opened by {usernames}")
317336

318337
# user id opened project uuid
319338
await projects_api.start_project_interactive_services(request, project, user_id)
@@ -381,18 +400,14 @@ async def _close_project_task() -> None:
381400
async def state_project(request: web.Request) -> web.Response:
382401
user_id = request[RQT_USERID_KEY]
383402
project_uuid = request.match_info.get("project_id")
384-
with managed_resource(user_id, None, request.app) as rt:
385-
# TODO: temporary hidden until get_handlers_from_namespace refactor to seek marked functions instead!
386-
from .projects_api import get_project_for_user
387-
388-
# check that project exists
389-
await get_project_for_user(
390-
request.app,
391-
project_uuid=project_uuid,
392-
user_id=user_id,
393-
include_templates=True,
394-
)
403+
# TODO: temporary hidden until get_handlers_from_namespace refactor to seek marked functions instead!
404+
from .projects_api import get_project_for_user
395405

406+
# check that project exists
407+
await get_project_for_user(
408+
request.app, project_uuid=project_uuid, user_id=user_id, include_templates=True,
409+
)
410+
with managed_resource(user_id, None, request.app) as rt:
396411
users_of_project = await rt.find_users_of_resource("project_id", project_uuid)
397412
usernames = [
398413
await get_user_name(request.app, uid) for uid in set(users_of_project)
@@ -416,19 +431,20 @@ async def get_active_project(request: web.Request) -> web.Response:
416431

417432
try:
418433
project = None
434+
user_active_projects = []
419435
with managed_resource(user_id, client_session_id, request.app) as rt:
420436
# get user's projects
421-
list_project_ids = await rt.find("project_id")
422-
if list_project_ids:
423-
# TODO: temporary hidden until get_handlers_from_namespace refactor to seek marked functions instead!
424-
from .projects_api import get_project_for_user
425-
426-
project = await get_project_for_user(
427-
request.app,
428-
project_uuid=list_project_ids[0],
429-
user_id=user_id,
430-
include_templates=True,
431-
)
437+
user_active_projects = await rt.find("project_id")
438+
if user_active_projects:
439+
# TODO: temporary hidden until get_handlers_from_namespace refactor to seek marked functions instead!
440+
from .projects_api import get_project_for_user
441+
442+
project = await get_project_for_user(
443+
request.app,
444+
project_uuid=user_active_projects[0],
445+
user_id=user_id,
446+
include_templates=True,
447+
)
432448

433449
return web.json_response({"data": project})
434450
except ProjectNotFoundError:

services/web/server/src/simcore_service_webserver/resource_manager/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
CONFIG_SECTION_NAME = "resource_manager"
1212
APP_CLIENT_REDIS_CLIENT_KEY = __name__ + ".resource_manager.redis_client"
13+
APP_CLIENT_REDIS_LOCK_KEY = __name__ + ".resource_manager.redis_lock"
1314
APP_CLIENT_SOCKET_REGISTRY_KEY = __name__ + ".resource_manager.registry"
1415
APP_RESOURCE_MANAGER_TASKS_KEY = __name__ + ".resource_manager.tasks.key"
1516
APP_GARBAGE_COLLECTOR_KEY = __name__ + ".resource_manager.garbage_collector_key"

services/web/server/src/simcore_service_webserver/resource_manager/redis.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,14 @@
44
from aiohttp import web
55
from tenacity import Retrying, before_log, stop_after_attempt, wait_random
66

7+
from aioredlock import Aioredlock
78
from servicelib.application_keys import APP_CONFIG_KEY
89

9-
from .config import APP_CLIENT_REDIS_CLIENT_KEY, CONFIG_SECTION_NAME
10+
from .config import (
11+
APP_CLIENT_REDIS_CLIENT_KEY,
12+
APP_CLIENT_REDIS_LOCK_KEY,
13+
CONFIG_SECTION_NAME,
14+
)
1015

1116
log = logging.getLogger(__name__)
1217

@@ -24,20 +29,32 @@ async def redis_client(app: web.Application):
2429
cfg = app[APP_CONFIG_KEY][CONFIG_SECTION_NAME]
2530
url = DSN.format(**cfg["redis"])
2631

32+
# create redis client
33+
client = None
2734
for attempt in Retrying(**retry_upon_init_policy):
2835
with attempt:
2936
client = await aioredis.create_redis_pool(url, encoding="utf-8")
37+
# create lock manager
38+
lock_manager = Aioredlock([url])
3039

3140
assert client # nosec
3241
app[APP_CLIENT_REDIS_CLIENT_KEY] = client
3342

43+
assert lock_manager # nosec
44+
app[APP_CLIENT_REDIS_LOCK_KEY] = lock_manager
45+
3446
yield
3547

3648
if client is not app[APP_CLIENT_REDIS_CLIENT_KEY]:
3749
log.critical("Invalid redis client in app")
50+
if lock_manager is not app[APP_CLIENT_REDIS_LOCK_KEY]:
51+
log.critical("Invalid redis lock manager in app")
3852

53+
# close client
3954
client.close()
4055
await client.wait_closed()
56+
# delete lock manager
57+
await lock_manager.destroy()
4158

4259

4360
def setup_redis_client(app: web.Application):
@@ -56,3 +73,7 @@ def setup_redis_client(app: web.Application):
5673

5774
def get_redis_client(app: web.Application) -> aioredis.Redis:
5875
return app[APP_CLIENT_REDIS_CLIENT_KEY]
76+
77+
78+
def get_redis_lock(app: web.Application) -> Aioredlock:
79+
return app[APP_CLIENT_REDIS_LOCK_KEY]

0 commit comments

Comments
 (0)