Skip to content

Commit

Permalink
Acquire lock on singleton to handle parallel pod deployments (#10)
Browse files Browse the repository at this point in the history
Co-authored-by: Ronny V <[email protected]>
  • Loading branch information
mad-anne and GitRon committed Jul 18, 2024
1 parent 832547f commit 20c55b1
Show file tree
Hide file tree
Showing 5 changed files with 68 additions and 18 deletions.
1 change: 1 addition & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

* *master* (unreleased)
* Use ORM to reset `django_migrations` table
* Lock rows to enable parallel deployments
* Dropped Python 3.8 support
* Added multiple ruff linters
* Updated GitHub actions
Expand Down
23 changes: 7 additions & 16 deletions django_migration_zero/managers.py
Original file line number Diff line number Diff line change
@@ -1,35 +1,26 @@
from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist
from django.db import ProgrammingError, models

from django_migration_zero.exceptions import MissingMigrationZeroConfigRecordError
from django_migration_zero.helpers.logger import get_logger


class MigrationZeroConfigurationQuerySet(models.QuerySet):
pass


class MigrationZeroConfigurationManager(models.Manager):
def fetch_singleton(self) -> None:
logger = get_logger()
try:
number_records = self.count()
config_singleton = self.select_for_update().get()
except ProgrammingError:
logger.warning(
"The migration zero table is missing. This might be ok for the first installation of "
'"django-migration-zero" but if you see this warning after that point, something went sideways.'
)
return None

if number_records > 1:
config_singleton = None
except MultipleObjectsReturned as e:
raise MissingMigrationZeroConfigRecordError(
"Too many configuration records detected. There can only be one."
)

config_singleton = self.all().first()
if not config_singleton:
raise MissingMigrationZeroConfigRecordError("No configuration record found in the database.")
) from e
except ObjectDoesNotExist as e:
raise MissingMigrationZeroConfigRecordError("No configuration record found in the database.") from e

return config_singleton


MigrationZeroConfigurationManager = MigrationZeroConfigurationManager.from_queryset(MigrationZeroConfigurationQuerySet)
2 changes: 2 additions & 0 deletions django_migration_zero/services/deployment.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from logging import Logger

from django.core.management import call_command
from django.db import transaction
from django.db.migrations.recorder import MigrationRecorder

from django_migration_zero.exceptions import InvalidMigrationTreeError
Expand All @@ -20,6 +21,7 @@ def __init__(self):

self.logger = get_logger()

@transaction.atomic
def process(self):
self.logger.info("Starting migration zero database adjustments...")

Expand Down
57 changes: 56 additions & 1 deletion tests/services/test_deployment.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from logging import Logger
from threading import Thread
from unittest import mock
from unittest.mock import Mock, call

from django.test import TestCase
from django.test import TestCase, TransactionTestCase
from django.utils import timezone
from freezegun import freeze_time

Expand Down Expand Up @@ -81,3 +83,56 @@ def test_process_validate_all_commands_are_executed(self, mocked_call_command):

self.assertEqual(calls[1].args, ("migrate",))
self.assertEqual(calls[1].kwargs, {"check": True})


class DatabasePreparationServiceTestParallelDeployment(TransactionTestCase):
@mock.patch("django_migration_zero.services.deployment.get_logger")
def test_process_multiple_threads(self, mock_get_logger):
"""Test parallel deployments to multiple pods."""
# Setup
mock_logger_info = Mock(return_value=None)
mock_get_logger.return_value = Mock(info=mock_logger_info)
config, _ = MigrationZeroConfiguration.objects.update_or_create(
defaults={
"migration_imminent": True,
"migration_date": timezone.now().date(),
}
)

# Testable
number_of_pods = 1
threads = [Thread(target=DatabasePreparationService().process) for _ in range(number_of_pods)]

for thread in threads:
thread.start()

for thread in threads:
thread.join()

# Assertions
self.assertEqual(mock_logger_info.call_count, 6 + number_of_pods, msg=mock_logger_info.call_args_list)
mock_logger_info.assert_has_calls(
[
call("Starting migration zero database adjustments..."),
call("Resetting migration history for all apps..."),
call("Populating migration history."),
call("Checking migration integrity."),
call("All good."),
call("Deactivating migration zero switch in database."),
call("Process successfully finished."),
],
any_order=True,
)
self.assertEqual(
len(
[
mock_call
for mock_call in mock_logger_info.call_args_list
if mock_call == call("Starting migration zero database adjustments...")
]
),
number_of_pods,
)

config.refresh_from_db()
self.assertFalse(config.migration_imminent)
3 changes: 2 additions & 1 deletion tests/test_managers.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from django.test import TestCase

from django_migration_zero.exceptions import MissingMigrationZeroConfigRecordError
from django_migration_zero.managers import MigrationZeroConfigurationManager
from django_migration_zero.models import MigrationZeroConfiguration


Expand All @@ -18,7 +19,7 @@ def test_fetch_singleton_regular(self):
def test_fetch_singleton_singleton_exists_via_migration(self):
self.assertEqual(MigrationZeroConfiguration.objects.all().count(), 1)

@mock.patch.object(MigrationZeroConfiguration.objects, "count", side_effect=ProgrammingError)
@mock.patch.object(MigrationZeroConfigurationManager, "select_for_update", side_effect=ProgrammingError)
def test_fetch_singleton_database_error(self, *args):
self.assertIsNone(MigrationZeroConfiguration.objects.fetch_singleton())

Expand Down

0 comments on commit 20c55b1

Please sign in to comment.