Skip to content

Commit 54f9e04

Browse files
AntoLCPanchoutNathan
authored andcommitted
🐛(backend) race condition create doc
When 2 docs are created almost at the same time, the second one will fail because the first one. We get a unicity error on the path key already used ("impress_document_path_key"). To fix this issue, we will lock the table the time to create the document, the next query will wait for the lock to be released.
1 parent 5ab822c commit 54f9e04

File tree

5 files changed

+129
-2
lines changed

5 files changed

+129
-2
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,10 @@ and this project adheres to
147147
- 🐛(email) invitation emails in receivers language
148148

149149

150+
## Fixed
151+
152+
- 🐛(backend) race condition create doc #633
153+
150154
## [2.2.0] - 2025-02-10
151155

152156
## Added

src/backend/core/api/viewsets.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@
1010
from django.contrib.postgres.search import TrigramSimilarity
1111
from django.core.exceptions import ValidationError
1212
from django.core.files.storage import default_storage
13+
from django.db import connection, transaction
1314
from django.db import models as db
14-
from django.db import transaction
1515
from django.db.models.expressions import RawSQL
1616
from django.db.models.functions import Left, Length
1717
from django.http import Http404, StreamingHttpResponse
@@ -529,6 +529,14 @@ def retrieve(self, request, *args, **kwargs):
529529
@transaction.atomic
530530
def perform_create(self, serializer):
531531
"""Set the current user as creator and owner of the newly created object."""
532+
533+
# locks the table to ensure safe concurrent access
534+
with connection.cursor() as cursor:
535+
cursor.execute(
536+
f'LOCK TABLE "{models.Document._meta.db_table}" ' # noqa: SLF001
537+
"IN SHARE ROW EXCLUSIVE MODE;"
538+
)
539+
532540
obj = models.Document.add_root(
533541
creator=self.request.user,
534542
**serializer.validated_data,
@@ -588,10 +596,19 @@ def trashbin(self, request, *args, **kwargs):
588596
permission_classes=[],
589597
url_path="create-for-owner",
590598
)
599+
@transaction.atomic
591600
def create_for_owner(self, request):
592601
"""
593602
Create a document on behalf of a specified owner (pre-existing user or invited).
594603
"""
604+
605+
# locks the table to ensure safe concurrent access
606+
with connection.cursor() as cursor:
607+
cursor.execute(
608+
f'LOCK TABLE "{models.Document._meta.db_table}" ' # noqa: SLF001
609+
"IN SHARE ROW EXCLUSIVE MODE;"
610+
)
611+
595612
# Deserialize and validate the data
596613
serializer = serializers.ServerCreateDocumentSerializer(data=request.data)
597614
if not serializer.is_valid():
@@ -697,7 +714,12 @@ def children(self, request, *args, **kwargs):
697714
serializer.is_valid(raise_exception=True)
698715

699716
with transaction.atomic():
700-
child_document = document.add_child(
717+
# "select_for_update" locks the table to ensure safe concurrent access
718+
locked_parent = models.Document.objects.select_for_update().get(
719+
pk=document.pk
720+
)
721+
722+
child_document = locked_parent.add_child(
701723
creator=request.user,
702724
**serializer.validated_data,
703725
)

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: children 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: 31 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
@@ -51,6 +52,36 @@ def test_api_documents_create_authenticated_success():
5152
assert document.accesses.filter(role="owner", user=user).exists()
5253

5354

55+
@pytest.mark.django_db(transaction=True)
56+
def test_api_documents_create_document_race_condition():
57+
"""
58+
It should be possible to create several documents at the same time
59+
without causing any race conditions or data integrity issues.
60+
"""
61+
62+
def create_document(title):
63+
user = factories.UserFactory()
64+
client = APIClient()
65+
client.force_login(user)
66+
return client.post(
67+
"/api/v1.0/documents/",
68+
{
69+
"title": title,
70+
},
71+
format="json",
72+
)
73+
74+
with ThreadPoolExecutor(max_workers=2) as executor:
75+
future1 = executor.submit(create_document, "my document 1")
76+
future2 = executor.submit(create_document, "my document 2")
77+
78+
response1 = future1.result()
79+
response2 = future2.result()
80+
81+
assert response1.status_code == 201
82+
assert response2.status_code == 201
83+
84+
5485
def test_api_documents_create_authenticated_title_null():
5586
"""It should be possible to create several documents with a null title."""
5687
user = factories.UserFactory()

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)