Skip to content

Commit 4744774

Browse files
authored
šŸ›Introduce timeout in project lock (a la aioredlock) (ITISFoundation#3675)
1 parent e1f6496 commit 4744774

File tree

4 files changed

+139
-20
lines changed

4 files changed

+139
-20
lines changed

ā€Žpackages/service-library/src/servicelib/background_task.pyā€Ž

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import contextlib
33
import datetime
44
import logging
5-
from typing import Awaitable, Callable, Optional
5+
from typing import AsyncIterator, Awaitable, Callable, Optional
66

77
from servicelib.logging_utils import log_catch, log_context
88
from tenacity import TryAgain
@@ -64,3 +64,22 @@ async def stop_periodic_task(
6464
asyncio_task.cancel()
6565
with log_catch(logger, reraise=False):
6666
await asyncio.wait((asyncio_task,), timeout=timeout)
67+
68+
69+
@contextlib.asynccontextmanager
70+
async def periodic_task(
71+
task: Callable[..., Awaitable[None]],
72+
*,
73+
interval: datetime.timedelta,
74+
task_name: str,
75+
**kwargs,
76+
) -> AsyncIterator[asyncio.Task]:
77+
asyncio_task = None
78+
try:
79+
asyncio_task = await start_periodic_task(
80+
task, interval=interval, task_name=task_name, **kwargs
81+
)
82+
yield asyncio_task
83+
finally:
84+
if asyncio_task is not None:
85+
await stop_periodic_task(asyncio_task)

ā€Žpackages/service-library/tests/test_background_task.pyā€Ž

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@
1313
from faker import Faker
1414
from pytest import FixtureRequest
1515
from pytest_mock.plugin import MockerFixture
16-
from servicelib.background_task import start_periodic_task, stop_periodic_task
16+
from servicelib.background_task import (
17+
periodic_task,
18+
start_periodic_task,
19+
stop_periodic_task,
20+
)
1721

1822
_FAST_POLL_INTERVAL = 1
1923

@@ -107,3 +111,20 @@ async def test_dynamic_scaling_task_correctly_cancels(
107111
await asyncio.sleep(5 * task_interval.total_seconds())
108112
# the task will be called once, and then stop
109113
mock_background_task.assert_called_once()
114+
115+
116+
async def test_periodic_task_context_manager(
117+
mock_background_task: mock.AsyncMock,
118+
task_interval: datetime.timedelta,
119+
faker: Faker,
120+
):
121+
task_name = faker.pystr()
122+
async with periodic_task(
123+
mock_background_task, interval=task_interval, task_name=task_name
124+
) as asyncio_task:
125+
assert asyncio_task.get_name() == task_name
126+
assert asyncio_task.cancelled() is False
127+
await asyncio.sleep(5 * task_interval.total_seconds())
128+
assert asyncio_task.cancelled() is False
129+
assert asyncio_task.done() is False
130+
assert asyncio_task.cancelled() is True

ā€Žservices/web/server/src/simcore_service_webserver/projects/project_lock.pyā€Ž

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,30 @@
1+
import datetime
12
from asyncio.log import logger
23
from contextlib import asynccontextmanager
3-
from typing import Optional, Union
4+
from typing import Final, Optional, Union
45

56
import redis
67
from aiohttp import web
78
from models_library.projects import ProjectID
89
from models_library.projects_state import Owner, ProjectLocked, ProjectStatus
910
from redis.asyncio.lock import Lock
11+
from servicelib.background_task import periodic_task
12+
from servicelib.logging_utils import log_catch
1013

1114
from ..redis import get_redis_lock_manager_client
1215
from ..users_api import UserNameDict
1316
from .projects_exceptions import ProjectLockError
1417

1518
PROJECT_REDIS_LOCK_KEY: str = "project_lock:{}"
16-
19+
PROJECT_LOCK_TIMEOUT: Final[datetime.timedelta] = datetime.timedelta(seconds=10)
1720
ProjectLock = Lock
1821

1922

23+
async def _auto_extend_project_lock(project_lock: Lock) -> None:
24+
with log_catch(logger, reraise=False):
25+
await project_lock.reacquire()
26+
27+
2028
@asynccontextmanager
2129
async def lock_project(
2230
app: web.Application,
@@ -38,7 +46,8 @@ async def lock_project(
3846
3947
"""
4048
redis_lock = get_redis_lock_manager_client(app).lock(
41-
PROJECT_REDIS_LOCK_KEY.format(project_uuid)
49+
PROJECT_REDIS_LOCK_KEY.format(project_uuid),
50+
timeout=PROJECT_LOCK_TIMEOUT.total_seconds(),
4251
)
4352
try:
4453
if not await redis_lock.acquire(
@@ -52,7 +61,12 @@ async def lock_project(
5261
raise ProjectLockError(
5362
f"Lock for project {project_uuid!r} user {user_id!r} could not be acquired"
5463
)
55-
yield
64+
async with periodic_task(
65+
_auto_extend_project_lock,
66+
interval=0.6 * PROJECT_LOCK_TIMEOUT,
67+
task_name=f"{PROJECT_REDIS_LOCK_KEY.format(project_uuid)}_lock_auto_extend",
68+
):
69+
yield
5670
finally:
5771
# let's ensure we release that stuff
5872
try:

ā€Žservices/web/server/tests/unit/with_dbs/03/test_redis.pyā€Ž

Lines changed: 79 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,39 +3,71 @@
33
# pylint:disable=redefined-outer-name
44

55
import asyncio
6+
import datetime
67

78
import pytest
89
import redis
910
import redis.asyncio as aioredis
11+
from faker import Faker
12+
from redis.asyncio.lock import Lock
13+
from servicelib.background_task import periodic_task
1014

1115

12-
async def test_aioredis(redis_client: aioredis.Redis):
13-
await redis_client.set("my-key", "value")
14-
val = await redis_client.get("my-key")
15-
assert val == "value"
16+
async def test_aioredis(redis_client: aioredis.Redis, faker: Faker):
17+
key = faker.pystr()
18+
value = faker.pystr()
19+
await redis_client.set(key, value)
20+
val = await redis_client.get(key)
21+
assert val == value
1622

1723

18-
async def test_redlocks_features(redis_client: aioredis.Redis):
19-
# Check wether a resourece acquired by any other redlock instance:
20-
lock = redis_client.lock("resource_name")
24+
async def test_lock_acquisition(redis_client: aioredis.Redis, faker: Faker):
25+
lock_name = faker.pystr()
26+
lock = redis_client.lock(lock_name)
2127
assert not await lock.locked()
2228

2329
# Try to acquire the lock:
2430
lock_acquired = await lock.acquire(blocking=False)
25-
assert lock_acquired, "Lock not acquired"
31+
assert lock_acquired is True
32+
assert await lock.locked() is True
33+
assert await lock.owned() is True
34+
with pytest.raises(redis.exceptions.LockError):
35+
# a lock with no timeout cannot be reacquired
36+
await lock.reacquire()
37+
with pytest.raises(redis.exceptions.LockError):
38+
# a lock with no timeout cannot be extended
39+
await lock.extend(2)
40+
41+
# try to acquire the lock a second time
42+
same_lock = redis_client.lock(lock_name)
43+
assert await same_lock.locked() is True
44+
assert await same_lock.owned() is False
45+
assert await same_lock.acquire(blocking=False) is False
46+
47+
# now release the lock
2648
await lock.release()
2749
assert not await lock.locked()
2850
assert not await lock.owned()
2951

30-
# use as context manager
52+
53+
async def test_lock_context_manager(redis_client: aioredis.Redis, faker: Faker):
54+
lock_name = faker.pystr()
55+
lock = redis_client.lock(lock_name)
56+
assert not await lock.locked()
57+
3158
async with lock:
3259
assert await lock.locked()
3360
assert await lock.owned()
34-
# a lock with no timeout cannot be extended
3561
with pytest.raises(redis.exceptions.LockError):
62+
# a lock with no timeout cannot be reacquired
63+
await lock.reacquire()
64+
65+
with pytest.raises(redis.exceptions.LockError):
66+
# a lock with no timeout cannot be extended
3667
await lock.extend(2)
68+
3769
# try to acquire the lock a second time
38-
same_lock = redis_client.lock("resource_name", blocking_timeout=1)
70+
same_lock = redis_client.lock(lock_name, blocking_timeout=1)
3971
assert await same_lock.locked()
4072
assert not await same_lock.owned()
4173
assert await same_lock.acquire() == False
@@ -44,13 +76,46 @@ async def test_redlocks_features(redis_client: aioredis.Redis):
4476
...
4577
assert not await lock.locked()
4678

47-
# now create a lock with a ttl
48-
ttl_lock = redis_client.lock("ttl_resource", timeout=2, blocking_timeout=1)
79+
80+
@pytest.fixture
81+
def lock_timeout() -> datetime.timedelta:
82+
return datetime.timedelta(seconds=2)
83+
84+
85+
async def test_lock_with_ttl(
86+
redis_client: aioredis.Redis, faker: Faker, lock_timeout: datetime.timedelta
87+
):
88+
ttl_lock = redis_client.lock(faker.pystr(), timeout=lock_timeout.total_seconds())
4989
assert not await ttl_lock.locked()
90+
5091
with pytest.raises(redis.exceptions.LockNotOwnedError):
5192
# this raises as the lock is lost
5293
async with ttl_lock:
5394
assert await ttl_lock.locked()
5495
assert await ttl_lock.owned()
55-
await asyncio.sleep(3)
96+
await asyncio.sleep(2 * lock_timeout.total_seconds())
5697
assert not await ttl_lock.locked()
98+
99+
100+
async def test_lock_with_auto_extent(
101+
redis_client: aioredis.Redis, faker: Faker, lock_timeout: datetime.timedelta
102+
):
103+
ttl_lock = redis_client.lock(faker.pystr(), timeout=lock_timeout.total_seconds())
104+
assert not await ttl_lock.locked()
105+
106+
async def _auto_extend_lock(lock: Lock) -> None:
107+
assert await lock.reacquire() is True
108+
109+
async with ttl_lock, periodic_task(
110+
_auto_extend_lock,
111+
interval=0.6 * lock_timeout,
112+
task_name=f"{ttl_lock.name}_auto_extend",
113+
lock=ttl_lock,
114+
):
115+
assert await ttl_lock.locked() is True
116+
assert await ttl_lock.owned() is True
117+
await asyncio.sleep(5 * lock_timeout.total_seconds())
118+
assert await ttl_lock.locked() is True
119+
assert await ttl_lock.owned() is True
120+
assert await ttl_lock.locked() is False
121+
assert await ttl_lock.owned() is False

0 commit comments

Comments
Ā (0)