-
Notifications
You must be signed in to change notification settings - Fork 300
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
Fix duplicate orders for user notification policies #2278
Merged
Merged
Changes from 10 commits
Commits
Show all changes
27 commits
Select commit
Hold shift + click to select a range
219d549
Add OrderedModel
vstpme 362bab5
Use OrderedModel in UserNotificationPolicy
vstpme 32dfeb9
Update internal API view and serializers
vstpme 21601aa
Update public API
vstpme 08bc4a0
delete excess if
vstpme 230fa50
increase dev mysql max_connections
vstpme 20ff1db
Add comments
vstpme af50a5e
typing + pk name
vstpme 0e989a5
comment
vstpme 1c7aab9
comment
vstpme e0c8dcd
Merge branch 'dev' into vadimkerr/ordered-model
vstpme b93cf70
--no-migrations
vstpme 637b979
use connection.ops.quote_name
vstpme e8cf3d3
remove raw SQL
vstpme 6bc753a
Merge branch 'dev' into vadimkerr/ordered-model
vstpme b73ff95
changelog
vstpme 00fb5f2
ignore migration
vstpme c790132
create_default_policies_for_user
vstpme 5bca007
create_default_policies_for_user
vstpme 93c5588
test
vstpme cb4d5ba
lock queryset
vstpme 53a61c1
less retries
vstpme 45c6997
Merge branch 'dev' into vadimkerr/ordered-model
vstpme 004c095
_adjust_order
vstpme 294a8e6
more tests
vstpme e9a73bb
more tests
vstpme 1fb035d
Merge remote-tracking branch 'origin/vadimkerr/ordered-model' into va…
vstpme File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -7,7 +7,7 @@ | |
from apps.base.models.user_notification_policy import NotificationChannelAPIOptions | ||
from apps.user_management.models import User | ||
from common.api_helpers.custom_fields import OrganizationFilteredPrimaryKeyRelatedField | ||
from common.api_helpers.exceptions import BadRequest, Forbidden | ||
from common.api_helpers.exceptions import Forbidden | ||
from common.api_helpers.mixins import EagerLoadingMixin | ||
|
||
|
||
|
@@ -34,6 +34,7 @@ class UserNotificationPolicyBaseSerializer(EagerLoadingMixin, serializers.ModelS | |
class Meta: | ||
model = UserNotificationPolicy | ||
fields = ["id", "step", "order", "notify_by", "wait_delay", "important", "user"] | ||
read_only_fields = ["order"] | ||
|
||
def to_internal_value(self, data): | ||
if data.get("wait_delay", None): | ||
|
@@ -67,7 +68,6 @@ def _notify_by_to_representation(self, instance, result): | |
|
||
|
||
class UserNotificationPolicySerializer(UserNotificationPolicyBaseSerializer): | ||
prev_step = serializers.CharField(required=False, write_only=True, allow_null=True) | ||
user = OrganizationFilteredPrimaryKeyRelatedField( | ||
queryset=User.objects, | ||
required=False, | ||
|
@@ -80,36 +80,16 @@ class UserNotificationPolicySerializer(UserNotificationPolicyBaseSerializer): | |
default=NotificationChannelAPIOptions.DEFAULT_NOTIFICATION_CHANNEL, | ||
) | ||
|
||
class Meta(UserNotificationPolicyBaseSerializer.Meta): | ||
fields = [*UserNotificationPolicyBaseSerializer.Meta.fields, "prev_step"] | ||
read_only_fields = ("order",) | ||
|
||
def create(self, validated_data): | ||
prev_step = validated_data.pop("prev_step", None) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
||
user = validated_data.get("user") | ||
user = validated_data.get("user") or self.context["request"].user | ||
organization = self.context["request"].auth.organization | ||
|
||
if not user: | ||
user = self.context["request"].user | ||
|
||
self_or_admin = user.self_or_admin(user_to_check=self.context["request"].user, organization=organization) | ||
if not self_or_admin: | ||
raise Forbidden() | ||
|
||
if prev_step is not None: | ||
try: | ||
prev_step = UserNotificationPolicy.objects.get(public_primary_key=prev_step) | ||
except UserNotificationPolicy.DoesNotExist: | ||
raise BadRequest(detail="Prev step does not exist") | ||
if prev_step.user != user or prev_step.important != validated_data.get("important", False): | ||
raise BadRequest(detail="UserNotificationPolicy can be created only with the same user and importance") | ||
instance = UserNotificationPolicy.objects.create(**validated_data) | ||
instance.to(prev_step.order + 1) | ||
return instance | ||
else: | ||
instance = UserNotificationPolicy.objects.create(**validated_data) | ||
return instance | ||
instance = UserNotificationPolicy.objects.create(**validated_data) | ||
return instance | ||
|
||
|
||
class UserNotificationPolicyUpdateSerializer(UserNotificationPolicyBaseSerializer): | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,48 @@ | ||
# Generated by Django 3.2.19 on 2023-06-16 15:10 | ||
|
||
from django.db import migrations, models | ||
from django.db.models import Count | ||
|
||
from common.database import get_random_readonly_database_key_if_present_otherwise_default | ||
|
||
|
||
def fix_duplicate_order_user_notification_policy(apps, schema_editor): | ||
UserNotificationPolicy = apps.get_model('base', 'UserNotificationPolicy') | ||
|
||
# it should be safe to use a readonly database because duplicates are pretty infrequent | ||
db = get_random_readonly_database_key_if_present_otherwise_default() | ||
|
||
# find all (user_id, important, order) tuples that have more than one entry (meaning duplicates) | ||
items_with_duplicate_orders = UserNotificationPolicy.objects.using(db).values( | ||
"user_id", "important", "order" | ||
).annotate(count=Count("order")).order_by().filter(count__gt=1) # use order_by() to reset any existing ordering | ||
|
||
# make sure we don't fix the same (user_id, important) pair more than once | ||
values_to_fix = set((item["user_id"], item["important"]) for item in items_with_duplicate_orders) | ||
|
||
for user_id, important in values_to_fix: | ||
policies = UserNotificationPolicy.objects.filter(user_id=user_id, important=important).order_by("order", "id") | ||
# assign correct sequential order for each policy starting from 0 | ||
for idx, policy in enumerate(policies): | ||
policy.order = idx | ||
UserNotificationPolicy.objects.bulk_update(policies, fields=["order"]) | ||
|
||
|
||
class Migration(migrations.Migration): | ||
|
||
dependencies = [ | ||
('base', '0003_delete_organizationlogrecord'), | ||
] | ||
|
||
operations = [ | ||
migrations.AlterField( | ||
model_name='usernotificationpolicy', | ||
name='order', | ||
field=models.PositiveIntegerField(db_index=True, editable=False, null=True), | ||
), | ||
migrations.RunPython(fix_duplicate_order_user_notification_policy, migrations.RunPython.noop), | ||
migrations.AddConstraint( | ||
model_name='usernotificationpolicy', | ||
constraint=models.UniqueConstraint(fields=('user_id', 'important', 'order'), name='unique_user_notification_policy_order'), | ||
), | ||
] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,157 @@ | ||
import random | ||
import time | ||
import typing | ||
from functools import wraps | ||
|
||
from django.db import IntegrityError, OperationalError, connection, models, transaction | ||
|
||
# Update object's order to NULL and shift other objects' orders accordingly in a single SQL query. | ||
SQL_TO = """ | ||
UPDATE `{db_table}` `t1` | ||
JOIN `{db_table}` `t2` ON `t2`.`{pk_name}` = %(pk)s | ||
SET `t1`.`order` = IF(`t1`.`{pk_name}` = `t2`.`{pk_name}`, null, IF(`t1`.`order` < `t2`.`order`, `t1`.`order` + 1, `t1`.`order` - 1)) | ||
WHERE {ordering_condition} | ||
AND `t2`.`order` != %(order)s | ||
AND `t1`.`order` >= IF(`t2`.`order` > %(order)s, %(order)s, `t2`.`order`) | ||
AND `t1`.`order` <= IF(`t2`.`order` > %(order)s, `t2`.`order`, %(order)s) | ||
ORDER BY IF(`t1`.`order` <= `t2`.`order`, `t1`.`order`, null) DESC, IF(`t1`.`order` >= `t2`.`order`, `t1`.`order`, null) ASC | ||
""" | ||
|
||
# Update object's order to NULL and set the other object's order to specified value in a single SQL query. | ||
SQL_SWAP = """ | ||
UPDATE `{db_table}` `t1` | ||
JOIN `{db_table}` `t2` ON `t2`.`{pk_name}` = %(pk)s | ||
SET `t1`.`order` = IF(`t1`.`{pk_name}` = `t2`.`{pk_name}`, null, `t2`.`order`) | ||
WHERE {ordering_condition} | ||
AND `t2`.`order` != %(order)s | ||
AND (`t1`.`{pk_name}` = `t2`.`{pk_name}` OR `t1`.`order` = %(order)s) | ||
ORDER BY IF(`t1`.`{pk_name}` = `t2`.`{pk_name}`, 0, 1) ASC | ||
""" | ||
|
||
|
||
def _retry(exc: typing.Type[Exception] | tuple[typing.Type[Exception], ...], max_attempts: int = 15) -> typing.Callable: | ||
def _retry_with_params(f): | ||
@wraps(f) | ||
def wrapper(*args, **kwargs): | ||
attempts = 0 | ||
while attempts < max_attempts: | ||
try: | ||
return f(*args, **kwargs) | ||
except exc: | ||
if attempts == max_attempts - 1: | ||
raise | ||
attempts += 1 | ||
time.sleep(random.random()) | ||
|
||
return wrapper | ||
|
||
return _retry_with_params | ||
|
||
|
||
class OrderedModel(models.Model): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Main focus of the PR |
||
""" | ||
This class is intended to be used as a mixin for models that need to be ordered. | ||
It's similar to django-ordered-model: https://github.com/django-ordered-model/django-ordered-model. | ||
The key difference of this implementation is that it allows orders to be unique at the database level and | ||
is designed to work correctly under concurrent load. | ||
|
||
Notable differences compared to django-ordered-model: | ||
- order can be unique at the database level; | ||
- order can temporarily be set to NULL while performing moving operations; | ||
- instance.delete() only deletes the instance, and doesn't shift other instances' orders; | ||
- some methods are not implemented because they're not used in the codebase; | ||
|
||
Example usage: | ||
class Step(OrderedModel): | ||
user = models.ForeignKey(User, on_delete=models.CASCADE) | ||
order_with_respect_to = ["user_id"] # steps are ordered per user | ||
|
||
class Meta: | ||
ordering = ["order"] # to make queryset ordering correct and consistent | ||
unique_together = ["user_id", "order"] # orders are unique per user at the database level | ||
|
||
It's possible for orders to be non-sequential, e.g. order sequence [100, 150, 400] is totally possible and valid. | ||
""" | ||
|
||
order = models.PositiveIntegerField(editable=False, db_index=True, null=True) | ||
order_with_respect_to: list[str] = [] | ||
|
||
class Meta: | ||
abstract = True | ||
ordering = ["order"] | ||
constraints = [ | ||
models.UniqueConstraint(fields=["order"], name="unique_order"), | ||
] | ||
|
||
def save(self, *args, **kwargs) -> None: | ||
if self.order is None: | ||
self._save_no_order_provided() | ||
else: | ||
super().save() | ||
|
||
@_retry(OperationalError) | ||
def delete(self, *args, **kwargs) -> None: | ||
super().delete(*args, **kwargs) | ||
|
||
@_retry((IntegrityError, OperationalError)) | ||
def _save_no_order_provided(self) -> None: | ||
max_order = self.max_order() | ||
self.order = max_order + 1 if max_order is not None else 0 | ||
super().save() | ||
|
||
@_retry((IntegrityError, OperationalError)) | ||
def to(self, order: int) -> None: | ||
if order is None or order < 0: | ||
raise ValueError("Order must be a positive integer.") | ||
|
||
sql = SQL_TO.format( | ||
db_table=self._meta.db_table, pk_name=self._meta.pk.name, ordering_condition=self._ordering_condition_sql | ||
) | ||
params = {"pk": self.pk, "order": order, **self._ordering_params} | ||
|
||
with transaction.atomic(): | ||
with connection.cursor() as cursor: | ||
cursor.execute(sql, params) | ||
self._meta.model.objects.filter(pk=self.pk).update(order=order) | ||
|
||
self.refresh_from_db(fields=["order"]) | ||
|
||
def to_index(self, index: int) -> None: | ||
order = self._get_ordering_queryset().values_list("order", flat=True)[index] | ||
self.to(order) | ||
|
||
@_retry((IntegrityError, OperationalError)) | ||
def swap(self, order: int) -> None: | ||
if order is None or order < 0: | ||
raise ValueError("Order must be a positive integer.") | ||
|
||
sql = SQL_SWAP.format( | ||
db_table=self._meta.db_table, pk_name=self._meta.pk.name, ordering_condition=self._ordering_condition_sql | ||
) | ||
params = {"pk": self.pk, "order": order, **self._ordering_params} | ||
|
||
with transaction.atomic(): | ||
with connection.cursor() as cursor: | ||
cursor.execute(sql, params) | ||
self._meta.model.objects.filter(pk=self.pk).update(order=order) | ||
|
||
self.refresh_from_db(fields=["order"]) | ||
|
||
def next(self) -> models.Model | None: | ||
return self._get_ordering_queryset().filter(order__gt=self.order).first() | ||
|
||
def max_order(self) -> int | None: | ||
return self._get_ordering_queryset().aggregate(models.Max("order"))["order__max"] | ||
|
||
def _get_ordering_queryset(self) -> models.QuerySet: | ||
return self._meta.model.objects.filter(**self._ordering_params) | ||
|
||
@property | ||
def _ordering_params(self) -> dict[str, typing.Any]: | ||
return {field: getattr(self, field) for field in self.order_with_respect_to} | ||
|
||
@property | ||
def _ordering_condition_sql(self) -> str: | ||
# This doesn't insert actual values into the query, but rather uses placeholders to avoid SQL injections. | ||
ordering_parts = ["`t1`.`{0}` = %({0})s".format(field) for field in self.order_with_respect_to] | ||
return " AND ".join(ordering_parts) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Increasing
max_connections
for local concurrency tests that open more connections than default number (151)There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
what do you think about adding a
./dev/mysql.ini
file?We have four containers (two in both this file +
docker-compose-mysql-rabbitmq.yml
) which run themysql
image. We could de-dupe this config and just volume mount this new file into all of these containers.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
All 4 mysql containers share only these two settings:
--character-set-server=utf8mb4
and--collation-server=utf8mb4_unicode_ci
. Not sure if deduping those is worth of the extra complexity added by volume mounting theini
file. I'll take a closer look at this, I think it should be outside of the scope of this PR.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree that it's outside the scope of this PR 👍 just a thought that came to mind 😄