Skip to content

Commit a1dc585

Browse files
authored
Support redis migrations (#8898)
Sometimes we need to modify the data stored in Redis (for instance, in the near future we will change the identifiers of RQ jobs). This PR introduces a common mechanism for handling Redis migrations.
1 parent 74b14c5 commit a1dc585

File tree

16 files changed

+292
-12
lines changed

16 files changed

+292
-12
lines changed

Diff for: backend_entrypoint.sh

+12-1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ wait_for_db() {
1111
wait-for-it "${CVAT_POSTGRES_HOST}:${CVAT_POSTGRES_PORT:-5432}" -t 0
1212
}
1313

14+
wait_for_redis_inmem() {
15+
wait-for-it "${CVAT_REDIS_INMEM_HOST}:${CVAT_REDIS_INMEM_PORT:-6379}" -t 0
16+
}
17+
1418
cmd_bash() {
1519
exec bash "$@"
1620
}
@@ -19,7 +23,8 @@ cmd_init() {
1923
wait_for_db
2024
~/manage.py migrate
2125

22-
wait-for-it "${CVAT_REDIS_INMEM_HOST}:${CVAT_REDIS_INMEM_PORT:-6379}" -t 0
26+
wait_for_redis_inmem
27+
~/manage.py migrateredis
2328
~/manage.py syncperiodicjobs
2429
}
2530

@@ -39,6 +44,12 @@ cmd_run() {
3944
sleep 10
4045
done
4146

47+
wait_for_redis_inmem
48+
echo "waiting for Redis migrations to complete..."
49+
while ! ~/manage.py migrateredis --check; do
50+
sleep 10
51+
done
52+
4253
exec supervisord -c "supervisord/$1.conf"
4354
}
4455

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
### Added
2+
3+
- Support for managing Redis migrations
4+
(<https://github.com/cvat-ai/cvat/pull/8898>)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Copyright (C) CVAT.ai Corporation
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
import django_rq
6+
from django.conf import settings
7+
from rq_scheduler import Scheduler
8+
9+
from cvat.apps.redis_handler.redis_migrations import BaseMigration
10+
11+
12+
class Migration(BaseMigration):
13+
@classmethod
14+
def run(cls):
15+
scheduler: Scheduler = django_rq.get_scheduler(settings.CVAT_QUEUES.EXPORT_DATA.value)
16+
17+
for job in scheduler.get_jobs():
18+
if job.func_name == "cvat.apps.dataset_manager.views.clear_export_cache":
19+
scheduler.cancel(job)
20+
job.delete()

Diff for: cvat/apps/engine/redis_migrations/__init__.py

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Copyright (C) CVAT.ai Corporation
2+
#
3+
# SPDX-License-Identifier: MIT

Diff for: cvat/apps/redis_handler/__init__.py

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Copyright (C) CVAT.ai Corporation
2+
#
3+
# SPDX-License-Identifier: MIT

Diff for: cvat/apps/redis_handler/apps.py

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# Copyright (C) CVAT.ai Corporation
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
6+
from django.apps import AppConfig
7+
8+
9+
class RedisHandlerConfig(AppConfig):
10+
name = "cvat.apps.redis_handler"

Diff for: cvat/apps/redis_handler/management/__init__.py

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Copyright (C) CVAT.ai Corporation
2+
#
3+
# SPDX-License-Identifier: MIT
+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Copyright (C) CVAT.ai Corporation
2+
#
3+
# SPDX-License-Identifier: MIT
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# Copyright (C) CVAT.ai Corporation
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
import sys
6+
import traceback
7+
from argparse import ArgumentParser
8+
9+
from django.conf import settings
10+
from django.core.management.base import BaseCommand, CommandError
11+
from redis import Redis
12+
13+
from cvat.apps.redis_handler.migration_loader import AppliedMigration, MigrationLoader
14+
15+
16+
class Command(BaseCommand):
17+
help = "Applies Redis migrations and records them in the database"
18+
19+
def add_arguments(self, parser: ArgumentParser) -> None:
20+
parser.add_argument(
21+
"--check",
22+
action="store_true",
23+
help="Checks whether Redis migrations have been applied; exits with non-zero status if not",
24+
)
25+
26+
def handle(self, *args, **options) -> None:
27+
conn = Redis(
28+
host=settings.REDIS_INMEM_SETTINGS["HOST"],
29+
port=settings.REDIS_INMEM_SETTINGS["PORT"],
30+
db=settings.REDIS_INMEM_SETTINGS["DB"],
31+
password=settings.REDIS_INMEM_SETTINGS["PASSWORD"],
32+
)
33+
loader = MigrationLoader(connection=conn)
34+
35+
if options["check"]:
36+
if not loader:
37+
return
38+
39+
sys.exit(1)
40+
41+
if not loader:
42+
self.stdout.write("No migrations to apply")
43+
return
44+
45+
for migration in loader:
46+
try:
47+
migration.run()
48+
49+
# add migration to applied ones
50+
applied_migration = AppliedMigration(
51+
name=migration.name,
52+
app_label=migration.app_label,
53+
)
54+
applied_migration.save(connection=conn)
55+
56+
except Exception as ex:
57+
self.stderr.write(
58+
self.style.ERROR(
59+
f"[{migration.app_label}] Failed to apply migration: {migration.name}"
60+
)
61+
)
62+
self.stderr.write(self.style.ERROR(f"\n{traceback.format_exc()}"))
63+
raise CommandError(str(ex))
64+
65+
self.stdout.write(
66+
self.style.SUCCESS(
67+
f"[{migration.app_label}] Successfully applied migration: {migration.name}"
68+
)
69+
)

Diff for: cvat/apps/redis_handler/migration_loader.py

+126
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
# Copyright (C) CVAT.ai Corporation
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
import importlib
6+
from datetime import datetime
7+
from pathlib import Path
8+
from typing import Any, ClassVar
9+
10+
from attrs import field, frozen, validators
11+
from django.apps import AppConfig, apps
12+
from django.utils import timezone
13+
from redis import Redis
14+
15+
from cvat.apps.redis_handler.redis_migrations import BaseMigration
16+
17+
18+
def to_datetime(value: float | str | datetime) -> datetime:
19+
if isinstance(value, datetime):
20+
return value
21+
elif isinstance(value, str):
22+
value = float(value)
23+
24+
return datetime.fromtimestamp(value)
25+
26+
27+
@frozen
28+
class AppliedMigration:
29+
SET_KEY: ClassVar[str] = "cvat:applied_migrations"
30+
KEY_PREFIX: ClassVar[str] = "cvat:applied_migration:"
31+
32+
name: str = field(validator=[validators.instance_of(str), validators.max_len(128)])
33+
app_label: str = field(validator=[validators.instance_of(str), validators.max_len(128)])
34+
applied_date: datetime = field(
35+
validator=[validators.instance_of(datetime)], converter=to_datetime, factory=timezone.now
36+
)
37+
38+
def get_key(self) -> str:
39+
return f"{self.app_label}.{self.name}"
40+
41+
def get_key_with_prefix(self) -> str:
42+
return self.KEY_PREFIX + self.get_key()
43+
44+
def to_dict(self) -> dict[str, Any]:
45+
return {
46+
"applied_date": self.applied_date.timestamp(),
47+
}
48+
49+
def save(self, *, connection: Redis) -> None:
50+
with connection.pipeline() as pipe:
51+
pipe.hset(self.get_key_with_prefix(), mapping=self.to_dict())
52+
pipe.sadd(self.SET_KEY, self.get_key())
53+
pipe.execute()
54+
55+
56+
class LoaderError(Exception):
57+
pass
58+
59+
60+
class MigrationLoader:
61+
REDIS_MIGRATIONS_DIR_NAME = "redis_migrations"
62+
REDIS_MIGRATION_CLASS_NAME = "Migration"
63+
64+
def __init__(self, *, connection: Redis) -> None:
65+
self._connection = connection
66+
self._app_config_mapping = {
67+
app_config.label: app_config for app_config in self._find_app_configs()
68+
}
69+
self._disk_migrations_per_app: dict[str, list[str]] = {}
70+
self._applied_migrations: dict[str, set[str]] = {}
71+
self._unapplied_migrations: list[BaseMigration] = []
72+
73+
self._load_from_disk()
74+
self._init_applied_migrations()
75+
self._init_unapplied_migrations()
76+
77+
def _find_app_configs(self) -> list[AppConfig]:
78+
return [
79+
app_config
80+
for app_config in apps.get_app_configs()
81+
if app_config.name.startswith("cvat")
82+
and (Path(app_config.path) / self.REDIS_MIGRATIONS_DIR_NAME).exists()
83+
]
84+
85+
def _load_from_disk(self):
86+
for app_label, app_config in self._app_config_mapping.items():
87+
migrations_dir = Path(app_config.path) / self.REDIS_MIGRATIONS_DIR_NAME
88+
for migration_file in sorted(migrations_dir.glob("[0-9]*.py")):
89+
migration_name = migration_file.stem
90+
(self._disk_migrations_per_app.setdefault(app_label, [])).append(migration_name)
91+
92+
def _init_applied_migrations(self):
93+
applied_migration_keys: list[str] = [
94+
i.decode("utf-8") for i in self._connection.smembers(AppliedMigration.SET_KEY)
95+
]
96+
for key in applied_migration_keys:
97+
app_label, migration_name = key.split(".")
98+
self._applied_migrations.setdefault(app_label, set()).add(migration_name)
99+
100+
def _init_unapplied_migrations(self):
101+
for app_label, migration_names in self._disk_migrations_per_app.items():
102+
app_config = self._app_config_mapping[app_label]
103+
app_unapplied_migrations = sorted(
104+
set(migration_names) - self._applied_migrations.get(app_label, set())
105+
)
106+
for migration_name in app_unapplied_migrations:
107+
MigrationClass = self.get_migration_class(app_config.name, migration_name)
108+
self._unapplied_migrations.append(
109+
MigrationClass(migration_name, app_config.label, connection=self._connection)
110+
)
111+
112+
def get_migration_class(self, app_name: str, migration_name: str) -> BaseMigration:
113+
migration_module_path = ".".join([app_name, self.REDIS_MIGRATIONS_DIR_NAME, migration_name])
114+
module = importlib.import_module(migration_module_path)
115+
MigrationClass = getattr(module, self.REDIS_MIGRATION_CLASS_NAME, None)
116+
117+
if not MigrationClass or not issubclass(MigrationClass, BaseMigration):
118+
raise LoaderError(f"Invalid migration: {migration_module_path}")
119+
120+
return MigrationClass
121+
122+
def __iter__(self):
123+
yield from self._unapplied_migrations
124+
125+
def __len__(self):
126+
return len(self._unapplied_migrations)

Diff for: cvat/apps/redis_handler/redis_migrations/__init__.py

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Copyright (C) CVAT.ai Corporation
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
from abc import ABCMeta, abstractmethod
6+
7+
from attrs import define, field, validators
8+
from redis import Redis
9+
10+
11+
@define
12+
class BaseMigration(metaclass=ABCMeta):
13+
name: str = field(validator=[validators.instance_of(str)])
14+
app_label: str = field(validator=[validators.instance_of(str)])
15+
connection: Redis = field(validator=[validators.instance_of(Redis)], kw_only=True)
16+
17+
@classmethod
18+
@abstractmethod
19+
def run(cls) -> None: ...

Diff for: cvat/settings/base.py

+11-10
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ def generate_secret_key():
119119
'cvat.apps.events',
120120
'cvat.apps.quality_control',
121121
'cvat.apps.analytics_report',
122+
'cvat.apps.redis_handler',
122123
]
123124

124125
SITE_ID = 1
@@ -284,7 +285,7 @@ class CVAT_QUEUES(Enum):
284285
redis_inmem_port = os.getenv('CVAT_REDIS_INMEM_PORT', 6379)
285286
redis_inmem_password = os.getenv('CVAT_REDIS_INMEM_PASSWORD', '')
286287

287-
shared_queue_settings = {
288+
REDIS_INMEM_SETTINGS = {
288289
'HOST': redis_inmem_host,
289290
'PORT': redis_inmem_port,
290291
'DB': 0,
@@ -293,39 +294,39 @@ class CVAT_QUEUES(Enum):
293294

294295
RQ_QUEUES = {
295296
CVAT_QUEUES.IMPORT_DATA.value: {
296-
**shared_queue_settings,
297+
**REDIS_INMEM_SETTINGS,
297298
'DEFAULT_TIMEOUT': '4h',
298299
},
299300
CVAT_QUEUES.EXPORT_DATA.value: {
300-
**shared_queue_settings,
301+
**REDIS_INMEM_SETTINGS,
301302
'DEFAULT_TIMEOUT': '4h',
302303
},
303304
CVAT_QUEUES.AUTO_ANNOTATION.value: {
304-
**shared_queue_settings,
305+
**REDIS_INMEM_SETTINGS,
305306
'DEFAULT_TIMEOUT': '24h',
306307
},
307308
CVAT_QUEUES.WEBHOOKS.value: {
308-
**shared_queue_settings,
309+
**REDIS_INMEM_SETTINGS,
309310
'DEFAULT_TIMEOUT': '1h',
310311
},
311312
CVAT_QUEUES.NOTIFICATIONS.value: {
312-
**shared_queue_settings,
313+
**REDIS_INMEM_SETTINGS,
313314
'DEFAULT_TIMEOUT': '1h',
314315
},
315316
CVAT_QUEUES.QUALITY_REPORTS.value: {
316-
**shared_queue_settings,
317+
**REDIS_INMEM_SETTINGS,
317318
'DEFAULT_TIMEOUT': '1h',
318319
},
319320
CVAT_QUEUES.ANALYTICS_REPORTS.value: {
320-
**shared_queue_settings,
321+
**REDIS_INMEM_SETTINGS,
321322
'DEFAULT_TIMEOUT': '1h',
322323
},
323324
CVAT_QUEUES.CLEANING.value: {
324-
**shared_queue_settings,
325+
**REDIS_INMEM_SETTINGS,
325326
'DEFAULT_TIMEOUT': '2h',
326327
},
327328
CVAT_QUEUES.CHUNKS.value: {
328-
**shared_queue_settings,
329+
**REDIS_INMEM_SETTINGS,
329330
'DEFAULT_TIMEOUT': '5m',
330331
},
331332
}

Diff for: site/content/en/docs/contributing/development-environment.md

+1
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ description: 'Installing a development environment for different operating syste
165165

166166
```bash
167167
python manage.py migrate
168+
python manage.py migrateredis
168169
python manage.py collectstatic
169170
python manage.py syncperiodicjobs
170171
python manage.py createsuperuser

Diff for: tests/python/shared/fixtures/init.py

+8-1
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,14 @@ def kube_restore_clickhouse_db():
250250

251251

252252
def _get_redis_inmem_keys_to_keep():
253-
return ("rq:worker:", "rq:workers", "rq:scheduler_instance:", "rq:queues:")
253+
return (
254+
"rq:worker:",
255+
"rq:workers",
256+
"rq:scheduler_instance:",
257+
"rq:queues:",
258+
"cvat:applied_migrations",
259+
"cvat:applied_migration:",
260+
)
254261

255262

256263
def docker_restore_redis_inmem():

0 commit comments

Comments
 (0)