Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit c2e82b2

Browse files
committedFeb 26, 2025·
fixup! 🐛(backend) race condition create doc
1 parent f28081a commit c2e82b2

File tree

4 files changed

+88
-1
lines changed

4 files changed

+88
-1
lines changed
 

‎src/backend/core/api/viewsets.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -573,6 +573,8 @@ def retrieve(self, request, *args, **kwargs):
573573
@transaction.atomic
574574
def perform_create(self, serializer):
575575
"""Set the current user as creator and owner of the newly created object."""
576+
577+
# locks the table to ensure safe concurrent access
576578
with connection.cursor() as cursor:
577579
cursor.execute(
578580
f'LOCK TABLE "{models.Document._meta.db_table}" ' # noqa: SLF001
@@ -637,10 +639,19 @@ def trashbin(self, request, *args, **kwargs):
637639
permission_classes=[],
638640
url_path="create-for-owner",
639641
)
642+
@transaction.atomic
640643
def create_for_owner(self, request):
641644
"""
642645
Create a document on behalf of a specified owner (pre-existing user or invited).
643646
"""
647+
648+
# locks the table to ensure safe concurrent access
649+
with connection.cursor() as cursor:
650+
cursor.execute(
651+
f'LOCK TABLE "{models.Document._meta.db_table}" ' # noqa: SLF001
652+
"IN SHARE ROW EXCLUSIVE MODE;"
653+
)
654+
644655
# Deserialize and validate the data
645656
serializer = serializers.ServerCreateDocumentSerializer(data=request.data)
646657
if not serializer.is_valid():
@@ -747,7 +758,12 @@ def children(self, request, *args, **kwargs):
747758
serializer.is_valid(raise_exception=True)
748759

749760
with transaction.atomic():
750-
child_document = document.add_child(
761+
# "select_for_update" locks the table to ensure safe concurrent access
762+
locked_parent = models.Document.objects.select_for_update().get(
763+
pk=document.pk
764+
)
765+
766+
child_document = locked_parent.add_child(
751767
creator=request.user,
752768
**serializer.validated_data,
753769
)

‎src/backend/core/tests/documents/test_api_documents_children_create.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
Tests for Documents API endpoint in impress's core app: create
33
"""
44

5+
from concurrent.futures import ThreadPoolExecutor
56
from uuid import uuid4
67

78
import pytest
@@ -249,3 +250,41 @@ def test_api_documents_children_create_force_id_existing():
249250
assert response.json() == {
250251
"id": ["A document with this ID already exists. You cannot override it."]
251252
}
253+
254+
255+
@pytest.mark.django_db(transaction=True)
256+
def test_api_documents_create_document_children_race_condition():
257+
"""
258+
It should be possible to create several documents at the same time
259+
without causing any race conditions or data integrity issues.
260+
"""
261+
262+
user = factories.UserFactory()
263+
264+
client = APIClient()
265+
client.force_login(user)
266+
267+
document = factories.DocumentFactory()
268+
269+
factories.UserDocumentAccessFactory(user=user, document=document, role="owner")
270+
271+
def create_document():
272+
return client.post(
273+
f"/api/v1.0/documents/{document.id}/children/",
274+
{
275+
"title": "my child",
276+
},
277+
)
278+
279+
with ThreadPoolExecutor(max_workers=2) as executor:
280+
future1 = executor.submit(create_document)
281+
future2 = executor.submit(create_document)
282+
283+
response1 = future1.result()
284+
response2 = future2.result()
285+
286+
assert response1.status_code == 201
287+
assert response2.status_code == 201
288+
289+
document.refresh_from_db()
290+
assert document.numchild == 2

‎src/backend/core/tests/documents/test_api_documents_create.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ def test_api_documents_create_authenticated_success():
5252
assert document.accesses.filter(role="owner", user=user).exists()
5353

5454

55+
@pytest.mark.django_db(transaction=True)
5556
def test_api_documents_create_document_race_condition():
5657
"""
5758
It should be possible to create several documents at the same time

‎src/backend/core/tests/documents/test_api_documents_create_for_owner.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
# pylint: disable=W0621
66

7+
from concurrent.futures import ThreadPoolExecutor
78
from unittest.mock import patch
89

910
from django.core import mail
@@ -425,6 +426,36 @@ def test_api_documents_create_for_owner_new_user_no_sub_no_fallback_allow_duplic
425426
assert document.creator == user
426427

427428

429+
@pytest.mark.django_db(transaction=True)
430+
def test_api_documents_create_document_race_condition():
431+
"""
432+
It should be possible to create several documents at the same time
433+
without causing any race conditions or data integrity issues.
434+
"""
435+
436+
def create_document(title):
437+
user = factories.UserFactory()
438+
client = APIClient()
439+
client.force_login(user)
440+
return client.post(
441+
"/api/v1.0/documents/",
442+
{
443+
"title": title,
444+
},
445+
format="json",
446+
)
447+
448+
with ThreadPoolExecutor(max_workers=2) as executor:
449+
future1 = executor.submit(create_document, "my document 1")
450+
future2 = executor.submit(create_document, "my document 2")
451+
452+
response1 = future1.result()
453+
response2 = future2.result()
454+
455+
assert response1.status_code == 201
456+
assert response2.status_code == 201
457+
458+
428459
@patch.object(ServerCreateDocumentSerializer, "_send_email_notification")
429460
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"], LANGUAGE_CODE="de-de")
430461
def test_api_documents_create_for_owner_with_default_language(

0 commit comments

Comments
 (0)
Please sign in to comment.