Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support redis migrations #8898

Merged
merged 33 commits into from
Feb 4, 2025
Merged
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
7a32e8b
Draft: add custom 'migrateredis' management command
Marishka17 Jan 3, 2025
78338c7
Merge branch 'develop' into mk/redis-migrations
Marishka17 Jan 10, 2025
10e3a1d
[draft] Add MigrationLoader
Marishka17 Jan 10, 2025
fae0974
Move logic to a separate app
Marishka17 Jan 15, 2025
88ef5a0
Merge branch 'develop' into mk/redis-migrations
Marishka17 Jan 15, 2025
dc1f3bb
Fix linter issues
Marishka17 Jan 15, 2025
233d0a9
Add test migration
Marishka17 Jan 16, 2025
a9ae8fd
Merge branch 'develop' into mk/redis-migrations
Marishka17 Jan 16, 2025
fac9f58
Run migrateredis before syncperiodicjobs
Marishka17 Jan 16, 2025
603fe90
linters
Marishka17 Jan 16, 2025
0b49bbe
Notify if there are no migrations
Marishka17 Jan 16, 2025
170e0f3
Support Redis migrations from enterprise apps
Marishka17 Jan 16, 2025
2bb68c9
Run CI
Marishka17 Jan 16, 2025
bd756e1
Fix minor stupid comments
Marishka17 Jan 16, 2025
580f9a8
Simplify the code
Marishka17 Jan 17, 2025
bbb12b6
Move periodic job commands to redis_handler
Marishka17 Jan 17, 2025
0bea986
Update development guide
Marishka17 Jan 17, 2025
0f75340
Store applied migartions in Redis
Marishka17 Jan 17, 2025
c46a7b4
Add check option
Marishka17 Jan 17, 2025
1131db9
Merge branch 'develop' into mk/redis-migrations
Marishka17 Jan 17, 2025
96f7a7a
allow using shared connection
Marishka17 Jan 17, 2025
1834099
Merge remote-tracking branch 'origin/mk/redis-migrations' into mk/red…
Marishka17 Jan 17, 2025
634f460
changelog
Marishka17 Jan 17, 2025
5582bd9
Update cvat/apps/redis_handler/migration_loader.py
Marishka17 Jan 17, 2025
36ae237
Resolve conflicts
Marishka17 Jan 27, 2025
ba1ef79
add empty line
Marishka17 Jan 27, 2025
942a78c
fix black issue
Marishka17 Jan 27, 2025
32243a7
[tests] Do not reset cvat migrations from redis
Marishka17 Jan 27, 2025
7478890
Apply comments
Marishka17 Jan 30, 2025
f08bd9b
Merge branch 'develop' into mk/redis-migrations
Marishka17 Jan 30, 2025
4d5f70c
Merge branch 'develop' into mk/redis-migrations
Marishka17 Jan 30, 2025
98d0037
Merge branch 'develop' into mk/redis-migrations
Marishka17 Jan 31, 2025
af4b01a
Pass connection to migration class && do not use key prefix in the set
Marishka17 Feb 4, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions backend_entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ cmd_init() {
~/manage.py migrate

wait-for-it "${CVAT_REDIS_INMEM_HOST}:${CVAT_REDIS_INMEM_PORT:-6379}" -t 0
~/manage.py migrateredis
Marishka17 marked this conversation as resolved.
Show resolved Hide resolved
~/manage.py syncperiodicjobs
}

Expand All @@ -39,6 +40,12 @@ cmd_run() {
sleep 10
done

wait-for-it "${CVAT_REDIS_INMEM_HOST}:${CVAT_REDIS_INMEM_PORT:-6379}" -t 0
Marishka17 marked this conversation as resolved.
Show resolved Hide resolved
echo "waiting for Redis migrations to complete..."
while ! ~/manage.py migrateredis --check; do
sleep 10
done

exec supervisord -c "supervisord/$1.conf"
}

Expand Down
4 changes: 4 additions & 0 deletions changelog.d/20250117_174701_maria_redis_migrations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
### Added

