-
Notifications
You must be signed in to change notification settings - Fork 27
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
🐛Refactoring of project locking using decorator (#7044)
- Loading branch information
Showing
21 changed files
with
451 additions
and
534 deletions.
There are no files selected for viewing
This file was deleted.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
99 changes: 99 additions & 0 deletions
99
packages/service-library/src/servicelib/redis/_project_lock.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,99 @@ | ||
import functools | ||
from collections.abc import Awaitable, Callable, Coroutine | ||
from typing import Any, Final, ParamSpec, TypeVar | ||
|
||
from models_library.projects import ProjectID | ||
from models_library.projects_access import Owner | ||
from models_library.projects_state import ProjectLocked, ProjectStatus | ||
|
||
from ._client import RedisClientSDK | ||
from ._decorators import exclusive | ||
from ._errors import CouldNotAcquireLockError, ProjectLockError | ||
|
||
_PROJECT_REDIS_LOCK_KEY: Final[str] = "project_lock:{}" | ||
|
||
|
||
P = ParamSpec("P") | ||
R = TypeVar("R") | ||
|
||
|
||
def with_project_locked( | ||
redis_client: RedisClientSDK | Callable[..., RedisClientSDK], | ||
*, | ||
project_uuid: str | ProjectID, | ||
status: ProjectStatus, | ||
owner: Owner | None, | ||
notification_cb: Callable[[], Awaitable[None]] | None, | ||
) -> Callable[ | ||
[Callable[P, Coroutine[Any, Any, R]]], Callable[P, Coroutine[Any, Any, R]] | ||
]: | ||
"""creates a distributed auto sustained Redis lock for project with project_uuid, keeping its status and owner in the lock data | ||
Arguments: | ||
redis_client -- the client to use to access redis | ||
project_uuid -- the project UUID | ||
status -- the project status | ||
owner -- the owner of the lock (default: {None}) | ||
notification_cb -- an optional notification callback that will be called AFTER the project is locked and AFTER it was unlocked | ||
Returns: | ||
the decorated function return value | ||
Raises: | ||
raises anything from the decorated function and from the optional notification callback | ||
""" | ||
|
||
def _decorator( | ||
func: Callable[P, Coroutine[Any, Any, R]], | ||
) -> Callable[P, Coroutine[Any, Any, R]]: | ||
@functools.wraps(func) | ||
async def _wrapper(*args: P.args, **kwargs: P.kwargs) -> R: | ||
@exclusive( | ||
redis_client, | ||
lock_key=_PROJECT_REDIS_LOCK_KEY.format(project_uuid), | ||
lock_value=ProjectLocked( | ||
value=True, | ||
owner=owner, | ||
status=status, | ||
).model_dump_json(), | ||
) | ||
async def _exclusive_func(*args, **kwargs) -> R: | ||
if notification_cb is not None: | ||
await notification_cb() | ||
return await func(*args, **kwargs) | ||
|
||
try: | ||
result = await _exclusive_func(*args, **kwargs) | ||
# we are now unlocked | ||
if notification_cb is not None: | ||
await notification_cb() | ||
return result | ||
except CouldNotAcquireLockError as e: | ||
raise ProjectLockError from e | ||
|
||
return _wrapper | ||
|
||
return _decorator | ||
|
||
|
||
async def is_project_locked( | ||
redis_client: RedisClientSDK, project_uuid: str | ProjectID | ||
) -> bool: | ||
redis_lock = redis_client.create_lock(_PROJECT_REDIS_LOCK_KEY.format(project_uuid)) | ||
return await redis_lock.locked() | ||
|
||
|
||
async def get_project_locked_state( | ||
redis_client: RedisClientSDK, project_uuid: str | ProjectID | ||
) -> ProjectLocked | None: | ||
""" | ||
Returns: | ||
ProjectLocked object if the project project_uuid is locked or None otherwise | ||
""" | ||
if await is_project_locked(redis_client, project_uuid=project_uuid) and ( | ||
lock_value := await redis_client.redis.get( | ||
_PROJECT_REDIS_LOCK_KEY.format(project_uuid) | ||
) | ||
): | ||
return ProjectLocked.model_validate_json(lock_value) | ||
return None |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
144 changes: 144 additions & 0 deletions
144
packages/service-library/tests/redis/test_project_lock.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,144 @@ | ||
# pylint: disable=no-value-for-parameter | ||
# pylint: disable=protected-access | ||
# pylint: disable=redefined-outer-name | ||
# pylint: disable=unused-argument | ||
# pylint: disable=unused-variable | ||
|
||
import asyncio | ||
from typing import cast | ||
from unittest import mock | ||
from uuid import UUID | ||
|
||
import pytest | ||
from faker import Faker | ||
from models_library.projects import ProjectID | ||
from models_library.projects_access import Owner | ||
from models_library.projects_state import ProjectLocked, ProjectStatus | ||
from servicelib.async_utils import cancel_wait_task | ||
from servicelib.redis import ( | ||
ProjectLockError, | ||
RedisClientSDK, | ||
get_project_locked_state, | ||
is_project_locked, | ||
with_project_locked, | ||
) | ||
from servicelib.redis._project_lock import _PROJECT_REDIS_LOCK_KEY | ||
|
||
pytest_simcore_core_services_selection = [ | ||
"redis", | ||
] | ||
pytest_simcore_ops_services_selection = [ | ||
"redis-commander", | ||
] | ||
|
||
|
||
@pytest.fixture() | ||
def project_uuid(faker: Faker) -> ProjectID: | ||
return cast(UUID, faker.uuid4(cast_to=None)) | ||
|
||
|
||
assert "json_schema_extra" in Owner.model_config | ||
assert isinstance(Owner.model_config["json_schema_extra"], dict) | ||
assert isinstance(Owner.model_config["json_schema_extra"]["examples"], list) | ||
|
||
|
||
@pytest.fixture(params=Owner.model_config["json_schema_extra"]["examples"]) | ||
def owner(request: pytest.FixtureRequest) -> Owner: | ||
return Owner(**request.param) | ||
|
||
|
||
@pytest.fixture | ||
def mocked_notification_cb() -> mock.AsyncMock: | ||
return mock.AsyncMock() | ||
|
||
|
||
@pytest.mark.parametrize( | ||
"project_status", | ||
[ | ||
ProjectStatus.CLOSING, | ||
ProjectStatus.CLONING, | ||
ProjectStatus.EXPORTING, | ||
ProjectStatus.OPENING, | ||
ProjectStatus.MAINTAINING, | ||
], | ||
) | ||
async def test_with_project_locked( | ||
redis_client_sdk: RedisClientSDK, | ||
project_uuid: ProjectID, | ||
owner: Owner, | ||
project_status: ProjectStatus, | ||
mocked_notification_cb: mock.AsyncMock, | ||
): | ||
@with_project_locked( | ||
redis_client_sdk, | ||
project_uuid=project_uuid, | ||
status=project_status, | ||
owner=owner, | ||
notification_cb=mocked_notification_cb, | ||
) | ||
async def _locked_fct() -> None: | ||
mocked_notification_cb.assert_called_once() | ||
assert await is_project_locked(redis_client_sdk, project_uuid) is True | ||
locked_state = await get_project_locked_state(redis_client_sdk, project_uuid) | ||
assert locked_state is not None | ||
assert locked_state == ProjectLocked( | ||
value=True, | ||
owner=owner, | ||
status=project_status, | ||
) | ||
# check lock name formatting is correct | ||
redis_lock = await redis_client_sdk.redis.get( | ||
_PROJECT_REDIS_LOCK_KEY.format(project_uuid) | ||
) | ||
assert redis_lock | ||
assert ProjectLocked.model_validate_json(redis_lock) == ProjectLocked( | ||
value=True, | ||
owner=owner, | ||
status=project_status, | ||
) | ||
|
||
mocked_notification_cb.assert_not_called() | ||
assert await get_project_locked_state(redis_client_sdk, project_uuid) is None | ||
assert await is_project_locked(redis_client_sdk, project_uuid) is False | ||
await _locked_fct() | ||
assert await is_project_locked(redis_client_sdk, project_uuid) is False | ||
assert await get_project_locked_state(redis_client_sdk, project_uuid) is None | ||
mocked_notification_cb.assert_called() | ||
assert mocked_notification_cb.call_count == 2 | ||
|
||
|
||
@pytest.mark.parametrize( | ||
"project_status", | ||
[ | ||
ProjectStatus.CLOSING, | ||
ProjectStatus.CLONING, | ||
ProjectStatus.EXPORTING, | ||
ProjectStatus.OPENING, | ||
ProjectStatus.MAINTAINING, | ||
], | ||
) | ||
async def test_lock_already_locked_project_raises( | ||
redis_client_sdk: RedisClientSDK, | ||
project_uuid: ProjectID, | ||
owner: Owner, | ||
project_status: ProjectStatus, | ||
): | ||
started_event = asyncio.Event() | ||
|
||
@with_project_locked( | ||
redis_client_sdk, | ||
project_uuid=project_uuid, | ||
status=project_status, | ||
owner=owner, | ||
notification_cb=None, | ||
) | ||
async def _locked_fct() -> None: | ||
started_event.set() | ||
await asyncio.sleep(10) | ||
|
||
task1 = asyncio.create_task(_locked_fct(), name="pytest_task_1") | ||
await started_event.wait() | ||
with pytest.raises(ProjectLockError): | ||
await _locked_fct() | ||
|
||
await cancel_wait_task(task1) |
This file was deleted.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.