Skip to content

Commit 9f3bcd7

Browse files
authored
Merge branch 'master' into pr-osparc-move-service-extras-to-catalog2
2 parents a9455c3 + 8909588 commit 9f3bcd7

File tree

21 files changed

+451
-534
lines changed

21 files changed

+451
-534
lines changed

packages/service-library/src/servicelib/project_lock.py

Lines changed: 0 additions & 80 deletions
This file was deleted.

packages/service-library/src/servicelib/redis/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,29 @@
55
CouldNotAcquireLockError,
66
CouldNotConnectToRedisError,
77
LockLostError,
8+
ProjectLockError,
89
)
910
from ._models import RedisManagerDBConfig
11+
from ._project_lock import (
12+
get_project_locked_state,
13+
is_project_locked,
14+
with_project_locked,
15+
)
1016
from ._utils import handle_redis_returns_union_types
1117

1218
__all__: tuple[str, ...] = (
1319
"CouldNotAcquireLockError",
1420
"CouldNotConnectToRedisError",
1521
"exclusive",
22+
"get_project_locked_state",
1623
"handle_redis_returns_union_types",
24+
"is_project_locked",
1725
"LockLostError",
26+
"ProjectLockError",
1827
"RedisClientSDK",
1928
"RedisClientsManager",
2029
"RedisManagerDBConfig",
30+
"with_project_locked",
2131
)
2232

2333
# nopycln: file

packages/service-library/src/servicelib/redis/_errors.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
from typing import TypeAlias
2+
3+
import redis.exceptions
14
from common_library.errors_classes import OsparcErrorMixin
25

36

@@ -19,3 +22,6 @@ class LockLostError(BaseRedisError):
1922
"TIP: check connection to Redis DBs or look for Synchronous "
2023
"code that might block the auto-extender task. Somehow the distributed lock disappeared!"
2124
)
25+
26+
27+
ProjectLockError: TypeAlias = redis.exceptions.LockError # NOTE: backwards compatible
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import functools
2+
from collections.abc import Awaitable, Callable, Coroutine
3+
from typing import Any, Final, ParamSpec, TypeVar
4+
5+
from models_library.projects import ProjectID
6+
from models_library.projects_access import Owner
7+
from models_library.projects_state import ProjectLocked, ProjectStatus
8+
9+
from ._client import RedisClientSDK
10+
from ._decorators import exclusive
11+
from ._errors import CouldNotAcquireLockError, ProjectLockError
12+
13+
_PROJECT_REDIS_LOCK_KEY: Final[str] = "project_lock:{}"
14+
15+
16+
P = ParamSpec("P")
17+
R = TypeVar("R")
18+
19+
20+
def with_project_locked(
21+
redis_client: RedisClientSDK | Callable[..., RedisClientSDK],
22+
*,
23+
project_uuid: str | ProjectID,
24+
status: ProjectStatus,
25+
owner: Owner | None,
26+
notification_cb: Callable[[], Awaitable[None]] | None,
27+
) -> Callable[
28+
[Callable[P, Coroutine[Any, Any, R]]], Callable[P, Coroutine[Any, Any, R]]
29+
]:
30+
"""creates a distributed auto sustained Redis lock for project with project_uuid, keeping its status and owner in the lock data
31+
32+
Arguments:
33+
redis_client -- the client to use to access redis
34+
project_uuid -- the project UUID
35+
status -- the project status
36+
owner -- the owner of the lock (default: {None})
37+
notification_cb -- an optional notification callback that will be called AFTER the project is locked and AFTER it was unlocked
38+
39+
Returns:
40+
the decorated function return value
41+
42+
Raises:
43+
raises anything from the decorated function and from the optional notification callback
44+
"""
45+
46+
def _decorator(
47+
func: Callable[P, Coroutine[Any, Any, R]],
48+
) -> Callable[P, Coroutine[Any, Any, R]]:
49+
@functools.wraps(func)
50+
async def _wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
51+
@exclusive(
52+
redis_client,
53+
lock_key=_PROJECT_REDIS_LOCK_KEY.format(project_uuid),
54+
lock_value=ProjectLocked(
55+
value=True,
56+
owner=owner,
57+
status=status,
58+
).model_dump_json(),
59+
)
60+
async def _exclusive_func(*args, **kwargs) -> R:
61+
if notification_cb is not None:
62+
await notification_cb()
63+
return await func(*args, **kwargs)
64+
65+
try:
66+
result = await _exclusive_func(*args, **kwargs)
67+
# we are now unlocked
68+
if notification_cb is not None:
69+
await notification_cb()
70+
return result
71+
except CouldNotAcquireLockError as e:
72+
raise ProjectLockError from e
73+
74+
return _wrapper
75+
76+
return _decorator
77+
78+
79+
async def is_project_locked(
80+
redis_client: RedisClientSDK, project_uuid: str | ProjectID
81+
) -> bool:
82+
redis_lock = redis_client.create_lock(_PROJECT_REDIS_LOCK_KEY.format(project_uuid))
83+
return await redis_lock.locked()
84+
85+
86+
async def get_project_locked_state(
87+
redis_client: RedisClientSDK, project_uuid: str | ProjectID
88+
) -> ProjectLocked | None:
89+
"""
90+
Returns:
91+
ProjectLocked object if the project project_uuid is locked or None otherwise
92+
"""
93+
if await is_project_locked(redis_client, project_uuid=project_uuid) and (
94+
lock_value := await redis_client.redis.get(
95+
_PROJECT_REDIS_LOCK_KEY.format(project_uuid)
96+
)
97+
):
98+
return ProjectLocked.model_validate_json(lock_value)
99+
return None