- Support for managing Redis migrations
(<https://github.com/cvat-ai/cvat/pull/8898>)
2 changes: 1 addition & 1 deletion cvat-ui/src/components/export-backup/styles.scss
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like these changes were made by pre-commit hook
@bsekachev

Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
//
// SPDX-License-Identifier: MIT

@import '../../base.scss';
@import '../../base';

.cvat-modal-export-option-item > .ant-select-item-option-content,
.cvat-modal-export-select .ant-select-selection-item {
Expand Down
2 changes: 1 addition & 1 deletion cvat-ui/src/components/layout-grid/styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
//
// SPDX-License-Identifier: MIT

@import './../../base.scss';
@import './../../base';

.grid {
display: grid;
Expand Down
2 changes: 1 addition & 1 deletion cvat-ui/src/components/storage/styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
//
// SPDX-License-Identifier: MIT

@import '../../base.scss';
@import '../../base';

.cvat-question-circle-filled-icon {
font-size: $grid-unit-size * 14;
Expand Down
20 changes: 20 additions & 0 deletions cvat/apps/engine/redis_migrations/001_cleanup_scheduled_jobs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Copyright (C) 2025 CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT

import django_rq
from django.conf import settings
from rq_scheduler import Scheduler

from cvat.apps.redis_handler.redis_migrations import BaseMigration


class Migration(BaseMigration):
@classmethod
def run(cls):
scheduler: Scheduler = django_rq.get_scheduler(settings.CVAT_QUEUES.EXPORT_DATA.value)
SpecLad marked this conversation as resolved.
Show resolved Hide resolved

for job in scheduler.get_jobs():
if job.func_name == "cvat.apps.dataset_manager.views.clear_export_cache":
scheduler.cancel(job)
job.delete()
3 changes: 3 additions & 0 deletions cvat/apps/engine/redis_migrations/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Copyright (C) 2025 CVAT.ai Corporation
Marishka17 marked this conversation as resolved.
Show resolved Hide resolved
#
# SPDX-License-Identifier: MIT
3 changes: 3 additions & 0 deletions cvat/apps/redis_handler/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Copyright (C) 2025 CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
10 changes: 10 additions & 0 deletions cvat/apps/redis_handler/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Copyright (C) 2025 CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT


from django.apps import AppConfig
Marishka17 marked this conversation as resolved.
Show resolved Hide resolved


class RedisHandlerConfig(AppConfig):
name = "cvat.apps.redis_handler"
3 changes: 3 additions & 0 deletions cvat/apps/redis_handler/management/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Copyright (C) 2025 CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
3 changes: 3 additions & 0 deletions cvat/apps/redis_handler/management/commands/__init__.py
Marishka17 marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Copyright (C) 2025 CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
76 changes: 76 additions & 0 deletions cvat/apps/redis_handler/management/commands/migrateredis.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Copyright (C) 2025 CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT

import sys
import traceback
from argparse import ArgumentParser

from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone
from redis import Redis

from cvat.apps.redis_handler.migration_loader import AppliedMigration, MigrationLoader


class Command(BaseCommand):
help = "Applies Redis migrations and records them in the database"

def add_arguments(self, parser: ArgumentParser) -> None:
parser.add_argument(
"--check",
action="store_true",
help="Checks whether Redis migrations have been applied; exits with non-zero status if not",
)

def handle(self, *args, **options) -> None:
loader = MigrationLoader()

if options["check"]:
if not loader:
return

sys.exit(1)

if not loader:
self.stdout.write("No migrations to apply")
return

conn = Redis(
Marishka17 marked this conversation as resolved.
Show resolved Hide resolved
host=settings.REDIS_INMEM_SETTINGS["HOST"],
port=settings.REDIS_INMEM_SETTINGS["PORT"],
db=settings.REDIS_INMEM_SETTINGS["DB"],
password=settings.REDIS_INMEM_SETTINGS["PASSWORD"],
)

for migration in loader:
with conn.pipeline() as pipe:
try:
migration.run()

# add migration to applied ones
applied_migration = AppliedMigration(
name=migration.name,
app_label=migration.app_label,
applied_date=timezone.now(),
)
applied_migration_key = applied_migration.get_key()
pipe.hset(applied_migration_key, mapping=applied_migration.to_dict())
pipe.zadd(applied_migration.SORTED_SET_KEY, {applied_migration_key: 1})
Marishka17 marked this conversation as resolved.
Show resolved Hide resolved

except Exception as ex:
self.stderr.write(
self.style.ERROR(
f"[{migration.app_label}] Failed to apply migration: {migration.name}"
)
)
self.stderr.write(self.style.ERROR(f"\n{traceback.format_exc()}"))
raise CommandError(str(ex))

pipe.execute()
self.stdout.write(
self.style.SUCCESS(
f"[{migration.app_label}] Successfully applied migration: {migration.name}"
)
)
134 changes: 134 additions & 0 deletions cvat/apps/redis_handler/migration_loader.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# Copyright (C) 2025 CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT

import importlib
from datetime import datetime
from pathlib import Path
from typing import Any, ClassVar
from uuid import UUID, uuid4

from attrs import asdict, field, frozen, validators
from django.apps import AppConfig, apps
from django.conf import settings
from redis import Redis

from cvat.apps.redis_handler.redis_migrations import BaseMigration


def to_datetime(value: float | str | datetime) -> datetime:
if isinstance(value, datetime):
return value
elif isinstance(value, str):
value = float(value)

return datetime.fromtimestamp(value)


def to_uuid(value: str | UUID) -> UUID:
if isinstance(value, UUID):
return value

return UUID(value)


@frozen
class AppliedMigration:
SORTED_SET_KEY: ClassVar[str] = "cvat:applied_migrations:"
Marishka17 marked this conversation as resolved.
Show resolved Hide resolved
KEY_PREFIX: ClassVar[str] = "cvat:applied_migration:"

name: str = field(validator=[validators.instance_of(str), validators.max_len(128)])
app_label: str = field(validator=[validators.instance_of(str), validators.max_len(128)])
applied_date: datetime = field(
validator=[validators.instance_of(datetime)], converter=to_datetime
)
identifier: UUID = field(factory=uuid4, converter=to_uuid)
SpecLad marked this conversation as resolved.
Show resolved Hide resolved

def get_key(self) -> str:
return self.KEY_PREFIX + str(self.identifier)

def to_dict(self) -> dict[str, Any]:
d = asdict(self, filter=lambda a, _: a.name != "identifier")
d["applied_date"] = self.applied_date.timestamp()

return d


class LoaderError(Exception):
pass


class MigrationLoader:
REDIS_MIGRATIONS_DIR_NAME = "redis_migrations"
REDIS_MIGRATION_CLASS_NAME = "Migration"

def __init__(self) -> None:
self._app_config_mapping = {
app_config.label: app_config for app_config in self._find_app_configs()
}
self._disk_migrations_per_app: dict[str, list[str]] = {}
self._applied_migrations: list[AppliedMigration] = []
self._unapplied_migrations: list[BaseMigration] = []

self._load_from_disk()
self._init_applied_migrations()
self._init_unapplied_migrations()

def _find_app_configs(self) -> list[AppConfig]:
return [
app_config
for app_config in apps.get_app_configs()
if app_config.name.startswith("cvat")
and (Path(app_config.path) / self.REDIS_MIGRATIONS_DIR_NAME).exists()
]

def _load_from_disk(self):
for app_label, app_config in self._app_config_mapping.items():
migrations_dir = Path(app_config.path) / self.REDIS_MIGRATIONS_DIR_NAME
for migration_file in sorted(migrations_dir.glob("[0-9]*.py")):
migration_name = migration_file.stem
(self._disk_migrations_per_app.setdefault(app_label, [])).append(migration_name)

def _init_applied_migrations(self):
conn = Redis(
host=settings.REDIS_INMEM_SETTINGS["HOST"],
port=settings.REDIS_INMEM_SETTINGS["PORT"],
db=settings.REDIS_INMEM_SETTINGS["DB"],
password=settings.REDIS_INMEM_SETTINGS["PASSWORD"],
)
applied_migration_keys = [
i.decode("utf-8") for i in conn.zrange(AppliedMigration.SORTED_SET_KEY, 0, -1)
Marishka17 marked this conversation as resolved.
Show resolved Hide resolved
]
for key in applied_migration_keys:
self._applied_migrations.append(
AppliedMigration(
**{k.decode("utf-8"): v.decode("utf-8") for k, v in conn.hgetall(key).items()}
)
)

def _init_unapplied_migrations(self):
for app_label, migration_names in self._disk_migrations_per_app.items():
app_config = self._app_config_mapping[app_label]
app_applied_migrations = {
m.name for m in self._applied_migrations if m.app_label == app_config.label
Marishka17 marked this conversation as resolved.
Show resolved Hide resolved
}
app_unapplied_migrations = sorted(set(migration_names) - app_applied_migrations)
for migration_name in app_unapplied_migrations:
MigrationClass = self.get_migration_class(app_config.name, migration_name)
self._unapplied_migrations.append(MigrationClass(migration_name, app_config.label))

def get_migration_class(self, app_name: str, migration_name: str) -> BaseMigration:
migration_module_path = ".".join([app_name, self.REDIS_MIGRATIONS_DIR_NAME, migration_name])
module = importlib.import_module(migration_module_path)
MigrationClass = getattr(module, self.REDIS_MIGRATION_CLASS_NAME, None)

if not MigrationClass or not issubclass(MigrationClass, BaseMigration):
raise LoaderError(f"Invalid migration: {migration_module_path}")

return MigrationClass

def __iter__(self):
yield from self._unapplied_migrations

def __len__(self):
return len(self._unapplied_migrations)
26 changes: 26 additions & 0 deletions cvat/apps/redis_handler/redis_migrations/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Copyright (C) 2025 CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT

from abc import ABCMeta, abstractmethod
from typing import ClassVar

from attrs import define, field, validators
from django.conf import settings
from redis import Redis


@define
class BaseMigration(metaclass=ABCMeta):
CONNECTION: ClassVar[Redis] = Redis(
SpecLad marked this conversation as resolved.
Show resolved Hide resolved
host=settings.REDIS_INMEM_SETTINGS["HOST"],
port=settings.REDIS_INMEM_SETTINGS["PORT"],
db=settings.REDIS_INMEM_SETTINGS["DB"],
password=settings.REDIS_INMEM_SETTINGS["PASSWORD"],
)
name: str = field(validator=[validators.instance_of(str)])
app_label: str = field(validator=[validators.instance_of(str)])

@classmethod
@abstractmethod
def run(cls) -> None: ...
Loading
Loading