Skip to content

Commit 6e8867b

Browse files
🎨 EFS Guardian adding data removal background task (#6562)
1 parent edc9ea0 commit 6e8867b

File tree

31 files changed

+950
-316
lines changed

31 files changed

+950
-316
lines changed

packages/models-library/src/models_library/projects_state.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ class ProjectStatus(str, Enum):
5252
EXPORTING = "EXPORTING"
5353
OPENING = "OPENING"
5454
OPENED = "OPENED"
55+
MAINTAINING = "MAINTAINING"
5556

5657

5758
class ProjectLocked(BaseModel):
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import uuid
2+
from datetime import datetime, timezone
3+
4+
import sqlalchemy as sa
5+
from pydantic import parse_obj_as
6+
from pydantic.errors import PydanticErrorMixin
7+
from sqlalchemy.ext.asyncio import AsyncConnection
8+
9+
from .models.projects import projects
10+
from .utils_repos import transaction_context
11+
12+
13+
class DBBaseProjectError(PydanticErrorMixin, Exception):
14+
msg_template: str = "Project utils unexpected error"
15+
16+
17+
class DBProjectNotFoundError(DBBaseProjectError):
18+
msg_template: str = "Project project_uuid={project_uuid!r} not found"
19+
20+
21+
class ProjectsRepo:
22+
def __init__(self, engine):
23+
self.engine = engine
24+
25+
async def get_project_last_change_date(
26+
self,
27+
project_uuid: uuid.UUID,
28+
*,
29+
connection: AsyncConnection | None = None,
30+
) -> datetime:
31+
async with transaction_context(self.engine, connection) as conn:
32+
get_stmt = sa.select(projects.c.last_change_date).where(
33+
projects.c.uuid == f"{project_uuid}"
34+
)
35+
36+
result = await conn.execute(get_stmt)
37+
row = result.first()
38+
if row is None:
39+
raise DBProjectNotFoundError(project_uuid=project_uuid)
40+
date = parse_obj_as(datetime, row[0])
41+
return date.replace(tzinfo=timezone.utc)

packages/postgres-database/tests/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ async def connection(aiopg_engine: Engine) -> AsyncIterator[SAConnection]:
206206

207207

208208
@pytest.fixture
209-
async def asyncpg_engine(
209+
async def asyncpg_engine( # <-- WE SHOULD USE THIS ONE
210210
is_pdb_enabled: bool,
211211
pg_sa_engine: sa.engine.Engine,
212212
make_asyncpg_engine: Callable[[bool], AsyncEngine],
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# pylint: disable=redefined-outer-name
2+
# pylint: disable=unused-argument
3+
# pylint: disable=unused-variable
4+
# pylint: disable=too-many-arguments
5+
import uuid
6+
from collections.abc import Awaitable, Callable
7+
from datetime import datetime
8+
from typing import Any, AsyncIterator
9+
10+
import pytest
11+
import sqlalchemy
12+
from aiopg.sa.connection import SAConnection
13+
from aiopg.sa.result import RowProxy
14+
from faker import Faker
15+
from simcore_postgres_database.models.projects import projects
16+
from simcore_postgres_database.utils_projects import (
17+
DBProjectNotFoundError,
18+
ProjectsRepo,
19+
)
20+
from sqlalchemy.ext.asyncio import AsyncEngine
21+
22+
23+
async def _delete_project(connection: SAConnection, project_uuid: uuid.UUID) -> None:
24+
result = await connection.execute(
25+
sqlalchemy.delete(projects).where(projects.c.uuid == f"{project_uuid}")
26+
)
27+
assert result.rowcount == 1
28+
29+
30+
@pytest.fixture
31+
async def registered_user(
32+
connection: SAConnection,
33+
create_fake_user: Callable[..., Awaitable[RowProxy]],
34+
) -> RowProxy:
35+
user = await create_fake_user(connection)
36+
assert user
37+
return user
38+
39+
40+
@pytest.fixture
41+
async def registered_project(
42+
connection: SAConnection,
43+
registered_user: RowProxy,
44+
create_fake_project: Callable[..., Awaitable[RowProxy]],
45+
) -> AsyncIterator[dict[str, Any]]:
46+
project = await create_fake_project(connection, registered_user)
47+
assert project
48+
49+
yield dict(project)
50+
51+
await _delete_project(connection, project["uuid"])
52+
53+
54+
async def test_get_project_last_change_date(
55+
asyncpg_engine: AsyncEngine, registered_project: dict, faker: Faker
56+
):
57+
projects_repo = ProjectsRepo(asyncpg_engine)
58+
59+
project_last_change_date = await projects_repo.get_project_last_change_date(
60+
project_uuid=registered_project["uuid"]
61+
)
62+
assert isinstance(project_last_change_date, datetime)
63+
64+
with pytest.raises(DBProjectNotFoundError):
65+
await projects_repo.get_project_last_change_date(
66+
project_uuid=faker.uuid4() # <-- Non existing uuid in DB
67+
)

packages/pytest-simcore/src/pytest_simcore/faker_projects_data.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,15 @@
99
"""
1010

1111

12+
from typing import Any
13+
1214
import pytest
1315
from faker import Faker
1416
from models_library.projects import ProjectID
1517
from models_library.projects_nodes_io import NodeID
18+
from models_library.users import UserID
1619
from pydantic import parse_obj_as
20+
from pytest_simcore.helpers.faker_factories import random_project
1721

1822
_MESSAGE = (
1923
"If set, it overrides the fake value of `{}` fixture."
@@ -43,3 +47,12 @@ def project_id(faker: Faker, request: pytest.FixtureRequest) -> ProjectID:
4347
@pytest.fixture
4448
def node_id(faker: Faker) -> NodeID:
4549
return parse_obj_as(NodeID, faker.uuid4())
50+
51+
52+
@pytest.fixture
53+
def project_data(
54+
faker: Faker,
55+
project_id: ProjectID,
56+
user_id: UserID,
57+
) -> dict[str, Any]:
58+
return random_project(fake=faker, uuid=f"{project_id}", prj_owner=user_id)
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import datetime
2+
import logging
3+
from asyncio.log import logger
4+
from collections.abc import AsyncIterator
5+
from contextlib import asynccontextmanager
6+
from typing import Final
7+
8+
import redis
9+
import redis.exceptions
10+
from models_library.projects import ProjectID
11+
from models_library.projects_access import Owner
12+
from models_library.projects_state import ProjectLocked, ProjectStatus
13+
from redis.asyncio.lock import Lock
14+
15+
from .background_task import periodic_task
16+
from .logging_utils import log_context
17+
18+
_logger = logging.getLogger(__name__)
19+
20+
PROJECT_REDIS_LOCK_KEY: str = "project_lock:{}"
21+
PROJECT_LOCK_TIMEOUT: Final[datetime.timedelta] = datetime.timedelta(seconds=10)
22+
ProjectLock = Lock
23+
24+
ProjectLockError = redis.exceptions.LockError
25+
26+
27+
async def _auto_extend_project_lock(project_lock: Lock) -> None:
28+
# NOTE: the background task already catches anything that might raise here
29+
await project_lock.reacquire()
30+
31+
32+
@asynccontextmanager
33+
async def lock_project(
34+
redis_lock: Lock,
35+
project_uuid: str | ProjectID,
36+
status: ProjectStatus,
37+
owner: Owner | None = None,
38+
) -> AsyncIterator[None]:
39+
"""Context manager to lock and unlock a project by user_id
40+
41+
Raises:
42+
ProjectLockError: if project is already locked
43+
"""
44+
45+
try:
46+
if not await redis_lock.acquire(
47+
blocking=False,
48+
token=ProjectLocked(
49+
value=True,
50+
owner=owner,
51+
status=status,
52+
).json(),
53+
):
54+
msg = f"Lock for project {project_uuid!r} owner {owner!r} could not be acquired"
55+
raise ProjectLockError(msg)
56+
57+
with log_context(
58+
_logger,
59+
logging.DEBUG,
60+
msg=f"with lock for {owner=}:{project_uuid=}:{status=}",
61+
):
62+
async with periodic_task(
63+
_auto_extend_project_lock,
64+
interval=0.6 * PROJECT_LOCK_TIMEOUT,
65+
task_name=f"{PROJECT_REDIS_LOCK_KEY.format(project_uuid)}_lock_auto_extend",
66+
project_lock=redis_lock,
67+
):
68+
yield
69+
70+
finally:
71+
# let's ensure we release that stuff
72+
try:
73+
if await redis_lock.owned():
74+
await redis_lock.release()
75+
except (redis.exceptions.LockError, redis.exceptions.LockNotOwnedError) as exc:
76+
logger.warning(
77+
"releasing %s unexpectedly raised an exception: %s",
78+
f"{redis_lock=!r}",
79+
f"{exc}",
80+
)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# NOTE: Tested in osparc-simcore/services/web/server/tests/unit/with_dbs/02/test_project_lock.py

services/director-v2/.env-devel

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ DIRECTOR_V2_GENERIC_RESOURCE_PLACEMENT_CONSTRAINTS_SUBSTITUTIONS='{}'
3131

3232
LOG_LEVEL=DEBUG
3333

34-
POSTGRES_ENDPOINT=${POSTGRES_ENDPOINT}
34+
POSTGRES_ENDPOINT=postgres:5432
3535
POSTGRES_USER=test
3636
POSTGRES_PASSWORD=test
3737
POSTGRES_DB=test

services/docker-compose.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,11 @@ services:
413413
REDIS_PORT: ${REDIS_PORT}
414414
REDIS_SECURE: ${REDIS_SECURE}
415415
REDIS_USER: ${REDIS_USER}
416+
POSTGRES_DB: ${POSTGRES_DB}
417+
POSTGRES_HOST: ${POSTGRES_HOST}
418+
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
419+
POSTGRES_PORT: ${POSTGRES_PORT}
420+
POSTGRES_USER: ${POSTGRES_USER}
416421
SC_USER_ID: ${SC_USER_ID}
417422
SC_USER_NAME: ${SC_USER_NAME}
418423
EFS_USER_ID: ${EFS_USER_ID}

services/efs-guardian/requirements/_base.in

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
--requirement ../../../packages/models-library/requirements/_base.in
1010
--requirement ../../../packages/settings-library/requirements/_base.in
1111
--requirement ../../../packages/aws-library/requirements/_base.in
12+
--requirement ../../../packages/postgres-database/requirements/_base.in
1213
# service-library[fastapi]
1314
--requirement ../../../packages/service-library/requirements/_base.in
1415
--requirement ../../../packages/service-library/requirements/_fastapi.in

0 commit comments

Comments
 (0)