packages/service-library/tests/redis/test_decorators.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ async def _():
7575
pass
7676

7777

78-
async def test_exclusive_decorator(
78+
async def test_exclusive_decorator_runs_original_method(
7979
redis_client_sdk: RedisClientSDK,
8080
lock_name: str,
8181
sleep_duration: float,
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
# pylint: disable=no-value-for-parameter
2+
# pylint: disable=protected-access
3+
# pylint: disable=redefined-outer-name
4+
# pylint: disable=unused-argument
5+
# pylint: disable=unused-variable
6+
7+
import asyncio
8+
from typing import cast
9+
from unittest import mock
10+
from uuid import UUID
11+
12+
import pytest
13+
from faker import Faker
14+
from models_library.projects import ProjectID
15+
from models_library.projects_access import Owner
16+
from models_library.projects_state import ProjectLocked, ProjectStatus
17+
from servicelib.async_utils import cancel_wait_task
18+
from servicelib.redis import (
19+
ProjectLockError,
20+
RedisClientSDK,
21+
get_project_locked_state,
22+
is_project_locked,
23+
with_project_locked,
24+
)
25+
from servicelib.redis._project_lock import _PROJECT_REDIS_LOCK_KEY
26+
27+
pytest_simcore_core_services_selection = [
28+
"redis",
29+
]
30+
pytest_simcore_ops_services_selection = [
31+
"redis-commander",
32+
]
33+
34+
35+
@pytest.fixture()
36+
def project_uuid(faker: Faker) -> ProjectID:
37+
return cast(UUID, faker.uuid4(cast_to=None))
38+
39+
40+
assert "json_schema_extra" in Owner.model_config
41+
assert isinstance(Owner.model_config["json_schema_extra"], dict)
42+
assert isinstance(Owner.model_config["json_schema_extra"]["examples"], list)
43+
44+
45+
@pytest.fixture(params=Owner.model_config["json_schema_extra"]["examples"])
46+
def owner(request: pytest.FixtureRequest) -> Owner:
47+
return Owner(**request.param)
48+
49+
50+
@pytest.fixture
51+
def mocked_notification_cb() -> mock.AsyncMock:
52+
return mock.AsyncMock()
53+
54+
55+
@pytest.mark.parametrize(
56+
"project_status",
57+
[
58+
ProjectStatus.CLOSING,
59+
ProjectStatus.CLONING,
60+
ProjectStatus.EXPORTING,
61+
ProjectStatus.OPENING,
62+
ProjectStatus.MAINTAINING,
63+
],
64+
)
65+
async def test_with_project_locked(
66+
redis_client_sdk: RedisClientSDK,
67+
project_uuid: ProjectID,
68+
owner: Owner,
69+
project_status: ProjectStatus,
70+
mocked_notification_cb: mock.AsyncMock,
71+
):
72+
@with_project_locked(
73+
redis_client_sdk,
74+
project_uuid=project_uuid,
75+
status=project_status,
76+
owner=owner,
77+
notification_cb=mocked_notification_cb,
78+
)
79+
async def _locked_fct() -> None:
80+
mocked_notification_cb.assert_called_once()
81+
assert await is_project_locked(redis_client_sdk, project_uuid) is True
82+
locked_state = await get_project_locked_state(redis_client_sdk, project_uuid)
83+
assert locked_state is not None
84+
assert locked_state == ProjectLocked(
85+
value=True,
86+
owner=owner,
87+
status=project_status,
88+
)
89+
# check lock name formatting is correct
90+
redis_lock = await redis_client_sdk.redis.get(
91+
_PROJECT_REDIS_LOCK_KEY.format(project_uuid)
92+
)
93+
assert redis_lock
94+
assert ProjectLocked.model_validate_json(redis_lock) == ProjectLocked(
95+
value=True,
96+
owner=owner,
97+
status=project_status,
98+
)
99+
100+
mocked_notification_cb.assert_not_called()
101+
assert await get_project_locked_state(redis_client_sdk, project_uuid) is None
102+
assert await is_project_locked(redis_client_sdk, project_uuid) is False
103+
await _locked_fct()
104+
assert await is_project_locked(redis_client_sdk, project_uuid) is False
105+
assert await get_project_locked_state(redis_client_sdk, project_uuid) is None
106+
mocked_notification_cb.assert_called()
107+
assert mocked_notification_cb.call_count == 2
108+
109+
110+
@pytest.mark.parametrize(
111+
"project_status",
112+
[
113+
ProjectStatus.CLOSING,
114+
ProjectStatus.CLONING,
115+
ProjectStatus.EXPORTING,
116+
ProjectStatus.OPENING,
117+
ProjectStatus.MAINTAINING,
118+
],
119+
)
120+
async def test_lock_already_locked_project_raises(
121+
redis_client_sdk: RedisClientSDK,
122+
project_uuid: ProjectID,
123+
owner: Owner,
124+
project_status: ProjectStatus,
125+
):
126+
started_event = asyncio.Event()
127+
128+
@with_project_locked(
129+
redis_client_sdk,
130+
project_uuid=project_uuid,
131+
status=project_status,
132+
owner=owner,
133+
notification_cb=None,
134+
)
135+
async def _locked_fct() -> None:
136+
started_event.set()
137+
await asyncio.sleep(10)
138+
139+
task1 = asyncio.create_task(_locked_fct(), name="pytest_task_1")
140+
await started_event.wait()
141+
with pytest.raises(ProjectLockError):
142+
await _locked_fct()
143+
144+
await cancel_wait_task(task1)

packages/service-library/tests/test_project_lock.py

Lines changed: 0 additions & 1 deletion
This file was deleted.

services/clusters-keeper/src/simcore_service_clusters_keeper/rpc/clusters.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,7 @@
77
from models_library.users import UserID
88
from models_library.wallets import WalletID
99
from servicelib.rabbitmq import RPCRouter
10-
from servicelib.redis._client import RedisClientSDK
11-
from servicelib.redis._decorators import exclusive
10+
from servicelib.redis import RedisClientSDK, exclusive
1211

1312
from ..core.settings import get_application_settings
1413
from ..modules import clusters

0 commit comments

Comments
 (0)